Pārlūkot izejas kodu

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

owen 1 gadu atpakaļ
vecāks
revīzija
6e9e1d0888
45 mainītis faili ar 2380 papildinājumiem un 230 dzēšanām
  1. 3 0
      .vscode/settings.json
  2. 16 17
      package.json
  3. 43 0
      src/api/crm/product/index.ts
  4. 33 0
      src/api/crm/productCategory/index.ts
  5. 1 1
      src/api/crm/receivablePlan/index.ts
  6. 37 0
      src/api/system/social/client/index.ts
  7. 24 0
      src/api/system/social/user/index.ts
  8. 1 1
      src/components/Card/src/CardTitle.vue
  9. 6 7
      src/components/ConfigGlobal/src/ConfigGlobal.vue
  10. 1 1
      src/components/ShortcutDateRangePicker/index.vue
  11. 2 2
      src/components/Sticky/src/Sticky.vue
  12. 2 2
      src/components/Tooltip/src/Tooltip.vue
  13. 49 0
      src/hooks/web/useGuide.ts
  14. 0 47
      src/hooks/web/useIntro.ts
  15. 21 0
      src/hooks/web/useNetwork.ts
  16. 60 0
      src/hooks/web/useNow.ts
  17. 63 0
      src/hooks/web/useTagsView.ts
  18. 29 31
      src/hooks/web/useValidator.ts
  19. 3 2
      src/layout/components/Message/src/Message.vue
  20. 3 1
      src/utils/dict.ts
  21. 2 2
      src/utils/formatTime.ts
  22. 16 2
      src/utils/index.ts
  23. 6 5
      src/views/Profile/Index.vue
  24. 19 5
      src/views/Profile/components/UserSocial.vue
  25. 70 0
      src/views/crm/product/ProductDetail.vue
  26. 185 0
      src/views/crm/product/ProductForm.vue
  27. 269 0
      src/views/crm/product/index.vue
  28. 110 0
      src/views/crm/productCategory/ProductCategoryForm.vue
  29. 138 0
      src/views/crm/productCategory/index.vue
  30. 28 18
      src/views/crm/receivable/ReceivableForm.vue
  31. 11 7
      src/views/crm/receivable/index.vue
  32. 27 18
      src/views/crm/receivablePlan/ReceivablePlanForm.vue
  33. 49 50
      src/views/crm/receivablePlan/index.vue
  34. 196 0
      src/views/mall/promotion/rewardActivity/RewardForm.vue
  35. 213 0
      src/views/mall/promotion/rewardActivity/index.vue
  36. 1 1
      src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue
  37. 1 1
      src/views/system/loginlog/LoginLogDetail.vue
  38. 5 5
      src/views/system/menu/MenuForm.vue
  39. 1 1
      src/views/system/menu/index.vue
  40. 154 0
      src/views/system/social/client/SocialClientForm.vue
  41. 227 0
      src/views/system/social/client/index.vue
  42. 60 0
      src/views/system/social/user/SocialUserDetail.vue
  43. 190 0
      src/views/system/social/user/index.vue
  44. 0 1
      tsconfig.json
  45. 5 2
      uno.config.ts

+ 3 - 0
.vscode/settings.json

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

+ 16 - 17
package.json

@@ -36,20 +36,20 @@
     "@wangeditor/editor-for-vue": "^5.1.10",
     "@zxcvbn-ts/core": "^3.0.4",
     "animate.css": "^4.1.1",
-    "axios": "^1.5.1",
+    "axios": "^1.6.0",
     "benz-amr-recorder": "^1.1.5",
     "bpmn-js-token-simulation": "^0.10.0",
     "camunda-bpmn-moddle": "^7.0.1",
     "cropperjs": "^1.6.1",
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.10",
-    "diagram-js": "^12.5.0",
+    "diagram-js": "^12.6.0",
+    "driver.js": "^1.3.0",
     "echarts": "^5.4.3",
     "echarts-wordcloud": "^2.1.0",
     "element-plus": "2.4.1",
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
-    "intro.js": "^7.2.0",
     "jsencrypt": "^3.3.2",
     "lodash-es": "^4.17.21",
     "min-dash": "^4.1.1",
@@ -64,7 +64,7 @@
     "video.js": "^7.21.5",
     "vue": "^3.3.7",
     "vue-dompurify-html": "^4.1.4",
-    "vue-i18n": "^9.5.0",
+    "vue-i18n": "^9.6.2",
     "vue-router": "^4.2.5",
     "vue-types": "^5.1.1",
     "vuedraggable": "^4.1.0",
@@ -72,20 +72,19 @@
     "xml-js": "^1.6.11"
   },
   "devDependencies": {
-    "@commitlint/cli": "^18.0.0",
-    "@commitlint/config-conventional": "^18.0.0",
-    "@iconify/json": "^2.2.132",
+    "@commitlint/cli": "^18.2.0",
+    "@commitlint/config-conventional": "^18.1.0",
+    "@iconify/json": "^2.2.135",
     "@intlify/unplugin-vue-i18n": "^1.4.0",
     "@purge-icons/generated": "^0.9.0",
-    "@types/intro.js": "^5.1.3",
     "@types/lodash-es": "^4.17.10",
-    "@types/node": "^20.8.8",
+    "@types/node": "^20.8.9",
     "@types/nprogress": "^0.2.2",
     "@types/qrcode": "^1.5.4",
     "@types/qs": "^6.9.9",
     "@types/sortablejs": "^1.15.4",
-    "@typescript-eslint/eslint-plugin": "^6.9.0",
-    "@typescript-eslint/parser": "^6.9.0",
+    "@typescript-eslint/eslint-plugin": "^6.9.1",
+    "@typescript-eslint/parser": "^6.9.1",
     "@unocss/transformer-variant-group": "^0.57.1",
     "@unocss/eslint-config": "^0.57.1",
     "@vitejs/plugin-legacy": "^4.1.1",
@@ -99,24 +98,24 @@
     "eslint-config-prettier": "^9.0.0",
     "eslint-define-config": "^1.24.1",
     "eslint-plugin-prettier": "^5.0.1",
-    "eslint-plugin-vue": "^9.18.0",
+    "eslint-plugin-vue": "^9.18.1",
     "lint-staged": "^15.0.2",
     "postcss": "^8.4.31",
     "postcss-html": "^1.5.0",
     "postcss-scss": "^4.0.9",
     "prettier": "^3.0.3",
     "rimraf": "^5.0.5",
-    "rollup": "^4.1.4",
-    "sass": "^1.69.4",
+    "rollup": "^4.1.5",
+    "sass": "^1.69.5",
     "stylelint": "^15.11.0",
     "stylelint-config-html": "^1.1.0",
     "stylelint-config-recommended": "^13.0.0",
     "stylelint-config-standard": "^34.0.0",
     "stylelint-order": "^6.0.3",
-    "terser": "^5.22.0",
+    "terser": "^5.23.0",
     "typescript": "5.2.2",
     "unocss": "^0.57.1",
-    "unplugin-auto-import": "^0.16.6",
+    "unplugin-auto-import": "^0.16.7",
     "unplugin-element-plus": "^0.8.0",
     "unplugin-vue-components": "^0.25.2",
     "vite": "4.5.0",
@@ -128,7 +127,7 @@
     "vite-plugin-svg-icons": "^2.0.1",
     "vite-plugin-top-level-await": "^1.3.1",
     "vue-eslint-parser": "^9.3.2",
-    "vue-tsc": "^1.8.20"
+    "vue-tsc": "^1.8.22"
   },
   "license": "MIT",
   "repository": {

+ 43 - 0
src/api/crm/product/index.ts

@@ -0,0 +1,43 @@
+import request from '@/config/axios'
+
+export interface ProductVO {
+  id: number
+  name: string
+  no: string
+  unit: string
+  price: number
+  status: number
+  categoryId: number
+  description: string
+  ownerUserId: number
+}
+
+// 查询产品列表
+export const getProductPage = async (params) => {
+  return await request.get({ url: `/crm/product/page`, params })
+}
+
+// 查询产品详情
+export const getProduct = async (id: number) => {
+  return await request.get({ url: `/crm/product/get?id=` + id })
+}
+
+// 新增产品
+export const createProduct = async (data: ProductVO) => {
+  return await request.post({ url: `/crm/product/create`, data })
+}
+
+// 修改产品
+export const updateProduct = async (data: ProductVO) => {
+  return await request.put({ url: `/crm/product/update`, data })
+}
+
+// 删除产品
+export const deleteProduct = async (id: number) => {
+  return await request.delete({ url: `/crm/product/delete?id=` + id })
+}
+
+// 导出产品 Excel
+export const exportProduct = async (params) => {
+  return await request.download({ url: `/crm/product/export-excel`, params })
+}

+ 33 - 0
src/api/crm/productCategory/index.ts

@@ -0,0 +1,33 @@
+import request from '@/config/axios'
+
+// TODO @zange:挪到 product 下,建个 category 包,挪进去哈;
+export interface ProductCategoryVO {
+  id: number
+  name: string
+  parentId: number
+}
+
+// 查询产品分类详情
+export const getProductCategory = async (id: number) => {
+  return await request.get({ url: `/crm/product-category/get?id=` + id })
+}
+
+// 新增产品分类
+export const createProductCategory = async (data: ProductCategoryVO) => {
+  return await request.post({ url: `/crm/product-category/create`, data })
+}
+
+// 修改产品分类
+export const updateProductCategory = async (data: ProductCategoryVO) => {
+  return await request.put({ url: `/crm/product-category/update`, data })
+}
+
+// 删除产品分类
+export const deleteProductCategory = async (id: number) => {
+  return await request.delete({ url: `/crm/product-category/delete?id=` + id })
+}
+
+// 产品分类列表
+export const getProductCategoryList = async (params) => {
+  return await request.get({ url: `/crm/product-category/list`, params })
+}

+ 1 - 1
src/api/crm/receivablePlan/index.ts

@@ -2,7 +2,7 @@ import request from '@/config/axios'
 
 export interface ReceivablePlanVO {
   id: number
-  indexNo: number
+  period: number
   receivableId: number
   status: number
   checkStatus: string

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 3 - 1
src/utils/dict.ts

@@ -116,6 +116,7 @@ export enum DICT_TYPE {
   SYSTEM_OAUTH2_GRANT_TYPE = 'system_oauth2_grant_type',
   SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status',
   SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type',
+  SYSTEM_SOCIAL_TYPE = 'system_social_type',
 
   // ========== INFRA 模块 ==========
   INFRA_BOOLEAN_STRING = 'infra_boolean_string',
@@ -193,5 +194,6 @@ export enum DICT_TYPE {
   CRM_RETURN_TYPE = 'crm_return_type',
   CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry',
   CRM_CUSTOMER_LEVEL = 'crm_customer_level',
-  CRM_CUSTOMER_SOURCE = 'crm_customer_source'
+  CRM_CUSTOMER_SOURCE = 'crm_customer_source',
+  CRM_PRODUCT_STATUS = 'crm_product_status'
 }

+ 2 - 2
src/utils/formatTime.ts

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

+ 16 - 2
src/utils/index.ts

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,110 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="父级id" prop="parentId">
+        <el-select v-model="formData.parentId" placeholder="请选择上级分类">
+          <el-option :key="0" label="顶级分类" :value="0" />
+          <el-option
+            v-for="item in productCategoryList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入名称" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as ProductCategoryApi from '@/api/crm/productCategory'
+
+defineOptions({ name: 'CrmProductCategoryForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  parentId: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
+  parentId: [{ required: true, message: '父级分类不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const productCategoryList = ref<any[]>([]) // 产品分类树
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ProductCategoryApi.getProductCategory(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得分类树
+  productCategoryList.value = await ProductCategoryApi.getProductCategoryList({ parentId: 0 })
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ProductCategoryApi.ProductCategoryVO
+    if (formType.value === 'create') {
+      await ProductCategoryApi.createProductCategory(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductCategoryApi.updateProductCategory(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    parentId: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,154 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+    >
+      <el-form-item label="应用名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入应用名" />
+      </el-form-item>
+      <el-form-item label="社交平台" prop="socialType">
+        <el-radio-group v-model="formData.socialType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="用户类型" prop="userType">
+        <el-radio-group v-model="formData.userType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="客户端编号" prop="clientId">
+        <el-input v-model="formData.clientId" placeholder="请输入客户端编号,对应各平台的appKey" />
+      </el-form-item>
+      <el-form-item label="客户端密钥" prop="clientSecret">
+        <el-input
+          v-model="formData.clientSecret"
+          placeholder="请输入客户端密钥,对应各平台的appSecret"
+        />
+      </el-form-item>
+      <el-form-item label="agentId" prop="agentId" v-if="formData!.socialType === 30">
+        <el-input v-model="formData.agentId" placeholder="授权方的网页应用 ID,有则填" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as SocialClientApi from '@/api/system/social/client'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  socialType: undefined,
+  userType: undefined,
+  clientId: undefined,
+  clientSecret: undefined,
+  agentId: undefined,
+  status: 0
+})
+const formRules = reactive({
+  name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }],
+  socialType: [{ required: true, message: '社交平台不能为空', trigger: 'blur' }],
+  userType: [{ required: true, message: '用户类型不能为空', trigger: 'blur' }],
+  clientId: [{ required: true, message: '客户端编号不能为空', trigger: 'blur' }],
+  clientSecret: [{ required: true, message: '客户端密钥不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await SocialClientApi.getSocialClient(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as SocialClientApi.SocialClientVO
+    if (formType.value === 'create') {
+      await SocialClientApi.createSocialClient(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SocialClientApi.updateSocialClient(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    socialType: undefined,
+    userType: undefined,
+    clientId: undefined,
+    clientSecret: undefined,
+    agentId: undefined,
+    status: 0
+  }
+  formRef.value?.resetFields()
+}
+</script>

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

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

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

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

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

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

+ 0 - 1
tsconfig.json

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

+ 5 - 2
uno.config.ts

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