Selaa lähdekoodia

Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into dev

# Conflicts:
#	src/components/DiyEditor/components/ComponentContainer.vue
YunaiV 1 vuosi sitten
vanhempi
commit
7c5ae17d04
59 muutettua tiedostoa jossa 4295 lisäystä ja 234 poistoa
  1. 3 2
      .eslintrc.js
  2. 6 6
      README.md
  3. 28 28
      package.json
  4. 65 0
      src/api/crm/contact/index.ts
  5. 5 5
      src/api/infra/codegen/index.ts
  6. 40 0
      src/api/infra/demo/demo01/index.ts
  7. 37 0
      src/api/infra/demo/demo02/index.ts
  8. 91 0
      src/api/infra/demo/demo03/erp/index.ts
  9. 53 0
      src/api/infra/demo/demo03/inner/index.ts
  10. 53 0
      src/api/infra/demo/demo03/normal/index.ts
  11. 1 1
      src/api/mall/promotion/combination/combinationActivity.ts
  12. 0 0
      src/assets/map/json/china.json
  13. 44 49
      src/components/DiyEditor/components/ComponentContainer.vue
  14. 1 0
      src/components/DiyEditor/components/ComponentContainerProperty.vue
  15. 22 14
      src/components/DiyEditor/components/ComponentLibrary.vue
  16. 1 1
      src/components/DiyEditor/components/mobile/ImageBar/index.vue
  17. 7 4
      src/components/DiyEditor/components/mobile/NavigationBar/index.vue
  18. 5 5
      src/components/DiyEditor/components/mobile/SearchBar/index.vue
  19. 4 3
      src/components/DiyEditor/components/mobile/TabBar/index.vue
  20. 6 6
      src/components/DiyEditor/components/mobile/TitleBar/index.vue
  21. 1 1
      src/components/DiyEditor/components/mobile/VideoPlayer/index.vue
  22. 35 19
      src/components/DiyEditor/index.vue
  23. 2 0
      src/components/UploadFile/src/UploadFile.vue
  24. 11 7
      src/components/VerticalButtonGroup/index.vue
  25. 1 1
      src/layout/components/Message/src/Message.vue
  26. 10 0
      src/router/modules/remaining.ts
  27. 348 0
      src/views/crm/contact/ContactForm.vue
  28. 71 0
      src/views/crm/contact/OwerSelect.vue
  29. 23 0
      src/views/crm/contact/detail/ContactBasicInfo.vue
  30. 93 0
      src/views/crm/contact/detail/ContactDetails.vue
  31. 147 0
      src/views/crm/contact/detail/index.vue
  32. 333 0
      src/views/crm/contact/index.vue
  33. 1 1
      src/views/infra/codegen/EditTable.vue
  34. 1 1
      src/views/infra/codegen/PreviewCode.vue
  35. 69 75
      src/views/infra/codegen/components/GenerateInfoForm.vue
  36. 3 1
      src/views/infra/codegen/index.vue
  37. 126 0
      src/views/infra/demo/demo01/Demo01ContactForm.vue
  38. 214 0
      src/views/infra/demo/demo01/index.vue
  39. 114 0
      src/views/infra/demo/demo02/Demo02CategoryForm.vue
  40. 207 0
      src/views/infra/demo/demo02/index.vue
  41. 121 0
      src/views/infra/demo/demo03/erp/Demo03StudentForm.vue
  42. 99 0
      src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue
  43. 126 0
      src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue
  44. 99 0
      src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue
  45. 126 0
      src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue
  46. 240 0
      src/views/infra/demo/demo03/erp/index.vue
  47. 153 0
      src/views/infra/demo/demo03/inner/Demo03StudentForm.vue
  48. 100 0
      src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue
  49. 51 0
      src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue
  50. 72 0
      src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue
  51. 55 0
      src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue
  52. 229 0
      src/views/infra/demo/demo03/inner/index.vue
  53. 153 0
      src/views/infra/demo/demo03/normal/Demo03StudentForm.vue
  54. 100 0
      src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue
  55. 72 0
      src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue
  56. 214 0
      src/views/infra/demo/demo03/normal/index.vue
  57. 0 4
      src/views/infra/testDemo/index.vue
  58. 2 0
      src/views/mall/statistics/member/components/MemberFunnelCard.vue
  59. 1 0
      src/views/mall/trade/delivery/pickUpOrder/index.vue

+ 3 - 2
.eslintrc.js

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

+ 6 - 6
README.md

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

+ 28 - 28
package.json

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
src/assets/map/json/china.json


+ 44 - 49
src/components/DiyEditor/components/ComponentContainer.vue

@@ -1,7 +1,6 @@
 <template>
   <div :class="['component', { active: active }]">
     <div
-      class="component-inner"
       :style="{
         ...style
       }"
@@ -127,114 +126,110 @@ $active-border-width: 2px;
 $hover-border-width: 1px;
 $name-position: -85px;
 $toolbar-position: -55px;
