ydmyzx 5 months ago
parent
commit
e2eb4d3aa4
36 changed files with 5510 additions and 0 deletions
  1. 1 0
      src/assets/ai/delete.svg
  2. 228 0
      src/components/AppLinkInput/data.ts
  3. 167 0
      src/components/Descriptions/src/Descriptions.vue
  4. 29 0
      src/components/Descriptions/src/DescriptionsItemLabel.vue
  5. 140 0
      src/components/Dialog/src/Dialog.vue
  6. 1961 0
      src/components/Icon/src/data.ts
  7. 24 0
      src/components/bpmnProcessDesigner/package/designer/plugins/defaultEmpty.js
  8. 14 0
      src/types/descriptions.d.ts
  9. 18 0
      src/utils/dateUtil.ts
  10. 55 0
      src/views/ai/music/components/mode/desc.vue
  11. 359 0
      src/views/bpm/disbursement/detail.vue
  12. 51 0
      src/views/bpm/oa/leave/detail.vue
  13. 111 0
      src/views/infra/dataSourceConfig/DataSourceConfigForm.vue
  14. 126 0
      src/views/infra/demo/demo01/Demo01ContactForm.vue
  15. 114 0
      src/views/infra/demo/demo02/Demo02CategoryForm.vue
  16. 121 0
      src/views/infra/demo/demo03/erp/Demo03StudentForm.vue
  17. 99 0
      src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue
  18. 130 0
      src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue
  19. 99 0
      src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue
  20. 130 0
      src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue
  21. 153 0
      src/views/infra/demo/demo03/inner/Demo03StudentForm.vue
  22. 100 0
      src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue
  23. 51 0
      src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue
  24. 72 0
      src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue
  25. 55 0
      src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue
  26. 153 0
      src/views/infra/demo/demo03/normal/Demo03StudentForm.vue
  27. 100 0
      src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue
  28. 72 0
      src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue
  29. 96 0
      src/views/mall/product/spu/form/DeliveryForm.vue
  30. 81 0
      src/views/mall/product/spu/form/DescriptionForm.vue
  31. 74 0
      src/views/mall/promotion/diy/page/decorate.vue
  32. 167 0
      src/views/mall/promotion/diy/template/decorate.vue
  33. BIN
      src/views/mall/promotion/kefu/components/asserts/daxiao.png
  34. 122 0
      src/views/pay/demo/transfer/DemoTransferForm.vue
  35. 174 0
      src/views/system/dept/DeptForm.vue
  36. 63 0
      src/views/system/user/DeptTree.vue

+ 1 - 0
src/assets/ai/delete.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715354120346" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M907.1 263.7H118.9c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4H907c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3257"></path><path d="M772.5 928.3H257.4c-27.7 0-50.2-22.5-50.2-50.2V247.2c0-9.1 7.3-16.4 16.4-16.4H801c12.1 0 21.9 9.8 21.9 21.9v625.2c0 27.8-22.6 50.4-50.4 50.4zM240 263.7v614.4c0 9.6 7.8 17.4 17.4 17.4h515.2c9.7 0 17.5-7.9 17.5-17.5V263.7H240zM657.4 131.1H368.6c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4h288.7c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3258"></path><path d="M416 754.5c-9.1 0-16.4-7.3-16.4-16.4V517.8c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0.1 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" p-id="3259"></path><path d="M416 465.2c-9.1 0-16.4-7.3-16.4-16.4v-59.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v59.4c0.1 9.1-7.3 16.4-16.4 16.4zM604.9 754.5c-9.1 0-16.4-7.3-16.4-16.4v-67.2c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" opacity=".4" p-id="3260"></path><path d="M604.9 619.1c-9.1 0-16.4-7.3-16.4-16.4V389.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v213.3c0 9.1-7.3 16.4-16.4 16.4z" fill="#8a8a8a" p-id="3261"></path></svg>

+ 228 - 0
src/components/AppLinkInput/data.ts

@@ -0,0 +1,228 @@
+// APP 链接分组
+export interface AppLinkGroup {
+  // 分组名称
+  name: string
+  // 链接列表
+  links: AppLink[]
+}
+// APP 链接
+export interface AppLink {
+  // 链接名称
+  name: string
+  // 链接地址
+  path: string
+  // 链接的类型
+  type?: APP_LINK_TYPE_ENUM
+}
+
+// APP 链接类型(需要特殊处理,例如商品详情)
+export const enum APP_LINK_TYPE_ENUM {
+  // 拼团活动
+  ACTIVITY_COMBINATION,
+  // 秒杀活动
+  ACTIVITY_SECKILL,
+  // 文章详情
+  ARTICLE_DETAIL,
+  // 优惠券详情
+  COUPON_DETAIL,
+  // 自定义页面详情
+  DIY_PAGE_DETAIL,
+  // 品类列表
+  PRODUCT_CATEGORY_LIST,
+  // 商品列表
+  PRODUCT_LIST,
+  // 商品详情
+  PRODUCT_DETAIL_NORMAL,
+  // 拼团商品详情
+  PRODUCT_DETAIL_COMBINATION,
+  // 秒杀商品详情
+  PRODUCT_DETAIL_SECKILL
+}
+
+// APP 链接列表(做一下持久化?)
+export const APP_LINK_GROUP_LIST = [
+  {
+    name: '商城',
+    links: [
+      {
+        name: '首页',
+        path: '/pages/index/index'
+      },
+      {
+        name: '商品分类',
+        path: '/pages/index/category',
+        type: APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST
+      },
+      {
+        name: '购物车',
+        path: '/pages/index/cart'
+      },
+      {
+        name: '个人中心',
+        path: '/pages/index/user'
+      },
+      {
+        name: '商品搜索',
+        path: '/pages/index/search'
+      },
+      {
+        name: '自定义页面',
+        path: '/pages/index/page',
+        type: APP_LINK_TYPE_ENUM.DIY_PAGE_DETAIL
+      },
+      {
+        name: '客服',
+        path: '/pages/chat/index'
+      },
+      {
+        name: '系统设置',
+        path: '/pages/public/setting'
+      },
+      {
+        name: '常见问题',
+        path: '/pages/public/faq'
+      }
+    ]
+  },
+  {
+    name: '商品',
+    links: [
+      {
+        name: '商品列表',
+        path: '/pages/goods/list',
+        type: APP_LINK_TYPE_ENUM.PRODUCT_LIST
+      },
+      {
+        name: '商品详情',
+        path: '/pages/goods/index',
+        type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_NORMAL
+      },
+      {
+        name: '拼团商品详情',
+        path: '/pages/goods/groupon',
+        type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_COMBINATION
+      },
+      {
+        name: '秒杀商品详情',
+        path: '/pages/goods/seckill',
+        type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_SECKILL
+      }
+    ]
+  },
+  {
+    name: '营销活动',
+    links: [
+      {
+        name: '拼团订单',
+        path: '/pages/activity/groupon/order'
+      },
+      {
+        name: '营销商品',
+        path: '/pages/activity/index'
+      },
+      {
+        name: '拼团活动',
+        path: '/pages/activity/groupon/list',
+        type: APP_LINK_TYPE_ENUM.ACTIVITY_COMBINATION
+      },
+      {
+        name: '秒杀活动',
+        path: '/pages/activity/seckill/list',
+        type: APP_LINK_TYPE_ENUM.ACTIVITY_SECKILL
+      },
+      {
+        name: '签到中心',
+        path: '/pages/app/sign'
+      },
+      {
+        name: '优惠券中心',
+        path: '/pages/coupon/list'
+      },
+      {
+        name: '优惠券详情',
+        path: '/pages/coupon/detail',
+        type: APP_LINK_TYPE_ENUM.COUPON_DETAIL
+      },
+      {
+        name: '文章详情',
+        path: '/pages/public/richtext',
+        type: APP_LINK_TYPE_ENUM.ARTICLE_DETAIL
+      }
+    ]
+  },
+  {
+    name: '分销商城',
+    links: [
+      {
+        name: '分销中心',
+        path: '/pages/commission/index'
+      },
+      {
+        name: '推广商品',
+        path: '/pages/commission/goods'
+      },
+      {
+        name: '分销订单',
+        path: '/pages/commission/order'
+      },
+      {
+        name: '我的团队',
+        path: '/pages/commission/team'
+      }
+    ]
+  },
+  {
+    name: '支付',
+    links: [
+      {
+        name: '充值余额',
+        path: '/pages/pay/recharge'
+      },
+      {
+        name: '充值记录',
+        path: '/pages/pay/recharge-log'
+      }
+    ]
+  },
+  {
+    name: '用户中心',
+    links: [
+      {
+        name: '用户信息',
+        path: '/pages/user/info'
+      },
+      {
+        name: '用户订单',
+        path: '/pages/order/list'
+      },
+      {
+        name: '售后订单',
+        path: '/pages/order/aftersale/list'
+      },
+      {
+        name: '商品收藏',
+        path: '/pages/user/goods-collect'
+      },
+      {
+        name: '浏览记录',
+        path: '/pages/user/goods-log'
+      },
+      {
+        name: '地址管理',
+        path: '/pages/user/address/list'
+      },
+      {
+        name: '用户佣金',
+        path: '/pages/user/wallet/commission'
+      },
+      {
+        name: '用户余额',
+        path: '/pages/user/wallet/money'
+      },
+      {
+        name: '用户积分',
+        path: '/pages/user/wallet/score'
+      }
+    ]
+  }
+] as AppLinkGroup[]

+ 167 - 0
src/components/Descriptions/src/Descriptions.vue

@@ -0,0 +1,167 @@
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import dayjs from 'dayjs'
+import { useDesign } from '@/hooks/web/useDesign'
+import { propTypes } from '@/utils/propTypes'
+import { useAppStore } from '@/store/modules/app'
+import { DescriptionsSchema } from '@/types/descriptions'
+
+defineOptions({ name: 'Descriptions' })
+
+const appStore = useAppStore()
+
+const mobile = computed(() => appStore.getMobile)
+
+const attrs = useAttrs()
+
+const slots = useSlots()
+
+const props = defineProps({
+  title: propTypes.string.def(''),
+  message: propTypes.string.def(''),
+  collapse: propTypes.bool.def(true),
+  columns: propTypes.number.def(1),
+  schema: {
+    type: Array as PropType<DescriptionsSchema[]>,
+    default: () => []
+  },
+  data: {
+    type: Object as PropType<any>,
+    default: () => ({})
+  }
+})
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('descriptions')
+
+const getBindValue = computed(() => {
+  const delArr: string[] = ['title', 'message', 'collapse', 'schema', 'data', 'class']
+  const obj = { ...attrs, ...props }
+  for (const key in obj) {
+    if (delArr.indexOf(key) !== -1) {
+      delete obj[key]
+    }
+  }
+  return obj
+})
+
+const getBindItemValue = (item: DescriptionsSchema) => {
+  const delArr: string[] = ['field']
+  const obj = { ...item }
+  for (const key in obj) {
+    if (delArr.indexOf(key) !== -1) {
+      delete obj[key]
+    }
+  }
+  return obj
+}
+
+// 折叠
+const show = ref(true)
+
+const toggleClick = () => {
+  if (props.collapse) {
+    show.value = !unref(show)
+  }
+}
+</script>
+
+<template>
+  <div
+    :class="[
+      prefixCls,
+      'bg-[var(--el-color-white)] dark:bg-[var(--el-bg-color)] dark:border-[var(--el-border-color)] dark:border-1px'
+    ]"
+  >
+    <div
+      v-if="title"
+      :class="[
+        `${prefixCls}-header`,
+        'h-50px flex justify-between items-center b-b-1 border-solid border-[var(--el-border-color)] px-10px cursor-pointer dark:border-[var(--el-border-color)]'
+      ]"
+      @click="toggleClick"
+    >
+      <div :class="[`${prefixCls}-header__title`, 'relative font-18px font-bold ml-10px']">
+        <div class="flex items-center">
+          {{ title }}
+          <ElTooltip v-if="message" :content="message" placement="right">
+            <Icon class="ml-5px" icon="ep:warning" />
+          </ElTooltip>
+        </div>
+      </div>
+      <Icon v-if="collapse" :icon="show ? 'ep:arrow-down' : 'ep:arrow-up'" />
+    </div>
+
+    <ElCollapseTransition>
+      <div v-show="show" :class="[`${prefixCls}-content`, 'p-10px']">
+        <ElDescriptions
+          :column="props.columns"
+          :direction="mobile ? 'vertical' : 'horizontal'"
+          border
+          v-bind="getBindValue"
+        >
+          <template v-if="slots['extra']" #extra>
+            <slot name="extra"></slot>
+          </template>
+          <ElDescriptionsItem
+            v-for="item in schema"
+            :key="item.field"
+            min-width="80"
+            v-bind="getBindItemValue(item)"
+          >
+            <template #label>
+              <slot
+                :name="`${item.field}-label`"
+                :row="{
+                  label: item.label
+                }"
+                >{{ item.label }}
+              </slot>
+            </template>
+
+            <template #default>
+              <slot v-if="item.dateFormat">
+                {{
+                  data[item.field] !== null ? dayjs(data[item.field]).format(item.dateFormat) : ''
+                }}
+              </slot>
+              <slot v-else-if="item.dictType">
+                <DictTag :type="item.dictType" :value="data[item.field] + ''" />
+              </slot>
+              <slot v-else :name="item.field" :row="data">
+                {{
+                    item.mappedField ? data[item.mappedField] : data[item.field]
+                }}
+              </slot>
+            </template>
+          </ElDescriptionsItem>
+        </ElDescriptions>
+      </div>
+    </ElCollapseTransition>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-descriptions;
+
+.#{$prefix-cls}-header {
+  &__title {
+    &::after {
+      position: absolute;
+      top: 3px;
+      left: -10px;
+      width: 4px;
+      height: 70%;
+      background: var(--el-color-primary);
+      content: '';
+    }
+  }
+}
+
+.#{$prefix-cls}-content {
+  :deep(.#{$elNamespace}-descriptions__cell) {
+    width: 0;
+  }
+}
+</style>

+ 29 - 0
src/components/Descriptions/src/DescriptionsItemLabel.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+const { label } = defineProps({
+  label: {
+    type: String,
+    required: true
+  },
+  icon: {
+    type: String,
+    required: false
+  }
+})
+</script>
+
+<template>
+  <div class="cell-item">
+    <Icon :icon="icon" v-if="icon" style="vertical-align: middle" :size="18" />
+    {{ label }}
+  </div>
+</template>
+
+<style scoped lang="scss">
+.cell-item {
+  display: inline;
+}
+
+.cell-item::after {
+  content: ':';
+}
+</style>

+ 140 - 0
src/components/Dialog/src/Dialog.vue

@@ -0,0 +1,140 @@
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { isNumber } from '@/utils/is'
+defineOptions({ name: 'Dialog' })
+
+const slots = useSlots()
+
+const props = defineProps({
+  modelValue: propTypes.bool.def(false),
+  title: propTypes.string.def('Dialog'),
+  fullscreen: propTypes.bool.def(true),
+  width: propTypes.oneOfType([String, Number]).def('40%'),
+  scroll: propTypes.bool.def(false), // 是否开启滚动条。如果是的话,按照 maxHeight 设置最大高度
+  maxHeight: propTypes.oneOfType([String, Number]).def('400px')
+})
+
+const getBindValue = computed(() => {
+  const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody']
+  const attrs = useAttrs()
+  const obj = { ...attrs, ...props }
+  for (const key in obj) {
+    if (delArr.indexOf(key) !== -1) {
+      delete obj[key]
+    }
+  }
+  return obj
+})
+
+const isFullscreen = ref(false)
+
+const toggleFull = () => {
+  isFullscreen.value = !unref(isFullscreen)
+}
+
+const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight)
+
+watch(
+  () => isFullscreen.value,
+  async (val: boolean) => {
+    await nextTick()
+    if (val) {
+      const windowHeight = document.documentElement.offsetHeight
+      dialogHeight.value = `${windowHeight - 55 - 60 - (slots.footer ? 63 : 0)}px`
+    } else {
+      dialogHeight.value = isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight
+    }
+  },
+  {
+    immediate: true
+  }
+)
+
+const dialogStyle = computed(() => {
+  return {
+    height: unref(dialogHeight)
+  }
+})
+</script>
+
+<template>
+  <ElDialog
+    v-bind="getBindValue"
+    :close-on-click-modal="true"
+    :fullscreen="isFullscreen"
+    :width="width"
+    destroy-on-close
+    lock-scroll
+    draggable
+    class="com-dialog"
+    :show-close="false"
+  >
+    <template #header="{ close }">
+      <div class="relative h-54px flex items-center justify-between pl-15px pr-15px">
+        <slot name="title">
+          {{ title }}
+        </slot>
+        <div
+          class="absolute right-15px top-[50%] h-54px flex translate-y-[-50%] items-center justify-between"
+        >
+          <Icon
+            v-if="fullscreen"
+            class="is-hover mr-10px cursor-pointer"
+            :icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'"
+            color="var(--el-color-info)"
+            hover-color="var(--el-color-primary)"
+            @click="toggleFull"
+          />
+          <Icon
+            class="is-hover cursor-pointer"
+            icon="ep:close"
+            hover-color="var(--el-color-primary)"
+            color="var(--el-color-info)"
+            @click="close"
+          />
+        </div>
+      </div>
+    </template>
+
+    <ElScrollbar v-if="scroll" :style="dialogStyle">
+      <slot></slot>
+    </ElScrollbar>
+    <slot v-else></slot>
+    <template v-if="slots.footer" #footer>
+      <slot name="footer"></slot>
+    </template>
+  </ElDialog>
+</template>
+
+<style lang="scss">
+.com-dialog {
+  .#{$elNamespace}-overlay-dialog {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .#{$elNamespace}-dialog {
+    margin: 0 !important;
+
+    &__header {
+      height: 54px;
+      padding: 0;
+      margin-right: 0 !important;
+      border-bottom: 1px solid var(--el-border-color);
+    }
+
+    &__body {
+      padding: 15px !important;
+    }
+
+    &__footer {
+      border-top: 1px solid var(--el-border-color);
+    }
+
+    &__headerbtn {
+      top: 0;
+    }
+  }
+}
+</style>

+ 1961 - 0
src/components/Icon/src/data.ts

@@ -0,0 +1,1961 @@
+export const IconJson = {
+  'ep:': [
+    'add-location',
+    'aim',
+    'alarm-clock',
+    'apple',
+    'arrow-down',
+    'arrow-down-bold',
+    'arrow-left',
+    'arrow-left-bold',
+    'arrow-right',
+    'arrow-right-bold',
+    'arrow-up',
+    'arrow-up-bold',
+    'avatar',
+    'back',
+    'baseball',
+    'basketball',
+    'bell',
+    'bell-filled',
+    'bicycle',
+    'bottom',
+    'bottom-left',
+    'bottom-right',
+    'bowl',
+    'box',
+    'briefcase',
+    'brush',
+    'brush-filled',
+    'burger',
+    'calendar',
+    'camera',
+    'camera-filled',
+    'caret-bottom',
+    'caret-left',
+    'caret-right',
+    'caret-top',
+    'cellphone',
+    'chat-dot-round',
+    'chat-dot-square',
+    'chat-line-round',
+    'chat-line-square',
+    'chat-round',
+    'chat-square',
+    'check',
+    'checked',
+    'cherry',
+    'chicken',
+    'circle-check',
+    'circle-check-filled',
+    'circle-close',
+    'circle-close-filled',
+    'circle-plus',
+    'circle-plus-filled',
+    'clock',
+    'close',
+    'close-bold',
+    'cloudy',
+    'coffee',
+    'coffee-cup',
+    'coin',
+    'cold-drink',
+    'collection',
+    'collection-tag',
+    'comment',
+    'compass',
+    'connection',
+    'coordinate',
+    'copy-document',
+    'cpu',
+    'credit-card',
+    'crop',
+    'd-arrow-left',
+    'd-arrow-right',
+    'd-caret',
+    'data-analysis',
+    'data-board',
+    'data-line',
+    'delete',
+    'delete-filled',
+    'delete-location',
+    'dessert',
+    'discount',
+    'dish',
+    'dish-dot',
+    'document',
+    'document-add',
+    'document-checked',
+    'document-copy',
+    'document-delete',
+    'document-remove',
+    'download',
+    'drizzling',
+    'edit',
+    'edit-pen',
+    'eleme',
+    'eleme-filled',
+    'expand',
+    'failed',
+    'female',
+    'files',
+    'film',
+    'filter',
+    'finished',
+    'first-aid-kit',
+    'flag',
+    'fold',
+    'folder',
+    'folder-add',
+    'folder-checked',
+    'folder-delete',
+    'folder-opened',
+    'folder-remove',
+    'food',
+    'football',
+    'fork-spoon',
+    'fries',
+    'full-screen',
+    'goblet',
+    'goblet-full',
+    'goblet-square',
+    'goblet-square-full',
+    'goods',
+    'goods-filled',
+    'grape',
+    'grid',
+    'guide',
+    'headset',
+    'help',
+    'help-filled',
+    'histogram',
+    'home-filled',
+    'hot-water',
+    'house',
+    'ice-cream',
+    'ice-cream-round',
+    'ice-cream-square',
+    'ice-drink',
+    'ice-tea',
+    'info-filled',
+    'iphone',
+    'key',
+    'knife-fork',
+    'lightning',
+    'link',
+    'list',
+    'loading',
+    'location',
+    'location-filled',
+    'location-information',
+    'lock',
+    'lollipop',
+    'magic-stick',
+    'magnet',
+    'male',
+    'management',
+    'map-location',
+    'medal',
+    'menu',
+    'message',
+    'message-box',
+    'mic',
+    'microphone',
+    'milk-tea',
+    'minus',
+    'money',
+    'monitor',
+    'moon',
+    'moon-night',
+    'more',
+    'more-filled',
+    'mostly-cloudy',
+    'mouse',
+    'mug',
+    'mute',
+    'mute-notification',
+    'no-smoking',
+    'notebook',
+    'notification',
+    'odometer',
+    'office-building',
+    'open',
+    'operation',
+    'opportunity',
+    'orange',
+    'paperclip',
+    'partly-cloudy',
+    'pear',
+    'phone',
+    'phone-filled',
+    'picture',
+    'picture-filled',
+    'picture-rounded',
+    'pie-chart',
+    'place',
+    'platform',
+    'plus',
+    'pointer',
+    'position',
+    'postcard',
+    'pouring',
+    'present',
+    'price-tag',
+    'printer',
+    'promotion',
+    'question-filled',
+    'rank',
+    'reading',
+    'reading-lamp',
+    'refresh',
+    'refresh-left',
+    'refresh-right',
+    'refrigerator',
+    'remove',
+    'remove-filled',
+    'right',
+    'scale-to-original',
+    'school',
+    'scissor',
+    'search',
+    'select',
+    'sell',
+    'semi-select',
+    'service',
+    'set-up',
+    'setting',
+    'share',
+    'ship',
+    'shop',
+    'shopping-bag',
+    'shopping-cart',
+    'shopping-cart-full',
+    'smoking',
+    'soccer',
+    'sold-out',
+    'sort',
+    'sort-down',
+    'sort-up',
+    'stamp',
+    'star',
+    'star-filled',
+    'stopwatch',
+    'success-filled',
+    'sugar',
+    'suitcase',
+    'sunny',
+    'sunrise',
+    'sunset',
+    'switch',
+    'switch-button',
+    'takeaway-box',
+    'ticket',
+    'tickets',
+    'timer',
+    'toilet-paper',
+    'tools',
+    'top',
+    'top-left',
+    'top-right',
+    'trend-charts',
+    'trophy',
+    'turn-off',
+    'umbrella',
+    'unlock',
+    'upload',
+    'upload-filled',
+    'user',
+    'user-filled',
+    'van',
+    'video-camera',
+    'video-camera-filled',
+    'video-pause',
+    'video-play',
+    'view',
+    'wallet',
+    'wallet-filled',
+    'warning',
+    'warning-filled',
+    'watch',
+    'watermelon',
+    'wind-power',
+    'zoom-in',
+    'zoom-out'
+  ],
+  'fa:': [
+    '500px',
+    'address-book',
+    'address-book-o',
+    'address-card',
+    'address-card-o',
+    'adjust',
+    'adn',
+    'align-center',
+    'align-justify',
+    'align-left',
+    'amazon',
+    'ambulance',
+    'american-sign-language-interpreting',
+    'anchor',
+    'android',
+    'angellist',
+    'angle-double-left',
+    'angle-double-up',
+    'angle-down',
+    'angle-left',
+    'angle-up',
+    'apple',
+    'archive',
+    'area-chart',
+    'arrow-circle-left',
+    'arrow-circle-o-left',
+    'arrow-circle-o-up',
+    'arrow-circle-up',
+    'arrow-left',
+    'arrow-up',
+    'arrows',
+    'arrows-alt',
+    'arrows-h',
+    'arrows-v',
+    'assistive-listening-systems',
+    'asterisk',
+    'at',
+    'audio-description',
+    'automobile',
+    'backward',
+    'balance-scale',
+    'ban',
+    'bandcamp',
+    'bank',
+    'bar-chart',
+    'barcode',
+    'bars',
+    'bath',
+    'battery',
+    'battery-0',
+    'battery-1',
+    'battery-2',
+    'battery-3',
+    'bed',
+    'beer',
+    'behance',
+    'behance-square',
+    'bell',
+    'bell-o',
+    'bell-slash',
+    'bell-slash-o',
+    'bicycle',
+    'binoculars',
+    'birthday-cake',
+    'bitbucket',
+    'bitbucket-square',
+    'bitcoin',
+    'black-tie',
+    'blind',
+    'bluetooth',
+    'bluetooth-b',
+    'bold',
+    'bolt',
+    'bomb',
+    'book',
+    'bookmark',
+    'bookmark-o',
+    'braille',
+    'briefcase',
+    'bug',
+    'building',
+    'building-o',
+    'bullhorn',
+    'bullseye',
+    'bus',
+    'buysellads',
+    'cab',
+    'calculator',
+    'calendar',
+    'calendar-check-o',
+    'calendar-minus-o',
+    'calendar-o',
+    'calendar-plus-o',
+    'calendar-times-o',
+    'camera',
+    'camera-retro',
+    'caret-down',
+    'caret-left',
+    'caret-square-o-left',
+    'caret-square-o-up',
+    'caret-up',
+    'cart-arrow-down',
+    'cart-plus',
+    'cc',
+    'cc-amex',
+    'cc-diners-club',
+    'cc-discover',
+    'cc-jcb',
+    'cc-mastercard',
+    'cc-paypal',
+    'cc-stripe',
+    'cc-visa',
+    'certificate',
+    'chain',
+    'chain-broken',
+    'check',
+    'check-circle',
+    'check-circle-o',
+    'check-square',
+    'check-square-o',
+    'chevron-circle-left',
+    'chevron-circle-up',
+    'chevron-down',
+    'chevron-left',
+    'chevron-up',
+    'child',
+    'chrome',
+    'circle',
+    'circle-o',
+    'circle-o-notch',
+    'circle-thin',
+    'clipboard',
+    'clock-o',
+    'clone',
+    'close',
+    'cloud',
+    'cloud-download',
+    'cloud-upload',
+    'cny',
+    'code',
+    'code-fork',
+    'codepen',
+    'codiepie',
+    'coffee',
+    'cog',
+    'cogs',
+    'columns',
+    'comment',
+    'comment-o',
+    'commenting',
+    'commenting-o',
+    'comments',
+    'comments-o',
+    'compass',
+    'compress',
+    'connectdevelop',
+    'contao',
+    'copy',
+    'copyright',
+    'creative-commons',
+    'credit-card',
+    'credit-card-alt',
+    'crop',
+    'crosshairs',
+    'css3',
+    'cube',
+    'cubes',
+    'cut',
+    'cutlery',
+    'dashboard',
+    'dashcube',
+    'database',
+    'deaf',
+    'dedent',
+    'delicious',
+    'desktop',
+    'deviantart',
+    'diamond',
+    'digg',
+    'dollar',
+    'dot-circle-o',
+    'download',
+    'dribbble',
+    'drivers-license',
+    'drivers-license-o',
+    'dropbox',
+    'drupal',
+    'edge',
+    'edit',
+    'eercast',
+    'eject',
+    'ellipsis-h',
+    'ellipsis-v',
+    'empire',
+    'envelope',
+    'envelope-o',
+    'envelope-open',
+    'envelope-open-o',
+    'envelope-square',
+    'envira',
+    'eraser',
+    'etsy',
+    'eur',
+    'exchange',
+    'exclamation',
+    'exclamation-circle',
+    'exclamation-triangle',
+    'expand',
+    'expeditedssl',
+    'external-link',
+    'external-link-square',
+    'eye',
+    'eye-slash',
+    'eyedropper',
+    'fa',
+    'facebook',
+    'facebook-official',
+    'facebook-square',
+    'fast-backward',
+    'fax',
+    'feed',
+    'female',
+    'fighter-jet',
+    'file',
+    'file-archive-o',
+    'file-audio-o',
+    'file-code-o',
+    'file-excel-o',
+    'file-image-o',
+    'file-movie-o',
+    'file-o',
+    'file-pdf-o',
+    'file-powerpoint-o',
+    'file-text',
+    'file-text-o',
+    'file-word-o',
+    'film',
+    'filter',
+    'fire',
+    'fire-extinguisher',
+    'firefox',
+    'first-order',
+    'flag',
+    'flag-checkered',
+    'flag-o',
+    'flask',
+    'flickr',
+    'floppy-o',
+    'folder',
+    'folder-o',
+    'folder-open',
+    'folder-open-o',
+    'font',
+    'fonticons',
+    'fort-awesome',
+    'forumbee',
+    'foursquare',
+    'free-code-camp',
+    'frown-o',
+    'futbol-o',
+    'gamepad',
+    'gavel',
+    'gbp',
+    'genderless',
+    'get-pocket',
+    'gg',
+    'gg-circle',
+    'gift',
+    'git',
+    'git-square',
+    'github',
+    'github-alt',
+    'github-square',
+    'gitlab',
+    'gittip',
+    'glass',
+    'glide',
+    'glide-g',
+    'globe',
+    'google',
+    'google-plus',
+    'google-plus-circle',
+    'google-plus-square',
+    'google-wallet',
+    'graduation-cap',
+    'grav',
+    'group',
+    'h-square',
+    'hacker-news',
+    'hand-grab-o',
+    'hand-lizard-o',
+    'hand-o-left',
+    'hand-o-up',
+    'hand-paper-o',
+    'hand-peace-o',
+    'hand-pointer-o',
+    'hand-scissors-o',
+    'hand-spock-o',
+    'handshake-o',
+    'hashtag',
+    'hdd-o',
+    'header',
+    'headphones',
+    'heart',
+    'heart-o',
+    'heartbeat',
+    'history',
+    'home',
+    'hospital-o',
+    'hourglass',
+    'hourglass-1',
+    'hourglass-2',
+    'hourglass-3',
+    'hourglass-o',
+    'houzz',
+    'html5',
+    'i-cursor',
+    'id-badge',
+    'ils',
+    'image',
+    'imdb',
+    'inbox',
+    'indent',
+    'industry',
+    'info',
+    'info-circle',
+    'inr',
+    'instagram',
+    'internet-explorer',
+    'intersex',
+    'ioxhost',
+    'italic',
+    'joomla',
+    'jsfiddle',
+    'key',
+    'keyboard-o',
+    'krw',
+    'language',
+    'laptop',
+    'lastfm',
+    'lastfm-square',
+    'leaf',
+    'leanpub',
+    'lemon-o',
+    'level-up',
+    'life-bouy',
+    'lightbulb-o',
+    'line-chart',
+    'linkedin',
+    'linkedin-square',
+    'linode',
+    'linux',
+    'list',
+    'list-alt',
+    'list-ol',
+    'list-ul',
+    'location-arrow',
+    'lock',
+    'long-arrow-left',
+    'long-arrow-up',
+    'low-vision',
+    'magic',
+    'magnet',
+    'mail-forward',
+    'mail-reply',
+    'mail-reply-all',
+    'male',
+    'map',
+    'map-marker',
+    'map-o',
+    'map-pin',
+    'map-signs',
+    'mars',
+    'mars-double',
+    'mars-stroke',
+    'mars-stroke-h',
+    'mars-stroke-v',
+    'maxcdn',
+    'meanpath',
+    'medium',
+    'medkit',
+    'meetup',
+    'meh-o',
+    'mercury',
+    'microchip',
+    'microphone',
+    'microphone-slash',
+    'minus',
+    'minus-circle',
+    'minus-square',
+    'minus-square-o',
+    'mixcloud',
+    'mobile',
+    'modx',
+    'money',
+    'moon-o',
+    'motorcycle',
+    'mouse-pointer',
+    'music',
+    'neuter',
+    'newspaper-o',
+    'object-group',
+    'object-ungroup',
+    'odnoklassniki',
+    'odnoklassniki-square',
+    'opencart',
+    'openid',
+    'opera',
+    'optin-monster',
+    'pagelines',
+    'paint-brush',
+    'paper-plane',
+    'paper-plane-o',
+    'paperclip',
+    'paragraph',
+    'pause',
+    'pause-circle',
+    'pause-circle-o',
+    'paw',
+    'paypal',
+    'pencil',
+    'pencil-square',
+    'percent',
+    'phone',
+    'phone-square',
+    'pie-chart',
+    'pied-piper',
+    'pied-piper-alt',
+    'pied-piper-pp',
+    'pinterest',
+    'pinterest-p',
+    'pinterest-square',
+    'plane',
+    'play',
+    'play-circle',
+    'play-circle-o',
+    'plug',
+    'plus',
+    'plus-circle',
+    'plus-square',
+    'plus-square-o',
+    'podcast',
+    'power-off',
+    'print',
+    'product-hunt',
+    'puzzle-piece',
+    'qq',
+    'qrcode',
+    'question',
+    'question-circle',
+    'question-circle-o',
+    'quora',
+    'quote-left',
+    'quote-right',
+    'ra',
+    'random',
+    'ravelry',
+    'recycle',
+    'reddit',
+    'reddit-alien',
+    'reddit-square',
+    'refresh',
+    'registered',
+    'renren',
+    'repeat',
+    'retweet',
+    'road',
+    'rocket',
+    'rotate-left',
+    'rouble',
+    'rss-square',
+    'safari',
+    'scribd',
+    'search',
+    'search-minus',
+    'search-plus',
+    'sellsy',
+    'server',
+    'share-alt',
+    'share-alt-square',
+    'share-square',
+    'share-square-o',
+    'shield',
+    'ship',
+    'shirtsinbulk',
+    'shopping-bag',
+    'shopping-basket',
+    'shopping-cart',
+    'shower',
+    'sign-in',
+    'sign-language',
+    'sign-out',
+    'signal',
+    'simplybuilt',
+    'sitemap',
+    'skyatlas',
+    'skype',
+    'slack',
+    'sliders',
+    'slideshare',
+    'smile-o',
+    'snapchat',
+    'snapchat-ghost',
+    'snapchat-square',
+    'snowflake-o',
+    'sort',
+    'sort-alpha-asc',
+    'sort-alpha-desc',
+    'sort-amount-asc',
+    'sort-amount-desc',
+    'sort-asc',
+    'sort-numeric-asc',
+    'sort-numeric-desc',
+    'soundcloud',
+    'space-shuttle',
+    'spinner',
+    'spoon',
+    'spotify',
+    'square',
+    'square-o',
+    'stack-exchange',
+    'stack-overflow',
+    'star',
+    'star-half',
+    'star-half-empty',
+    'star-o',
+    'steam',
+    'steam-square',
+    'step-backward',
+    'stethoscope',
+    'sticky-note',
+    'sticky-note-o',
+    'stop',
+    'stop-circle',
+    'stop-circle-o',
+    'street-view',
+    'strikethrough',
+    'stumbleupon',
+    'stumbleupon-circle',
+    'subscript',
+    'subway',
+    'suitcase',
+    'sun-o',
+    'superpowers',
+    'superscript',
+    'table',
+    'tablet',
+    'tag',
+    'tags',
+    'tasks',
+    'telegram',
+    'television',
+    'tencent-weibo',
+    'terminal',
+    'text-height',
+    'text-width',
+    'th',
+    'th-large',
+    'th-list',
+    'themeisle',
+    'thermometer',
+    'thermometer-0',
+    'thermometer-1',
+    'thermometer-2',
+    'thermometer-3',
+    'thumb-tack',
+    'thumbs-down',
+    'thumbs-o-up',
+    'thumbs-up',
+    'ticket',
+    'times-circle',
+    'times-circle-o',
+    'times-rectangle',
+    'times-rectangle-o',
+    'tint',
+    'toggle-off',
+    'toggle-on',
+    'trademark',
+    'train',
+    'transgender-alt',
+    'trash',
+    'trash-o',
+    'tree',
+    'trello',
+    'tripadvisor',
+    'trophy',
+    'truck',
+    'try',
+    'tty',
+    'tumblr',
+    'tumblr-square',
+    'twitch',
+    'twitter',
+    'twitter-square',
+    'umbrella',
+    'underline',
+    'universal-access',
+    'unlock',
+    'unlock-alt',
+    'upload',
+    'usb',
+    'user',
+    'user-circle',
+    'user-circle-o',
+    'user-md',
+    'user-o',
+    'user-plus',
+    'user-secret',
+    'user-times',
+    'venus',
+    'venus-double',
+    'venus-mars',
+    'viacoin',
+    'viadeo',
+    'viadeo-square',
+    'video-camera',
+    'vimeo',
+    'vimeo-square',
+    'vine',
+    'vk',
+    'volume-control-phone',
+    'volume-down',
+    'volume-off',
+    'volume-up',
+    'wechat',
+    'weibo',
+    'whatsapp',
+    'wheelchair',
+    'wheelchair-alt',
+    'wifi',
+    'wikipedia-w',
+    'window-maximize',
+    'window-minimize',
+    'window-restore',
+    'windows',
+    'wordpress',
+    'wpbeginner',
+    'wpexplorer',
+    'wpforms',
+    'wrench',
+    'xing',
+    'xing-square',
+    'y-combinator',
+    'yahoo',
+    'yelp',
+    'yoast',
+    'youtube',
+    'youtube-play',
+    'youtube-square'
+  ],
+  'fa-solid:': [
+    'abacus',
+    'ad',
+    'address-book',
+    'address-card',
+    'adjust',
+    'air-freshener',
+    'align-center',
+    'align-justify',
+    'align-left',
+    'align-right',
+    'allergies',
+    'ambulance',
+    'american-sign-language-interpreting',
+    'anchor',
+    'angle-double-down',
+    'angle-double-left',
+    'angle-double-right',
+    'angle-double-up',
+    'angle-down',
+    'angle-left',
+    'angle-right',
+    'angle-up',
+    'angry',
+    'ankh',
+    'apple-alt',
+    'archive',
+    'archway',
+    'arrow-alt-circle-down',
+    'arrow-alt-circle-left',
+    'arrow-alt-circle-right',
+    'arrow-alt-circle-up',
+    'arrow-circle-down',
+    'arrow-circle-left',
+    'arrow-circle-right',
+    'arrow-circle-up',
+    'arrow-down',
+    'arrow-left',
+    'arrow-right',
+    'arrow-up',
+    'arrows-alt',
+    'arrows-alt-h',
+    'arrows-alt-v',
+    'assistive-listening-systems',
+    'asterisk',
+    'at',
+    'atlas',
+    'atom',
+    'audio-description',
+    'award',
+    'baby',
+    'baby-carriage',
+    'backspace',
+    'backward',
+    'bacon',
+    'bacteria',
+    'bacterium',
+    'bahai',
+    'balance-scale',
+    'balance-scale-left',
+    'balance-scale-right',
+    'ban',
+    'band-aid',
+    'barcode',
+    'bars',
+    'baseball-ball',
+    'basketball-ball',
+    'bath',
+    'battery-empty',
+    'battery-full',
+    'battery-half',
+    'battery-quarter',
+    'battery-three-quarters',
+    'bed',
+    'beer',
+    'bell',
+    'bell-slash',
+    'bezier-curve',
+    'bible',
+    'bicycle',
+    'biking',
+    'binoculars',
+    'biohazard',
+    'birthday-cake',
+    'blender',
+    'blender-phone',
+    'blind',
+    'blog',
+    'bold',
+    'bolt',
+    'bomb',
+    'bone',
+    'bong',
+    'book',
+    'book-dead',
+    'book-medical',
+    'book-open',
+    'book-reader',
+    'bookmark',
+    'border-all',
+    'border-none',
+    'border-style',
+    'bowling-ball',
+    'box',
+    'box-open',
+    'box-tissue',
+    'boxes',
+    'braille',
+    'brain',
+    'bread-slice',
+    'briefcase',
+    'briefcase-medical',
+    'broadcast-tower',
+    'broom',
+    'brush',
+    'bug',
+    'building',
+    'bullhorn',
+    'bullseye',
+    'burn',
+    'bus',
+    'bus-alt',
+    'business-time',
+    'calculator',
+    'calculator-alt',
+    'calendar',
+    'calendar-alt',
+    'calendar-check',
+    'calendar-day',
+    'calendar-minus',
+    'calendar-plus',
+    'calendar-times',
+    'calendar-week',
+    'camera',
+    'camera-retro',
+    'campground',
+    'candy-cane',
+    'cannabis',
+    'capsules',
+    'car',
+    'car-alt',
+    'car-battery',
+    'car-crash',
+    'car-side',
+    'caravan',
+    'caret-down',
+    'caret-left',
+    'caret-right',
+    'caret-square-down',
+    'caret-square-left',
+    'caret-square-right',
+    'caret-square-up',
+    'caret-up',
+    'carrot',
+    'cart-arrow-down',
+    'cart-plus',
+    'cash-register',
+    'cat',
+    'certificate',
+    'chair',
+    'chalkboard',
+    'chalkboard-teacher',
+    'charging-station',
+    'chart-area',
+    'chart-bar',
+    'chart-line',
+    'chart-pie',
+    'check',
+    'check-circle',
+    'check-double',
+    'check-square',
+    'cheese',
+    'chess',
+    'chess-bishop',
+    'chess-board',
+    'chess-king',
+    'chess-knight',
+    'chess-pawn',
+    'chess-queen',
+    'chess-rook',
+    'chevron-circle-down',
+    'chevron-circle-left',
+    'chevron-circle-right',
+    'chevron-circle-up',
+    'chevron-down',
+    'chevron-left',
+    'chevron-right',
+    'chevron-up',
+    'child',
+    'church',
+    'circle',
+    'circle-notch',
+    'city',
+    'clinic-medical',
+    'clipboard',
+    'clipboard-check',
+    'clipboard-list',
+    'clock',
+    'clone',
+    'closed-captioning',
+    'cloud',
+    'cloud-download-alt',
+    'cloud-meatball',
+    'cloud-moon',
+    'cloud-moon-rain',
+    'cloud-rain',
+    'cloud-showers-heavy',
+    'cloud-sun',
+    'cloud-sun-rain',
+    'cloud-upload-alt',
+    'cocktail',
+    'code',
+    'code-branch',
+    'coffee',
+    'cog',
+    'cogs',
+    'coins',
+    'columns',
+    'comment',
+    'comment-alt',
+    'comment-dollar',
+    'comment-dots',
+    'comment-medical',
+    'comment-slash',
+    'comments',
+    'comments-dollar',
+    'compact-disc',
+    'compass',
+    'compress',
+    'compress-alt',
+    'compress-arrows-alt',
+    'concierge-bell',
+    'cookie',
+    'cookie-bite',
+    'copy',
+    'copyright',
+    'couch',
+    'credit-card',
+    'crop',
+    'crop-alt',
+    'cross',
+    'crosshairs',
+    'crow',
+    'crown',
+    'crutch',
+    'cube',
+    'cubes',
+    'cut',
+    'database',
+    'deaf',
+    'democrat',
+    'desktop',
+    'dharmachakra',
+    'diagnoses',
+    'dice',
+    'dice-d20',
+    'dice-d6',
+    'dice-five',
+    'dice-four',
+    'dice-one',
+    'dice-six',
+    'dice-three',
+    'dice-two',
+    'digital-tachograph',
+    'directions',
+    'disease',
+    'divide',
+    'dizzy',
+    'dna',
+    'dog',
+    'dollar-sign',
+    'dolly',
+    'dolly-flatbed',
+    'donate',
+    'door-closed',
+    'door-open',
+    'dot-circle',
+    'dove',
+    'download',
+    'drafting-compass',
+    'dragon',
+    'draw-polygon',
+    'drum',
+    'drum-steelpan',
+    'drumstick-bite',
+    'dumbbell',
+    'dumpster',
+    'dumpster-fire',
+    'dungeon',
+    'edit',
+    'egg',
+    'eject',
+    'ellipsis-h',
+    'ellipsis-v',
+    'empty-set',
+    'envelope',
+    'envelope-open',
+    'envelope-open-text',
+    'envelope-square',
+    'equals',
+    'eraser',
+    'ethernet',
+    'euro-sign',
+    'exchange-alt',
+    'exclamation',
+    'exclamation-circle',
+    'exclamation-triangle',
+    'expand',
+    'expand-alt',
+    'expand-arrows-alt',
+    'external-link-alt',
+    'external-link-square-alt',
+    'eye',
+    'eye-dropper',
+    'eye-slash',
+    'fan',
+    'fast-backward',
+    'fast-forward',
+    'faucet',
+    'fax',
+    'feather',
+    'feather-alt',
+    'female',
+    'fighter-jet',
+    'file',
+    'file-alt',
+    'file-archive',
+    'file-audio',
+    'file-code',
+    'file-contract',
+    'file-csv',
+    'file-download',
+    'file-excel',
+    'file-export',
+    'file-image',
+    'file-import',
+    'file-invoice',
+    'file-invoice-dollar',
+    'file-medical',
+    'file-medical-alt',
+    'file-pdf',
+    'file-powerpoint',
+    'file-prescription',
+    'file-signature',
+    'file-upload',
+    'file-video',
+    'file-word',
+    'fill',
+    'fill-drip',
+    'film',
+    'filter',
+    'fingerprint',
+    'fire',
+    'fire-alt',
+    'fire-extinguisher',
+    'first-aid',
+    'fish',
+    'fist-raised',
+    'flag',
+    'flag-checkered',
+    'flag-usa',
+    'flask',
+    'flushed',
+    'folder',
+    'folder-minus',
+    'folder-open',
+    'folder-plus',
+    'font',
+    'football-ball',
+    'forward',
+    'frog',
+    'frown',
+    'frown-open',
+    'function',
+    'funnel-dollar',
+    'futbol',
+    'gamepad',
+    'gas-pump',
+    'gavel',
+    'gem',
+    'genderless',
+    'ghost',
+    'gift',
+    'gifts',
+    'glass-cheers',
+    'glass-martini',
+    'glass-martini-alt',
+    'glass-whiskey',
+    'glasses',
+    'globe',
+    'globe-africa',
+    'globe-americas',
+    'globe-asia',
+    'globe-europe',
+    'golf-ball',
+    'gopuram',
+    'graduation-cap',
+    'greater-than',
+    'greater-than-equal',
+    'grimace',
+    'grin',
+    'grin-alt',
+    'grin-beam',
+    'grin-beam-sweat',
+    'grin-hearts',
+    'grin-squint',
+    'grin-squint-tears',
+    'grin-stars',
+    'grin-tears',
+    'grin-tongue',
+    'grin-tongue-squint',
+    'grin-tongue-wink',
+    'grin-wink',
+    'grip-horizontal',
+    'grip-lines',
+    'grip-lines-vertical',
+    'grip-vertical',
+    'guitar',
+    'h-square',
+    'hamburger',
+    'hammer',
+    'hamsa',
+    'hand-holding',
+    'hand-holding-heart',
+    'hand-holding-medical',
+    'hand-holding-usd',
+    'hand-holding-water',
+    'hand-lizard',
+    'hand-middle-finger',
+    'hand-paper',
+    'hand-peace',
+    'hand-point-down',
+    'hand-point-left',
+    'hand-point-right',
+    'hand-point-up',
+    'hand-pointer',
+    'hand-rock',
+    'hand-scissors',
+    'hand-sparkles',
+    'hand-spock',
+    'hands',
+    'hands-helping',
+    'hands-wash',
+    'handshake',
+    'handshake-alt-slash',
+    'handshake-slash',
+    'hanukiah',
+    'hard-hat',
+    'hashtag',
+    'hat-cowboy',
+    'hat-cowboy-side',
+    'hat-wizard',
+    'hdd',
+    'head-side-cough',
+    'head-side-cough-slash',
+    'head-side-mask',
+    'head-side-virus',
+    'heading',
+    'headphones',
+    'headphones-alt',
+    'headset',
+    'heart',
+    'heart-broken',
+    'heartbeat',
+    'helicopter',
+    'highlighter',
+    'hiking',
+    'hippo',
+    'history',
+    'hockey-puck',
+    'holly-berry',
+    'home',
+    'horse',
+    'horse-head',
+    'hospital',
+    'hospital-alt',
+    'hospital-symbol',
+    'hospital-user',
+    'hot-tub',
+    'hotdog',
+    'hotel',
+    'hourglass',
+    'hourglass-end',
+    'hourglass-half',
+    'hourglass-start',
+    'house-damage',
+    'house-user',
+    'hryvnia',
+    'i-cursor',
+    'ice-cream',
+    'icicles',
+    'icons',
+    'id-badge',
+    'id-card',
+    'id-card-alt',
+    'igloo',
+    'image',
+    'images',
+    'inbox',
+    'indent',
+    'industry',
+    'infinity',
+    'info',
+    'info-circle',
+    'integral',
+    'intersection',
+    'italic',
+    'jedi',
+    'joint',
+    'journal-whills',
+    'kaaba',
+    'key',
+    'keyboard',
+    'khanda',
+    'kiss',
+    'kiss-beam',
+    'kiss-wink-heart',
+    'kiwi-bird',
+    'lambda',
+    'landmark',
+    'language',
+    'laptop',
+    'laptop-code',
+    'laptop-house',
+    'laptop-medical',
+    'laugh',
+    'laugh-beam',
+    'laugh-squint',
+    'laugh-wink',
+    'layer-group',
+    'leaf',
+    'lemon',
+    'less-than',
+    'less-than-equal',
+    'level-down-alt',
+    'level-up-alt',
+    'life-ring',
+    'lightbulb',
+    'link',
+    'lira-sign',
+    'list',
+    'list-alt',
+    'list-ol',
+    'list-ul',
+    'location-arrow',
+    'lock',
+    'lock-open',
+    'long-arrow-alt-down',
+    'long-arrow-alt-left',
+    'long-arrow-alt-right',
+    'long-arrow-alt-up',
+    'low-vision',
+    'luggage-cart',
+    'lungs',
+    'lungs-virus',
+    'magic',
+    'magnet',
+    'mail-bulk',
+    'male',
+    'map',
+    'map-marked',
+    'map-marked-alt',
+    'map-marker',
+    'map-marker-alt',
+    'map-pin',
+    'map-signs',
+    'marker',
+    'mars',
+    'mars-double',
+    'mars-stroke',
+    'mars-stroke-h',
+    'mars-stroke-v',
+    'mask',
+    'medal',
+    'medkit',
+    'meh',
+    'meh-blank',
+    'meh-rolling-eyes',
+    'memory',
+    'menorah',
+    'mercury',
+    'meteor',
+    'microchip',
+    'microphone',
+    'microphone-alt',
+    'microphone-alt-slash',
+    'microphone-slash',
+    'microscope',
+    'minus',
+    'minus-circle',
+    'minus-square',
+    'mitten',
+    'mobile',
+    'mobile-alt',
+    'money-bill',
+    'money-bill-alt',
+    'money-bill-wave',
+    'money-bill-wave-alt',
+    'money-check',
+    'money-check-alt',
+    'monument',
+    'moon',
+    'mortar-pestle',
+    'mosque',
+    'motorcycle',
+    'mountain',
+    'mouse',
+    'mouse-pointer',
+    'mug-hot',
+    'music',
+    'network-wired',
+    'neuter',
+    'newspaper',
+    'not-equal',
+    'notes-medical',
+    'object-group',
+    'object-ungroup',
+    'oil-can',
+    'om',
+    'omega',
+    'otter',
+    'outdent',
+    'pager',
+    'paint-brush',
+    'paint-roller',
+    'palette',
+    'pallet',
+    'paper-plane',
+    'paperclip',
+    'parachute-box',
+    'paragraph',
+    'parking',
+    'passport',
+    'pastafarianism',
+    'paste',
+    'pause',
+    'pause-circle',
+    'paw',
+    'peace',
+    'pen',
+    'pen-alt',
+    'pen-fancy',
+    'pen-nib',
+    'pen-square',
+    'pencil-alt',
+    'pencil-ruler',
+    'people-arrows',
+    'people-carry',
+    'pepper-hot',
+    'percent',
+    'percentage',
+    'person-booth',
+    'phone',
+    'phone-alt',
+    'phone-slash',
+    'phone-square',
+    'phone-square-alt',
+    'phone-volume',
+    'photo-video',
+    'pi',
+    'piggy-bank',
+    'pills',
+    'pizza-slice',
+    'place-of-worship',
+    'plane',
+    'plane-arrival',
+    'plane-departure',
+    'plane-slash',
+    'play',
+    'play-circle',
+    'plug',
+    'plus',
+    'plus-circle',
+    'plus-square',
+    'podcast',
+    'poll',
+    'poll-h',
+    'poo',
+    'poo-storm',
+    'poop',
+    'portrait',
+    'pound-sign',
+    'power-off',
+    'pray',
+    'praying-hands',
+    'prescription',
+    'prescription-bottle',
+    'prescription-bottle-alt',
+    'print',
+    'procedures',
+    'project-diagram',
+    'pump-medical',
+    'pump-soap',
+    'puzzle-piece',
+    'qrcode',
+    'question',
+    'question-circle',
+    'quidditch',
+    'quote-left',
+    'quote-right',
+    'quran',
+    'radiation',
+    'radiation-alt',
+    'rainbow',
+    'random',
+    'receipt',
+    'record-vinyl',
+    'recycle',
+    'redo',
+    'redo-alt',
+    'registered',
+    'remove-format',
+    'reply',
+    'reply-all',
+    'republican',
+    'restroom',
+    'retweet',
+    'ribbon',
+    'ring',
+    'road',
+    'robot',
+    'rocket',
+    'route',
+    'rss',
+    'rss-square',
+    'ruble-sign',
+    'ruler',
+    'ruler-combined',
+    'ruler-horizontal',
+    'ruler-vertical',
+    'running',
+    'rupee-sign',
+    'sad-cry',
+    'sad-tear',
+    'satellite',
+    'satellite-dish',
+    'save',
+    'school',
+    'screwdriver',
+    'scroll',
+    'sd-card',
+    'search',
+    'search-dollar',
+    'search-location',
+    'search-minus',
+    'search-plus',
+    'seedling',
+    'server',
+    'shapes',
+    'share',
+    'share-alt',
+    'share-alt-square',
+    'share-square',
+    'shekel-sign',
+    'shield-alt',
+    'shield-virus',
+    'ship',
+    'shipping-fast',
+    'shoe-prints',
+    'shopping-bag',
+    'shopping-basket',
+    'shopping-cart',
+    'shower',
+    'shuttle-van',
+    'sigma',
+    'sign',
+    'sign-in-alt',
+    'sign-language',
+    'sign-out-alt',
+    'signal',
+    'signal-alt',
+    'signal-alt-slash',
+    'signal-slash',
+    'signature',
+    'sim-card',
+    'sink',
+    'sitemap',
+    'skating',
+    'skiing',
+    'skiing-nordic',
+    'skull',
+    'skull-crossbones',
+    'slash',
+    'sleigh',
+    'sliders-h',
+    'smile',
+    'smile-beam',
+    'smile-wink',
+    'smog',
+    'smoking',
+    'smoking-ban',
+    'sms',
+    'snowboarding',
+    'snowflake',
+    'snowman',
+    'snowplow',
+    'soap',
+    'socks',
+    'solar-panel',
+    'sort',
+    'sort-alpha-down',
+    'sort-alpha-down-alt',
+    'sort-alpha-up',
+    'sort-alpha-up-alt',
+    'sort-amount-down',
+    'sort-amount-down-alt',
+    'sort-amount-up',
+    'sort-amount-up-alt',
+    'sort-down',
+    'sort-numeric-down',
+    'sort-numeric-down-alt',
+    'sort-numeric-up',
+    'sort-numeric-up-alt',
+    'sort-up',
+    'spa',
+    'space-shuttle',
+    'spell-check',
+    'spider',
+    'spinner',
+    'splotch',
+    'spray-can',
+    'square',
+    'square-full',
+    'square-root',
+    'square-root-alt',
+    'stamp',
+    'star',
+    'star-and-crescent',
+    'star-half',
+    'star-half-alt',
+    'star-of-david',
+    'star-of-life',
+    'step-backward',
+    'step-forward',
+    'stethoscope',
+    'sticky-note',
+    'stop',
+    'stop-circle',
+    'stopwatch',
+    'stopwatch-20',
+    'store',
+    'store-alt',
+    'store-alt-slash',
+    'store-slash',
+    'stream',
+    'street-view',
+    'strikethrough',
+    'stroopwafel',
+    'subscript',
+    'subway',
+    'suitcase',
+    'suitcase-rolling',
+    'sun',
+    'superscript',
+    'surprise',
+    'swatchbook',
+    'swimmer',
+    'swimming-pool',
+    'synagogue',
+    'sync',
+    'sync-alt',
+    'syringe',
+    'table',
+    'table-tennis',
+    'tablet',
+    'tablet-alt',
+    'tablets',
+    'tachometer-alt',
+    'tag',
+    'tags',
+    'tally',
+    'tape',
+    'tasks',
+    'taxi',
+    'teeth',
+    'teeth-open',
+    'temperature-high',
+    'temperature-low',
+    'tenge',
+    'terminal',
+    'text-height',
+    'text-width',
+    'th',
+    'th-large',
+    'th-list',
+    'theater-masks',
+    'thermometer',
+    'thermometer-empty',
+    'thermometer-full',
+    'thermometer-half',
+    'thermometer-quarter',
+    'thermometer-three-quarters',
+    'theta',
+    'thumbs-down',
+    'thumbs-up',
+    'thumbtack',
+    'ticket-alt',
+    'tilde',
+    'times',
+    'times-circle',
+    'tint',
+    'tint-slash',
+    'tired',
+    'toggle-off',
+    'toggle-on',
+    'toilet',
+    'toilet-paper',
+    'toilet-paper-slash',
+    'toolbox',
+    'tools',
+    'tooth',
+    'torah',
+    'torii-gate',
+    'tractor',
+    'trademark',
+    'traffic-light',
+    'trailer',
+    'train',
+    'tram',
+    'transgender',
+    'transgender-alt',
+    'trash',
+    'trash-alt',
+    'trash-restore',
+    'trash-restore-alt',
+    'tree',
+    'trophy',
+    'truck',
+    'truck-loading',
+    'truck-monster',
+    'truck-moving',
+    'truck-pickup',
+    'tshirt',
+    'tty',
+    'tv',
+    'umbrella',
+    'umbrella-beach',
+    'underline',
+    'undo',
+    'undo-alt',
+    'union',
+    'universal-access',
+    'university',
+    'unlink',
+    'unlock',
+    'unlock-alt',
+    'upload',
+    'user',
+    'user-alt',
+    'user-alt-slash',
+    'user-astronaut',
+    'user-check',
+    'user-circle',
+    'user-clock',
+    'user-cog',
+    'user-edit',
+    'user-friends',
+    'user-graduate',
+    'user-injured',
+    'user-lock',
+    'user-md',
+    'user-minus',
+    'user-ninja',
+    'user-nurse',
+    'user-plus',
+    'user-secret',
+    'user-shield',
+    'user-slash',
+    'user-tag',
+    'user-tie',
+    'user-times',
+    'users',
+    'users-cog',
+    'users-slash',
+    'utensil-spoon',
+    'utensils',
+    'value-absolute',
+    'vector-square',
+    'venus',
+    'venus-double',
+    'venus-mars',
+    'vest',
+    'vest-patches',
+    'vial',
+    'vials',
+    'video',
+    'video-slash',
+    'vihara',
+    'virus',
+    'virus-slash',
+    'viruses',
+    'voicemail',
+    'volleyball-ball',
+    'volume',
+    'volume-down',
+    'volume-mute',
+    'volume-off',
+    'volume-slash',
+    'volume-up',
+    'vote-yea',
+    'vr-cardboard',
+    'walking',
+    'wallet',
+    'warehouse',
+    'water',
+    'wave-square',
+    'weight',
+    'weight-hanging',
+    'wheelchair',
+    'wifi',
+    'wifi-slash',
+    'wind',
+    'window-close',
+    'window-maximize',
+    'window-minimize',
+    'window-restore',
+    'wine-bottle',
+    'wine-glass',
+    'wine-glass-alt',
+    'won-sign',
+    'wrench',
+    'x-ray',
+    'yen-sign',
+    'yin-yang'
+  ]
+}

+ 24 - 0
src/components/bpmnProcessDesigner/package/designer/plugins/defaultEmpty.js

@@ -0,0 +1,24 @@
+export default (key, name, type) => {
+  if (!type) type = 'camunda'
+  const TYPE_TARGET = {
+    activiti: 'http://activiti.org/bpmn',
+    camunda: 'http://bpmn.io/schema/bpmn',
+    flowable: 'http://flowable.org/bpmn'
+  }
+  return `<?xml version="1.0" encoding="UTF-8"?>
+<bpmn2:definitions 
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xmlns:bpmn2="http://www.omg.org/spec/BPMN/20100524/MODEL"
+  xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
+  xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
+  xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
+  id="diagram_${key}"
+  targetNamespace="${TYPE_TARGET[type]}">
+  <bpmn2:process id="${key}" name="${name}" isExecutable="true">
+  </bpmn2:process>
+  <bpmndi:BPMNDiagram id="BPMNDiagram_1">
+    <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="${key}">
+    </bpmndi:BPMNPlane>
+  </bpmndi:BPMNDiagram>
+</bpmn2:definitions>`
+}

+ 14 - 0
src/types/descriptions.d.ts

@@ -0,0 +1,14 @@
+export interface DescriptionsSchema {
+  span?: number // 占多少分
+  field: string // 字段名
+  label?: string // label名
+  mappedField?: string // 字段映射
+  width?: string | number
+  minWidth?: string | number
+  align?: 'left' | 'center' | 'right'
+  labelAlign?: 'left' | 'center' | 'right'
+  className?: string
+  labelClassName?: string
+  dateFormat?: string // add by 星语:支持时间的格式化
+  dictType?: string // add by 星语:支持 dict 字典数据
+}

+ 18 - 0
src/utils/dateUtil.ts

@@ -0,0 +1,18 @@
+/**
+ * Independent time operation tool to facilitate subsequent switch to dayjs
+ */
+// TODO 芋艿:【锁屏】可能后面删除掉
+import dayjs from 'dayjs'
+
+const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
+const DATE_FORMAT = 'YYYY-MM-DD'
+
+export function formatToDateTime(date?: dayjs.ConfigType, format = DATE_TIME_FORMAT): string {
+  return dayjs(date).format(format)
+}
+
+export function formatToDate(date?: dayjs.ConfigType, format = DATE_FORMAT): string {
+  return dayjs(date).format(format)
+}
+
+export const dateUtil = dayjs

+ 55 - 0
src/views/ai/music/components/mode/desc.vue

@@ -0,0 +1,55 @@
+<template>
+  <div>
+    <Title title="音乐/歌词说明" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲">
+      <el-input
+        v-model="formData.desc"
+        :autosize="{ minRows: 6, maxRows: 6}"
+        resize="none"
+        type="textarea"
+        maxlength="1200"
+        show-word-limit
+        placeholder="一首关于糟糕分手的欢快歌曲"
+      />
+    </Title>
+
+    <Title title="纯音乐" desc="创建一首没有歌词的歌曲">
+      <template #extra>
+        <el-switch v-model="formData.pure" size="small"/>
+      </template>
+    </Title>
+
+    <Title title="版本" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲">
+      <el-select v-model="formData.version" placeholder="请选择">
+        <el-option
+          v-for="item in [{
+            value: '3',
+            label: 'V3'
+          }, {
+            value: '2',
+            label: 'V2'
+          }]"
+          :key="item.value"
+          :label="item.label"
+          :value="item.value"
+        />
+      </el-select>
+    </Title>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Title from '../title/index.vue'
+
+defineOptions({ name: 'Desc' })
+
+const formData = reactive({
+  desc: '',
+  pure: false,
+  version: '3'
+})
+
+defineExpose({
+  formData
+})
+
+</script>

+ 359 - 0
src/views/bpm/disbursement/detail.vue

@@ -0,0 +1,359 @@
+<template>
+  <ContentWrap>
+      <el-form
+          ref="formRef"
+          :model="formData"
+          :rules="formRules"
+          label-width="100px"
+          v-loading="formLoading"
+      >
+        <el-form-item label="天地图配置">
+          <el-input  v-for="(tdtitem,index) in tdtArray" :key="index" v-model="tdtArray[index]" >
+            <template #append>
+              <el-button @click="tdtArray.splice(index,1) ">删除</el-button>
+            </template>
+          </el-input>
+          <el-button @click="tdtArray.push('') ">添加+</el-button>
+        </el-form-item>
+
+        <el-form-item label="地图初始位置">
+          <div>
+            <el-input v-model="formData.itemZoomSet[0]">
+              <template #prepend>
+                经度
+              </template>
+            </el-input>
+            <el-input v-model="formData.itemZoomSet[1]">
+              <template #prepend>
+                维度
+              </template>
+            </el-input>
+            <el-input v-model="formData.itemZoomSet[2]">
+              <template #prepend>
+                高度
+              </template>
+            </el-input>
+            <el-input v-model="formData.itemZoomSet[3]">
+              <template #prepend>
+                倾角
+              </template>
+            </el-input>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="数据绑定" prop="itemShp">
+          <!-- <el-input v-model="formData.itemShp" placeholder="请输入项目相关shp上传(保存url,可null)" />
+            -->
+          <el-select
+              v-model="formData.itemShp"
+              multiple
+              filterable
+              allow-create
+              default-first-option
+              placeholder="请选择项目相关图层">
+            <el-option
+                v-for="dict in datagisname"
+                :key="dict.id"
+                :label="dict.shpName"
+                :value="dict.shpName"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="item" v-for="(item) in formData.itemShp" :key="item" >
+          图层属性配置:
+          <el-select
+              v-model="shpItemSelected[item]"
+              multiple
+              filterable
+              default-first-option
+              placeholder="图层属性配置">
+            <el-option
+                v-for="dict in shpItemArr[item] "
+                :key="dict"
+                :label="dict"
+                :value="dict"
+            />
+          </el-select>
+          <el-button @click="fieldsConfig(item)">配置表单</el-button>
+          <div v-if="shpItemField[item] != null">
+            <form-create :inFor="true" :rule="shpItemField[item]" v-model:api="fApi"  :option="shpItemConf[item]"/>
+          </div>
+<!--          <div v-else-if="shpItemField[item] != null && shpItemConf[item] == null">-->
+<!--            <form-create :rule="shpItemField[item]" v-model:api="fApi"  :option="options"/>-->
+<!--          </div>-->
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+        </el-form-item>
+      </el-form>
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <twinEditor @submit-filed="handleSubmitFiled" ref="twinEditorRef"/>
+
+</template>
+<script lang="ts" setup>/** 资金拨付流程表	 表单 */
+import {getgisAttr, getSimpleGisNameList} from "@/api/layer/gisname";
+import {DisbursementApi} from "@/api/bpm/disbursement";
+import twinEditor from "./editor/index.vue"
+import {string} from "vue-types";
+
+defineOptions({ name: 'BpmDisbursementDetail' })
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const { query } = useRoute() // 查询参数
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  userId: undefined,
+  reason: undefined,
+  processInstanceId: undefined,
+  file: undefined,
+  id: undefined,
+  type: undefined,
+  funding: undefined,
+  status: undefined,
+  specialLoansFile: undefined,
+  itemName: undefined,
+  itemType: undefined,
+  itemBelong: undefined,
+  itemShp: undefined,
+  itemImage: undefined,
+  fundingApplicationFile: undefined,
+  itemDisbursementStatus: undefined,
+  shpItemSelected: {},
+  itemZoomSet:[],
+  shpItemField:{},
+  shpItemConf:{}
+})
+const options = ref( {
+  submitBtn: { show: false },
+  resetBtn: { show: false },
+})
+const shpItemField = ref({});
+const shpItemConf = ref({});
+const formRef = ref() // 表单 Ref
+const datagisname = ref([]);
+const shpItemArr = ref({});
+const shpItemSelected = ref({});
+const tdtArray = ref([""]);
+const twinEditorRef = ref()
+const fApi = ref(null);
+
+watch(() => formData.value.itemShp,
+    async (newValue) => {
+      // console.log("ltyWatching",newValue);
+      if(newValue){
+        (await newValue).forEach(element => {
+          // console.log(element);
+          getgisAttr(element).then((res)=> {
+            // console.log("ltyWriting",[element,res])
+            shpItemArr.value[element] = res
+            if(!shpItemSelected.value[element] )
+              shpItemSelected.value[element] = [] || shpItemSelected.value[element];
+          });
+        });
+      }
+    },
+    {
+      deep:true,
+      immediate:true
+    }
+)
+
+const newShpItemSelected = computed(() => {
+  return JSON.stringify(shpItemSelected.value)
+})
+watch(
+    newShpItemSelected,
+    async (newValue, oldValue) => {
+      let newObj = JSON.parse(newValue);
+      let oldObj = JSON.parse(oldValue);
+
+      for (let shp in newObj) {
+        if (shp) {
+          let addedKeys: string[] = [];
+          let removedKeys: string[] = [];
+
+          if(!Array.isArray(shpItemConf.value[shp])){
+            shpItemConf.value[shp] = options.value;
+          }
+          console.log(shpItemConf.value[shp])
+          const newEntries = Array.isArray(newObj[shp]) ? newObj[shp] : [];
+          const oldEntries = Array.isArray(oldObj[shp]) ? oldObj[shp] : [];
+          // 找出新增的字段
+          for (let newItem of newEntries) {
+            if (!oldEntries.includes(newItem)) {
+              addedKeys.push(newItem);
+            }
+          }
+
+          // 找出减少的字段
+          for (let oldItem of oldEntries) {
+            if (!newEntries.includes(oldItem)) {
+              removedKeys.push(oldItem);
+            }
+          }
+
+          if (!Array.isArray(shpItemField.value[shp])) {
+            shpItemField.value[shp] = [];
+          }
+          if (addedKeys.length > 0) {
+            addedKeys.forEach(addedKey => {
+              const newItem = generateJsonTemplate([addedKey]);
+              if (newItem) {
+                const exists = shpItemField.value[shp].some(item => {
+                  return item["field"] === newItem[0]["field"]; // 假设“field”是用于比较的属性
+                });
+                // console.log(exists)
+                if (!exists) {
+                  shpItemField.value[shp].push(newItem[0]);
+                }
+              }
+            });
+          }
+          // 处理移除的字段
+          for (let removedKey of removedKeys) {
+            shpItemField.value[shp] = shpItemField.value[shp].filter(item => {
+              return removedKey !== item["field"]; // 保留没有匹配的item
+            });
+          }
+
+        }
+      }
+    },
+    {
+      deep: true,
+      immediate: false,
+    }
+);
+const generateJsonTemplate = (fields ) => {
+  // 为每个不存在的字段生成新的对象
+  return fields.map(field => ({
+    "type": "input",
+    "field": field,
+    "title": field,
+    "info": "",
+    "$required": false,
+    "_fc_drag_tag": "input",
+    "hidden": false,
+    "display": true,
+  }));
+};
+const gisnamedatalist = async () => {
+  datagisname.value = await getSimpleGisNameList();
+  // console.log(datagisname.value);
+}
+
+
+/** 初始化 */
+onMounted(async () => {
+  resetForm()
+  // 修改时,设置数据
+  if (query.id) {
+    formLoading.value = true
+    try {
+      formData.value = await DisbursementApi.getDisbursement(query.id)
+      console.log(formData.value)
+      tdtArray.value = formData.value.itemBaseLayer || [""];
+      formData.value.itemZoomSet = formData.value.itemZoomSet || [109.2,31.277,156777.64156853,0];
+
+      //NOTE - 解析JSON中的shpItemSelected
+      let shpItemInfo = JSON.parse(formData.value.shpItemInfo);
+      let shpItemSelected_helper = {};
+      shpItemField.value = formData.value.shpItemField;
+      shpItemConf.value = formData.value.shpItemConf;
+
+
+      for(let shp in shpItemInfo){
+        shpItemSelected_helper[shp] = shpItemInfo[shp].map((shpItemInfoMapper)=>{return shpItemInfoMapper.shpItem});
+      }
+      Object.assign(shpItemSelected.value, shpItemSelected_helper );
+
+      // console.log("lty162",[formData.value,shpItemSelected.value,JSON.parse(formData.value.shpItemInfo) ]);
+    } finally {
+      formLoading.value = false
+    }
+  }
+})
+const fieldsConfig = ( item: any  ) => {
+  twinEditorRef.value.open(item,shpItemField.value[item],shpItemConf.value[item])
+}
+
+const handleSubmitFiled = async (shpItem :any,fields : any , conf :any)=>{
+
+  shpItemField.value[shpItem] = fields;
+  shpItemConf.value[shpItem] = conf;
+}
+
+const submitForm = async () => {
+  // 校验表单
+  // await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  console.log('ltyDSubmitFrom',formData.value);
+  try {
+    //NOTE -  处理 shp属性配置
+    let shpItemSelected_helper = {}
+    for(let item in shpItemSelected.value){
+      shpItemSelected_helper[item] = shpItemSelected.value[item].map((shpItem)=>{
+        return {
+          shpItem: shpItem
+        };
+      });
+    }
+    const data = {
+      ... formData.value ,
+      shpItemInfo: shpItemSelected_helper,
+      shpLabelInfo: JSON.parse(formData.value.shpLabelInfo) || {},
+      itemBaseLayer: tdtArray.value,
+      shpItemField:shpItemField.value,
+      shpItemConf:shpItemConf.value
+    };
+    console.log('ltyDSubmitFrom',data);
+    if (formType.value === 'create') {
+      await DisbursementApi.createDisbursement(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DisbursementApi.updateDisbursement(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    userId: undefined,
+    reason: undefined,
+    processInstanceId: undefined,
+    file: undefined,
+    id: undefined,
+    type: undefined,
+    funding: undefined,
+    status: undefined,
+    specialLoansFile: undefined,
+    itemName: undefined,
+    itemType: undefined,
+    itemBelong: undefined,
+    itemShp: undefined,
+    itemImage: undefined,
+    fundingApplicationFile: undefined,
+    itemDisbursementStatus: undefined,
+    shpItemSelected: {},
+    itemBaseLayer:undefined,
+    itemZoomSet:[],
+    shpItemField:{},
+    shpItemConf:{}
+  }
+  formRef.value?.resetFields()
+  gisnamedatalist()
+}
+</script>

+ 51 - 0
src/views/bpm/oa/leave/detail.vue

@@ -0,0 +1,51 @@
+<template>
+  <ContentWrap>
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="请假类型">
+        <dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="detailData.type" />
+      </el-descriptions-item>
+      <el-descriptions-item label="开始时间">
+        {{ formatDate(detailData.startTime, 'YYYY-MM-DD') }}
+      </el-descriptions-item>
+      <el-descriptions-item label="结束时间">
+        {{ formatDate(detailData.endTime, 'YYYY-MM-DD') }}
+      </el-descriptions-item>
+      <el-descriptions-item label="原因">
+        {{ detailData.reason }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+import { propTypes } from '@/utils/propTypes'
+import * as LeaveApi from '@/api/bpm/leave'
+
+defineOptions({ name: 'BpmOALeaveDetail' })
+
+const { query } = useRoute() // 查询参数
+
+const props = defineProps({
+  id: propTypes.number.def(undefined)
+})
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref<any>({}) // 详情数据
+const queryId = query.id as unknown as number // 从 URL 传递过来的 id 编号
+
+/** 获得数据 */
+const getInfo = async () => {
+  detailLoading.value = true
+  try {
+    detailData.value = await LeaveApi.getLeave(props.id || queryId)
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open: getInfo }) // 提供 open 方法,用于打开弹窗
+
+/** 初始化 **/
+onMounted(() => {
+  getInfo()
+})
+</script>

+ 111 - 0
src/views/infra/dataSourceConfig/DataSourceConfigForm.vue

@@ -0,0 +1,111 @@
+<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="数据源连接" prop="url">
+        <el-input v-model="formData.url" placeholder="请输入数据源连接" />
+      </el-form-item>
+      <el-form-item label="用户名" prop="username">
+        <el-input v-model="formData.username" placeholder="请输入用户名" />
+      </el-form-item>
+      <el-form-item label="密码" prop="password">
+        <el-input v-model="formData.password" placeholder="请输入密码" />
+      </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 DataSourceConfigApi from '@/api/infra/dataSourceConfig'
+
+defineOptions({ name: 'InfraDataSourceConfigForm' })
+
+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<DataSourceConfigApi.DataSourceConfigVO>({
+  id: undefined,
+  name: '',
+  url: '',
+  username: '',
+  password: ''
+})
+const formRules = reactive({
+  name: [{ required: true, message: '数据源名称不能为空', trigger: 'blur' }],
+  url: [{ required: true, message: '数据源连接不能为空', trigger: 'blur' }],
+  username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
+  password: [{ 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 DataSourceConfigApi.getDataSourceConfig(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 DataSourceConfigApi.DataSourceConfigVO
+    if (formType.value === 'create') {
+      await DataSourceConfigApi.createDataSourceConfig(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DataSourceConfigApi.updateDataSourceConfig(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: '',
+    url: '',
+    username: '',
+    password: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 126 - 0
src/views/infra/demo/demo01/Demo01ContactForm.vue

@@ -0,0 +1,126 @@
+<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="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="description">
+        <Editor v-model="formData.description" height="150px" />
+      </el-form-item>
+      <el-form-item label="头像" prop="avatar">
+        <UploadImg v-model="formData.avatar" />
+      </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 * as Demo01ContactApi from '@/api/infra/demo/demo01'
+
+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,
+  sex: undefined,
+  birthday: undefined,
+  description: undefined,
+  avatar: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
+  birthday: [{ required: true, message: '出生年不能为空', trigger: 'blur' }],
+  description: [{ 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 Demo01ContactApi.getDemo01Contact(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 Demo01ContactApi.Demo01ContactVO
+    if (formType.value === 'create') {
+      await Demo01ContactApi.createDemo01Contact(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await Demo01ContactApi.updateDemo01Contact(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    sex: undefined,
+    birthday: undefined,
+    description: undefined,
+    avatar: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 114 - 0
src/views/infra/demo/demo02/Demo02CategoryForm.vue

@@ -0,0 +1,114 @@
+<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="parentId">
+        <el-tree-select
+          v-model="formData.parentId"
+          :data="demo02CategoryTree"
+          :props="defaultProps"
+          check-strictly
+          default-expand-all
+          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 Demo02CategoryApi from '@/api/infra/demo/demo02'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  parentId: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  parentId: [{ required: true, message: '父级编号不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const demo02CategoryTree = 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 Demo02CategoryApi.getDemo02Category(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  await getDemo02CategoryTree()
+}
+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 Demo02CategoryApi.Demo02CategoryVO
+    if (formType.value === 'create') {
+      await Demo02CategoryApi.createDemo02Category(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await Demo02CategoryApi.updateDemo02Category(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    parentId: undefined
+  }
+  formRef.value?.resetFields()
+}
+
+/** 获得示例分类树 */
+const getDemo02CategoryTree = async () => {
+  demo02CategoryTree.value = []
+  const data = await Demo02CategoryApi.getDemo02CategoryList()
+  const root: Tree = { id: 0, name: '顶级示例分类', children: [] }
+  root.children = handleTree(data, 'id', 'parentId')
+  demo02CategoryTree.value.push(root)
+}
+</script>

+ 121 - 0
src/views/infra/demo/demo03/erp/Demo03StudentForm.vue

@@ -0,0 +1,121 @@
+<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="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="description">
+        <Editor v-model="formData.description" height="150px" />
+      </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 * as Demo03StudentApi from '@/api/infra/demo/demo03/erp'
+
+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,
+  sex: undefined,
+  birthday: undefined,
+  description: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
+  birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
+  description: [{ 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 Demo03StudentApi.getDemo03Student(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 Demo03StudentApi.Demo03StudentVO
+    if (formType.value === 'create') {
+      await Demo03StudentApi.createDemo03Student(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await Demo03StudentApi.updateDemo03Student(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    sex: undefined,
+    birthday: undefined,
+    description: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 99 - 0
src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue

@@ -0,0 +1,99 @@
+<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="score">
+        <el-input v-model="formData.score" 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 Demo03StudentApi from '@/api/infra/demo/demo03/erp'
+
+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,
+  studentId: undefined,
+  name: undefined,
+  score: undefined
+})
+const formRules = reactive({
+  studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  score: [{ required: true, message: '分数不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number, studentId: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  formData.value.studentId = studentId
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await Demo03StudentApi.getDemo03Course(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
+    if (formType.value === 'create') {
+      await Demo03StudentApi.createDemo03Course(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await Demo03StudentApi.updateDemo03Course(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    studentId: undefined,
+    name: undefined,
+    score: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 130 - 0
src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue

@@ -0,0 +1,130 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-button
+      v-hasPermi="['infra:demo03-student:create']"
+      plain
+      type="primary"
+      @click="openForm('create')"
+    >
+      <Icon class="mr-5px" icon="ep:plus" />
+      新增
+    </el-button>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="编号" prop="id" />
+      <el-table-column align="center" label="名字" prop="name" />
+      <el-table-column align="center" label="分数" prop="score" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="操作">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['infra:demo03-student:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['infra:demo03-student:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <Demo03CourseForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp'
+import Demo03CourseForm from './Demo03CourseForm.vue'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const props = defineProps<{
+  studentId?: number // 学生编号(主表的关联字段)
+}>()
+const loading = ref(false) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  studentId: undefined as unknown
+})
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  (val: number) => {
+    if (!val) {
+      return
+    }
+    queryParams.studentId = val
+    handleQuery()
+  },
+  { immediate: true, deep: true }
+)
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo03StudentApi.getDemo03CoursePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  if (!props.studentId) {
+    message.error('请选择一个学生')
+    return
+  }
+  formRef.value.open(type, id, props.studentId)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await Demo03StudentApi.deleteDemo03Course(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+</script>

+ 99 - 0
src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue

@@ -0,0 +1,99 @@
+<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="teacher">
+        <el-input v-model="formData.teacher" 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 Demo03StudentApi from '@/api/infra/demo/demo03/erp'
+
+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,
+  studentId: undefined,
+  name: undefined,
+  teacher: undefined
+})
+const formRules = reactive({
+  studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number, studentId: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  formData.value.studentId = studentId
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await Demo03StudentApi.getDemo03Grade(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
+    if (formType.value === 'create') {
+      await Demo03StudentApi.createDemo03Grade(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await Demo03StudentApi.updateDemo03Grade(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    studentId: undefined,
+    name: undefined,
+    teacher: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 130 - 0
src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue

@@ -0,0 +1,130 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-button
+      v-hasPermi="['infra:demo03-student:create']"
+      plain
+      type="primary"
+      @click="openForm('create')"
+    >
+      <Icon class="mr-5px" icon="ep:plus" />
+      新增
+    </el-button>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="编号" prop="id" />
+      <el-table-column align="center" label="名字" prop="name" />
+      <el-table-column align="center" label="班主任" prop="teacher" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="操作">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['infra:demo03-student:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['infra:demo03-student:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <Demo03GradeForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp'
+import Demo03GradeForm from './Demo03GradeForm.vue'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const props = defineProps<{
+  studentId?: number // 学生编号(主表的关联字段)
+}>()
+const loading = ref(false) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  studentId: undefined as unknown
+})
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  (val: number) => {
+    if (!val) {
+      return
+    }
+    queryParams.studentId = val
+    handleQuery()
+  },
+  { immediate: true, deep: true }
+)
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo03StudentApi.getDemo03GradePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  if (!props.studentId) {
+    message.error('请选择一个学生')
+    return
+  }
+  formRef.value.open(type, id, props.studentId)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await Demo03StudentApi.deleteDemo03Grade(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+</script>

+ 153 - 0
src/views/infra/demo/demo03/inner/Demo03StudentForm.vue

@@ -0,0 +1,153 @@
+<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="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="description">
+        <Editor v-model="formData.description" height="150px" />
+      </el-form-item>
+    </el-form>
+    <!-- 子表的表单 -->
+    <el-tabs v-model="subTabsName">
+      <el-tab-pane label="学生课程" name="demo03Course">
+        <Demo03CourseForm ref="demo03CourseFormRef" :student-id="formData.id" />
+      </el-tab-pane>
+      <el-tab-pane label="学生班级" name="demo03Grade">
+        <Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData.id" />
+      </el-tab-pane>
+    </el-tabs>
+    <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 * as Demo03StudentApi from '@/api/infra/demo/demo03/inner'
+import Demo03CourseForm from './components/Demo03CourseForm.vue'
+import Demo03GradeForm from './components/Demo03GradeForm.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,
+  name: undefined,
+  sex: undefined,
+  birthday: undefined,
+  description: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
+  birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
+  description: [{ required: true, message: '简介不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 子表的表单 */
+const subTabsName = ref('demo03Course')
+const demo03CourseFormRef = ref()
+const demo03GradeFormRef = 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 Demo03StudentApi.getDemo03Student(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 校验子表单
+  try {
+    await demo03CourseFormRef.value.validate()
+  } catch (e) {
+    subTabsName.value = 'demo03Course'
+    return
+  }
+  try {
+    await demo03GradeFormRef.value.validate()
+  } catch (e) {
+    subTabsName.value = 'demo03Grade'
+    return
+  }
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as Demo03StudentApi.Demo03StudentVO
+    // 拼接子表的数据
+    data.demo03Courses = demo03CourseFormRef.value.getData()
+    data.demo03Grade = demo03GradeFormRef.value.getData()
+    if (formType.value === 'create') {
+      await Demo03StudentApi.createDemo03Student(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await Demo03StudentApi.updateDemo03Student(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    sex: undefined,
+    birthday: undefined,
+    description: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 100 - 0
src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue

@@ -0,0 +1,100 @@
+<template>
+  <el-form
+    ref="formRef"
+    :model="formData"
+    :rules="formRules"
+    v-loading="formLoading"
+    label-width="0px"
+    :inline-message="true"
+  >
+    <el-table :data="formData" class="-mt-10px">
+      <el-table-column label="序号" type="index" width="100" />
+      <el-table-column label="名字" min-width="150">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.name`" :rules="formRules.name" class="mb-0px!">
+            <el-input v-model="row.name" placeholder="请输入名字" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="分数" min-width="150">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.score`" :rules="formRules.score" class="mb-0px!">
+            <el-input v-model="row.score" placeholder="请输入分数" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" width="60">
+        <template #default="{ $index }">
+          <el-button @click="handleDelete($index)" link>—</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-form>
+  <el-row justify="center" class="mt-3">
+    <el-button @click="handleAdd" round>+ 添加学生课程</el-button>
+  </el-row>
+</template>
+<script setup lang="ts">
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner'
+
+const props = defineProps<{
+  studentId: undefined // 学生编号(主表的关联字段)
+}>()
+const formLoading = ref(false) // 表单的加载中
+const formData = ref([])
+const formRules = reactive({
+  studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  score: [{ required: true, message: '分数不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  async (val) => {
+    // 1. 重置表单
+    formData.value = []
+    // 2. val 非空,则加载数据
+    if (!val) {
+      return
+    }
+    try {
+      formLoading.value = true
+      formData.value = await Demo03StudentApi.getDemo03CourseListByStudentId(val)
+    } finally {
+      formLoading.value = false
+    }
+  },
+  { immediate: true }
+)
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  const row = {
+    id: undefined,
+    studentId: undefined,
+    name: undefined,
+    score: undefined
+  }
+  row.studentId = props.studentId
+  formData.value.push(row)
+}
+
+/** 删除按钮操作 */
+const handleDelete = (index) => {
+  formData.value.splice(index, 1)
+}
+
+/** 表单校验 */
+const validate = () => {
+  return formRef.value.validate()
+}
+
+/** 表单值 */
+const getData = () => {
+  return formData.value
+}
+
+defineExpose({ validate, getData })
+</script>

+ 51 - 0
src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue

@@ -0,0 +1,51 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column label="分数" align="center" prop="score" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+    </el-table>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const props = defineProps<{
+  studentId: undefined // 学生编号(主表的关联字段)
+}>()
+const loading = ref(false) // 列表的加载中
+const list = ref([]) // 列表的数据
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    list.value = await Demo03StudentApi.getDemo03CourseListByStudentId(props.studentId)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 72 - 0
src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue

@@ -0,0 +1,72 @@
+<template>
+  <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="teacher">
+      <el-input v-model="formData.teacher" placeholder="请输入班主任" />
+    </el-form-item>
+  </el-form>
+</template>
+<script setup lang="ts">
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner'
+
+const props = defineProps<{
+  studentId: undefined // 学生编号(主表的关联字段)
+}>()
+const formLoading = ref(false) // 表单的加载中
+const formData = ref([])
+const formRules = reactive({
+  studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  async (val) => {
+    // 1. 重置表单
+    formData.value = {
+      id: undefined,
+      studentId: undefined,
+      name: undefined,
+      teacher: undefined
+    }
+    // 2. val 非空,则加载数据
+    if (!val) {
+      return
+    }
+    try {
+      formLoading.value = true
+      const data = await Demo03StudentApi.getDemo03GradeByStudentId(val)
+      if (!data) {
+        return
+      }
+      formData.value = data
+    } finally {
+      formLoading.value = false
+    }
+  },
+  { immediate: true }
+)
+
+/** 表单校验 */
+const validate = () => {
+  return formRef.value.validate()
+}
+
+/** 表单值 */
+const getData = () => {
+  return formData.value
+}
+
+defineExpose({ validate, getData })
+</script>

+ 55 - 0
src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue

@@ -0,0 +1,55 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column label="班主任" align="center" prop="teacher" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+    </el-table>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const props = defineProps<{
+  studentId: undefined // 学生编号(主表的关联字段)
+}>()
+const loading = ref(false) // 列表的加载中
+const list = ref([]) // 列表的数据
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo03StudentApi.getDemo03GradeByStudentId(props.studentId)
+    if (!data) {
+      return
+    }
+    list.value.push(data)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 153 - 0
src/views/infra/demo/demo03/normal/Demo03StudentForm.vue

@@ -0,0 +1,153 @@
+<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="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="description">
+        <Editor v-model="formData.description" height="150px" />
+      </el-form-item>
+    </el-form>
+    <!-- 子表的表单 -->
+    <el-tabs v-model="subTabsName">
+      <el-tab-pane label="学生课程" name="demo03Course">
+        <Demo03CourseForm ref="demo03CourseFormRef" :student-id="formData.id" />
+      </el-tab-pane>
+      <el-tab-pane label="学生班级" name="demo03Grade">
+        <Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData.id" />
+      </el-tab-pane>
+    </el-tabs>
+    <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 * as Demo03StudentApi from '@/api/infra/demo/demo03/normal'
+import Demo03CourseForm from './components/Demo03CourseForm.vue'
+import Demo03GradeForm from './components/Demo03GradeForm.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,
+  name: undefined,
+  sex: undefined,
+  birthday: undefined,
+  description: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
+  birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
+  description: [{ required: true, message: '简介不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 子表的表单 */
+const subTabsName = ref('demo03Course')
+const demo03CourseFormRef = ref()
+const demo03GradeFormRef = 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 Demo03StudentApi.getDemo03Student(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 校验子表单
+  try {
+    await demo03CourseFormRef.value.validate()
+  } catch (e) {
+    subTabsName.value = 'demo03Course'
+    return
+  }
+  try {
+    await demo03GradeFormRef.value.validate()
+  } catch (e) {
+    subTabsName.value = 'demo03Grade'
+    return
+  }
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as Demo03StudentApi.Demo03StudentVO
+    // 拼接子表的数据
+    data.demo03Courses = demo03CourseFormRef.value.getData()
+    data.demo03Grade = demo03GradeFormRef.value.getData()
+    if (formType.value === 'create') {
+      await Demo03StudentApi.createDemo03Student(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await Demo03StudentApi.updateDemo03Student(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    sex: undefined,
+    birthday: undefined,
+    description: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 100 - 0
src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue

@@ -0,0 +1,100 @@
+<template>
+  <el-form
+    ref="formRef"
+    :model="formData"
+    :rules="formRules"
+    v-loading="formLoading"
+    label-width="0px"
+    :inline-message="true"
+  >
+    <el-table :data="formData" class="-mt-10px">
+      <el-table-column label="序号" type="index" width="100" />
+      <el-table-column label="名字" min-width="150">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.name`" :rules="formRules.name" class="mb-0px!">
+            <el-input v-model="row.name" placeholder="请输入名字" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="分数" min-width="150">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.score`" :rules="formRules.score" class="mb-0px!">
+            <el-input v-model="row.score" placeholder="请输入分数" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" width="60">
+        <template #default="{ $index }">
+          <el-button @click="handleDelete($index)" link>—</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-form>
+  <el-row justify="center" class="mt-3">
+    <el-button @click="handleAdd" round>+ 添加学生课程</el-button>
+  </el-row>
+</template>
+<script setup lang="ts">
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/normal'
+
+const props = defineProps<{
+  studentId: undefined // 学生编号(主表的关联字段)
+}>()
+const formLoading = ref(false) // 表单的加载中
+const formData = ref([])
+const formRules = reactive({
+  studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  score: [{ required: true, message: '分数不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  async (val) => {
+    // 1. 重置表单
+    formData.value = []
+    // 2. val 非空,则加载数据
+    if (!val) {
+      return
+    }
+    try {
+      formLoading.value = true
+      formData.value = await Demo03StudentApi.getDemo03CourseListByStudentId(val)
+    } finally {
+      formLoading.value = false
+    }
+  },
+  { immediate: true }
+)
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  const row = {
+    id: undefined,
+    studentId: undefined,
+    name: undefined,
+    score: undefined
+  }
+  row.studentId = props.studentId
+  formData.value.push(row)
+}
+
+/** 删除按钮操作 */
+const handleDelete = (index) => {
+  formData.value.splice(index, 1)
+}
+
+/** 表单校验 */
+const validate = () => {
+  return formRef.value.validate()
+}
+
+/** 表单值 */
+const getData = () => {
+  return formData.value
+}
+
+defineExpose({ validate, getData })
+</script>

+ 72 - 0
src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue

@@ -0,0 +1,72 @@
+<template>
+  <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="teacher">
+      <el-input v-model="formData.teacher" placeholder="请输入班主任" />
+    </el-form-item>
+  </el-form>
+</template>
+<script setup lang="ts">
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/normal'
+
+const props = defineProps<{
+  studentId: undefined // 学生编号(主表的关联字段)
+}>()
+const formLoading = ref(false) // 表单的加载中
+const formData = ref([])
+const formRules = reactive({
+  studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
+  teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  async (val) => {
+    // 1. 重置表单
+    formData.value = {
+      id: undefined,
+      studentId: undefined,
+      name: undefined,
+      teacher: undefined
+    }
+    // 2. val 非空,则加载数据
+    if (!val) {
+      return
+    }
+    try {
+      formLoading.value = true
+      const data = await Demo03StudentApi.getDemo03GradeByStudentId(val)
+      if (!data) {
+        return
+      }
+      formData.value = data
+    } finally {
+      formLoading.value = false
+    }
+  },
+  { immediate: true }
+)
+
+/** 表单校验 */
+const validate = () => {
+  return formRef.value.validate()
+}
+
+/** 表单值 */
+const getData = () => {
+  return formData.value
+}
+
+defineExpose({ validate, getData })
+</script>

+ 96 - 0
src/views/mall/product/spu/form/DeliveryForm.vue

@@ -0,0 +1,96 @@
+<!-- 商品发布 - 物流设置 -->
+<template>
+  <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail">
+    <el-form-item label="配送方式" prop="deliveryTypes">
+      <el-checkbox-group v-model="formData.deliveryTypes" class="w-80">
+        <el-checkbox
+          v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE)"
+          :key="dict.value"
+          :label="dict.value"
+        >
+          {{ dict.label }}
+        </el-checkbox>
+      </el-checkbox-group>
+    </el-form-item>
+    <el-form-item
+      label="运费模板"
+      prop="deliveryTemplateId"
+      v-if="formData.deliveryTypes?.includes(DeliveryTypeEnum.EXPRESS.type)"
+    >
+      <el-select placeholder="请选择运费模板" v-model="formData.deliveryTemplateId" class="w-80">
+        <el-option
+          v-for="item in deliveryTemplateList"
+          :key="item.id"
+          :label="item.name"
+          :value="item.id"
+        />
+      </el-select>
+    </el-form-item>
+  </el-form>
+</template>
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import { copyValueToTarget } from '@/utils'
+import { propTypes } from '@/utils/propTypes'
+import type { Spu } from '@/api/mall/product/spu'
+import * as ExpressTemplateApi from '@/api/mall/trade/delivery/expressTemplate'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { DeliveryTypeEnum } from '@/utils/constants'
+
+defineOptions({ name: 'ProductDeliveryForm' })
+
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<Spu>,
+    default: () => {}
+  },
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
+})
+const formRef = ref() // 表单 Ref
+const formData = reactive<Spu>({
+  deliveryTypes: [], // 配送方式
+  deliveryTemplateId: undefined // 运费模版
+})
+const rules = reactive({
+  deliveryTypes: [required],
+  deliveryTemplateId: [required]
+})
+
+/** 将传进来的值赋值给 formData */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) {
+      return
+    }
+    copyValueToTarget(formData, data)
+  },
+  {
+    immediate: true
+  }
+)
+
+/** 表单校验 */
+const emit = defineEmits(['update:activeName'])
+const validate = async () => {
+  if (!formRef) return
+  try {
+    await unref(formRef)?.validate()
+    // 校验通过更新数据
+    Object.assign(props.propFormData, formData)
+  } catch (e) {
+    message.error('【物流设置】不完善,请填写相关信息')
+    emit('update:activeName', 'delivery')
+    throw e // 目的截断之后的校验
+  }
+}
+defineExpose({ validate })
+
+/** 初始化 */
+const deliveryTemplateList = ref([]) // 运费模版
+onMounted(async () => {
+  deliveryTemplateList.value = await ExpressTemplateApi.getSimpleTemplateList()
+})
+</script>

+ 81 - 0
src/views/mall/product/spu/form/DescriptionForm.vue

@@ -0,0 +1,81 @@
+<!-- 商品发布 - 商品详情 -->
+<template>
+  <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail">
+    <!--富文本编辑器组件-->
+    <el-form-item label="商品详情" prop="description">
+      <Editor v-model:modelValue="formData.description" />
+    </el-form-item>
+  </el-form>
+</template>
+<script lang="ts" setup>
+import type { Spu } from '@/api/mall/product/spu'
+import { Editor } from '@/components/Editor'
+import { PropType } from 'vue'
+import { propTypes } from '@/utils/propTypes'
+import { copyValueToTarget } from '@/utils'
+
+defineOptions({ name: 'ProductDescriptionForm' })
+
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<Spu>,
+    default: () => {}
+  },
+  activeName: propTypes.string.def(''),
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
+})
+const formRef = ref() // 表单Ref
+const formData = ref<Spu>({
+  description: '' // 商品详情
+})
+// 表单规则
+const rules = reactive({
+  description: [required]
+})
+
+/** 富文本编辑器如果输入过再清空会有残留,需再重置一次 */
+watch(
+  () => formData.value.description,
+  (newValue) => {
+    if ('<p><br></p>' === newValue) {
+      formData.value.description = ''
+    }
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+/** 将传进来的值赋值给 formData */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    // fix:三个表单组件监听赋值必须使用 copyValueToTarget 使用 formData.value = data 会监听非常多次
+    copyValueToTarget(formData.value, data)
+  },
+  {
+    // fix: 去掉深度监听只有对象引用发生改变的时候才执行,解决改一动多的问题
+    immediate: true
+  }
+)
+
+/** 表单校验 */
+const emit = defineEmits(['update:activeName'])
+const validate = async () => {
+  if (!formRef) return
+  try {
+    await unref(formRef)?.validate()
+    // 校验通过更新数据
+    Object.assign(props.propFormData, formData.value)
+  } catch (e) {
+    message.error('【商品详情】不完善,请填写相关信息')
+    emit('update:activeName', 'description')
+    throw e // 目的截断之后的校验
+  }
+}
+defineExpose({ validate })
+</script>

+ 74 - 0
src/views/mall/promotion/diy/page/decorate.vue

@@ -0,0 +1,74 @@
+<template>
+  <DiyEditor
+    v-if="formData && !formLoading"
+    v-model="formData.property"
+    :title="formData.name"
+    :libs="PAGE_LIBS"
+    @save="submitForm"
+  />
+</template>
+<script setup lang="ts">
+import * as DiyPageApi from '@/api/mall/promotion/diy/page'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { PAGE_LIBS } from '@/components/DiyEditor/util'
+
+/** 装修页面表单 */
+defineOptions({ name: 'DiyPageDecorate' })
+
+const message = useMessage() // 消息弹窗
+
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref<DiyPageApi.DiyPageVO>()
+const formRef = ref() // 表单 Ref
+
+// 获取详情
+const getPageDetail = async (id: any) => {
+  formLoading.value = true
+  try {
+    formData.value = await DiyPageApi.getDiyPageProperty(id)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// 提交表单
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    await DiyPageApi.updateDiyPageProperty(unref(formData)!)
+    message.success('保存成功')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// 重置表单
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    templateId: undefined,
+    name: '',
+    remark: '',
+    previewPicUrls: [],
+    property: ''
+  } as DiyPageApi.DiyPageVO
+  formRef.value?.resetFields()
+}
+
+/** 初始化 **/
+const { currentRoute } = useRouter() // 路由
+const { delView } = useTagsViewStore() // 视图操作
+const route = useRoute()
+onMounted(() => {
+  resetForm()
+  if (!route.params.id) {
+    message.warning('参数错误,页面编号不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  getPageDetail(route.params.id)
+})
+</script>

+ 167 - 0
src/views/mall/promotion/diy/template/decorate.vue

@@ -0,0 +1,167 @@
+<template>
+  <DiyEditor
+    v-if="formData && !formLoading"
+    v-model="currentFormData!.property"
+    :title="templateItems[selectedTemplateItem].name"
+    :libs="libs"
+    :show-page-config="selectedTemplateItem !== 0"
+    :show-tab-bar="selectedTemplateItem === 0"
+    :show-navigation-bar="selectedTemplateItem !== 0"
+    :preview-url="previewUrl"
+    @save="submitForm"
+    @reset="handleEditorReset"
+  >
+    <template #toolBarLeft>
+      <el-radio-group
+        v-model="selectedTemplateItem"
+        class="h-full!"
+        @change="handleTemplateItemChange"
+      >
+        <el-tooltip v-for="(item, index) in templateItems" :key="index" :content="item.name">
+          <el-radio-button :label="index">
+            <Icon :icon="item.icon" :size="24" />
+          </el-radio-button>
+        </el-tooltip>
+      </el-radio-group>
+    </template>
+  </DiyEditor>
+</template>
+<script setup lang="ts">
+// TODO @疯狂:要不要建个 decorate 目录,然后挪进去,改成 index.vue,这样可以更明确看到是个独立界面哈,更好找
+import * as DiyTemplateApi from '@/api/mall/promotion/diy/template'
+import * as DiyPageApi from '@/api/mall/promotion/diy/page'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { DiyComponentLibrary, PAGE_LIBS } from '@/components/DiyEditor/util' // 商城的 DIY 组件,在 DiyEditor 目录下
+import { toNumber } from 'lodash-es'
+
+/** 装修模板表单 */
+defineOptions({ name: 'DiyTemplateDecorate' })
+
+// 左上角工具栏操作按钮
+const selectedTemplateItem = ref(0)
+const templateItems = reactive([
+  { name: '基础设置', icon: 'ep:iphone' },
+  { name: '首页', icon: 'ep:home-filled' },
+  { name: '我的', icon: 'ep:user-filled' }
+])
+
+const message = useMessage() // 消息弹窗
+
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref<DiyTemplateApi.DiyTemplatePropertyVO>()
+const formRef = ref() // 表单 Ref
+// 当前编辑的属性
+const currentFormData = ref<DiyTemplateApi.DiyTemplatePropertyVO | DiyPageApi.DiyPageVO>()
+// 商城 H5 预览地址
+const previewUrl = ref('')
+
+// 获取详情
+const getPageDetail = async (id: any) => {
+  formLoading.value = true
+  try {
+    formData.value = await DiyTemplateApi.getDiyTemplateProperty(id)
+    currentFormData.value = formData.value
+
+    // 拼接手机预览链接
+    const domain = import.meta.env.VITE_MALL_H5_DOMAIN
+    previewUrl.value = `${domain}/#/pages/index/index?templateId=${formData.value.id}`
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// 模板组件库
+const templateLibs = [] as DiyComponentLibrary[]
+// 当前组件库
+const libs = ref<DiyComponentLibrary[]>(templateLibs)
+// 模板选项切换
+const handleTemplateItemChange = () => {
+  // 编辑模板
+  if (selectedTemplateItem.value === 0) {
+    libs.value = templateLibs
+    currentFormData.value = formData.value
+    return
+  }
+
+  // 编辑页面
+  libs.value = PAGE_LIBS
+  currentFormData.value = formData.value!.pages.find(
+    (page: DiyPageApi.DiyPageVO) => page.name === templateItems[selectedTemplateItem.value].name
+  )
+}
+
+// 提交表单
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    if (selectedTemplateItem.value === 0) {
+      // 提交模板属性
+      await DiyTemplateApi.updateDiyTemplateProperty(unref(formData)!)
+    } else {
+      // 提交页面属性
+      await DiyPageApi.updateDiyPageProperty(unref(currentFormData)!)
+    }
+    message.success('保存成功')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// 重置表单
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: '',
+    used: false,
+    usedTime: undefined,
+    remark: '',
+    previewPicUrls: [],
+    property: '',
+    pages: []
+  } as DiyTemplateApi.DiyTemplatePropertyVO
+  formRef.value?.resetFields()
+}
+
+// 重置时记录当前编辑的页面
+const handleEditorReset = () => storePageIndex()
+
+//#region 无感刷新
+// 记录标识
+const DIY_PAGE_INDEX_KEY = 'diy_page_index'
+// 1. 记录
+const storePageIndex = () =>
+  sessionStorage.setItem(DIY_PAGE_INDEX_KEY, `${selectedTemplateItem.value}`)
+// 2. 恢复
+const recoverPageIndex = () => {
+  // 恢复重置前的页面,默认是第一个页面
+  const pageIndex = toNumber(sessionStorage.getItem(DIY_PAGE_INDEX_KEY)) || 0
+  // 移除标记
+  sessionStorage.removeItem(DIY_PAGE_INDEX_KEY)
+  // 切换页面
+  if (pageIndex !== selectedTemplateItem.value) {
+    selectedTemplateItem.value = pageIndex
+    handleTemplateItemChange()
+  }
+}
+//#endregion
+
+/** 初始化 **/
+const { currentRoute } = useRouter() // 路由
+const { delView } = useTagsViewStore() // 视图操作
+onMounted(async () => {
+  resetForm()
+  if (!currentRoute.value.params.id) {
+    message.warning('参数错误,页面编号不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+
+  // 查询详情
+  await getPageDetail(currentRoute.value.params.id)
+  // 恢复重置前的页面
+  recoverPageIndex()
+})
+</script>

BIN
src/views/mall/promotion/kefu/components/asserts/daxiao.png


+ 122 - 0
src/views/pay/demo/transfer/DemoTransferForm.vue

@@ -0,0 +1,122 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="800px">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="转账类型" prop="type">
+        <el-radio-group v-model="formData.type">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PAY_TRANSFER_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+            :disabled="dict.value === 2 || dict.value === 3 || dict.value === 4"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="转账金额(元)" prop="price">
+        <el-input-number
+          v-model="formData.price"
+          :min="0"
+          :precision="2"
+          :step="0.01"
+          placeholder="请输入转账金额"
+          style="width: 200px"
+        />
+      </el-form-item>
+      <el-form-item label="收款人姓名" prop="userName">
+        <el-input v-model="formData.userName" placeholder="请输入收款人姓名" />
+      </el-form-item>
+      <el-form-item v-show="formData.type === 1" label="支付宝登录账号" prop="alipayLogonId">
+        <el-input v-model="formData.alipayLogonId" placeholder="请输入支付宝登录账号" />
+      </el-form-item>
+      <el-form-item v-show="formData.type === 2" label="微信 openid" prop="openid">
+        <el-input v-model="formData.openid" placeholder="请输入微信 openid" />
+      </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 DemoTransferApi from '@/api/pay/demo/transfer'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { yuanToFen } from '@/utils'
+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,
+  price: undefined,
+  type: undefined,
+  userName: undefined,
+  alipayLogonId: undefined,
+  openid: undefined
+})
+const formRules = reactive({
+  price: [{ required: true, message: '转账金额不能为空', trigger: 'blur' }],
+  type: [{ required: true, message: '转账类型不能为空', trigger: 'change' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+}
+/** 关闭弹窗 */
+const close = async () => {
+  dialogVisible.value = false
+  resetForm()
+}
+defineExpose({ open, close }) // 提供 open, close 方法,用于打开, 关闭弹窗
+
+/** 提交表单 */
+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 }
+    data.price = yuanToFen(data.price)
+    if (formType.value === 'create') {
+      await DemoTransferApi.createDemoTransfer(data)
+      message.success(t('common.createSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    price: undefined,
+    userName: undefined,
+    alipayLogonId: undefined,
+    openid: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 174 - 0
src/views/system/dept/DeptForm.vue

@@ -0,0 +1,174 @@
+<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="parentId">
+        <el-tree-select
+          v-model="formData.parentId"
+          :data="deptTree"
+          :props="defaultProps"
+          check-strictly
+          default-expand-all
+          placeholder="请选择上级部门"
+          value-key="deptId"
+        />
+      </el-form-item>
+      <el-form-item label="部门名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入部门名称" />
+      </el-form-item>
+      <el-form-item label="显示排序" prop="sort">
+        <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
+      </el-form-item>
+      <el-form-item label="负责人" prop="leaderUserId">
+        <el-select v-model="formData.leaderUserId" clearable placeholder="请输入负责人">
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="联系电话" prop="phone">
+        <el-input v-model="formData.phone" maxlength="11" placeholder="请输入联系电话" />
+      </el-form-item>
+      <el-form-item label="邮箱" prop="email">
+        <el-input v-model="formData.email" maxlength="50" placeholder="请输入邮箱" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="formData.status" clearable placeholder="请选择状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button 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 { defaultProps, handleTree } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+import * as UserApi from '@/api/system/user'
+import { CommonStatusEnum } from '@/utils/constants'
+import { FormRules } from 'element-plus'
+
+defineOptions({ name: 'SystemDeptForm' })
+
+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,
+  title: '',
+  parentId: undefined,
+  name: undefined,
+  sort: undefined,
+  leaderUserId: undefined,
+  phone: undefined,
+  email: undefined,
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = reactive<FormRules>({
+  parentId: [{ required: true, message: '上级部门不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '部门名称不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '显示排序不能为空', trigger: 'blur' }],
+  email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }],
+  phone: [
+    { pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: '请输入正确的手机号码', trigger: 'blur' }
+  ],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const deptTree = ref() // 树形结构
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 打开弹窗 */
+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 DeptApi.getDept(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+  // 获得部门树
+  await getTree()
+}
+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 DeptApi.DeptVO
+    if (formType.value === 'create') {
+      await DeptApi.createDept(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DeptApi.updateDept(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    title: '',
+    parentId: undefined,
+    name: undefined,
+    sort: undefined,
+    leaderUserId: undefined,
+    phone: undefined,
+    email: undefined,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+
+/** 获得部门树 */
+const getTree = async () => {
+  deptTree.value = []
+  const data = await DeptApi.getSimpleDeptList()
+  let dept: Tree = { id: 0, name: '顶级部门', children: [] }
+  dept.children = handleTree(data)
+  deptTree.value.push(dept)
+}
+</script>

+ 63 - 0
src/views/system/user/DeptTree.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="head-container">
+    <el-input v-model="deptName" class="mb-20px" clearable placeholder="请输入部门名称">
+      <template #prefix>
+        <Icon icon="ep:search" />
+      </template>
+    </el-input>
+  </div>
+  <div class="head-container">
+    <el-tree
+      ref="treeRef"
+      :data="deptList"
+      :expand-on-click-node="false"
+      :filter-node-method="filterNode"
+      :props="defaultProps"
+      default-expand-all
+      highlight-current
+      node-key="id"
+      @node-click="handleNodeClick"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ElTree } from 'element-plus'
+import * as DeptApi from '@/api/system/dept'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+defineOptions({ name: 'SystemUserDeptTree' })
+
+const deptName = ref('')
+const deptList = ref<Tree[]>([]) // 树形结构
+const treeRef = ref<InstanceType<typeof ElTree>>()
+
+/** 获得部门树 */
+const getTree = async () => {
+  const res = await DeptApi.getSimpleDeptList()
+  deptList.value = []
+  deptList.value.push(...handleTree(res))
+}
+
+/** 基于名字过滤 */
+const filterNode = (name: string, data: Tree) => {
+  if (!name) return true
+  return data.name.includes(name)
+}
+
+/** 处理部门被点击 */
+const handleNodeClick = async (row: { [key: string]: any }) => {
+  emits('node-click', row)
+}
+const emits = defineEmits(['node-click'])
+
+/** 监听deptName */
+watch(deptName, (val) => {
+  treeRef.value!.filter(val)
+})
+
+/** 初始化 */
+onMounted(async () => {
+  await getTree()
+})
+</script>