+
 /* 组件 */
 .component {
   position: relative;
   cursor: move;
-  .component-inner {
-    position: relative;
-    z-index: 1;
-  }
-  /* 用于包裹组件,为组件提供 组件名称、工具栏、边框等样式 */
+
   .component-wrap {
-    z-index: 0;
-    // 不可以被点击
-    // component-wrap会遮挡组件,导致组件不能触发鼠标事件,所以这里要先禁用,然后在组件名称、工具栏上开启。
-    pointer-events: none;
-    display: block;
     position: absolute;
-    left: -$active-border-width;
     top: 0;
+    left: -$active-border-width;
+    display: block;
     width: 100%;
     height: 100%;
+
+    /* 鼠标放到组件上时 */
+    &:hover {
+      border: $hover-border-width dashed var(--el-color-primary);
+      box-shadow: 0 0 5px 0 rgb(24 144 255 / 30%);
+
+      .component-name {
+        top: $hover-border-width;
+
+        /* 防止加了边框之后,位置移动 */
+        left: $name-position - $hover-border-width;
+      }
+    }
+
     /* 左侧:组件名称 */
     .component-name {
-      // 可以被点击
-      pointer-events: auto;
-      display: block;
       position: absolute;
+      top: $active-border-width;
+      left: $name-position;
+      display: block;
       width: 80px;
-      text-align: center;
-      line-height: 25px;
       height: 25px;
-      background: #fff;
       font-size: 12px;
-      left: $name-position;
-      top: $active-border-width;
+      line-height: 25px;
+      text-align: center;
+      background: #fff;
       box-shadow:
         0 0 4px #00000014,
         0 2px 6px #0000000f,
         0 4px 8px 2px #0000000a;
+
       /* 右侧小三角 */
-      &:after {
+      &::after {
         position: absolute;
         top: 7.5px;
         right: -10px;
-        content: ' ';
-        height: 0;
         width: 0;
+        height: 0;
         border: 5px solid transparent;
         border-left-color: #fff;
+        content: ' ';
       }
     }
+
     /* 右侧:组件操作工具栏 */
     .component-toolbar {
-      // 可以被点击
-      pointer-events: auto;
-      display: none;
       position: absolute;
       top: 0;
       right: $toolbar-position;
+      display: none;
+
       /* 左侧小三角 */
-      &:before {
+      &::before {
         position: absolute;
         top: 10px;
         left: -10px;
-        content: ' ';
-        height: 0;
         width: 0;
+        height: 0;
         border: 5px solid transparent;
         border-right-color: #2d8cf0;
+        content: ' ';
       }
     }
   }
+
   /* 组件选中时 */
   &.active {
     margin-bottom: 4px;
 
     .component-wrap {
-      z-index: 2;
-      border: $active-border-width solid var(--el-color-primary) !important;
-      box-shadow: 0 0 10px 0 rgba(24, 144, 255, 0.3);
       margin-bottom: $active-border-width + $active-border-width;
+      border: $active-border-width solid var(--el-color-primary) !important;
+      box-shadow: 0 0 10px 0 rgb(24 144 255 / 30%);
 
       .component-name {
-        background: var(--el-color-primary);
-        color: #fff;
+        top: 0 !important;
+
         /* 防止加了边框之后,位置移动 */
         left: $name-position - $active-border-width !important;
-        top: 0 !important;
-        &:after {
+        color: #fff;
+        background: var(--el-color-primary);
+
+        &::after {
           border-left-color: var(--el-color-primary);
         }
       }
+
       .component-toolbar {
         display: block;
       }
     }
   }
-  /* 鼠标放到组件上时 */
-  &:hover {
-    .component-wrap {
-      z-index: 2;
-      border: $hover-border-width dashed var(--el-color-primary);
-      box-shadow: 0 0 5px 0 rgba(24, 144, 255, 0.3);
-      .component-name {
-        /* 防止加了边框之后,位置移动 */
-        left: $name-position - $hover-border-width;
-        top: $hover-border-width;
-      }
-    }
-  }
 }
 </style>

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

@@ -157,6 +157,7 @@ const handleSliderChange = (prop: string) => {
 :deep(.el-slider__runway) {
   margin-right: 16px;
 }
+
 :deep(.el-input-number) {
   width: 50px;
 }

+ 22 - 14
src/components/DiyEditor/components/ComponentLibrary.vue

@@ -90,23 +90,26 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
 .editor-left {
   z-index: 1;
   flex-shrink: 0;
-  box-shadow: 8px 0 8px -8px rgba(0, 0, 0, 0.12);
+  box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
 
   :deep(.el-collapse) {
     border-top: none;
   }
+
   :deep(.el-collapse-item__wrap) {
     border-bottom: none;
   }
+
   :deep(.el-collapse-item__content) {
     padding-bottom: 0;
   }
+
   :deep(.el-collapse-item__header) {
-    border-bottom: none;
-    background-color: var(--el-bg-color-page);
-    padding: 0 24px;
     height: 32px;
+    padding: 0 24px;
     line-height: 32px;
+    background-color: var(--el-bg-color-page);
+    border-bottom: none;
   }
 
   .component-container {
@@ -116,25 +119,26 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
   }
 
   .component {
+    display: flex;
     width: 86px;
     height: 86px;
-    display: flex;
+    cursor: move;
+    border-right: 1px solid var(--el-border-color-lighter);
+    border-bottom: 1px solid var(--el-border-color-lighter);
     flex-direction: column;
     align-items: center;
     justify-content: center;
-    border-right: 1px solid var(--el-border-color-lighter);
-    border-bottom: 1px solid var(--el-border-color-lighter);
-    cursor: move;
 
     .el-icon {
       margin-bottom: 4px;
       color: gray;
     }
   }
+
   .component.active,
   .component:hover {
-    background: var(--el-color-primary);
     color: var(--el-color-white);
+    background: var(--el-color-primary);
 
     .el-icon {
       color: var(--el-color-white);
@@ -155,11 +159,10 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
 .drag-area {
   /* 拖拽到手机区域时的样式 */
   .draggable-ghost {
+    display: flex;
     width: 100%;
     height: 40px;
-    display: flex;
-    justify-content: center;
-    align-items: center;
+
     /* 条纹背景 */
     background: linear-gradient(
       45deg,
@@ -174,20 +177,25 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
     );
     background-size: 1rem 1rem;
     transition: all 0.5s;
+    justify-content: center;
+    align-items: center;
+
     span {
-      color: #fff;
       display: inline-block;
       width: 140px;
       height: 25px;
       font-size: 12px;
-      text-align: center;
       line-height: 25px;
+      color: #fff;
+      text-align: center;
       background: #5487df;
     }
+
     /* 拖拽时隐藏组件 */
     .component {
       display: none;
     }
+
     /* 拖拽时显示占位提示 */
     .drag-placement {
       display: block;

+ 1 - 1
src/components/DiyEditor/components/mobile/ImageBar/index.vue

@@ -17,8 +17,8 @@ defineProps<{ property: ImageBarProperty }>()
 <style scoped lang="scss">
 /* 图片 */
 img {
+  display: block;
   width: 100%;
   height: 100%;
-  display: block;
 }
 </style>

+ 7 - 4
src/components/DiyEditor/components/mobile/NavigationBar/index.vue

@@ -35,22 +35,25 @@ defineProps<{ property: NavigationBarProperty }>()
 </script>
 <style lang="scss" scoped>
 .navigation-bar {
+  display: flex;
   height: 35px;
   background: #fff;
-  display: flex;
   justify-content: space-between;
   align-items: center;
+
   /* 左边 */
   .left {
     margin-left: 8px;
   }
+
   .center {
-    flex: 1;
-    text-align: center;
     font-size: 14px;
     line-height: 35px;
-    color: #333333;
+    color: #333;
+    text-align: center;
+    flex: 1;
   }
+
   /* 右边 */
   .right {
     margin-right: 8px;

+ 5 - 5
src/components/DiyEditor/components/mobile/SearchBar/index.vue

@@ -45,21 +45,21 @@ defineProps<{ property: SearchProperty }>()
   /* 搜索框 */
   .inner {
     position: relative;
-    min-height: 28px;
     display: flex;
-    align-items: center;
+    min-height: 28px;
     font-size: 14px;
+    align-items: center;
 
     .placeholder {
       display: flex;
-      align-items: center;
       width: 100%;
       padding: 0 8px;
-      gap: 2px;
-      text-overflow: ellipsis;
       overflow: hidden;
+      text-overflow: ellipsis;
       word-break: break-all;
       white-space: nowrap;
+      align-items: center;
+      gap: 2px;
     }
 
     .right {

+ 4 - 3
src/components/DiyEditor/components/mobile/TabBar/index.vue

@@ -30,8 +30,9 @@ defineProps<{ property: TabBarProperty }>()
 </script>
 <style lang="scss" scoped>
 .tab-bar {
-  width: 100%;
   z-index: 2;
+  width: 100%;
+
   .tab-bar-bg {
     display: flex;
     flex-direction: row;
@@ -41,11 +42,11 @@ defineProps<{ property: TabBarProperty }>()
 
     .tab-bar-item {
       display: flex;
+      width: 100%;
+      font-size: 12px;
       flex-direction: column;
       align-items: center;
       justify-content: center;
-      font-size: 12px;
-      width: 100%;
 
       img {
         width: 26px;

+ 6 - 6
src/components/DiyEditor/components/mobile/TitleBar/index.vue

@@ -56,23 +56,23 @@ defineProps<{ property: TitleBarProperty }>()
 </script>
 <style scoped lang="scss">
 .title-bar {
-  border: 2px solid #fff;
-  box-sizing: border-box;
+  position: relative;
   width: 100%;
-  padding: 8px 16px;
   min-height: 20px;
-  position: relative;
+  padding: 8px 16px;
+  border: 2px solid #fff;
+  box-sizing: border-box;
 
   /* 更多 */
   .more {
     position: absolute;
-    right: 8px;
     top: 0;
+    right: 8px;
     bottom: 0;
+    display: flex;
     margin: auto;
     font-size: 10px;
     color: #969799;
-    display: flex;
     align-items: center;
     justify-content: center;
   }

+ 1 - 1
src/components/DiyEditor/components/mobile/VideoPlayer/index.vue

@@ -23,8 +23,8 @@ defineProps<{ property: VideoPlayerProperty }>()
 <style scoped lang="scss">
 /* 图片 */
 img {
+  display: block;
   width: 100%;
   height: 100%;
-  display: block;
 }
 </style>

+ 35 - 19
src/components/DiyEditor/index.vue

@@ -337,28 +337,33 @@ onMounted(() => setDefaultSelectedComponent())
 /* 手机宽度 */
 $phone-width: 375px;
 $toolbar-height: 42px;
+
 /* 根节点样式 */
 .editor {
+  display: flex;
   height: 100%;
   margin: calc(0px - var(--app-content-padding));
-  display: flex;
   flex-direction: column;
+
   /* 顶部:工具栏 */
   .editor-header {
     display: flex;
-    align-items: center;
-    justify-content: space-between;
     height: $toolbar-height;
     padding: 0;
-    border-bottom: solid 1px var(--el-border-color);
     background-color: var(--el-bg-color);
+    border-bottom: solid 1px var(--el-border-color);
+    align-items: center;
+    justify-content: space-between;
+
     /* 工具栏:右侧按钮 */
     .header-right {
       height: 100%;
+
       .el-button {
         height: 100%;
       }
     }
+
     /* 隐藏工具栏按钮的边框 */
     :deep(.el-radio-button__inner),
     :deep(.el-button) {
@@ -367,33 +372,40 @@ $toolbar-height: 42px;
       border-radius: 0 !important;
     }
   }
+
   /* 中心操作区 */
   .editor-container {
     height: calc(
       100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) -
         $toolbar-height
     );
+
     /* 右侧属性面板 */
     .editor-right {
-      flex-shrink: 0;
-      box-shadow: -8px 0 8px -8px rgba(0, 0, 0, 0.12);
       overflow: hidden;
+      box-shadow: -8px 0 8px -8px rgb(0 0 0 / 12%);
+      flex-shrink: 0;
+
       /* 属性面板顶部:减少内边距 */
       :deep(.el-card__header) {
         padding: 8px 16px;
       }
+
       /* 属性面板分组 */
       :deep(.property-group) {
         margin: 0 -20px;
+
         &.el-card {
           border: none;
         }
+
         /* 属性分组名称 */
         .el-card__header {
-          border: none;
-          background: var(--el-bg-color-page);
           padding: 8px 32px;
+          background: var(--el-bg-color-page);
+          border: none;
         }
+
         .el-card__body {
           border: none;
         }
@@ -403,33 +415,36 @@ $toolbar-height: 42px;
     /* 中心区域 */
     .editor-center {
       position: relative;
-      flex: 1 1 0;
-      background-color: var(--app-content-bg-color);
       display: flex;
+      width: 100%;
+      margin: 16px 0 0;
+      overflow: hidden;
+      background-color: var(--app-content-bg-color);
+      flex: 1 1 0;
       flex-direction: column;
       justify-content: center;
-      margin: 16px 0 0 0;
-      overflow: hidden;
-      width: 100%;
 
       /* 手机顶部 */
       .editor-design-top {
+        display: flex;
         width: $phone-width;
         margin: 0 auto;
-        display: flex;
         flex-direction: column;
+
         /* 手机顶部状态栏 */
         .status-bar {
-          height: 20px;
           width: $phone-width;
+          height: 20px;
           background-color: #fff;
         }
       }
+
       /* 手机底部导航 */
       .editor-design-bottom {
         width: $phone-width;
         margin: 0 auto;
       }
+
       /* 手机页面编辑区域 */
       :deep(.editor-design-center) {
         width: 100%;
@@ -437,14 +452,15 @@ $toolbar-height: 42px;
         /* 主体内容 */
         .phone-container {
           position: relative;
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          height: 100%;
           width: $phone-width;
+          height: 100%;
           margin: 0 auto;
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+
           .drag-area {
-            height: 100%;
             width: 100%;
+            height: 100%;
           }
         }
       }

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

@@ -144,6 +144,8 @@ watch(
     } else if (isArray(props.modelValue)) {
       // 情况2:字符串
       files.concat(props.modelValue)
+    } else if (props.modelValue == null) {
+      // 情况3:undefined 不处理
     } else {
       throw new Error('不支持的 modelValue 类型')
     }

+ 11 - 7
src/components/VerticalButtonGroup/index.vue

@@ -17,24 +17,28 @@ defineOptions({ name: 'VerticalButtonGroup' })
   display: inline-flex;
   flex-direction: column;
 }
+
 .el-button-group > :deep(.el-button:first-child) {
-  border-bottom-left-radius: 0;
-  border-bottom-right-radius: 0;
-  border-top-right-radius: var(--el-border-radius-base);
   border-bottom-color: var(--el-button-divide-border-color);
+  border-top-right-radius: var(--el-border-radius-base);
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
 }
+
 .el-button-group > :deep(.el-button:last-child) {
-  border-top-left-radius: 0;
+  border-top-color: var(--el-button-divide-border-color);
   border-top-right-radius: 0;
   border-bottom-left-radius: var(--el-border-radius-base);
-  border-top-color: var(--el-button-divide-border-color);
+  border-top-left-radius: 0;
 }
-.el-button-group :deep(.el-button--primary:not(:first-child):not(:last-child)) {
+
+.el-button-group :deep(.el-button--primary:not(:first-child, :last-child)) {
   border-top-color: var(--el-button-divide-border-color);
   border-bottom-color: var(--el-button-divide-border-color);
 }
+
 .el-button-group > :deep(.el-button:not(:last-child)) {
-  margin-bottom: -1px;
   margin-right: 0;
+  margin-bottom: -1px;
 }
 </style>

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

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

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

@@ -503,6 +503,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
           hidden: true
         },
         component: () => import('@/views/crm/customer/detail/index.vue')
+      },
+      {
+        path: 'contact/detail/:id',
+        name: 'CrmContactDetail',
+        meta: {
+          title: '联系人详情',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/crm/contact/detail/index.vue')
       }
     ]
   }

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

@@ -0,0 +1,348 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" :width="800">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="130px"
+      v-loading="formLoading"
+      :inline="true"
+    >
+      <el-form-item label="姓名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入姓名" />
+      </el-form-item>
+      <el-form-item label="负责人" prop="ownerUserId">
+        <el-select
+          v-model="ownerUserList"
+          placeholder="请选择负责人"
+          multiple
+          value-key="id"
+          lable-key="nickname"
+          @click="openOwerForm('open')"
+        >
+          <el-option
+            v-for="item in ownerUserList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="客户名称" prop="customerName">
+        <el-popover
+          placement="bottom"
+          :width="600"
+          trigger="click"
+          :teleported="false"
+          :visible="showCustomer"
+          :offset="10"
+        >
+          <template #reference>
+            <el-input
+              placeholder="请选择"
+              @click="openCustomerSelect"
+              v-model="formData.customerName"
+            />
+          </template>
+          <el-table :data="list" ref="multipleTableRef" @select="handleSelectionChange">
+            <el-table-column label="选择" type="selection" width="55" />
+            <el-table-column width="100" property="id" label="编号" />
+            <el-table-column width="150" property="name" label="客户名称" />
+            <el-table-column label="客户来源" align="center" prop="source" width="100">
+              <template #default="scope">
+                <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" />
+              </template>
+            </el-table-column>
+            <el-table-column label="客户等级" align="center" prop="level" width="120">
+              <template #default="scope">
+                <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" />
+              </template>
+            </el-table-column>
+          </el-table>
+          <!-- 分页 -->
+          <el-row :gutter="20">
+            <el-col>
+              <Pagination
+                :total="total"
+                v-model:page="queryParams.pageNo"
+                v-model:limit="queryParams.pageSize"
+                @pagination="getList"
+                layout="sizes, prev, pager, next"
+              />
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="10" :offset="13">
+              <el-button @click="selectCustomer">确认</el-button>
+              <el-button @click="showCustomer = false">取消</el-button>
+            </el-col>
+          </el-row>
+        </el-popover>
+      </el-form-item>
+      <el-form-item label="性别" prop="sex">
+        <el-select v-model="formData.sex" placeholder="请选择">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="手机号" prop="mobile">
+        <el-input v-model="formData.mobile" placeholder="请输入手机号" />
+      </el-form-item>
+      <el-form-item label="座机" prop="telephone">
+        <el-input v-model="formData.telephone" placeholder="请输入座机" style="width: 215px" />
+      </el-form-item>
+      <el-form-item label="邮箱" prop="email">
+        <el-input v-model="formData.email" placeholder="请输入邮箱" />
+      </el-form-item>
+      <el-form-item label="QQ" prop="qq">
+        <el-input v-model="formData.qq" placeholder="请输入QQ" style="width: 215px" />
+      </el-form-item>
+      <el-form-item label="微信" prop="webchat">
+        <el-input v-model="formData.webchat" placeholder="请输入微信" />
+      </el-form-item>
+      <el-form-item label="下次联系时间" prop="nextTime">
+        <el-date-picker
+          v-model="formData.nextTime"
+          type="date"
+          value-format="x"
+          placeholder="选择下次联系时间"
+        />
+      </el-form-item>
+      <el-form-item label="地址" prop="address">
+        <el-input v-model="formData.address" placeholder="请输入地址" />
+      </el-form-item>
+      <el-form-item label="直属上级" prop="parentId">
+        <el-select v-model="formData.parentId" placeholder="请选择">
+          <el-option
+            v-for="item in allContactList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+            :disabled="item.id == formData.id"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="职位" prop="post">
+        <el-input v-model="formData.post" placeholder="请输入职位" />
+      </el-form-item>
+
+      <el-form-item label="是否关键决策人" prop="policyMakers" style="width: 400px">
+        <el-radio-group v-model="formData.policyMakers">
+          <el-radio
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输入备注" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+  <OwerSelect
+    ref="owerRef"
+    @confirmOwerSelect="owerSelectValue"
+    :initOwerUser="formData.ownerUserId"
+  />
+</template>
+<script setup lang="ts">
+import * as ContactApi from '@/api/crm/contact'
+import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
+import OwerSelect from './OwerSelect.vue'
+import * as UserApi from '@/api/system/user'
+import * as CustomerApi from '@/api/crm/customer'
+import { ElTable } from 'element-plus'
+
+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({
+  nextTime: undefined,
+  mobile: undefined,
+  telephone: undefined,
+  email: undefined,
+  customerId: undefined,
+  customerName: undefined,
+  address: undefined,
+  remark: undefined,
+  ownerUserId: undefined,
+  lastTime: undefined,
+  id: undefined,
+  parentId: undefined,
+  name: undefined,
+  post: undefined,
+  qq: undefined,
+  webchat: undefined,
+  sex: undefined,
+  policyMakers: undefined
+})
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  mobile: null,
+  industryId: null,
+  level: null,
+  source: null
+})
+const formRules = reactive({
+  name: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
+  customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const ownerUserList = ref<any[]>([])
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  allContactList.value = await ContactApi.simpleAlllist()
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ContactApi.getContact(id)
+      userList.value = await UserApi.getSimpleUserList()
+      await gotOwnerUser(formData.value.ownerUserId)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CustomerApi.getCustomerPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+const gotOwnerUser = (owerUserId: any) => {
+  if (owerUserId !== null) {
+    owerUserId.split(',').forEach((item: string) => {
+      userList.value.find((user: { id: any }) => {
+        if (user.id == item) {
+          ownerUserList.value.push(user)
+        }
+      })
+    })
+  }
+}
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  owerSelectValue(ownerUserList)
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ContactApi.ContactVO
+    if (formType.value === 'create') {
+      await ContactApi.createContact(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ContactApi.updateContact(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    nextTime: undefined,
+    mobile: undefined,
+    telephone: undefined,
+    email: undefined,
+    customerId: undefined,
+    address: undefined,
+    remark: undefined,
+    ownerUserId: undefined,
+    lastTime: undefined,
+    id: undefined,
+    parentId: undefined,
+    name: undefined,
+    post: undefined,
+    qq: undefined,
+    webchat: undefined,
+    sex: undefined,
+    policyMakers: undefined
+  }
+  formRef.value?.resetFields()
+  ownerUserList.value = []
+}
+/** 添加/修改操作 */
+const owerRef = ref()
+const openOwerForm = (type: string) => {
+  owerRef.value.open(type, ownerUserList.value)
+}
+
+const owerSelectValue = (value) => {
+  ownerUserList.value = value.value
+  formData.value.ownerUserId = undefined
+  value.value.forEach((item, index) => {
+    if (index != 0) {
+      formData.value.ownerUserId = formData.value.ownerUserId + ',' + item.id
+    } else {
+      formData.value.ownerUserId = item.id
+    }
+  })
+}
+//选择客户
+const showCustomer = ref(false)
+const openCustomerSelect = () => {
+  showCustomer.value = !showCustomer.value
+  queryParams.pageNo = 1
+  getList()
+}
+const multipleTableRef = ref<InstanceType<typeof ElTable>>()
+const multipleSelection = ref()
+const handleSelectionChange = ({}, row) => {
+  multipleSelection.value = row
+  multipleTableRef.value!.clearSelection()
+  multipleTableRef.value!.toggleRowSelection(row, undefined)
+}
+const selectCustomer = () => {
+  formData.value.customerId = multipleSelection.value.id
+  formData.value.customerName = multipleSelection.value.name
+  showCustomer.value = !showCustomer.value
+}
+const allContactList = ref([]) //所有联系人列表
+onMounted(async () => {
+  allContactList.value = await ContactApi.simpleAlllist()
+})
+</script>

+ 71 - 0
src/views/crm/contact/OwerSelect.vue

@@ -0,0 +1,71 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="600px">
+    <el-transfer
+      v-model="value"
+      :data="data"
+      :titles="transferTitles"
+      :props="transferDataProp"
+      :right-default-checked="[1]"
+    />
+    <el-row justify="end">
+      <el-col :span="4">
+        <el-button type="primary" @click="confirmOwerSelect">确认</el-button>
+      </el-col>
+      <el-col :span="4">
+        <el-button type="primary" @click="confirmOwerSelect">取消</el-button>
+      </el-col>
+    </el-row>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as UserApi from '@/api/system/user'
+import { parseBigInt } from 'jsencrypt/lib/lib/jsbn/jsbn'
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('选择') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('')
+const transferTitles = ref(['待选择', '已选择'])
+const transferDataProp = ref({
+  key: 'id',
+  label: 'nickname'
+})
+const emit = defineEmits(['confirmOwerSelect'])
+const data = ref<UserApi.UserVO[]>([])
+const value = ref<any[]>([])
+const rightDefaultChecked = ref<any[]>([])
+/** 打开弹窗 */
+const open = async (type: string, ownerUserList: any[]) => {
+  dialogVisible.value = true
+  formType.value = type
+  // 修改时,设置数据
+  if (ownerUserList) {
+    formLoading.value = true
+    try {
+      ownerUserList.forEach((item) => {
+        value.value.push(item.id)
+      })
+    } finally {
+      formLoading.value = false
+    }
+  }
+  rightDefaultChecked.value = []
+  data.value = await UserApi.getSimpleUserList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+const confirmOwerSelect = () => {
+  const returnData = ref<any[]>([])
+  data.value.forEach((item) => {
+    if (value.value.indexOf(item.id) > -1) {
+      returnData.value.push(item)
+    }
+  })
+  emit('confirmOwerSelect', returnData)
+  dialogVisible.value = false
+  value.value = []
+}
+</script>
+<style>
+.el-row {
+  margin-top: 20px;
+}
+</style>

+ 23 - 0
src/views/crm/contact/detail/ContactBasicInfo.vue

@@ -0,0 +1,23 @@
+<!--
+ * @Author: zyna
+ * @Date: 2023-11-11 14:50:11
+ * @LastEditTime: 2023-11-11 14:52:47
+ * @FilePath: \yudao-ui-admin-vue3\src\views\crm\contact\detail\ContactBasicInfo.vue
+ * @Description: 
+-->
+<template>
+  <el-col>
+    <el-row>
+      <span class="text-xl font-bold">{{ contact.name }}</span>
+    </el-row>
+  </el-col>
+  <el-col class="mt-10px">
+    <!-- TODO 标签 -->
+    <!--    <Icon icon="ant-design:tag-filled" />-->
+  </el-col>
+</template>
+<script setup lang="ts">
+import * as ContactApi from '@/api/crm/contact'
+
+const { contact } = defineProps<{ contact: ContactApi.ContactVO }>()
+</script>

+ 93 - 0
src/views/crm/contact/detail/ContactDetails.vue

@@ -0,0 +1,93 @@
+<template>
+  <el-collapse v-model="activeNames">
+    <el-collapse-item name="basicInfo">
+      <template #title>
+        <span class="text-base font-bold">基本信息</span>
+      </template>
+      <el-descriptions :column="4">
+        <el-descriptions-item label="姓名">
+          {{ contact.name }}
+        </el-descriptions-item>
+        <el-descriptions-item label="客户名称">
+          {{ contact.customerName }}
+        </el-descriptions-item>
+        <el-descriptions-item label="手机">
+          {{ contact.mobile }}
+        </el-descriptions-item>
+        <el-descriptions-item label="座机">
+          {{ contact.telephone }}
+        </el-descriptions-item>
+        <el-descriptions-item label="邮箱">
+          {{ contact.email }}
+        </el-descriptions-item>
+        <el-descriptions-item label="QQ">
+          {{ contact.qq }}
+        </el-descriptions-item>
+        <el-descriptions-item label="微信">
+          {{ contact.webchat }}
+        </el-descriptions-item>
+        <el-descriptions-item label="详细地址">
+          {{ contact.address }}
+        </el-descriptions-item>
+        <el-descriptions-item label="下次联系时间">
+          {{ contact.nextTime ? formatDate(contact.nextTime) : '空' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="性别">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="contact.sex" />
+        </el-descriptions-item>
+        <el-descriptions-item label="备注">
+          {{ contact.remark }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-collapse-item>
+    <el-collapse-item name="systemInfo">
+      <template #title>
+        <span class="text-base font-bold">系统信息</span>
+      </template>
+      <el-descriptions :column="2">
+        <el-descriptions-item label="负责人">
+          {{ gotOwnerUser(contact.ownerUserId) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建人">
+          {{ contact.creatorName }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ contact.createTime ? formatDate(contact.createTime) : '空' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="更新时间">
+          {{ contact.updateTime ? formatDate(contact.updateTime) : '空' }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-collapse-item>
+  </el-collapse>
+</template>
+<script setup lang="ts">
+import * as ContactApi from '@/api/crm/contact'
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+import * as UserApi from '@/api/system/user'
+const { contact } = defineProps<{ contact: ContactApi.ContactVO }>()
+
+// 展示的折叠面板
+const activeNames = ref(['basicInfo', 'systemInfo'])
+const gotOwnerUser = (owerUserId: string) => {
+  let ownerName = ''
+  if (owerUserId !== null && owerUserId != undefined) {
+    owerUserId.split(',').forEach((item: string, index: number) => {
+      if (index != 0) {
+        ownerName =
+          ownerName + ',' + userList.value.find((user: { id: any }) => user.id == item)?.nickname
+      } else {
+        ownerName = userList.value.find((user: { id: any }) => user.id == item)?.nickname || ''
+      }
+    })
+  }
+  return ownerName
+}
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+/** 初始化 **/
+onMounted(async () => {
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>
+<style scoped lang="scss"></style>

+ 147 - 0
src/views/crm/contact/detail/index.vue

@@ -0,0 +1,147 @@
+<template>
+  <div v-loading="loading">
+    <div class="flex items-start justify-between">
+      <div>
+        <!-- 左上:客户基本信息 -->
+        <ContactBasicInfo :contact="contact" />
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <el-button @click="openForm('update', contact.id)" v-hasPermi="['crm:contact:update']">
+          编辑
+        </el-button>
+      </div>
+    </div>
+    <el-row class="mt-10px">
+      <el-button>
+        <Icon icon="ph:calendar-fill" class="mr-5px" />
+        创建任务
+      </el-button>
+      <el-button>
+        <Icon icon="carbon:email" class="mr-5px" />
+        发送邮件
+      </el-button>
+      <el-button>
+        <Icon icon="system-uicons:contacts" class="mr-5px" />
+        创建联系人
+      </el-button>
+      <el-button>
+        <Icon icon="ep:opportunity" class="mr-5px" />
+        创建商机
+      </el-button>
+      <el-button>
+        <Icon icon="clarity:contract-line" class="mr-5px" />
+        创建合同
+      </el-button>
+      <el-button>
+        <Icon icon="icon-park:income-one" class="mr-5px" />
+        创建回款
+      </el-button>
+      <el-button>
+        <Icon icon="fluent:people-team-add-20-filled" class="mr-5px" />
+        添加团队成员
+      </el-button>
+    </el-row>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="vertical">
+      <el-descriptions-item label="客户名称">
+        {{ contact.customerName }}
+      </el-descriptions-item>
+      <el-descriptions-item label="职务">
+        {{ contact.post }}
+      </el-descriptions-item>
+      <el-descriptions-item label="手机">
+        {{ contact.mobile }}
+      </el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ contact.createTime ? formatDate(contact.createTime) : '空' }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+  <!-- TODO wanwan:这个 tab 拉满哈,可以更好看; -->
+  <el-col :span="18">
+    <el-tabs>
+      <el-tab-pane label="详细资料">
+        <!-- TODO wanwan:这个 ml-2 是不是可以优化下,不要整个左移,而是里面的内容有个几 px 的偏移,不顶在框里 -->
+        <ContactDetails class="ml-2" :contact="contact" />
+      </el-tab-pane>
+      <el-tab-pane label="活动" lazy> 活动</el-tab-pane>
+      <el-tab-pane label="邮件" lazy> 邮件</el-tab-pane>
+      <el-tab-pane label="工商信息" lazy> 工商信息</el-tab-pane>
+      <!-- TODO wanwan 以下标签上的数量需要接口统计返回 -->
+      <el-tab-pane label="客户" lazy>
+        <template #label> 客户<el-badge :value="12" class="item" type="primary" /> </template>
+        客户
+      </el-tab-pane>
+      <el-tab-pane label="团队成员" lazy>
+        <template #label> 团队成员<el-badge :value="2" class="item" type="primary" /> </template>
+        团队成员
+      </el-tab-pane>
+      <el-tab-pane label="商机" lazy> 商机</el-tab-pane>
+      <el-tab-pane label="合同" lazy>
+        <template #label> 合同<el-badge :value="3" class="item" type="primary" /> </template>
+        合同
+      </el-tab-pane>
+      <el-tab-pane label="回款" lazy>
+        <template #label> 回款<el-badge :value="4" class="item" type="primary" /> </template>
+        回款
+      </el-tab-pane>
+      <el-tab-pane label="回访" lazy> 回访</el-tab-pane>
+      <el-tab-pane label="发票" lazy> 发票</el-tab-pane>
+    </el-tabs>
+  </el-col>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ContactForm ref="formRef" @success="getContactData(id)" />
+</template>
+
+<script setup lang="ts">
+import { ElMessage } from 'element-plus'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import * as ContactApi from '@/api/crm/contact'
+import ContactBasicInfo from '@/views/crm/contact/detail/ContactBasicInfo.vue'
+import ContactDetails from '@/views/crm/contact/detail/ContactDetails.vue'
+import ContactForm from '@/views/crm/contact/ContactForm.vue'
+import { formatDate } from '@/utils/formatTime'
+import * as CustomerApi from '@/api/crm/customer'
+
+defineOptions({ name: 'ContactDetail' })
+const { delView } = useTagsViewStore() // 视图操作
+const route = useRoute()
+const { currentRoute } = useRouter() // 路由
+const id = Number(route.params.id)
+const loading = ref(true) // 加载中
+// 联系人详情
+const contact = ref<ContactApi.ContactVO>({} as ContactApi.ContactVO)
+/**
+ * 获取详情
+ *
+ * @param id
+ */
+const getContactData = async (id: number) => {
+  loading.value = true
+  try {
+    contact.value = await ContactApi.getContact(id)
+  } finally {
+    loading.value = false
+  }
+}
+
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/**
+ * 初始化
+ */
+onMounted(async () => {
+  if (!id) {
+    ElMessage.warning('参数错误,联系人不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  await getContactData(id)
+})
+</script>

+ 333 - 0
src/views/crm/contact/index.vue

@@ -0,0 +1,333 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="客户编号" prop="customerId">
+        <el-input
+          v-model="queryParams.customerId"
+          placeholder="请输入客户编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="姓名" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入姓名"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="手机号" prop="mobile">
+        <el-input
+          v-model="queryParams.mobile"
+          placeholder="请输入手机号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="座机" prop="telephone">
+        <el-input
+          v-model="queryParams.telephone"
+          placeholder="请输入电话"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+
+      <el-form-item label="QQ" prop="qq">
+        <el-input
+          v-model="queryParams.qq"
+          placeholder="请输入QQ"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="微信" prop="webchat">
+        <el-input
+          v-model="queryParams.webchat"
+          placeholder="请输入微信"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="电子邮箱" prop="email">
+        <el-input
+          v-model="queryParams.email"
+          placeholder="请输入电子邮箱"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button type="primary" @click="openForm('create')" v-hasPermi="['crm:contact:create']">
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['crm:contact:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="姓名" fixed="left" align="center" prop="name">
+        <template #default="scope">
+          <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)">{{
+            scope.row.name
+          }}</el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="客户名称" fixed="left" align="center" prop="customerName" />
+      <el-table-column label="性别" align="center" prop="sex">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" />
+        </template>
+      </el-table-column>
+      <el-table-column label="职位" align="center" prop="post" />
+      <el-table-column label="是否关键决策人" align="center" prop="policyMakers">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.policyMakers" />
+        </template>
+      </el-table-column>
+      <el-table-column label="直属上级" align="center" prop="parentId">
+        <template #default="scope">
+          {{ allContactList.find((contact) => contact.id === scope.row.parentId)?.name }}
+        </template>
+      </el-table-column>
+      <el-table-column label="手机号" align="center" prop="mobile" />
+      <el-table-column label="座机" align="center" prop="telephone" />
+      <el-table-column label="QQ" align="center" prop="qq" />
+      <el-table-column label="微信" align="center" prop="webchat" />
+      <el-table-column label="邮箱" align="center" prop="email" />
+      <el-table-column label="地址" align="center" prop="address" />
+      <el-table-column
+        label="下次联系时间"
+        align="center"
+        prop="nextTime"
+        width="180px"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column
+        label="最后跟进时间"
+        align="center"
+        prop="lastTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="负责人" align="center" prop="ownerUserId">
+        <template #default="scope">
+          {{ gotOwnerUser(scope.row.ownerUserId) }}
+        </template>
+      </el-table-column>
+      <!-- <el-table-column label="所属部门" align="center" prop="ownerUserId" /> -->
+      <el-table-column
+        label="更新时间"
+        align="center"
+        prop="updateTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <!-- <el-table-column
+        label="创建人"
+        align="center"
+        prop="creator"
+        :formatter="dateFormatter"
+        width="180px"
+      >
+        <template #default="scope">
+          {{ userList.find((user) => user.id === scope.row.creator)?.nickname }}
+        </template>
+      </el-table-column> -->
+      <el-table-column label="操作" align="center" fixed="right" width="200">
+        <template #default="scope">
+          <el-button
+            plain
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['crm:contact:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            plain
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['crm:contact:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ContactForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import * as ContactApi from '@/api/crm/contact'
+import ContactForm from './ContactForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+import * as UserApi from '@/api/system/user'
+import * as CustomerApi from '@/api/crm/customer'
+
+defineOptions({ name: 'CrmContact' })
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  nextTime: [],
+  mobile: null,
+  telephone: null,
+  email: null,
+  customerId: null,
+  address: null,
+  remark: null,
+  ownerUserId: null,
+  createTime: [],
+  lastTime: [],
+  parentId: null,
+  name: null,
+  post: null,
+  qq: null,
+  webchat: null,
+  sex: null,
+  policyMakers: null
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ContactApi.getContactPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ContactApi.deleteContact(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await ContactApi.exportContact(queryParams)
+    download.excel(data, '联系人.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+const gotOwnerUser = (owerUserId: string) => {
+  let ownerName = ''
+  if (owerUserId !== null) {
+    owerUserId.split(',').forEach((item: string, index: number) => {
+      if (index != 0) {
+        ownerName =
+          ownerName + ',' + userList.value.find((user: { id: any }) => user.id == item)?.nickname
+      } else {
+        ownerName = userList.value.find((user: { id: any }) => user.id == item)?.nickname || ''
+      }
+    })
+  }
+  return ownerName
+}
+/** 详情页面 */
+/** 打开客户详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmContactDetail', params: { id } })
+}
+
+const allContactList = ref([]) //所有联系人列表
+const allCustomerList = ref([]) //客户列表
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  userList.value = await UserApi.getSimpleUserList()
+  allContactList.value = await ContactApi.simpleAlllist()
+})
+</script>

+ 1 - 1
src/views/infra/codegen/EditTable.vue

@@ -8,7 +8,7 @@
         <colum-info-form ref="columInfoRef" :columns="formData.columns" />
       </el-tab-pane>
       <el-tab-pane label="生成信息" name="generateInfo">
-        <generate-info-form ref="generateInfoRef" :table="formData.table" />
+        <generate-info-form ref="generateInfoRef" :table="formData.table" :columns="formData.columns" />
       </el-tab-pane>
     </el-tabs>
     <el-form>

+ 1 - 1
src/views/infra/codegen/PreviewCode.vue

@@ -20,8 +20,8 @@
             ref="treeRef"
             :data="preview.fileTree"
             :expand-on-click-node="false"
-            highlight-current
             default-expand-all
+            highlight-current
             node-key="id"
             @node-click="handleNodeClick"
           />

+ 69 - 75
src/views/infra/codegen/components/GenerateInfoForm.vue

@@ -3,7 +3,7 @@
     <el-row>
       <el-col :span="12">
         <el-form-item label="生成模板" prop="templateType">
-          <el-select v-model="formData.templateType" @change="tplSelectChange">
+          <el-select v-model="formData.templateType">
             <el-option
               v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_CODEGEN_TEMPLATE_TYPE)"
               :key="dict.value"
@@ -182,110 +182,112 @@
       </el-col>
     </el-row>
 
-    <el-row v-show="formData.tplCategory === 'tree'">
-      <h4 class="form-header">其他信息</h4>
+    <!-- 树表信息 -->
+    <el-row v-if="formData.templateType == 2">
+      <el-col :span="24">
+        <h4 class="form-header">树表信息</h4>
+      </el-col>
       <el-col :span="12">
-        <el-form-item>
+        <el-form-item prop="treeParentColumnId">
           <template #label>
             <span>
-              树编码字段
-              <el-tooltip content="树显示的编码字段名, 如:dept_id" placement="top">
+              父编号字段
+              <el-tooltip content="树显示的父编码字段名, 如:parent_Id" placement="top">
                 <Icon icon="ep:question-filled" />
               </el-tooltip>
             </span>
           </template>
-          <el-select v-model="formData.treeCode" placeholder="请选择">
+          <el-select v-model="formData.treeParentColumnId" placeholder="请选择">
             <el-option
-              v-for="(column, index) in formData.columns"
+              v-for="(column, index) in props.columns"
               :key="index"
               :label="column.columnName + ':' + column.columnComment"
-              :value="column.columnName"
+              :value="column.id"
             />
           </el-select>
         </el-form-item>
       </el-col>
       <el-col :span="12">
-        <el-form-item>
+        <el-form-item prop="treeNameColumnId">
           <template #label>
             <span>
-              树父编码字段
-              <el-tooltip content="树显示的父编码字段名, 如:parent_Id" placement="top">
+              树名称字段
+              <el-tooltip content="树节点的显示名称字段名, 如:dept_name" placement="top">
                 <Icon icon="ep:question-filled" />
               </el-tooltip>
             </span>
           </template>
-          <el-select v-model="formData.treeParentCode" placeholder="请选择">
+          <el-select v-model="formData.treeNameColumnId" placeholder="请选择">
             <el-option
-              v-for="(column, index) in formData.columns"
+              v-for="(column, index) in props.columns"
               :key="index"
               :label="column.columnName + ':' + column.columnComment"
-              :value="column.columnName"
+              :value="column.id"
             />
           </el-select>
         </el-form-item>
       </el-col>
+    </el-row>
+
+    <!-- 主表信息 -->
+    <el-row v-if="formData.templateType == 15">
+      <el-col :span="24">
+        <h4 class="form-header">主表信息</h4>
+      </el-col>
       <el-col :span="12">
-        <el-form-item>
+        <el-form-item prop="masterTableId">
           <template #label>
             <span>
-              树名称字段
-              <el-tooltip content="树节点的显示名称字段名, 如:dept_name" placement="top">
+              关联的主表
+              <el-tooltip content="关联主表(父表)的表名, 如:system_user" placement="top">
                 <Icon icon="ep:question-filled" />
               </el-tooltip>
             </span>
           </template>
-
-          <el-select v-model="formData.treeName" placeholder="请选择">
+          <el-select v-model="formData.masterTableId" placeholder="请选择">
             <el-option
-              v-for="(column, index) in formData.columns"
+              v-for="(table0, index) in tables"
               :key="index"
-              :label="column.columnName + ':' + column.columnComment"
-              :value="column.columnName"
+              :label="table0.tableName + ':' + table0.tableComment"
+              :value="table0.id"
             />
           </el-select>
         </el-form-item>
       </el-col>
-    </el-row>
-    <el-row v-show="formData.tplCategory === 'sub'">
-      <h4 class="form-header">关联信息</h4>
       <el-col :span="12">
-        <el-form-item>
+        <el-form-item prop="subJoinColumnId">
           <template #label>
             <span>
-              关联子表的表名
-              <el-tooltip content="关联子表的表名, 如:sys_user" placement="top">
+              子表关联的字段
+              <el-tooltip content="子表关联的字段, 如:user_id" placement="top">
                 <Icon icon="ep:question-filled" />
               </el-tooltip>
             </span>
           </template>
-          <el-select v-model="formData.subTableName" placeholder="请选择" @change="subSelectChange">
+          <el-select v-model="formData.subJoinColumnId" placeholder="请选择">
             <el-option
-              v-for="(table0, index) in tables"
+              v-for="(column, index) in props.columns"
               :key="index"
-              :label="table0.tableName + ':' + table0.tableComment"
-              :value="table0.tableName"
+              :label="column.columnName + ':' + column.columnComment"
+              :value="column.id"
             />
           </el-select>
         </el-form-item>
       </el-col>
       <el-col :span="12">
-        <el-form-item>
+        <el-form-item prop="subJoinMany">
           <template #label>
             <span>
-              子表关联的外键名
-              <el-tooltip content="子表关联的外键名, 如:user_id" placement="top">
+              关联关系
+              <el-tooltip content="主表与子表的关联关系" placement="top">
                 <Icon icon="ep:question-filled" />
               </el-tooltip>
             </span>
           </template>
-          <el-select v-model="formData.subTableFkName" placeholder="请选择">
-            <el-option
-              v-for="(column, index) in subColumns"
-              :key="index"
-              :label="column.columnName + ':' + column.columnComment"
-              :value="column.columnName"
-            />
-          </el-select>
+          <el-radio-group v-model="formData.subJoinMany" placeholder="请选择">
+            <el-radio :label="true">一对多</el-radio>
+            <el-radio :label="false">一对一</el-radio>
+          </el-radio-group>
         </el-form-item>
       </el-col>
     </el-row>
@@ -305,6 +307,10 @@ const props = defineProps({
   table: {
     type: Object as PropType<Nullable<CodegenApi.CodegenTableVO>>,
     default: () => null
+  },
+  columns: {
+    type: Array as unknown as PropType<CodegenApi.CodegenColumnVO[]>,
+    default: () => null
   }
 })
 
@@ -319,13 +325,12 @@ const formData = ref({
   classComment: '',
   parentMenuId: null,
   genPath: '',
-  treeCode: '',
-  treeParentCode: '',
-  treeName: '',
-  tplCategory: '',
-  subTableName: '',
-  subTableFkName: '',
-  genType: ''
+  genType: '',
+  masterTableId: undefined,
+  subJoinColumnId: undefined,
+  subJoinMany: undefined,
+  treeParentColumnId: undefined,
+  treeNameColumnId: undefined
 })
 
 const rules = reactive({
@@ -336,41 +341,29 @@ const rules = reactive({
   businessName: [required],
   businessPackage: [required],
   className: [required],
-  classComment: [required]
+  classComment: [required],
+  masterTableId: [required],
+  subJoinColumnId: [required],
+  subJoinMany: [required],
+  treeParentColumnId: [required],
+  treeNameColumnId: [required]
 })
 
-const tables = ref([])
-const subColumns = ref([])
+const tables = ref([]) // 表定义列表
 const menus = ref<any[]>([])
 const menuTreeProps = {
   label: 'name'
 }
 
-/** 选择子表名触发 */
-const subSelectChange = () => {
-  formData.value.subTableFkName = ''
-}
-
-/** 选择生成模板触发 */
-const tplSelectChange = (value) => {
-  if (value !== 1) {
-    // TODO 芋艿:暂时不考虑支持树形结构
-    message.error(
-      '暂时不考虑支持【树形】和【主子表】的代码生成。原因是:导致 vm 模板过于复杂,不利于胖友二次开发'
-    )
-    return false
-  }
-  if (value !== 'sub') {
-    formData.value.subTableName = ''
-    formData.value.subTableFkName = ''
-  }
-}
-
 watch(
   () => props.table,
-  (table) => {
+  async (table) => {
     if (!table) return
     formData.value = table as any
+    // 加载表列表
+    if (table.dataSourceConfigId >= 0) {
+      tables.value = await CodegenApi.getCodegenTableList(formData.value.dataSourceConfigId)
+    }
   },
   {
     deep: true,
@@ -380,6 +373,7 @@ watch(
 
 onMounted(async () => {
   try {
+    // 加载菜单
     const resp = await MenuApi.getSimpleMenusList()
     menus.value = handleTree(resp)
   } catch {}

+ 3 - 1
src/views/infra/codegen/index.vue

@@ -1,5 +1,7 @@
 <template>
-  <doc-alert title="代码生成" url="https://doc.iocoder.cn/new-feature/" />
+  <doc-alert title="代码生成(单表)" url="https://doc.iocoder.cn/new-feature/" />
+  <doc-alert title="代码生成(树表)" url="https://doc.iocoder.cn/new-feature/tree/" />
+  <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" />
   <doc-alert title="单元测试" url="https://doc.iocoder.cn/unit-test/" />
 
   <!-- 搜索 -->

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

+ 214 - 0
src/views/infra/demo/demo01/index.vue

@@ -0,0 +1,214 @@
+<template>
+  <doc-alert title="代码生成(单表)" url="https://doc.iocoder.cn/new-feature/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名字" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入名字"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="性别" prop="sex">
+        <el-select v-model="queryParams.sex" placeholder="请选择性别" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['infra:demo01-contact:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['infra:demo01-contact:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column label="性别" align="center" prop="sex">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="出生年"
+        align="center"
+        prop="birthday"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="简介" align="center" prop="description" />
+      <el-table-column label="头像" align="center" prop="avatar" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['infra:demo01-contact:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['infra:demo01-contact:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <Demo01ContactForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import * as Demo01ContactApi from '@/api/infra/demo/demo01'
+import Demo01ContactForm from './Demo01ContactForm.vue'
+
+defineOptions({ name: 'Demo01Contact' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  sex: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo01ContactApi.getDemo01ContactPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await Demo01ContactApi.deleteDemo01Contact(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await Demo01ContactApi.exportDemo01Contact(queryParams)
+    download.excel(data, '示例联系人.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</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>

+ 207 - 0
src/views/infra/demo/demo02/index.vue

@@ -0,0 +1,207 @@
+<template>
+  <doc-alert title="代码生成(树表)" url="https://doc.iocoder.cn/new-feature/tree/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名字" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入名字"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['infra:demo02-category:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['infra:demo02-category:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+        <el-button type="danger" plain @click="toggleExpandAll">
+          <Icon icon="ep:sort" class="mr-5px" /> 展开/折叠
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+      row-key="id"
+      :default-expand-all="isExpandAll"
+      v-if="refreshTable"
+    >
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['infra:demo02-category:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['infra:demo02-category:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <Demo02CategoryForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { handleTree } from '@/utils/tree'
+import download from '@/utils/download'
+import * as Demo02CategoryApi from '@/api/infra/demo/demo02'
+import Demo02CategoryForm from './Demo02CategoryForm.vue'
+
+defineOptions({ name: 'Demo02Category' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  name: null,
+  parentId: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo02CategoryApi.getDemo02CategoryList(queryParams)
+    list.value = handleTree(data, 'id', 'parentId')
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await Demo02CategoryApi.deleteDemo02Category(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await Demo02CategoryApi.exportDemo02Category(queryParams)
+    download.excel(data, '示例分类.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 展开/折叠操作 */
+const isExpandAll = ref(true) // 是否展开,默认全部展开
+const refreshTable = ref(true) // 重新渲染表格状态
+const toggleExpandAll = async () => {
+  refreshTable.value = false
+  isExpandAll.value = !isExpandAll.value
+  await nextTick()
+  refreshTable.value = true
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</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>

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

@@ -0,0 +1,126 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-button
+      type="primary"
+      plain
+      @click="openForm('create')"
+      v-hasPermi="['infra:demo03-student:create']"
+    >
+      <Icon icon="ep:plus" class="mr-5px" /> 新增
+    </el-button>
+    <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-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['infra:demo03-student:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['infra:demo03-student:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+    <!-- 表单弹窗:添加/修改 -->
+    <Demo03CourseForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+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: undefined // 学生编号(主表的关联字段)
+}>()
+const loading = ref(false) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  studentId: undefined
+})
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  (val) => {
+    queryParams.studentId = val
+    handleQuery()
+  },
+  { immediate: false }
+)
+
+/** 查询列表 */
+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>

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

@@ -0,0 +1,126 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-button
+      type="primary"
+      plain
+      @click="openForm('create')"
+      v-hasPermi="['infra:demo03-student:create']"
+    >
+      <Icon icon="ep:plus" class="mr-5px" /> 新增
+    </el-button>
+    <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-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['infra:demo03-student:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['infra:demo03-student:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+    <!-- 表单弹窗:添加/修改 -->
+    <Demo03GradeForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+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: undefined // 学生编号(主表的关联字段)
+}>()
+const loading = ref(false) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  studentId: undefined
+})
+
+/** 监听主表的关联字段的变化,加载对应的子表数据 */
+watch(
+  () => props.studentId,
+  (val) => {
+    queryParams.studentId = val
+    handleQuery()
+  },
+  { immediate: false }
+)
+
+/** 查询列表 */
+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>

+ 240 - 0
src/views/infra/demo/demo03/erp/index.vue

@@ -0,0 +1,240 @@
+<template>
+  <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名字" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入名字"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="性别" prop="sex">
+        <el-select v-model="queryParams.sex" placeholder="请选择性别" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['infra:demo03-student:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['infra:demo03-student:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+      highlight-current-row
+      @current-change="handleCurrentChange"
+    >
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column label="性别" align="center" prop="sex">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="出生日期"
+        align="center"
+        prop="birthday"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="简介" align="center" prop="description" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['infra:demo03-student:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['infra:demo03-student:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <Demo03StudentForm ref="formRef" @success="getList" />
+  <!-- 子表的列表 -->
+  <ContentWrap>
+    <el-tabs model-value="demo03Course">
+      <el-tab-pane label="学生课程" name="demo03Course">
+        <Demo03CourseList :student-id="currentRow.id" />
+      </el-tab-pane>
+      <el-tab-pane label="学生班级" name="demo03Grade">
+        <Demo03GradeList :student-id="currentRow.id" />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp'
+import Demo03StudentForm from './Demo03StudentForm.vue'
+import Demo03CourseList from './components/Demo03CourseList.vue'
+import Demo03GradeList from './components/Demo03GradeList.vue'
+
+defineOptions({ name: 'Demo03Student' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  sex: null,
+  description: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo03StudentApi.getDemo03StudentPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await Demo03StudentApi.deleteDemo03Student(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await Demo03StudentApi.exportDemo03Student(queryParams)
+    download.excel(data, '学生.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 选中行操作 */
+const currentRow = ref({}) // 选中行
+const handleCurrentChange = (row) => {
+  currentRow.value = row
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</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>

+ 229 - 0
src/views/infra/demo/demo03/inner/index.vue

@@ -0,0 +1,229 @@
+<template>
+  <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名字" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入名字"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="性别" prop="sex">
+        <el-select v-model="queryParams.sex" placeholder="请选择性别" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['infra:demo03-student:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['infra:demo03-student:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <!-- 子表的列表 -->
+      <el-table-column type="expand">
+        <template #default="scope">
+          <el-tabs model-value="demo03Course">
+            <el-tab-pane label="学生课程" name="demo03Course">
+              <Demo03CourseList :student-id="scope.row.id" />
+            </el-tab-pane>
+            <el-tab-pane label="学生班级" name="demo03Grade">
+              <Demo03GradeList :student-id="scope.row.id" />
+            </el-tab-pane>
+          </el-tabs>
+        </template>
+      </el-table-column>
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column label="性别" align="center" prop="sex">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="出生日期"
+        align="center"
+        prop="birthday"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="简介" align="center" prop="description" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['infra:demo03-student:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['infra:demo03-student:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <Demo03StudentForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner'
+import Demo03StudentForm from './Demo03StudentForm.vue'
+import Demo03CourseList from './components/Demo03CourseList.vue'
+import Demo03GradeList from './components/Demo03GradeList.vue'
+
+defineOptions({ name: 'Demo03Student' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  sex: null,
+  description: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo03StudentApi.getDemo03StudentPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await Demo03StudentApi.deleteDemo03Student(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await Demo03StudentApi.exportDemo03Student(queryParams)
+    download.excel(data, '学生.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 初始化 **/
+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>

+ 214 - 0
src/views/infra/demo/demo03/normal/index.vue

@@ -0,0 +1,214 @@
+<template>
+  <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名字" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入名字"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="性别" prop="sex">
+        <el-select v-model="queryParams.sex" placeholder="请选择性别" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['infra:demo03-student:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['infra:demo03-student:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column label="性别" align="center" prop="sex">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="出生日期"
+        align="center"
+        prop="birthday"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="简介" align="center" prop="description" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['infra:demo03-student:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['infra:demo03-student:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <Demo03StudentForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import * as Demo03StudentApi from '@/api/infra/demo/demo03/normal'
+import Demo03StudentForm from './Demo03StudentForm.vue'
+
+defineOptions({ name: 'Demo03Student' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  sex: null,
+  description: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Demo03StudentApi.getDemo03StudentPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await Demo03StudentApi.deleteDemo03Student(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await Demo03StudentApi.exportDemo03Student(queryParams)
+    download.excel(data, '学生.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 0 - 4
src/views/infra/testDemo/index.vue

@@ -1,4 +0,0 @@
-<template>
-  <div>index</div>
-</template>
-<script lang="ts" setup></script>

+ 2 - 0
src/views/mall/statistics/member/components/MemberFunnelCard.vue

@@ -110,9 +110,11 @@ const handleTimeRangeChange = async (times: [dayjs.ConfigType, dayjs.ConfigType]
 .trapezoid1 {
   transform: perspective(5em) rotateX(-11deg);
 }
+
 .trapezoid2 {
   transform: perspective(7em) rotateX(-20deg);
 }
+
 .trapezoid3 {
   transform: perspective(3em) rotateX(-13deg);
 }

+ 1 - 0
src/views/mall/trade/delivery/pickUpOrder/index.vue

@@ -316,6 +316,7 @@ onMounted(() => {
 :deep(.order-table-col > .cell) {
   padding: 0;
 }
+
 .summary {
   .el-col {
     margin-bottom: 1rem;

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä