Forráskód Böngészése

!111 【众测版】合并最新的 Vue3 重构
Merge pull request !111 from 芋道源码/dev

芋道源码 2 éve
szülő
commit
5e705776b8
100 módosított fájl, 5025 hozzáadás és 991 törlés
  1. 3 0
      .env
  2. 1 1
      .env.dev
  3. 1 1
      package.json
  4. 1 1
      src/api/bpm/leave/index.ts
  5. 0 36
      src/api/infra/redis/index.ts
  6. 0 9
      src/api/infra/redis/types.ts
  7. 1 5
      src/api/login/index.ts
  8. 41 0
      src/api/login/oauth2/index.ts
  9. 0 14
      src/api/login/types.ts
  10. 56 0
      src/api/mall/product/brand.ts
  11. 0 0
      src/api/mp/user/index.ts
  12. 20 5
      src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue
  13. 4 4
      src/components/bpmnProcessDesigner/package/penal/listeners/template.js
  14. 5 5
      src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue
  15. 3 2
      src/config/axios/service.ts
  16. 1 0
      src/locales/zh-CN.ts
  17. 2 0
      src/main.ts
  18. 23 0
      src/plugins/tongji/index.ts
  19. 2 2
      src/router/index.ts
  20. 18 20
      src/router/modules/remaining.ts
  21. 1 4
      src/types/auto-components.d.ts
  22. 1 0
      src/types/auto-imports.d.ts
  23. 0 1
      src/utils/dict.ts
  24. 11 8
      src/views/Login/Login.vue
  25. 8 2
      src/views/Login/components/LoginForm.vue
  26. 2 1
      src/views/Login/components/LoginFormTitle.vue
  27. 186 0
      src/views/Login/components/SSOLogin.vue
  28. 2 1
      src/views/Login/components/index.ts
  29. 2 1
      src/views/Login/components/useLogin.ts
  30. 3 3
      src/views/bpm/definition/index.vue
  31. 2 3
      src/views/bpm/form/index.vue
  32. 1 1
      src/views/bpm/group/index.vue
  33. 1 1
      src/views/bpm/model/editor/index.vue
  34. 4 4
      src/views/bpm/model/index.vue
  35. 97 46
      src/views/bpm/oa/leave/create.vue
  36. 32 34
      src/views/bpm/oa/leave/detail.vue
  37. 207 54
      src/views/bpm/oa/leave/index.vue
  38. 0 91
      src/views/bpm/oa/leave/leave.data.ts
  39. 1 1
      src/views/bpm/processInstance/create/index.vue
  40. 1 1
      src/views/bpm/processInstance/detail/index.vue
  41. 207 53
      src/views/bpm/processInstance/index.vue
  42. 0 94
      src/views/bpm/processInstance/process.data.ts
  43. 1 1
      src/views/bpm/task/done/index.vue
  44. 110 20
      src/views/bpm/task/todo/index.vue
  45. 0 58
      src/views/bpm/task/todo/todo.data.ts
  46. 1 1
      src/views/bpm/taskAssignRule/index.vue
  47. 1 1
      src/views/infra/apiAccessLog/index.vue
  48. 1 2
      src/views/infra/apiErrorLog/index.vue
  49. 1 1
      src/views/infra/build/index.vue
  50. 1 1
      src/views/infra/codegen/index.vue
  51. 1 1
      src/views/infra/config/index.vue
  52. 1 1
      src/views/infra/dataSourceConfig/index.vue
  53. 24 30
      src/views/infra/dbDoc/index.vue
  54. 18 4
      src/views/infra/druid/index.vue
  55. 45 22
      src/views/infra/file/FileForm.vue
  56. 11 6
      src/views/infra/file/index.vue
  57. 16 3
      src/views/infra/fileConfig/index.vue
  58. 1 1
      src/views/infra/job/index.vue
  59. 1 1
      src/views/infra/job/logger/index.vue
  60. 7 111
      src/views/infra/redis/index.vue
  61. 19 4
      src/views/infra/server/index.vue
  62. 18 2
      src/views/infra/skywalking/index.vue
  63. 18 4
      src/views/infra/swagger/index.vue
  64. 120 0
      src/views/mall/product/brand/BrandForm.vue
  65. 177 0
      src/views/mall/product/brand/index.vue
  66. 1 1
      src/views/mall/product/category/CategoryForm.vue
  67. 1 1
      src/views/mall/product/property/index.vue
  68. 1 1
      src/views/mall/product/property/value/index.vue
  69. 421 1
      src/views/mp/autoReply/index.vue
  70. 201 0
      src/views/mp/components/wx-editor/WxEditor.vue
  71. 45 0
      src/views/mp/components/wx-editor/quill-options.js
  72. 117 119
      src/views/mp/components/wx-material-select/main.vue
  73. 1 1
      src/views/mp/components/wx-msg/main.vue
  74. 35 62
      src/views/mp/components/wx-reply/main.vue
  75. 1 1
      src/views/mp/components/wx-voice-play/main.vue
  76. 811 1
      src/views/mp/draft/index.vue
  77. 151 0
      src/views/mp/draft/mock.js
  78. 1 1
      src/views/mp/freePublish/index.vue
  79. 526 1
      src/views/mp/material/index.vue
  80. BIN
      src/views/mp/menu/assets/iphone_backImg.png
  81. BIN
      src/views/mp/menu/assets/menu_foot.png
  82. BIN
      src/views/mp/menu/assets/menu_head.png
  83. 780 1
      src/views/mp/menu/index.vue
  84. 42 0
      src/views/mp/menu/menuOptions.ts
  85. 0 3
      src/views/mp/mpuser/index.vue
  86. 99 0
      src/views/mp/user/UserForm.vue
  87. 187 0
      src/views/mp/user/index.vue
  88. 1 1
      src/views/pay/app/index.vue
  89. 1 1
      src/views/pay/merchant/index.vue
  90. 1 1
      src/views/pay/order/index.vue
  91. 1 1
      src/views/pay/refund/index.vue
  92. 1 1
      src/views/system/area/index.vue
  93. 1 1
      src/views/system/dept/index.vue
  94. 1 1
      src/views/system/dict/data/index.vue
  95. 1 1
      src/views/system/dict/index.vue
  96. 1 1
      src/views/system/errorCode/index.vue
  97. 1 1
      src/views/system/loginlog/index.vue
  98. 26 0
      src/views/system/mail/account/MailAccountDetail.vue
  99. 6 2
      src/views/system/mail/account/account.data.ts
  100. 18 1
      src/views/system/mail/account/index.vue

+ 3 - 0
.env

@@ -15,3 +15,6 @@ VITE_APP_CAPTCHA_ENABLE=true
 
 # 验证码的开关
 VITE_APP_CAPTCHA_ENABLE=true
+
+# 百度统计
+VITE_APP_BAIDU_CODE = a1ff8825baa73c3a78eb96aa40325abc

+ 1 - 1
.env.dev

@@ -16,7 +16,7 @@ VITE_API_BASEPATH=/dev-api
 VITE_API_URL=/admin-api
 
 # 打包路径
-VITE_BASE_PATH=/dist-dev/
+VITE_BASE_PATH=/
 
 # 是否删除debugger
 VITE_DROP_DEBUGGER=false

+ 1 - 1
package.json

@@ -6,7 +6,7 @@
   "private": false,
   "scripts": {
     "i": "pnpm install",
-    "dev": "pnpm vite",
+    "dev": "vite --mode base",
     "front": "vite --mode front",
     "ts:check": "vue-tsc --noEmit",
     "build:pro": "node --max_old_space_size=8000 ./node_modules/vite/bin/vite.js build --mode pro",

+ 1 - 1
src/api/bpm/leave/index.ts

@@ -22,6 +22,6 @@ export const getLeave = async (id: number) => {
 }
 
 // 获得请假申请分页
-export const getLeavePage = async (params) => {
+export const getLeavePage = async (params: PageParam) => {
   return await request.get({ url: '/bpm/oa/leave/page', params })
 }

+ 0 - 36
src/api/infra/redis/index.ts

@@ -6,39 +6,3 @@ import request from '@/config/axios'
 export const getCache = () => {
   return request.get({ url: '/infra/redis/get-monitor-info' })
 }
-
-// 获取模块
-export const getKeyDefineList = () => {
-  return request.get({ url: '/infra/redis/get-key-define-list' })
-}
-
-/**
- * 获取redis key列表
- */
-export const getKeyList = (keyTemplate: string) => {
-  return request.get({
-    url: '/infra/redis/get-key-list',
-    params: {
-      keyTemplate
-    }
-  })
-}
-
-// 获取缓存内容
-export const getKeyValue = (key: string) => {
-  return request.get({ url: '/infra/redis/get-key-value?key=' + key })
-}
-
-// 根据键名删除缓存
-export const deleteKey = (key: string) => {
-  return request.delete({ url: '/infra/redis/delete-key?key=' + key })
-}
-
-export const deleteKeys = (keyTemplate: string) => {
-  return request.delete({
-    url: '/infra/redis/delete-keys?',
-    params: {
-      keyTemplate
-    }
-  })
-}

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

@@ -174,12 +174,3 @@ export interface RedisCommandStatsVO {
   calls: number
   usec: number
 }
-
-export interface RedisKeyInfo {
-  keyTemplate: string
-  keyType: string
-  valueType: string
-  timeoutType: number
-  timeout: number
-  memo: string
-}

+ 1 - 5
src/api/login/index.ts

@@ -2,15 +2,11 @@ import request from '@/config/axios'
 import { getRefreshToken } from '@/utils/auth'
 import type { UserLoginVO } from './types'
 
-export interface CodeImgResult {
-  captchaOnOff: boolean
-  img: string
-  uuid: string
-}
 export interface SmsCodeVO {
   mobile: string
   scene: number
 }
+
 export interface SmsLoginVO {
   mobile: string
   code: string

+ 41 - 0
src/api/login/oauth2/index.ts

@@ -0,0 +1,41 @@
+import request from '@/config/axios'
+
+// 获得授权信息
+export const getAuthorize = (clientId: string) => {
+  return request.get({ url: '/system/oauth2/authorize?clientId=' + clientId })
+}
+
+// 发起授权
+export const authorize = (
+  responseType: string,
+  clientId: string,
+  redirectUri: string,
+  state: string,
+  autoApprove: boolean,
+  checkedScopes: string[],
+  uncheckedScopes: string[]
+) => {
+  // 构建 scopes
+  const scopes = {}
+  for (const scope of checkedScopes) {
+    scopes[scope] = true
+  }
+  for (const scope of uncheckedScopes) {
+    scopes[scope] = false
+  }
+  // 发起请求
+  return request.post({
+    url: '/system/oauth2/authorize',
+    headers: {
+      'Content-type': 'application/x-www-form-urlencoded'
+    },
+    params: {
+      response_type: responseType,
+      client_id: clientId,
+      redirect_uri: redirectUri,
+      state: state,
+      auto_approve: autoApprove,
+      scope: JSON.stringify(scopes)
+    }
+  })
+}

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

@@ -26,17 +26,3 @@ export type UserVO = {
   loginIp: string
   loginDate: string
 }
-
-export type UserInfoVO = {
-  permissions: []
-  roles: []
-  user: {
-    avatar: string
-    id: number
-    nickname: string
-  }
-}
-
-export type TentantNameVO = {
-  name: string
-}

+ 56 - 0
src/api/mall/product/brand.ts

@@ -0,0 +1,56 @@
+import request from '@/config/axios'
+
+/**
+ * 商品品牌
+ */
+export interface BrandVO {
+  /**
+   * 品牌编号
+   */
+  id?: number
+  /**
+   * 品牌名称
+   */
+  name: string
+  /**
+   * 品牌图片
+   */
+  picUrl: string
+  /**
+   * 品牌排序
+   */
+  sort?: number
+  /**
+   * 品牌描述
+   */
+  description?: string
+  /**
+   * 开启状态
+   */
+  status: number
+}
+
+// 创建商品品牌
+export const createBrand = (data: BrandVO) => {
+  return request.post({ url: '/product/brand/create', data })
+}
+
+// 更新商品品牌
+export const updateBrand = (data: BrandVO) => {
+  return request.put({ url: '/product/brand/update', data })
+}
+
+// 删除商品品牌
+export const deleteBrand = (id: number) => {
+  return request.delete({ url: `/product/brand/delete?id=${id}` })
+}
+
+// 获得商品品牌
+export const getBrand = (id: number) => {
+  return request.get({ url: `/product/brand/get?id=${id}` })
+}
+
+// 获得商品品牌列表
+export const getBrandParam = (params: PageParam) => {
+  return request.get({ url: '/product/brand/page', params })
+}

+ 0 - 0
src/api/mp/mpuser/index.ts → src/api/mp/user/index.ts


+ 20 - 5
src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue

@@ -33,9 +33,15 @@
         />
         <el-table-column label="操作" width="90px">
           <template #default="scope">
-            <el-button type="text" @click="openFieldForm(scope, scope.$index)">编辑</el-button>
+            <el-button type="primary" link @click="openFieldForm(scope, scope.$index)"
+              >编辑</el-button
+            >
             <el-divider direction="vertical" />
-            <el-button type="text" style="color: #ff4d4f" @click="removeField(scope, scope.$index)"
+            <el-button
+              type="primary"
+              link
+              style="color: #ff4d4f"
+              @click="removeField(scope, scope.$index)"
               >移除</el-button
             >
           </template>
@@ -97,7 +103,10 @@
           <el-table-column label="枚举值名称" prop="name" min-width="100px" show-overflow-tooltip />
           <el-table-column label="操作" width="90px">
             <template #default="scope">
-              <el-button type="text" @click="openFieldOptionForm(scope, scope.$index, 'enum')"
+              <el-button
+                type="primary"
+                link
+                @click="openFieldOptionForm(scope, scope.$index, 'enum')"
                 >编辑</el-button
               >
               <el-divider direction="vertical" />
@@ -126,7 +135,10 @@
         <el-table-column label="约束配置" prop="config" min-width="100px" show-overflow-tooltip />
         <el-table-column label="操作" width="90px">
           <template #default="scope">
-            <el-button type="text" @click="openFieldOptionForm(scope, scope.$index, 'constraint')"
+            <el-button
+              type="primary"
+              link
+              @click="openFieldOptionForm(scope, scope.$index, 'constraint')"
               >编辑</el-button
             >
             <el-divider direction="vertical" />
@@ -154,7 +166,10 @@
         <el-table-column label="属性值" prop="value" min-width="100px" show-overflow-tooltip />
         <el-table-column label="操作" width="90px">
           <template #default="scope">
-            <el-button type="text" @click="openFieldOptionForm(scope, scope.$index, 'property')"
+            <el-button
+              type="primary"
+              link
+              @click="openFieldOptionForm(scope, scope.$index, 'property')"
               >编辑</el-button
             >
             <el-divider direction="vertical" />

+ 4 - 4
src/components/bpmnProcessDesigner/package/penal/listeners/template.js

@@ -7,9 +7,9 @@ export const template = (isTaskListener) => {
       <el-table-column label="监听器类型" min-width="100px" show-overflow-tooltip :formatter="row => listenerTypeObject[row.listenerType]" />
       <el-table-column label="操作" width="90px">
         <template #default="scope">
-          <el-button size="small" type="text" @click="openListenerForm(scope, scope.$index)">编辑</el-button>
+          <el-button size="small" type="primary" link @click="openListenerForm(scope, scope.$index)">编辑</el-button>
           <el-divider direction="vertical" />
-          <el-button size="small" type="text" style="color: #ff4d4f" @click="removeListener(scope, scope.$index)">移除</el-button>
+          <el-button size="small" type="primary" link style="color: #ff4d4f" @click="removeListener(scope, scope.$index)">移除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -125,9 +125,9 @@ export const template = (isTaskListener) => {
         <el-table-column label="字段值/表达式" min-width="100px" show-overflow-tooltip :formatter="row => row.string || row.expression" />
         <el-table-column label="操作" width="100px">
           <template #default="scope">
-            <el-button size="small" type="text" @click="openListenerFieldForm(scope, scope.$index)">编辑</el-button>
+            <el-button size="small" type="primary" link @click="openListenerFieldForm(scope, scope.$index)">编辑</el-button>
             <el-divider direction="vertical" />
-            <el-button size="small" type="text" style="color: #ff4d4f" @click="removeListenerField(scope, scope.$index)">移除</el-button>
+            <el-button size="small" type="primary" link style="color: #ff4d4f" @click="removeListenerField(scope, scope.$index)">移除</el-button>
           </template>
         </el-table-column>
       </el-table>

+ 5 - 5
src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue

@@ -23,7 +23,7 @@
     </el-table>
 
     <el-dialog
-      v-model="modelVisible"
+      v-model="dialogVisible"
       :title="modelConfig.title"
       :close-on-click-modal="false"
       width="400px"
@@ -39,7 +39,7 @@
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="modelVisible = false">取 消</el-button>
+        <el-button @click="dialogVisible = false">取 消</el-button>
         <el-button type="primary" @click="addNewObject">保 存</el-button>
       </template>
     </el-dialog>
@@ -49,7 +49,7 @@
 const message = useMessage()
 const signalList = ref<any[]>([])
 const messageList = ref<any[]>([])
-const modelVisible = ref(false)
+const dialogVisible = ref(false)
 const modelType = ref('')
 const modelObjectForm = ref<any>({})
 const rootElements = ref()
@@ -85,7 +85,7 @@ const initDataList = () => {
 const openModel = (type) => {
   modelType.value = type
   modelObjectForm.value = {}
-  modelVisible.value = true
+  dialogVisible.value = true
 }
 const addNewObject = () => {
   if (modelType.value === 'message') {
@@ -101,7 +101,7 @@ const addNewObject = () => {
     const signalRef = bpmnInstances().moddle.create('bpmn:Signal', modelObjectForm.value)
     rootElements.value.push(signalRef)
   }
-  modelVisible.value = false
+  dialogVisible.value = false
   initDataList()
 }
 

+ 3 - 2
src/config/axios/service.ts

@@ -1,8 +1,8 @@
 import axios, {
+  AxiosError,
   AxiosInstance,
   AxiosRequestHeaders,
   AxiosResponse,
-  AxiosError,
   InternalAxiosRequestConfig
 } from 'axios'
 
@@ -230,7 +230,8 @@ const handleAuthorized = () => {
       wsCache.clear()
       removeToken()
       isRelogin.show = false
-      window.location.href = import.meta.env.VITE_BASE_PATH
+      // 干掉token后再走一次路由让它过router.beforeEach的校验
+      window.location.href = window.location.href
     })
   }
   return Promise.reject(t('sys.api.timeoutMessage'))

+ 1 - 0
src/locales/zh-CN.ts

@@ -352,6 +352,7 @@ export default {
     login: {
       backSignIn: '返回',
       signInFormTitle: '登录',
+      ssoFormTitle: '三方授权',
       mobileSignInFormTitle: '手机登录',
       qrSignInFormTitle: '二维码登录',
       signUpFormTitle: '注册',

+ 2 - 0
src/main.ts

@@ -52,6 +52,8 @@ import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css'
 import hljs from 'highlight.js' //导入代码高亮文件
 import 'highlight.js/styles/github.css' //导入代码高亮样式  新版
 
+import '@/plugins/tongji' // 百度统计
+
 import Logger from '@/utils/Logger'
 
 // 本地开发模式 全局引入 element-plus 样式,加快第一次进入速度

+ 23 - 0
src/plugins/tongji/index.ts

@@ -0,0 +1,23 @@
+import router from '@/router'
+
+// 用于 router push
+window._hmt = window._hmt || []
+// HM_ID
+const HM_ID = import.meta.env.VITE_APP_BAIDU_CODE
+;(function () {
+  // 有值的时候,才开启
+  if (!HM_ID) {
+    return
+  }
+  const hm = document.createElement('script')
+  hm.src = 'https://hm.baidu.com/hm.js?' + HM_ID
+  const s = document.getElementsByTagName('script')[0]
+  s.parentNode.insertBefore(hm, s)
+})()
+
+router.afterEach(function (to) {
+  if (!HM_ID) {
+    return
+  }
+  _hmt.push(['_trackPageview', to.fullPath])
+})

+ 2 - 2
src/router/index.ts

@@ -1,11 +1,11 @@
 import type { App } from 'vue'
 import type { RouteRecordRaw } from 'vue-router'
-import { createRouter, createWebHashHistory } from 'vue-router'
+import { createRouter, createWebHistory } from 'vue-router'
 import remainingRouter from './modules/remaining'
 
 // 创建路由实例
 const router = createRouter({
-  history: createWebHashHistory(), // createWebHashHistory URL带#,createWebHistory URL不带#
+  history: createWebHistory(), // createWebHashHistory URL带#,createWebHistory URL不带#
   strict: true,
   routes: remainingRouter as RouteRecordRaw[],
   scrollBehavior: () => ({ left: 0, top: 0 })

+ 18 - 20
src/router/modules/remaining.ts

@@ -116,7 +116,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: 'type/data/:dictType',
         component: () => import('@/views/system/dict/data/index.vue'),
-        name: 'data',
+        name: 'SystemDictData',
         meta: {
           title: '字典数据',
           noCache: true,
@@ -140,7 +140,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: 'edit',
         component: () => import('@/views/infra/codegen/EditTable.vue'),
-        name: 'EditTable',
+        name: 'InfraCodegenEditTable',
         meta: {
           noCache: true,
           hidden: true,
@@ -163,7 +163,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: 'job-log',
         component: () => import('@/views/infra/job/logger/index.vue'),
-        name: 'JobLog',
+        name: 'InfraJobLog',
         meta: {
           noCache: true,
           hidden: true,
@@ -185,6 +185,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
       noTagsView: true
     }
   },
+  {
+    path: '/sso',
+    component: () => import('@/views/Login/Login.vue'),
+    name: 'SSOLogin',
+    meta: {
+      hidden: true,
+      title: t('router.login'),
+      noTagsView: true
+    }
+  },
   {
     path: '/403',
     component: () => import('@/views/Error/403.vue'),
@@ -226,7 +236,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: '/manager/form/edit',
         component: () => import('@/views/bpm/form/editor/index.vue'),
-        name: 'bpmFormEditor',
+        name: 'BpmFormEditor',
         meta: {
           noCache: true,
           hidden: true,
@@ -238,7 +248,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: '/manager/model/edit',
         component: () => import('@/views/bpm/model/editor/index.vue'),
-        name: 'modelEditor',
+        name: 'BpmModelEditor',
         meta: {
           noCache: true,
           hidden: true,
@@ -250,7 +260,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: '/manager/definition',
         component: () => import('@/views/bpm/definition/index.vue'),
-        name: 'BpmProcessDefinitionList',
+        name: 'BpmProcessDefinition',
         meta: {
           noCache: true,
           hidden: true,
@@ -262,7 +272,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: '/manager/task-assign-rule',
         component: () => import('@/views/bpm/taskAssignRule/index.vue'),
-        name: 'BpmTaskAssignRuleList',
+        name: 'BpmTaskAssignRule',
         meta: {
           noCache: true,
           hidden: true,
@@ -305,18 +315,6 @@ const remainingRouter: AppRouteRecordRaw[] = [
           title: '发起 OA 请假',
           activeMenu: 'bpm/oa/leave/create'
         }
-      },
-      {
-        path: '/bpm/oa/leave/detail',
-        component: () => import('@/views/bpm/oa/leave/detail.vue'),
-        name: 'OALeaveDetail',
-        meta: {
-          noCache: true,
-          hidden: true,
-          canTo: true,
-          title: '查看 OA 请假',
-          activeMenu: 'bpm/oa/leave/detail'
-        }
       }
     ]
   },
@@ -331,7 +329,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       {
         path: 'value/:propertyId(\\d+)',
         component: () => import('@/views/mall/product/property/value/index.vue'),
-        name: 'PropertyValue',
+        name: 'ProductPropertyValue',
         meta: { title: '商品属性值', icon: '', activeMenu: '/product/property' }
       }
     ]

+ 1 - 4
src/types/auto-components.d.ts

@@ -25,13 +25,12 @@ declare module '@vue/runtime-core' {
     Echart: typeof import('./../components/Echart/src/Echart.vue')['default']
     Editor: typeof import('./../components/Editor/src/Editor.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
-    ElAutoResizer: typeof import('element-plus/es')['ElAutoResizer']
-    ElAvatar: typeof import('element-plus/es')['ElAvatar']
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
     ElCard: typeof import('element-plus/es')['ElCard']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElCollapse: typeof import('element-plus/es')['ElCollapse']
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -71,11 +70,9 @@ declare module '@vue/runtime-core' {
     ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
-    ElSpace: typeof import('element-plus/es')['ElSpace']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
-    ElTableV2: typeof import('element-plus/es')['ElTableV2']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']

+ 1 - 0
src/types/auto-imports.d.ts

@@ -6,6 +6,7 @@ export {}
 declare global {
   const DICT_TYPE: typeof import('@/utils/dict')['DICT_TYPE']
   const EffectScope: typeof import('vue')['EffectScope']
+  const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
   const computed: typeof import('vue')['computed']
   const createApp: typeof import('vue')['createApp']
   const customRef: typeof import('vue')['customRef']

+ 0 - 1
src/utils/dict.ts

@@ -112,7 +112,6 @@ export enum DICT_TYPE {
 
   // ========== INFRA 模块 ==========
   INFRA_BOOLEAN_STRING = 'infra_boolean_string',
-  INFRA_REDIS_TIMEOUT_TYPE = 'infra_redis_timeout_type',
   INFRA_JOB_STATUS = 'infra_job_status',
   INFRA_JOB_LOG_STATUS = 'infra_job_log_status',
   INFRA_API_ERROR_LOG_PROCESS_STATUS = 'infra_api_error_log_process_status',

+ 11 - 8
src/views/Login/Login.vue

@@ -9,19 +9,19 @@
       >
         <!-- 左上角的 logo + 系统标题 -->
         <div class="flex items-center relative text-white">
-          <img src="@/assets/imgs/logo.png" alt="" class="w-48px h-48px mr-10px" />
+          <img alt="" class="w-48px h-48px mr-10px" src="@/assets/imgs/logo.png" />
           <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
         </div>
         <!-- 左边的背景图 + 欢迎语 -->
         <div class="flex justify-center items-center h-[calc(100%-60px)]">
           <TransitionGroup
             appear
-            tag="div"
             enter-active-class="animate__animated animate__bounceInLeft"
+            tag="div"
           >
-            <img src="@/assets/svgs/login-box-bg.svg" key="1" alt="" class="w-350px" />
-            <div class="text-3xl text-white" key="2">{{ t('login.welcome') }}</div>
-            <div class="mt-5 font-normal text-white text-14px" key="3">
+            <img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" />
+            <div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div>
+            <div key="3" class="mt-5 font-normal text-white text-14px">
               {{ t('login.message') }}
             </div>
           </TransitionGroup>
@@ -31,7 +31,7 @@
         <!-- 右上角的主题、语言选择 -->
         <div class="flex justify-between items-center text-white @2xl:justify-end @xl:justify-end">
           <div class="flex items-center @2xl:hidden @xl:hidden">
-            <img src="@/assets/imgs/logo.png" alt="" class="w-48px h-48px mr-10px" />
+            <img alt="" class="w-48px h-48px mr-10px" src="@/assets/imgs/logo.png" />
             <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
           </div>
           <div class="flex justify-end items-center space-x-10px">
@@ -52,20 +52,23 @@
             <QrCodeForm class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" />
             <!-- 注册 -->
             <RegisterForm class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" />
+            <!-- 三方登录 -->
+            <SSOLoginVue class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" />
           </div>
         </Transition>
       </div>
     </div>
   </div>
 </template>
-<script setup lang="ts">
+<script lang="ts" setup>
 import { underlineToHump } from '@/utils'
 
 import { useDesign } from '@/hooks/web/useDesign'
 import { useAppStore } from '@/store/modules/app'
 import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
 import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
-import { LoginForm, MobileForm, RegisterForm, QrCodeForm } from './components'
+
+import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components'
 
 const { t } = useI18n()
 const appStore = useAppStore()

+ 8 - 2
src/views/Login/components/LoginForm.vue

@@ -137,7 +137,7 @@ import { useIcon } from '@/hooks/web/useIcon'
 import * as authUtil from '@/utils/auth'
 import { usePermissionStore } from '@/store/modules/permission'
 import * as LoginApi from '@/api/login'
-import { LoginStateEnum, useLoginState, useFormValid } from './useLogin'
+import { LoginStateEnum, useFormValid, useLoginState } from './useLogin'
 
 const { t } = useI18n()
 const message = useMessage()
@@ -240,7 +240,12 @@ const handleLogin = async (params) => {
     if (!redirect.value) {
       redirect.value = '/'
     }
-    push({ path: redirect.value || permissionStore.addRouters[0].path })
+    // 判断是否为SSO登录
+    if (redirect.value.indexOf('sso') !== -1) {
+      window.location.href = window.location.href.replace('/login?redirect=', '')
+    } else {
+      push({ path: redirect.value || permissionStore.addRouters[0].path })
+    }
   } catch {
     loginLoading.value = false
   } finally {
@@ -291,6 +296,7 @@ onMounted(() => {
     color: var(--el-color-primary) !important;
   }
 }
+
 .login-code {
   width: 100%;
   height: 38px;

+ 2 - 1
src/views/Login/components/LoginFormTitle.vue

@@ -16,7 +16,8 @@ const getFormTitle = computed(() => {
     [LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'),
     [LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'),
     [LoginStateEnum.MOBILE]: t('sys.login.mobileSignInFormTitle'),
-    [LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle')
+    [LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle'),
+    [LoginStateEnum.SSO]: t('sys.login.ssoFormTitle')
   }
   return titleObj[unref(getLoginState)]
 })

+ 186 - 0
src/views/Login/components/SSOLogin.vue

@@ -0,0 +1,186 @@
+<template>
+  <div v-show="ssoVisible" class="form-cont">
+    <!-- 应用名 -->
+    <LoginFormTitle style="width: 100%" />
+    <el-tabs class="form" style="float: none" value="uname">
+      <el-tab-pane :label="client.name" name="uname" />
+    </el-tabs>
+    <div>
+      <el-form :model="formData" class="login-form">
+        <!-- 授权范围的选择 -->
+        此第三方应用请求获得以下权限:
+        <el-form-item prop="scopes">
+          <el-checkbox-group v-model="formData.scopes">
+            <el-checkbox
+              v-for="scope in queryParams.scopes"
+              :key="scope"
+              :label="scope"
+              style="display: block; margin-bottom: -10px"
+            >
+              {{ formatScope(scope) }}
+            </el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+        <!-- 下方的登录按钮 -->
+        <el-form-item class="w-1/1">
+          <el-button
+            :loading="formLoading"
+            class="w-6/10"
+            type="primary"
+            @click.prevent="handleAuthorize(true)"
+          >
+            <span v-if="!formLoading">同意授权</span>
+            <span v-else>授 权 中...</span>
+          </el-button>
+          <el-button class="w-3/10" @click.prevent="handleAuthorize(false)">拒绝</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+<script lang="ts" name="SSOLogin" setup>
+import LoginFormTitle from './LoginFormTitle.vue'
+import * as OAuth2Api from '@/api/login/oauth2'
+import { LoginStateEnum, useLoginState } from './useLogin'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+const route = useRoute() // 路由
+const { currentRoute } = useRouter() // 路由
+const { getLoginState, setLoginState } = useLoginState()
+
+const client = ref({
+  // 客户端信息
+  name: '',
+  logo: ''
+})
+const queryParams = reactive({
+  // URL 上的 client_id、scope 等参数
+  responseType: '',
+  clientId: '',
+  redirectUri: '',
+  state: '',
+  scopes: [] // 优先从 query 参数获取;如果未传递,从后端获取
+})
+const ssoVisible = computed(() => unref(getLoginState) === LoginStateEnum.SSO) // 是否展示 SSO 登录的表单
+const formData = reactive({
+  scopes: [] // 已选中的 scope 数组
+})
+const formLoading = ref(false) // 表单是否提交中
+
+/** 初始化授权信息 */
+const init = async () => {
+  // 防止在没有登录的情况下循环弹窗
+  if (typeof route.query.client_id === 'undefined') return
+  // 解析参数
+  // 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write
+  // 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read
+  queryParams.responseType = route.query.response_type as string
+  queryParams.clientId = route.query.client_id as string
+  queryParams.redirectUri = route.query.redirect_uri as string
+  queryParams.state = route.query.state as string
+  if (route.query.scope) {
+    queryParams.scopes = (route.query.scope as string).split(' ')
+  }
+
+  // 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。
+  if (queryParams.scopes.length > 0) {
+    const data = await doAuthorize(true, queryParams.scopes, [])
+    if (data) {
+      location.href = data
+      return
+    }
+  }
+
+  // 获取授权页的基本信息
+  const data = await OAuth2Api.getAuthorize(queryParams.clientId)
+  client.value = data.client
+  // 解析 scope
+  let scopes
+  // 1.1 如果 params.scope 非空,则过滤下返回的 scopes
+  if (queryParams.scopes.length > 0) {
+    scopes = []
+    for (const scope of data.scopes) {
+      if (queryParams.scopes.indexOf(scope.key) >= 0) {
+        scopes.push(scope)
+      }
+    }
+    // 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它
+  } else {
+    scopes = data.scopes
+    for (const scope of scopes) {
+      queryParams.scopes.push(scope.key)
+    }
+  }
+  // 生成已选中的 checkedScopes
+  for (const scope of scopes) {
+    if (scope.value) {
+      formData.scopes.push(scope.key)
+    }
+  }
+}
+
+/** 处理授权的提交 */
+const handleAuthorize = async (approved) => {
+  // 计算 checkedScopes + uncheckedScopes
+  let checkedScopes
+  let uncheckedScopes
+  if (approved) {
+    // 同意授权,按照用户的选择
+    checkedScopes = formData.scopes
+    uncheckedScopes = queryParams.scopes.filter((item) => checkedScopes.indexOf(item) === -1)
+  } else {
+    // 拒绝,则都是取消
+    checkedScopes = []
+    uncheckedScopes = queryParams.scopes
+  }
+  // 提交授权的请求
+  formLoading.value = true
+  try {
+    const data = await doAuthorize(false, checkedScopes, uncheckedScopes)
+    if (!data) {
+      return
+    }
+    location.href = data
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 调用授权 API 接口 */
+const doAuthorize = (autoApprove, checkedScopes, uncheckedScopes) => {
+  return OAuth2Api.authorize(
+    queryParams.responseType,
+    queryParams.clientId,
+    queryParams.redirectUri,
+    queryParams.state,
+    autoApprove,
+    checkedScopes,
+    uncheckedScopes
+  )
+}
+
+/** 格式化 scope 文本 */
+const formatScope = (scope) => {
+  // 格式化 scope 授权范围,方便用户理解。
+  // 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。
+  switch (scope) {
+    case 'user.read':
+      return '访问你的个人信息'
+    case 'user.write':
+      return '修改你的个人信息'
+    default:
+      return scope
+  }
+}
+
+/** 监听当前路由为 SSOLogin 时,进行数据的初始化 */
+watch(
+  () => currentRoute.value,
+  (route: RouteLocationNormalizedLoaded) => {
+    if (route.name === 'SSOLogin') {
+      setLoginState(LoginStateEnum.SSO)
+      init()
+    }
+  },
+  { immediate: true }
+)
+</script>

+ 2 - 1
src/views/Login/components/index.ts

@@ -3,5 +3,6 @@ import MobileForm from './MobileForm.vue'
 import LoginFormTitle from './LoginFormTitle.vue'
 import RegisterForm from './RegisterForm.vue'
 import QrCodeForm from './QrCodeForm.vue'
+import SSOLoginVue from './SSOLogin.vue'
 
-export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm }
+export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue }

+ 2 - 1
src/views/Login/components/useLogin.ts

@@ -5,7 +5,8 @@ export enum LoginStateEnum {
   REGISTER,
   RESET_PASSWORD,
   MOBILE,
-  QR_CODE
+  QR_CODE,
+  SSO
 }
 
 const currentState = ref(LoginStateEnum.LOGIN)

+ 3 - 3
src/views/bpm/definition/index.vue

@@ -4,7 +4,7 @@
       <el-table-column label="定义编号" align="center" prop="id" width="400" />
       <el-table-column label="流程名称" align="center" prop="name" width="200">
         <template #default="scope">
-          <el-button type="text" @click="handleBpmnDetail(scope.row)">
+          <el-button type="primary" link @click="handleBpmnDetail(scope.row)">
             <span>{{ scope.row.name }}</span>
           </el-button>
         </template>
@@ -23,7 +23,7 @@
           >
             <span>{{ scope.row.formName }}</span>
           </el-button>
-          <el-button v-else type="text" @click="handleFormDetail(scope.row)">
+          <el-button v-else type="primary" link @click="handleFormDetail(scope.row)">
             <span>{{ scope.row.formCustomCreatePath }}</span>
           </el-button>
         </template>
@@ -93,7 +93,7 @@
   </Dialog>
 </template>
 
-<script setup lang="ts" name="Form">
+<script setup lang="ts" name="BpmProcessDefinition">
 import { DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as DefinitionApi from '@/api/bpm/definition'

+ 2 - 3
src/views/bpm/form/index.vue

@@ -83,12 +83,11 @@
   </Dialog>
 </template>
 
-<script setup lang="ts" name="Form">
+<script setup lang="ts" name="BpmForm">
 import { DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as FormApi from '@/api/bpm/form'
 import { setConfAndFields2 } from '@/utils/formCreate'
-
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 const { push } = useRouter() // 路由
@@ -130,7 +129,7 @@ const resetQuery = () => {
 /** 添加/修改操作 */
 const openForm = (id?: number) => {
   push({
-    name: 'bpmFormEditor',
+    name: 'BpmFormEditor',
     query: {
       id
     }

+ 1 - 1
src/views/bpm/group/index.vue

@@ -111,7 +111,7 @@
   <UserGroupForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts" name="UserGroup">
+<script setup lang="ts" name="BpmUserGroup">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as UserGroupApi from '@/api/bpm/userGroup'

+ 1 - 1
src/views/bpm/model/editor/index.vue

@@ -24,7 +24,7 @@
   </ContentWrap>
 </template>
 
-<script setup lang="ts">
+<script setup lang="ts" name="BpmModelEditor">
 // 自定义元素选中时的弹出菜单(修改 默认任务 为 用户任务)
 import CustomContentPadProvider from '@/components/bpmnProcessDesigner/package/designer/plugins/content-pad'
 // 自定义左侧菜单(修改 默认任务 为 用户任务)

+ 4 - 4
src/views/bpm/model/index.vue

@@ -65,7 +65,7 @@
       <el-table-column label="流程标识" align="center" prop="key" width="200" />
       <el-table-column label="流程名称" align="center" prop="name" width="200">
         <template #default="scope">
-          <el-button type="text" @click="handleBpmnDetail(scope.row)">
+          <el-button type="primary" link @click="handleBpmnDetail(scope.row)">
             <span>{{ scope.row.name }}</span>
           </el-button>
         </template>
@@ -224,7 +224,7 @@
   </Dialog>
 </template>
 
-<script setup lang="ts" name="Form">
+<script setup lang="ts" name="BpmModel">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter, formatDate } from '@/utils/formatTime'
 import * as ModelApi from '@/api/bpm/model'
@@ -319,7 +319,7 @@ const handleChangeState = async (row) => {
 /** 设计流程 */
 const handleDesign = (row) => {
   push({
-    name: 'modelEditor',
+    name: 'BpmModelEditor',
     query: {
       modelId: row.id
     }
@@ -352,7 +352,7 @@ const handleAssignRule = (row) => {
 /** 跳转到指定流程定义列表 */
 const handleDefinitionList = (row) => {
   push({
-    name: 'BpmProcessDefinitionList',
+    name: 'BpmProcessDefinition',
     query: {
       key: row.key
     }

+ 97 - 46
src/views/bpm/oa/leave/create.vue

@@ -1,56 +1,107 @@
 <template>
-  <ContentWrap>
-    <!-- 对话框(添加 / 修改) -->
-    <Form :schema="allSchemas.formSchema" :rules="rules" ref="formRef" />
-    <!-- 按钮:保存 -->
-    <XButton
-      type="primary"
-      :title="t('action.save')"
-      :loading="actionLoading"
-      @click="submitForm"
-    />
-  </ContentWrap>
+  <Dialog title="发起 OA 请假流程" v-model="modelVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="请假类型" prop="type">
+        <el-select v-model="formData.type" placeholder="请选择请假类型" clearable>
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="开始时间" prop="startTime">
+        <el-date-picker
+          clearable
+          v-model="formData.startTime"
+          type="datetime"
+          value-format="x"
+          placeholder="请选择开始时间"
+        />
+      </el-form-item>
+      <el-form-item label="结束时间" prop="endTime">
+        <el-date-picker
+          clearable
+          v-model="formData.endTime"
+          type="datetime"
+          value-format="x"
+          placeholder="请选择结束时间"
+        />
+      </el-form-item>
+      <el-form-item label="原因" prop="reason">
+        <el-input v-model="formData.reason" type="textarea" placeholder="请输请假原因" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="modelVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
 </template>
 <script setup lang="ts">
-import { FormExpose } from '@/components/Form'
-// import XEUtils from 'xe-utils'
-
-// 业务相关的 import
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import * as LeaveApi from '@/api/bpm/leave'
-import { rules, allSchemas } from './leave.data'
-
-const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
-const { push } = useRouter() // 路由
 
-// 表单参数
-const actionLoading = ref(false) // 按钮 Loading
-const formRef = ref<FormExpose>() // 表单 Ref
+const modelVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  type: undefined,
+  reason: undefined,
+  startTime: undefined,
+  endTime: undefined
+})
+const formRules = reactive({
+  type: [{ required: true, message: '请假类型不能为空', trigger: 'blur' }],
+  reason: [{ required: true, message: '请假原因不能为空', trigger: 'change' }],
+  startTime: [{ required: true, message: '请假开始时间不能为空', trigger: 'change' }],
+  endTime: [{ required: true, message: '请假结束时间不能为空', trigger: 'change' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async () => {
+  modelVisible.value = true
+  resetForm()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
-// 提交按钮
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
 const submitForm = async () => {
-  const elForm = unref(formRef)?.getElFormRef()
-  if (!elForm) return
-  elForm.validate(async (valid) => {
-    if (!valid) {
-      return
-    }
-    try {
-      // 设置提交中
-      actionLoading.value = true
-      const data = unref(formRef)?.formModel as LeaveApi.LeaveVO
-      // data.startTime = XEUtils.toDateString(data.startTime, 'yyyy-MM-dd HH:mm:ss')
-      // data.endTime = XEUtils.toDateString(data.endTime, 'yyyy-MM-dd HH:mm:ss')
-      data.startTime = Date.parse(new Date(data.startTime).toString()).toString()
-      data.endTime = Date.parse(new Date(data.endTime).toString()).toString()
-      // 添加的提交
-      await LeaveApi.createLeave(data)
-      message.success(t('common.createSuccess'))
-      // 关闭窗口
-      push('/bpm/oa/leave')
-    } finally {
-      actionLoading.value = false
-    }
-  })
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as LeaveApi.LeaveVO
+    await LeaveApi.createLeave(data)
+    message.success('新增成功')
+    modelVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    type: undefined,
+    reason: undefined,
+    startTime: undefined,
+    endTime: undefined
+  }
+  formRef.value?.resetFields()
 }
 </script>

+ 32 - 34
src/views/bpm/oa/leave/detail.vue

@@ -1,42 +1,40 @@
 <template>
-  <ContentWrap>
-    <!-- 详情 -->
-    <Descriptions :schema="allSchemas.detailSchema" :data="formData" />
-    <el-button @click="routerReturn" type="primary">返回</el-button>
-  </ContentWrap>
+  <Dialog title="详情" v-model="modelVisible" :scroll="true" :max-height="200">
+    <el-descriptions border :column="1">
+      <el-descriptions-item label="请假类型">
+        <dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="detailData.type" />
+      </el-descriptions-item>
+      <el-descriptions-item label="开始时间">
+        {{ formatDate(detailData.startTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="结束时间">
+        {{ formatDate(detailData.endTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="原因">
+        {{ detailData.reason }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
 </template>
-
 <script setup lang="ts">
-// 业务相关的 import
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
 import * as LeaveApi from '@/api/bpm/leave'
-import { allSchemas } from '@/views/bpm/oa/leave/leave.data'
-import { useRouter } from 'vue-router'
-const router = useRouter()
-const { query } = useRoute() // 查询参数
-const message = useMessage() // 消息弹窗
-
-const id = ref() // 请假编号
-// 表单参数
-const formData = ref({
-  startTime: undefined,
-  endTime: undefined,
-  type: undefined,
-  reason: undefined
-})
 
-const routerReturn = () => {
-  router.back()
-}
+const modelVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref() // 详情数据
 
-onMounted(() => {
-  id.value = query.id
-  if (!id.value) {
-    message.error('未传递 id 参数,无法查看 OA 请假信息')
-    return
+/** 打开弹窗 */
+const open = async (data: LeaveApi.LeaveVO) => {
+  modelVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = data
+  } finally {
+    detailLoading.value = false
   }
-  // 获得请假信息
-  LeaveApi.getLeave(id.value).then((data) => {
-    formData.value = data
-  })
-})
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 </script>

+ 207 - 54
src/views/bpm/oa/leave/index.vue

@@ -1,83 +1,236 @@
 <template>
   <ContentWrap>
-    <XTable @register="registerTable">
-      <template #toolbar_buttons>
-        <!-- 操作:发起请假 -->
-        <XButton type="primary" preIcon="ep:plus" title="发起请假" @click="handleCreate()" />
-      </template>
-      <template #actionbtns_default="{ row }">
-        <!-- 操作: 取消请假 -->
-        <XTextButton
-          preIcon="ep:delete"
-          title="取消请假"
-          v-hasPermi="['bpm:oa-leave:create']"
-          v-if="row.result === 1"
-          @click="cancelLeave(row)"
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="请假类型" prop="type">
+        <el-select
+          v-model="queryParams.type"
+          placeholder="请选择请假类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)"
+            :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"
         />
-        <!-- 操作: 详情 -->
-        <XTextButton preIcon="ep:view" :title="t('action.detail')" @click="handleDetail(row)" />
-        <!-- 操作: 审批进度 -->
-        <XTextButton preIcon="ep:edit-pen" title="审批进度" @click="handleProcessDetail(row)" />
-      </template>
-    </XTable>
+      </el-form-item>
+      <el-form-item label="结果" prop="result">
+        <el-select v-model="queryParams.result" placeholder="请选择结果" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="原因" prop="reason">
+        <el-input
+          v-model="queryParams.reason"
+          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="handleCreate()">
+          <Icon icon="ep:plus" class="mr-5px" /> 发起请假
+        </el-button>
+      </el-form-item>
+    </el-form>
   </ContentWrap>
-</template>
 
-<script setup lang="ts">
-// 全局相关的 import
-import { ElMessageBox } from 'element-plus'
-// 业务相关的 import
-import { allSchemas } from './leave.data'
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="申请编号" align="center" prop="id" />
+      <el-table-column label="状态" align="center" prop="result">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT" :value="scope.row.result" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="开始时间"
+        align="center"
+        prop="startTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column
+        label="结束时间"
+        align="center"
+        prop="endTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="请假类型" align="center" prop="type">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="scope.row.type" />
+        </template>
+      </el-table-column>
+      <el-table-column label="原因" align="center" prop="reason" />
+      <el-table-column
+        label="申请时间"
+        align="center"
+        prop="createTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="操作" align="center" width="200">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="handleDetail(scope.row)"
+            v-hasPermi="['bpm:oa-leave:query']"
+          >
+            详情
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            @click="handleProcessDetail(scope.row)"
+            v-hasPermi="['bpm:oa-leave:query']"
+          >
+            进度
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="cancelLeave(scope.row)"
+            v-hasPermi="['bpm:oa-leave:create']"
+            v-if="scope.row.result === 1"
+          >
+            取消
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:详情 -->
+  <LeaveDetail ref="detailRef" />
+  <!-- 表单弹窗:添加 -->
+  <LeaveForm ref="formRef" @success="getList" />
+</template>
+<script setup lang="ts" name="BpmOALeave">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
 import * as LeaveApi from '@/api/bpm/leave'
 import * as ProcessInstanceApi from '@/api/bpm/processInstance'
-
-const { t } = useI18n() // 国际化
+import LeaveDetail from './detail.vue'
+import LeaveForm from './create.vue'
 const message = useMessage() // 消息弹窗
-const { push } = useRouter() // 路由
+const router = useRouter() // 路由
+const { t } = useI18n() // 国际化
 
-const [registerTable, { reload }] = useXTable({
-  allSchemas: allSchemas,
-  getListApi: LeaveApi.getLeavePage
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: undefined,
+  result: undefined,
+  reason: undefined,
+  createTime: []
 })
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await LeaveApi.getLeavePage(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 handleCreate = () => {
-  push({
-    name: 'OALeaveCreate'
-  })
+  formRef.value.open()
 }
 
-// 取消请假
-const cancelLeave = (row) => {
-  ElMessageBox.prompt('请输入取消原因', '取消流程', {
+/** 详情操作 */
+const detailRef = ref()
+const handleDetail = (data: LeaveApi.LeaveVO) => {
+  detailRef.value.open(data)
+}
+
+/** 取消请假操作 */
+const cancelLeave = async (row) => {
+  // 二次确认
+  const { value } = await ElMessageBox.prompt('请输入取消原因', '取消流程', {
     confirmButtonText: t('common.ok'),
     cancelButtonText: t('common.cancel'),
     inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
     inputErrorMessage: '取消原因不能为空'
-  }).then(async ({ value }) => {
-    await ProcessInstanceApi.cancelProcessInstance(row.id, value)
-    message.success('取消成功')
-    reload()
-  })
-}
-
-// 详情
-const handleDetail = (row) => {
-  push({
-    name: 'OALeaveDetail',
-    query: {
-      id: row.id
-    }
   })
+  // 发起取消
+  await ProcessInstanceApi.cancelProcessInstance(row.id, value)
+  message.success('取消成功')
+  // 刷新列表
+  await getList()
 }
 
-// 审批进度
+/** 审批进度 */
 const handleProcessDetail = (row) => {
-  push({
+  router.push({
     name: 'BpmProcessInstanceDetail',
     query: {
       id: row.processInstanceId
     }
   })
 }
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
 </script>

+ 0 - 91
src/views/bpm/oa/leave/leave.data.ts

@@ -1,91 +0,0 @@
-import type { VxeCrudSchema } from '@/hooks/web/useVxeCrudSchemas'
-
-const { t } = useI18n() // 国际化
-
-// 表单校验
-export const rules = reactive({
-  startTime: [{ required: true, message: '开始时间不能为空', trigger: 'blur' }],
-  endTime: [{ required: true, message: '结束时间不能为空', trigger: 'blur' }],
-  type: [{ required: true, message: '请假类型不能为空', trigger: 'change' }]
-})
-
-// crudSchemas
-const crudSchemas = reactive<VxeCrudSchema>({
-  primaryKey: 'id',
-  primaryType: 'id',
-  primaryTitle: '申请编号',
-  action: true,
-  actionWidth: '260',
-  searchSpan: 8,
-  columns: [
-    {
-      title: t('common.status'),
-      field: 'result',
-      dictType: DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT,
-      dictClass: 'number',
-      isSearch: true,
-      isForm: false
-    },
-    {
-      title: t('common.startTimeText'),
-      field: 'startTime',
-      formatter: 'formatDay',
-      table: {
-        width: 180
-      },
-      detail: {
-        dateFormat: 'YYYY-MM-DD'
-      },
-      form: {
-        component: 'DatePicker'
-      }
-    },
-    {
-      title: t('common.endTimeText'),
-      field: 'endTime',
-      formatter: 'formatDay',
-      table: {
-        width: 180
-      },
-      detail: {
-        dateFormat: 'YYYY-MM-DD'
-      },
-      form: {
-        component: 'DatePicker'
-      }
-    },
-    {
-      title: '请假类型',
-      field: 'type',
-      dictType: DICT_TYPE.BPM_OA_LEAVE_TYPE,
-      dictClass: 'number',
-      isSearch: true
-    },
-    {
-      title: '原因',
-      field: 'reason',
-      isSearch: true,
-      componentProps: {
-        type: 'textarea',
-        rows: 4
-      }
-    },
-    {
-      title: '申请时间',
-      field: 'createTime',
-      formatter: 'formatDate',
-      table: {
-        width: 180
-      },
-      isSearch: true,
-      search: {
-        show: true,
-        itemRender: {
-          name: 'XDataTimePicker'
-        }
-      },
-      isForm: false
-    }
-  ]
-})
-export const { allSchemas } = useVxeCrudSchemas(crudSchemas)

+ 1 - 1
src/views/bpm/processInstance/create/index.vue

@@ -46,7 +46,7 @@
     <ProcessInstanceBpmnViewer :bpmn-xml="bpmnXML" />
   </ContentWrap>
 </template>
-<script setup lang="ts">
+<script setup lang="ts" name="BpmProcessInstanceCreate">
 import { DICT_TYPE } from '@/utils/dict'
 import * as DefinitionApi from '@/api/bpm/definition'
 import * as ProcessInstanceApi from '@/api/bpm/processInstance'

+ 1 - 1
src/views/bpm/processInstance/detail/index.vue

@@ -96,7 +96,7 @@
     <TaskUpdateAssigneeForm ref="taskUpdateAssigneeFormRef" @success="getDetail" />
   </ContentWrap>
 </template>
-<script setup lang="ts">
+<script setup lang="ts" name="BpmProcessInstanceDetail">
 import { useUserStore } from '@/store/modules/user'
 import { setConfAndFields2 } from '@/utils/formCreate'
 import type { ApiAttrs } from '@form-create/element-ui/types/config'

+ 207 - 53
src/views/bpm/processInstance/index.vue

@@ -1,64 +1,211 @@
 <template>
   <ContentWrap>
-    <!-- 列表 -->
-    <XTable @register="registerTable">
-      <template #toolbar_buttons>
-        <!-- 操作:新增 -->
-        <XButton
+    <!-- 搜索工作栏 -->
+    <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="processDefinitionId">
+        <el-input
+          v-model="queryParams.processDefinitionId"
+          placeholder="请输入流程定义的编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="流程分类" prop="category">
+        <el-select
+          v-model="queryParams.category"
+          placeholder="请选择流程分类"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_CATEGORY)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </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.BPM_PROCESS_INSTANCE_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="结果" prop="result">
+        <el-select v-model="queryParams.result" placeholder="请选择结果" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT)"
+            :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"
-          preIcon="ep:zoom-in"
-          title="发起流程"
+          plain
           v-hasPermi="['bpm:process-instance:query']"
           @click="handleCreate"
-        />
-      </template>
-      <!-- 流程分类 -->
-      <template #category_default="{ row }">
-        <DictTag :type="DICT_TYPE.BPM_MODEL_CATEGORY" :value="Number(row?.category)" />
-      </template>
-      <!-- 当前审批任务 -->
-      <template #tasks_default="{ row }">
-        <el-button v-for="task in row.tasks" :key="task.id" link>
-          <span>{{ task.name }}</span>
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 发起流程
         </el-button>
-      </template>
-      <!-- 操作 -->
-      <template #actionbtns_default="{ row }">
-        <XTextButton
-          preIcon="ep:view"
-          :title="t('action.detail')"
-          v-hasPermi="['bpm:process-instance:cancel']"
-          @click="handleDetail(row)"
-        />
-        <XTextButton
-          preIcon="ep:delete"
-          title="取消"
-          v-if="row.result === 1"
-          v-hasPermi="['bpm:process-instance:query']"
-          @click="handleCancel(row)"
-        />
-      </template>
-    </XTable>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="流程编号" align="center" prop="id" width="300px" />
+      <el-table-column label="流程名称" align="center" prop="name" />
+      <el-table-column label="流程分类" align="center" prop="category">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.BPM_MODEL_CATEGORY" :value="scope.row.category" />
+        </template>
+      </el-table-column>
+      <el-table-column label="当前审批任务" align="center" prop="tasks">
+        <template #default="scope">
+          <el-button type="primary" v-for="task in scope.row.tasks" :key="task.id" link>
+            <span>{{ task.name }}</span>
+          </el-button>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="结果" prop="result">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT" :value="scope.row.result" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="提交时间"
+        align="center"
+        prop="createTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column
+        label="结束时间"
+        align="center"
+        prop="endTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            v-hasPermi="['bpm:process-instance:cancel']"
+            @click="handleDetail(scope.row)"
+          >
+            详情
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            v-if="scope.row.result === 1"
+            v-hasPermi="['bpm:process-instance:query']"
+            @click="handleCancel(scope.row)"
+          >
+            取消
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
   </ContentWrap>
 </template>
-<script setup lang="ts">
-// 全局相关的 import
+<script setup lang="ts" name="BpmProcessInstance">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
 import { ElMessageBox } from 'element-plus'
-import { DICT_TYPE } from '@/utils/dict'
-
-// 业务相关的 import
 import * as ProcessInstanceApi from '@/api/bpm/processInstance'
-import { allSchemas } from './process.data'
-
 const router = useRouter() // 路由
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
-// ========== 列表相关 ==========
-const [registerTable, { reload }] = useXTable({
-  allSchemas: allSchemas,
-  getListApi: ProcessInstanceApi.getMyProcessInstancePage
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: '',
+  processDefinitionId: undefined,
+  category: undefined,
+  status: undefined,
+  result: undefined,
+  createTime: []
 })
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProcessInstanceApi.getMyProcessInstancePage(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 handleCreate = () => {
@@ -67,7 +214,7 @@ const handleCreate = () => {
   })
 }
 
-// 列表操作
+/** 查看详情 */
 const handleDetail = (row) => {
   router.push({
     name: 'BpmProcessInstanceDetail',
@@ -78,16 +225,23 @@ const handleDetail = (row) => {
 }
 
 /** 取消按钮操作 */
-const handleCancel = (row) => {
-  ElMessageBox.prompt('请输入取消原因', '取消流程', {
+const handleCancel = async (row) => {
+  // 二次确认
+  const { value } = await ElMessageBox.prompt('请输入取消原因', '取消流程', {
     confirmButtonText: t('common.ok'),
     cancelButtonText: t('common.cancel'),
     inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
     inputErrorMessage: '取消原因不能为空'
-  }).then(async ({ value }) => {
-    await ProcessInstanceApi.cancelProcessInstance(row.id, value)
-    message.success('取消成功')
-    reload()
   })
+  // 发起取消
+  await ProcessInstanceApi.cancelProcessInstance(row.id, value)
+  message.success('取消成功')
+  // 刷新列表
+  await getList()
 }
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
 </script>

+ 0 - 94
src/views/bpm/processInstance/process.data.ts

@@ -1,94 +0,0 @@
-import type { VxeCrudSchema } from '@/hooks/web/useVxeCrudSchemas'
-
-const { t } = useI18n() // 国际化
-
-// CrudSchema
-const crudSchemas = reactive<VxeCrudSchema>({
-  primaryKey: 'id',
-  primaryType: null,
-  primaryTitle: '编号',
-  action: true,
-  actionWidth: '200px',
-  columns: [
-    {
-      title: '编号',
-      field: 'id',
-      table: {
-        width: 320
-      }
-    },
-    {
-      title: '流程名',
-      field: 'name',
-      isSearch: true
-    },
-    {
-      title: '所属流程',
-      field: 'processDefinitionId',
-      isSearch: true,
-      isTable: false
-    },
-    {
-      title: '流程分类',
-      field: 'category',
-      dictType: DICT_TYPE.BPM_MODEL_CATEGORY,
-      dictClass: 'number',
-      isSearch: true,
-      table: {
-        slots: {
-          default: 'category_default'
-        }
-      }
-    },
-    {
-      title: '当前审批任务',
-      field: 'tasks',
-      table: {
-        width: 140,
-        slots: {
-          default: 'tasks_default'
-        }
-      }
-    },
-    {
-      title: t('common.status'),
-      field: 'status',
-      dictType: DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
-      dictClass: 'number',
-      isSearch: true
-    },
-    {
-      title: '结果',
-      field: 'result',
-      dictType: DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT,
-      dictClass: 'number',
-      isSearch: true
-    },
-    {
-      title: '提交时间',
-      field: 'createTime',
-      formatter: 'formatDate',
-      table: {
-        width: 180
-      },
-      isForm: false,
-      isSearch: true,
-      search: {
-        show: true,
-        itemRender: {
-          name: 'XDataTimePicker'
-        }
-      }
-    },
-    {
-      title: '结束时间',
-      field: 'endTime',
-      formatter: 'formatDate',
-      table: {
-        width: 180
-      },
-      isForm: false
-    }
-  ]
-})
-export const { allSchemas } = useVxeCrudSchemas(crudSchemas)

+ 1 - 1
src/views/bpm/task/done/index.vue

@@ -74,7 +74,7 @@
   <!-- 表单弹窗:详情 -->
   <TaskDetail ref="detailRef" @success="getList" />
 </template>
-<script setup lang="tsx">
+<script setup lang="tsx" name="BpmTodoTask">
 import { DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as TaskApi from '@/api/bpm/task'

+ 110 - 20
src/views/bpm/task/todo/index.vue

@@ -1,32 +1,117 @@
 <template>
   <ContentWrap>
-    <XTable @register="registerTable">
-      <template #suspensionState_default="{ row }">
-        <el-tag type="success" v-if="row.suspensionState === 1">激活</el-tag>
-        <el-tag type="warning" v-if="row.suspensionState === 2">挂起</el-tag>
-      </template>
-      <template #actionbtns_default="{ row }">
-        <!-- 操作: 审批进度 -->
-        <XTextButton preIcon="ep:edit-pen" title="审批进度" @click="handleAudit(row)" />
-      </template>
-    </XTable>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="任务名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入任务名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
   </ContentWrap>
-</template>
 
-<script setup lang="ts">
-// 业务相关的 import
-import { allSchemas } from './todo.data'
-import * as TaskApi from '@/api/bpm/task'
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="任务编号" align="center" prop="id" width="300px" />
+      <el-table-column label="任务名称" align="center" prop="name" />
+      <el-table-column label="所属流程" align="center" prop="processInstance.name" />
+      <el-table-column label="流程发起人" align="center" prop="processInstance.startUserNickname" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="任务状态" prop="suspensionState">
+        <template #default="scope">
+          <el-tag type="success" v-if="scope.row.suspensionState === 1">激活</el-tag>
+          <el-tag type="warning" v-if="scope.row.suspensionState === 2">挂起</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button link type="primary" @click="handleAudit(scope.row)">审批进度</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
 
+<script setup lang="tsx" name="BpmDoneTask">
+import { dateFormatter } from '@/utils/formatTime'
 const { push } = useRouter() // 路由
+import * as TaskApi from '@/api/bpm/task'
 
-const [registerTable] = useXTable({
-  allSchemas: allSchemas,
-  topActionSlots: false,
-  getListApi: TaskApi.getTodoTaskPage
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: '',
+  createTime: []
 })
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询任务列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await TaskApi.getTodoTaskPage(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 handleAudit = (row) => {
   push({
     name: 'BpmProcessInstanceDetail',
@@ -35,4 +120,9 @@ const handleAudit = (row) => {
     }
   })
 }
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
 </script>

+ 0 - 58
src/views/bpm/task/todo/todo.data.ts

@@ -1,58 +0,0 @@
-import type { VxeCrudSchema } from '@/hooks/web/useVxeCrudSchemas'
-
-const { t } = useI18n() // 国际化
-
-// crudSchemas
-const crudSchemas = reactive<VxeCrudSchema>({
-  primaryKey: 'id',
-  primaryType: null,
-  action: true,
-  searchSpan: 8,
-  columns: [
-    {
-      title: '任务编号',
-      field: 'id',
-      table: {
-        width: 320
-      }
-    },
-    {
-      title: '任务名称',
-      field: 'name',
-      isSearch: true
-    },
-    {
-      title: '所属流程',
-      field: 'processInstance.name'
-    },
-    {
-      title: '流程发起人',
-      field: 'processInstance.startUserNickname'
-    },
-    {
-      title: t('common.createTime'),
-      field: 'createTime',
-      formatter: 'formatDate',
-      table: {
-        width: 180
-      },
-      isSearch: true,
-      search: {
-        show: true,
-        itemRender: {
-          name: 'XDataTimePicker'
-        }
-      }
-    },
-    {
-      title: '任务状态',
-      field: 'suspensionState',
-      table: {
-        slots: {
-          default: 'suspensionState_default'
-        }
-      }
-    }
-  ]
-})
-export const { allSchemas } = useVxeCrudSchemas(crudSchemas)

+ 1 - 1
src/views/bpm/taskAssignRule/index.vue

@@ -32,7 +32,7 @@
   <!-- 添加/修改弹窗 -->
   <TaskAssignRuleForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="TaskAssignRule">
+<script setup lang="ts" name="BpmTaskAssignRule">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import * as TaskAssignRuleApi from '@/api/bpm/taskAssignRule'
 import * as RoleApi from '@/api/system/role'

+ 1 - 1
src/views/infra/apiAccessLog/index.vue

@@ -139,7 +139,7 @@
   <!-- 表单弹窗:详情 -->
   <ApiAccessLogDetail ref="detailRef" />
 </template>
-<script setup lang="ts" name="ApiAccessLog">
+<script setup lang="ts" name="InfraApiAccessLog">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import download from '@/utils/download'
 import { formatDate } from '@/utils/formatTime'

+ 1 - 2
src/views/infra/apiErrorLog/index.vue

@@ -158,14 +158,13 @@
   <ApiErrorLogDetail ref="detailRef" />
 </template>
 
-<script setup lang="ts" name="ApiErrorLog">
+<script setup lang="ts" name="InfraApiErrorLog">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'
 import * as ApiErrorLogApi from '@/api/infra/apiErrorLog'
 import ApiErrorLogDetail from './ApiErrorLogDetail.vue'
 import { InfraApiErrorLogProcessStatusEnum } from '@/utils/constants'
-
 const message = useMessage() // 消息弹窗
 
 const loading = ref(true) // 列表的加载中

+ 1 - 1
src/views/infra/build/index.vue

@@ -31,7 +31,7 @@
     </div>
   </Dialog>
 </template>
-<script setup lang="ts" name="Build">
+<script setup lang="ts" name="InfraBuild">
 import formCreate from '@form-create/element-ui'
 import { useClipboard } from '@vueuse/core'
 const { t } = useI18n() // 国际化

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

@@ -142,7 +142,7 @@
   <!-- 弹窗:预览代码 -->
   <PreviewCode ref="previewRef" />
 </template>
-<script setup lang="ts" name="Codegen">
+<script setup lang="ts" name="InfraCodegen">
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'
 import * as CodegenApi from '@/api/infra/codegen'

+ 1 - 1
src/views/infra/config/index.vue

@@ -137,7 +137,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <ConfigForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="Config">
+<script setup lang="ts" name="InfraConfig">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'

+ 1 - 1
src/views/infra/dataSourceConfig/index.vue

@@ -57,7 +57,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <DataSourceConfigForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="DataSourceConfig">
+<script setup lang="ts" name="InfraDataSourceConfig">
 import { dateFormatter } from '@/utils/formatTime'
 import * as DataSourceConfigApi from '@/api/infra/dataSourceConfig'
 import DataSourceConfigForm from './DataSourceConfigForm.vue'

+ 24 - 30
src/views/infra/dbDoc/index.vue

@@ -2,46 +2,38 @@
   <doc-alert title="数据库文档" url="https://doc.iocoder.cn/db-doc/" />
 
   <ContentWrap title="数据库文档">
-    <!-- 操作工具栏 -->
     <div class="mb-10px">
-      <XButton
-        type="primary"
-        preIcon="ep:download"
-        :title="t('action.export') + ' HTML'"
-        @click="handleExport('HTML')"
-      />
-      <XButton
-        type="primary"
-        preIcon="ep:download"
-        :title="t('action.export') + ' Word'"
-        @click="handleExport('Word')"
-      />
-      <XButton
-        type="primary"
-        preIcon="ep:download"
-        :title="t('action.export') + ' Markdown'"
-        @click="handleExport('Markdown')"
-      />
+      <el-button type="primary" plain @click="handleExport('HTML')">
+        <Icon icon="ep:download" /> 导出 HTML
+      </el-button>
+      <el-button type="primary" plain @click="handleExport('Word')">
+        <Icon icon="ep:download" /> 导出 Word
+      </el-button>
+      <el-button type="primary" plain @click="handleExport('Markdown')">
+        <Icon icon="ep:download" /> 导出 Markdown
+      </el-button>
     </div>
-    <IFrame v-if="!loding" v-loading="loding" :src="src" />
+    <IFrame v-if="!loading" v-loading="loading" :src="src" />
   </ContentWrap>
 </template>
-<script setup lang="ts" name="DbDoc">
+<script setup lang="ts" name="InfraDBDoc">
 import download from '@/utils/download'
-
 import * as DbDocApi from '@/api/infra/dbDoc'
 
-const { t } = useI18n() // 国际化
-const src = ref('')
-const loding = ref(true)
+const loading = ref(true) // 是否加载中
+const src = ref('') // HTML 的地址
+
 /** 页面加载 */
 const init = async () => {
-  const res = await DbDocApi.exportHtml()
-  let blob = new Blob([res], { type: 'text/html' })
-  let blobUrl = window.URL.createObjectURL(blob)
-  src.value = blobUrl
-  loding.value = false
+  try {
+    const data = await DbDocApi.exportHtml()
+    const blob = new Blob([data], { type: 'text/html' })
+    src.value = window.URL.createObjectURL(blob)
+  } finally {
+    loading.value = false
+  }
 }
+
 /** 处理导出  */
 const handleExport = async (type: string) => {
   if (type === 'HTML') {
@@ -57,6 +49,8 @@ const handleExport = async (type: string) => {
     download.markdown(res, '数据库文档.md')
   }
 }
+
+/** 初始化 */
 onMounted(async () => {
   await init()
 })

+ 18 - 4
src/views/infra/druid/index.vue

@@ -3,10 +3,24 @@
   <doc-alert title="多数据源(读写分离)" url="https://doc.iocoder.cn/dynamic-datasource/" />
 
   <ContentWrap>
-    <IFrame :src="src" />
+    <IFrame v-if="!loading" :src="url" />
   </ContentWrap>
 </template>
-<script setup lang="ts" name="Druid">
-const BASE_URL = import.meta.env.VITE_BASE_URL
-const src = ref(BASE_URL + '/druid/index.html')
+<script setup lang="ts" name="InfraDruid">
+import * as ConfigApi from '@/api/infra/config'
+
+const loading = ref(true) // 是否加载中
+const url = ref(import.meta.env.VITE_BASE_URL + '/druid/index.html')
+
+/** 初始化 */
+onMounted(async () => {
+  try {
+    const data = await ConfigApi.getConfigKey('url.druid')
+    if (data && data.length > 0) {
+      url.value = data
+    }
+  } finally {
+    loading.value = false
+  }
+})
 </script>

+ 45 - 22
src/views/infra/file/FileForm.vue

@@ -2,17 +2,19 @@
   <Dialog title="上传文件" v-model="dialogVisible">
     <el-upload
       ref="uploadRef"
-      :limit="1"
-      accept=".jpg, .png, .gif"
-      :auto-upload="false"
-      drag
-      :headers="headers"
       :action="url"
       :data="data"
-      :disabled="formLoading"
+      :headers="uploadHeaders"
+      v-model:file-list="fileList"
+      drag
+      accept=".jpg, .png, .gif"
+      :limit="1"
+      :on-success="submitFormSuccess"
+      :on-exceed="handleExceed"
+      :on-error="submitFormError"
       :on-change="handleFileChange"
-      :on-progress="handleFileUploadProgress"
-      :on-success="handleFileSuccess"
+      :auto-upload="false"
+      :disabled="formLoading"
     >
       <i class="el-icon-upload"></i>
       <div class="el-upload__text"> 将文件拖到此处,或 <em>点击上传</em> </div>
@@ -29,44 +31,47 @@
   </Dialog>
 </template>
 <script setup lang="ts">
-import { Dialog } from '@/components/Dialog'
-import { getAccessToken } from '@/utils/auth'
+import { getAccessToken, getTenantId } from '@/utils/auth'
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 
 const dialogVisible = ref(false) // 弹窗的是否展示
-const dialogTitle = ref('') // 弹窗的标题
-const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formLoading = ref(false) // 表单的加载中
 const url = import.meta.env.VITE_UPLOAD_URL
-const headers = { Authorization: 'Bearer ' + getAccessToken() }
+const uploadHeaders = ref() // 上传 Header 头
+const fileList = ref([]) // 文件列表
 const data = ref({ path: '' })
 const uploadRef = ref()
 
 /** 打开弹窗 */
 const open = async () => {
   dialogVisible.value = true
+  resetForm()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
-/** 提交表单 */
-const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
 /** 处理上传的文件发生变化 */
 const handleFileChange = (file) => {
   data.value.path = file.name
 }
 
-/** 处理文件上传中 */
-const handleFileUploadProgress = () => {
-  formLoading.value = true // 禁止修改
-}
-
-/** 发起文件上传 */
+/** 提交表单 */
 const submitFileForm = () => {
+  if (fileList.value.length == 0) {
+    message.error('请上传文件')
+    return
+  }
+  // 提交请求
+  uploadHeaders.value = {
+    Authorization: 'Bearer ' + getAccessToken(),
+    'tenant-id': getTenantId()
+  }
   unref(uploadRef)?.submit()
 }
 
 /** 文件上传成功处理 */
-const handleFileSuccess = () => {
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitFormSuccess = () => {
   // 清理
   dialogVisible.value = false
   formLoading.value = false
@@ -75,4 +80,22 @@ const handleFileSuccess = () => {
   message.success(t('common.createSuccess'))
   emit('success')
 }
+
+/** 上传错误提示 */
+const submitFormError = (): void => {
+  message.error('上传失败,请您重新上传!')
+  formLoading.value = false
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  // 重置上传状态和文件
+  formLoading.value = false
+  uploadRef.value?.clearFiles()
+}
+
+/** 文件数超出提示 */
+const handleExceed = (): void => {
+  message.error('最多只能上传一个文件!')
+}
 </script>

+ 11 - 6
src/views/infra/file/index.vue

@@ -1,9 +1,14 @@
 <template>
-  <doc-alert title="上传下载" url="https://doc.iocoder.cn/file/"/>
-
+  <doc-alert title="上传下载" url="https://doc.iocoder.cn/file/" />
   <!-- 搜索 -->
   <ContentWrap>
-    <el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true">
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
       <el-form-item label="文件路径" prop="path">
         <el-input
           v-model="queryParams.path"
@@ -33,7 +38,7 @@
       <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">
+        <el-button type="primary" plain @click="openForm">
           <Icon icon="ep:upload" class="mr-5px" /> 上传文件
         </el-button>
       </el-form-item>
@@ -86,11 +91,11 @@
   <!-- 表单弹窗:添加/修改 -->
   <FileForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="Config">
+<script setup lang="ts" name="InfraFile">
 import { fileSizeFormatter } from '@/utils'
 import { dateFormatter } from '@/utils/formatTime'
 import * as FileApi from '@/api/infra/file'
-import FileUploadForm from './FileForm.vue'
+import FileForm from './FileForm.vue'
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 

+ 16 - 3
src/views/infra/fileConfig/index.vue

@@ -3,17 +3,29 @@
 
   <!-- 搜索 -->
   <ContentWrap>
-    <el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true">
+    <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="storage">
-        <el-select v-model="queryParams.storage" placeholder="请选择存储器" clearable>
+        <el-select
+          v-model="queryParams.storage"
+          placeholder="请选择存储器"
+          clearable
+          class="!w-240px"
+        >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)"
             :key="dict.value"
@@ -30,6 +42,7 @@
           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>
@@ -113,7 +126,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <FileConfigForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="Config">
+<script setup lang="ts" name="InfraFileConfig">
 import * as FileConfigApi from '@/api/infra/fileConfig'
 import FileConfigForm from './FileConfigForm.vue'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'

+ 1 - 1
src/views/infra/job/index.vue

@@ -147,7 +147,7 @@
   <!-- 表单弹窗:查看 -->
   <JobDetail ref="detailRef" />
 </template>
-<script setup lang="ts" name="Job">
+<script setup lang="ts" name="InfraJob">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { checkPermi } from '@/utils/permission'
 import JobForm from './JobForm.vue'

+ 1 - 1
src/views/infra/job/logger/index.vue

@@ -121,7 +121,7 @@
   <!-- 表单弹窗:查看 -->
   <JobLogDetail ref="detailRef" />
 </template>
-<script setup lang="ts" name="JobLog">
+<script setup lang="ts" name="InfraJobLog">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { formatDate } from '@/utils/formatTime'
 import download from '@/utils/download'

+ 7 - 111
src/views/infra/redis/index.vue

@@ -4,6 +4,7 @@
 
   <el-scrollbar height="calc(100vh - 88px - 40px - 50px)">
     <el-row>
+      <!-- 基本信息 -->
       <el-col :span="24" class="card-box" shadow="hover">
         <el-card>
           <el-descriptions title="基本信息" :column="6" border>
@@ -47,106 +48,33 @@
           </el-descriptions>
         </el-card>
       </el-col>
+      <!-- 命令统计 -->
       <el-col :span="12" class="mt-3">
         <el-card :gutter="12" shadow="hover">
           <div ref="commandStatsRef" class="h-88"></div>
         </el-card>
       </el-col>
+      <!-- 内存使用量统计 -->
       <el-col :span="12" class="mt-3">
         <el-card class="ml-3" :gutter="12" shadow="hover">
           <div ref="usedmemory" class="h-88"></div>
         </el-card>
       </el-col>
     </el-row>
-    <el-row class="mt-3">
-      <el-col :span="24" class="card-box" shadow="hover">
-        <el-card>
-          <el-table
-            v-loading="keyListLoad"
-            :data="keyList"
-            row-key="id"
-            @row-click="openKeyTemplate"
-          >
-            <el-table-column prop="keyTemplate" label="Key 模板" width="200" />
-            <el-table-column prop="keyType" label="Key 类型" width="100" />
-            <el-table-column prop="valueType" label="Value 类型" />
-            <el-table-column prop="timeoutType" label="超时时间" width="200">
-              <template #default="{ row }">
-                <DictTag :type="DICT_TYPE.INFRA_REDIS_TIMEOUT_TYPE" :value="row?.timeoutType" />
-                <span v-if="row?.timeout > 0">({{ row?.timeout / 1000 }} 秒)</span>
-              </template>
-            </el-table-column>
-            <el-table-column prop="memo" label="备注" />
-          </el-table>
-        </el-card>
-      </el-col>
-    </el-row>
   </el-scrollbar>
-  <XModal v-model="dialogVisible" :title="keyTemplate + ' 模板'">
-    <el-row>
-      <el-col :span="14" class="mt-3">
-        <el-card shadow="always">
-          <template #header>
-            <div class="card-header">
-              <span>键名列表</span>
-            </div>
-          </template>
-          <el-table :data="cacheKeys" style="width: 100%" @row-click="handleKeyValue">
-            <el-table-column label="缓存键名" align="center" :show-overflow-tooltip="true">
-              <template #default="{ row }">
-                {{ row }}
-              </template>
-            </el-table-column>
-            <el-table-column label="操作" align="right" width="60">
-              <template #default="{ row }">
-                <XTextButton preIcon="ep:delete" @click="handleDeleteKey(row)" />
-              </template>
-            </el-table-column>
-          </el-table>
-        </el-card>
-      </el-col>
-      <el-col :span="10" class="mt-3">
-        <el-card shadow="always">
-          <template #header>
-            <div class="card-header">
-              <span>缓存内容</span>
-              <XTextButton
-                preIcon="ep:refresh"
-                title="清理全部"
-                @click="handleDeleteKeys(keyTemplate)"
-                class="float-right p-1"
-              />
-            </div>
-          </template>
-          <el-descriptions :column="1">
-            <el-descriptions-item label="缓存键名:">{{ cacheForm.key }}</el-descriptions-item>
-            <el-descriptions-item label="缓存内容:">{{ cacheForm.value }}</el-descriptions-item>
-          </el-descriptions>
-        </el-card>
-      </el-col>
-    </el-row>
-  </XModal>
 </template>
-<script setup lang="ts" name="Redis">
+<script setup lang="ts" name="InfraRedis">
 import * as echarts from 'echarts'
-import { DICT_TYPE } from '@/utils/dict'
-
 import * as RedisApi from '@/api/infra/redis'
-import { RedisKeyInfo, RedisMonitorInfoVO } from '@/api/infra/redis/types'
-const { t } = useI18n() // 国际化
-const message = useMessage() // 消息弹窗
+import { RedisMonitorInfoVO } from '@/api/infra/redis/types'
 
 const cache = ref<RedisMonitorInfoVO>()
-const keyListLoad = ref(true)
-const keyList = ref<RedisKeyInfo[]>([])
+
 // 基本信息
 const readRedisInfo = async () => {
   const data = await RedisApi.getCache()
   cache.value = data
   loadEchartOptions(data.commandStats)
-  const redisKeysInfo = await RedisApi.getKeyDefineList()
-  keyList.value = redisKeysInfo
-  keyListLoad.value = false //加载完成
 }
 // 图表
 const commandStatsRef = ref<HTMLElement>()
@@ -241,40 +169,8 @@ const loadEchartOptions = (stats) => {
     ]
   })
 }
-const dialogVisible = ref(false)
-const keyTemplate = ref('')
-const cacheKeys = ref()
-const cacheForm = ref<{
-  key: string
-  value: string
-}>({
-  key: '',
-  value: ''
-})
-const openKeyTemplate = async (row: RedisKeyInfo) => {
-  keyTemplate.value = row.keyTemplate
-  cacheKeys.value = await RedisApi.getKeyList(row.keyTemplate)
-  dialogVisible.value = true
-}
-const handleDeleteKey = async (row) => {
-  RedisApi.deleteKey(row)
-  message.success(t('common.delSuccess'))
-}
-const handleDeleteKeys = async (row) => {
-  RedisApi.deleteKeys(row)
-  message.success(t('common.delSuccess'))
-}
-const handleKeyValue = async (row) => {
-  const res = await RedisApi.getKeyValue(row)
-  cacheForm.value = res
-}
+
 onBeforeMount(() => {
   readRedisInfo()
 })
 </script>
-<style scoped>
-.redis {
-  height: 600px;
-  max-height: 860px;
-}
-</style>

+ 19 - 4
src/views/infra/server/index.vue

@@ -1,10 +1,25 @@
 <template>
   <doc-alert title="服务监控" url="https://doc.iocoder.cn/server-monitor/" />
+
   <ContentWrap>
-    <IFrame :src="src" />
+    <IFrame v-if="!loading" v-loading="loading" :src="src" />
   </ContentWrap>
 </template>
-<script setup lang="ts" name="AdminServer">
-const BASE_URL = import.meta.env.VITE_BASE_URL
-const src = ref(BASE_URL + '/admin/applications')
+<script setup lang="ts" name="InfraAdminServer">
+import * as ConfigApi from '@/api/infra/config'
+
+const loading = ref(true) // 是否加载中
+const src = ref(import.meta.env.VITE_BASE_URL + '/admin/applications')
+
+/** 初始化 */
+onMounted(async () => {
+  try {
+    const data = await ConfigApi.getConfigKey('url.spring-boot-admin')
+    if (data && data.length > 0) {
+      src.value = data
+    }
+  } finally {
+    loading.value = false
+  }
+})
 </script>

+ 18 - 2
src/views/infra/skywalking/index.vue

@@ -1,9 +1,25 @@
 <template>
   <doc-alert title="服务监控" url="https://doc.iocoder.cn/server-monitor/" />
+
   <ContentWrap>
-    <IFrame :src="src" />
+    <IFrame v-if="!loading" v-loading="loading" :src="src" />
   </ContentWrap>
 </template>
-<script setup lang="ts" name="Skywalking">
+<script setup lang="ts" name="InfraSkyWalking">
+import * as ConfigApi from '@/api/infra/config'
+
+const loading = ref(true) // 是否加载中
 const src = ref('http://skywalking.shop.iocoder.cn')
+
+/** 初始化 */
+onMounted(async () => {
+  try {
+    const data = await ConfigApi.getConfigKey('url.skywalking')
+    if (data && data.length > 0) {
+      src.value = data
+    }
+  } finally {
+    loading.value = false
+  }
+})
 </script>

+ 18 - 4
src/views/infra/swagger/index.vue

@@ -5,8 +5,22 @@
     <IFrame :src="src" />
   </ContentWrap>
 </template>
-<script setup lang="ts" name="Swagger">
-const BASE_URL = import.meta.env.VITE_BASE_URL
-// const src = ref(BASE_URL + '/doc.html')
-const src = ref(BASE_URL + '/swagger-ui')
+<script setup lang="ts" name="InfraSwagger">
+import * as ConfigApi from '@/api/infra/config'
+
+const loading = ref(true) // 是否加载中
+const src = ref(import.meta.env.VITE_BASE_URL + '/doc.html') // Knife4j UI
+// const src = ref(import.meta.env.VITE_BASE_URL + '/swagger-ui') // Swagger UI
+
+/** 初始化 */
+onMounted(async () => {
+  try {
+    const data = await ConfigApi.getConfigKey('url.swagger')
+    if (data && data.length > 0) {
+      src.value = data
+    }
+  } finally {
+    loading.value = false
+  }
+})
 </script>

+ 120 - 0
src/views/mall/product/brand/BrandForm.vue

@@ -0,0 +1,120 @@
+<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="picUrl">
+        <UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" />
+      </el-form-item>
+      <el-form-item label="品牌排序" prop="sort">
+        <el-input-number v-model="formData.sort" controls-position="right" :min="0" />
+      </el-form-item>
+      <el-form-item label="品牌状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="品牌描述">
+        <el-input v-model="formData.description" type="textarea" 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" name="ProductBrandForm">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as ProductBrandApi from '@/api/mall/product/brand'
+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: '',
+  picUrl: '',
+  status: CommonStatusEnum.ENABLE,
+  description: ''
+})
+const formRules = reactive({
+  name: [{ required: true, message: '品牌名称不能为空', trigger: 'blur' }],
+  picUrl: [{ required: true, message: '品牌图片不能为空', trigger: 'blur' }],
+  sort: [{ 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)
+    } 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 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>

+ 177 - 0
src/views/mall/product/brand/index.vue

@@ -0,0 +1,177 @@
+<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.COMMON_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" sortable />
+      <el-table-column label="品牌图片" align="center" prop="picUrl">
+        <template #default="scope">
+          <img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="品牌图片" class="h-100px" />
+        </template>
+      </el-table-column>
+      <el-table-column label="品牌排序" align="center" prop="sort" />
+      <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="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>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <BrandForm ref="formRef" @success="getList" />
+</template>
+<script setup lang="ts" name="ProductBrand">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as ProductBrandApi from '@/api/mall/product/brand'
+import BrandForm from './BrandForm.vue'
+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)
+    list.value = data.list
+    total.value = data.total
+  } 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 ProductBrandApi.deleteBrand(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 1 - 1
src/views/mall/product/category/CategoryForm.vue

@@ -50,7 +50,7 @@
     </template>
   </Dialog>
 </template>
-<script setup lang="ts">
+<script setup lang="ts" name="ProductCategory">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { CommonStatusEnum } from '@/utils/constants'
 import { handleTree } from '@/utils/tree'

+ 1 - 1
src/views/mall/product/property/index.vue

@@ -92,7 +92,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <PropertyForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="Config">
+<script setup lang="ts" name="ProductProperty">
 import { dateFormatter } from '@/utils/formatTime'
 import * as PropertyApi from '@/api/mall/product/property'
 import PropertyForm from './PropertyForm.vue'

+ 1 - 1
src/views/mall/product/property/value/index.vue

@@ -88,7 +88,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <ValueForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="Config">
+<script setup lang="ts" name="ProductPropertyValue">
 import { dateFormatter } from '@/utils/formatTime'
 import * as PropertyApi from '@/api/mall/product/property'
 import ValueForm from './ValueForm.vue'

+ 421 - 1
src/views/mp/autoReply/index.vue

@@ -1,3 +1,423 @@
 <template>
-  <span>开发中</span>
+  <doc-alert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" />
+
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="公众号" prop="accountId">
+        <el-select v-model="queryParams.accountId" placeholder="请选择公众号" class="!w-240px">
+          <el-option
+            v-for="item in accountList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- tab 切换 -->
+  <ContentWrap>
+    <el-tabs v-model="type" @tab-change="handleTabChange">
+      <!-- 操作工具栏 -->
+      <el-row :gutter="10" class="mb8">
+        <el-col :span="1.5">
+          <el-button
+            type="primary"
+            plain
+            @click="handleAdd"
+            v-hasPermi="['mp:auto-reply:create']"
+            v-if="type !== '1' || list.length <= 0"
+          >
+            <Icon icon="ep:plus" />新增
+          </el-button>
+        </el-col>
+      </el-row>
+      <!-- tab 项 -->
+      <el-tab-pane name="1">
+        <template #label>
+          <span><Icon icon="ep:star-off" /> 关注时回复</span>
+        </template>
+      </el-tab-pane>
+      <el-tab-pane name="2">
+        <template #label>
+          <span><Icon icon="ep:chat-line-round" /> 消息回复</span>
+        </template>
+      </el-tab-pane>
+      <el-tab-pane name="3">
+        <template #label>
+          <span><Icon icon="ep:news" /> 关键词回复</span>
+        </template>
+      </el-tab-pane>
+    </el-tabs>
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column
+        label="请求消息类型"
+        align="center"
+        prop="requestMessageType"
+        v-if="type === '2'"
+      />
+      <el-table-column label="关键词" align="center" prop="requestKeyword" v-if="type === '3'" />
+      <el-table-column label="匹配类型" align="center" prop="requestMatch" v-if="type === '3'">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH" :value="scope.row.requestMatch" />
+        </template>
+      </el-table-column>
+      <el-table-column label="回复消息类型" align="center">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.MP_MESSAGE_TYPE" :value="scope.row.responseMessageType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="回复内容" align="center">
+        <template #default="scope">
+          <div v-if="scope.row.responseMessageType === 'text'">{{ scope.row.responseContent }}</div>
+          <div v-else-if="scope.row.responseMessageType === 'voice'">
+            <WxVoicePlayer :url="scope.row.responseMediaUrl" />
+          </div>
+          <div v-else-if="scope.row.responseMessageType === 'image'">
+            <a target="_blank" :href="scope.row.responseMediaUrl">
+              <img :src="scope.row.responseMediaUrl" style="width: 100px" />
+            </a>
+          </div>
+          <div
+            v-else-if="
+              scope.row.responseMessageType === 'video' ||
+              scope.row.responseMessageType === 'shortvideo'
+            "
+          >
+            <WxVideoPlayer :url="scope.row.responseMediaUrl" style="margin-top: 10px" />
+          </div>
+          <div v-else-if="scope.row.responseMessageType === 'news'">
+            <WxNews :articles="scope.row.responseArticles" />
+          </div>
+          <div v-else-if="scope.row.responseMessageType === 'music'">
+            <WxMusic
+              :title="scope.row.responseTitle"
+              :description="scope.row.responseDescription"
+              :thumb-media-url="scope.row.responseThumbMediaUrl"
+              :music-url="scope.row.responseMusicUrl"
+              :hq-music-url="scope.row.responseHqMusicUrl"
+            />
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            type="primary"
+            link
+            @click="handleUpdate(scope.row)"
+            v-hasPermi="['mp:auto-reply:update']"
+          >
+            修改
+          </el-button>
+          <el-button
+            type="danger"
+            link
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['mp:auto-reply:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 添加或修改自动回复的对话框 -->
+    <el-dialog :title="title" v-model="open" width="800px" append-to-body>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="消息类型" prop="requestMessageType" v-if="type === '2'">
+          <el-select v-model="form.requestMessageType" placeholder="请选择">
+            <template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value">
+              <el-option
+                v-if="requestMessageTypes.includes(dict.value)"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </template>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="匹配类型" prop="requestMatch" v-if="type === '3'">
+          <el-select v-model="form.requestMatch" placeholder="请选择匹配类型" clearable>
+            <el-option
+              v-for="dict in getDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="关键词" prop="requestKeyword" v-if="type === '3'">
+          <el-input v-model="form.requestKeyword" placeholder="请输入内容" clearable />
+        </el-form-item>
+        <el-form-item label="回复消息">
+          <WxReplySelect :objData="objData" v-if="hackResetWxReplySelect" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="cancel">取 消</el-button>
+          <el-button type="primary" @click="handleSubmit">确 定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </ContentWrap>
 </template>
+<script setup name="MpAutoReply">
+import { ref, reactive, onMounted, nextTick } from 'vue'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
+import WxMusic from '@/views/mp/components/wx-music/main.vue'
+import WxNews from '@/views/mp/components/wx-news/main.vue'
+import WxReplySelect from '@/views/mp/components/wx-reply/main.vue'
+import { getSimpleAccountList } from '@/api/mp/account'
+import {
+  createAutoReply,
+  deleteAutoReply,
+  getAutoReply,
+  getAutoReplyPage,
+  updateAutoReply
+} from '@/api/mp/autoReply'
+
+import { DICT_TYPE, getDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { ContentWrap } from '@/components/ContentWrap'
+
+const message = useMessage()
+
+const queryFormRef = ref()
+const formRef = ref()
+
+// tab 类型(1、关注时回复;2、消息回复;3、关键词回复)
+const type = ref('3')
+// 允许选择的请求消息类型
+const requestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link']
+// 遮罩层
+const loading = ref(true)
+// 显示搜索条件
+// const showSearch = ref(true)
+// 总条数
+const total = ref(0)
+// 自动回复列表
+const list = ref([])
+// 查询参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  accountId: undefined
+})
+
+// 弹出层标题
+const title = ref('')
+// 是否显示弹出层
+const open = ref(false)
+// 表单参数
+const form = ref({})
+// 回复消息
+const objData = ref({
+  type: 'text'
+})
+// 表单校验
+const rules = {
+  requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }],
+  requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }]
+}
+
+const hackResetWxReplySelect = ref(false) // 重置 WxReplySelect 组件,解决无法清除的问题
+
+// 公众号账号列表
+const accountList = ref([])
+
+onMounted(() => {
+  getSimpleAccountList().then((data) => {
+    accountList.value = data
+    // 默认选中第一个
+    if (accountList.value.length > 0) {
+      queryParams.accountId = accountList.value[0].id
+    }
+    // 加载数据
+    getList()
+  })
+})
+
+/** 查询列表 */
+const getList = async () => {
+  // 如果没有选中公众号账号,则进行提示。
+  if (!queryParams.accountId) {
+    message.error('未选中公众号,无法查询自动回复')
+    return false
+  }
+
+  loading.value = false
+  // 处理查询参数
+  let params = {
+    ...queryParams,
+    type: type.value
+  }
+  // 执行查询
+  getAutoReplyPage(params).then((data) => {
+    list.value = data.list
+    total.value = data.total
+    loading.value = false
+  })
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  // 默认选中第一个
+  if (accountList.value.length > 0) {
+    queryParams.accountId = accountList.value[0].id
+  }
+  handleQuery()
+}
+
+const handleTabChange = (tabName) => {
+  type.value = tabName
+  handleQuery()
+}
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  reset()
+  resetEditor()
+  // 打开表单,并设置初始化
+  open.value = true
+  title.value = '新增自动回复'
+  objData.value = {
+    type: 'text',
+    accountId: queryParams.accountId
+  }
+}
+
+/** 修改按钮操作 */
+const handleUpdate = (row) => {
+  reset()
+  resetEditor()
+  console.log(row)
+
+  getAutoReply(row.id).then((data) => {
+    // 设置属性
+    form.value = { ...data }
+    delete form.value['responseMessageType']
+    delete form.value['responseContent']
+    delete form.value['responseMediaId']
+    delete form.value['responseMediaUrl']
+    delete form.value['responseDescription']
+    delete form.value['responseArticles']
+    objData.value = {
+      type: data.responseMessageType,
+      accountId: queryParams.accountId,
+      content: data.responseContent,
+      mediaId: data.responseMediaId,
+      url: data.responseMediaUrl,
+      title: data.responseTitle,
+      description: data.responseDescription,
+      thumbMediaId: data.responseThumbMediaId,
+      thumbMediaUrl: data.responseThumbMediaUrl,
+      articles: data.responseArticles,
+      musicUrl: data.responseMusicUrl,
+      hqMusicUrl: data.responseHqMusicUrl
+    }
+
+    // 打开表单
+    open.value = true
+    title.value = '修改自动回复'
+  })
+}
+
+const handleSubmit = () => {
+  formRef.value?.validate((valid) => {
+    if (!valid) {
+      return
+    }
+
+    // 处理回复消息
+    const form = { ...form.value }
+    form.responseMessageType = objData.value.type
+    form.responseContent = objData.value.content
+    form.responseMediaId = objData.value.mediaId
+    form.responseMediaUrl = objData.value.url
+    form.responseTitle = objData.value.title
+    form.responseDescription = objData.value.description
+    form.responseThumbMediaId = objData.value.thumbMediaId
+    form.responseThumbMediaUrl = objData.value.thumbMediaUrl
+    form.responseArticles = objData.value.articles
+    form.responseMusicUrl = objData.value.musicUrl
+    form.responseHqMusicUrl = objData.value.hqMusicUrl
+
+    if (form.value.id !== undefined) {
+      updateAutoReply(form).then(() => {
+        message.success('修改成功')
+        open.value = false
+        getList()
+      })
+    } else {
+      createAutoReply(form).then(() => {
+        message.success('新增成功')
+        open.value = false
+        getList()
+      })
+    }
+  })
+}
+
+// 表单重置
+const reset = () => {
+  form.value = {
+    id: undefined,
+    accountId: queryParams.accountId,
+    type: type.value,
+    requestKeyword: undefined,
+    requestMatch: type.value === '3' ? 1 : undefined,
+    requestMessageType: undefined
+  }
+  formRef.value?.resetFields()
+}
+
+// 取消按钮
+const cancel = () => {
+  open.value = false
+  reset()
+}
+
+// 表单 Editor 重置
+const resetEditor = () => {
+  hackResetWxReplySelect.value = false // 销毁组件
+  nextTick(() => {
+    hackResetWxReplySelect.value = true // 重建组件
+  })
+}
+
+const handleDelete = async (row) => {
+  await message.confirm('是否确认删除此数据?')
+  await deleteAutoReply(row.id)
+  await getList()
+  message.success('删除成功')
+}
+</script>

+ 201 - 0
src/views/mp/components/wx-editor/WxEditor.vue

@@ -0,0 +1,201 @@
+<script setup>
+import { ref, reactive } from 'vue'
+import { QuillEditor } from '@vueup/vue-quill'
+import '@vueup/vue-quill/dist/vue-quill.snow.css'
+import { getAccessToken } from '@/utils/auth'
+import editorOptions from './quill-options'
+
+const BASE_URL = import.meta.env.VITE_BASE_URL
+
+const message = useMessage()
+
+const props = defineProps({
+  /* 公众号账号编号 */
+  accountId: {
+    type: Number,
+    required: true
+  },
+  /* 编辑器的内容 */
+  value: {
+    type: String,
+    default: ''
+  },
+  /* 图片大小 */
+  maxSize: {
+    type: Number,
+    default: 4000 // kb
+  }
+})
+
+const emit = defineEmits(['input'])
+
+const myQuillEditorRef = ref()
+
+const content = ref(props.value.replace(/data-src/g, 'src'))
+
+const loading = ref(false) // 根据图片上传状态来确定是否显示loading动画,刚开始是false,不显示
+
+const actionUrl = ref(BASE_URL + '/admin-api/mp/material/upload-news-image') // 这里写你要上传的图片服务器地址
+const headers = ref({ Authorization: 'Bearer ' + getAccessToken() }) // 设置上传的请求头部
+const uploadData = reactive({
+  type: 'image', // TODO 芋艿:试试要不要换成 thumb
+  accountId: props.accountId
+})
+
+const onEditorChange = () => {
+  //内容改变事件
+  emit('input', content.value)
+}
+
+// 富文本图片上传前
+const beforeUpload = () => {
+  // 显示 loading 动画
+  loading.value = true
+}
+
+// 图片上传成功
+// 注意!由于微信公众号的图片有访问限制,所以会显示“此图片来自微信公众号,未经允许不可引用”
+const uploadSuccess = (res) => {
+  // res为图片服务器返回的数据
+  // 获取富文本组件实例
+  const quill = myQuillEditorRef.value.quill
+  // 如果上传成功
+  const link = res.data
+  if (link) {
+    // 获取光标所在位置
+    let length = quill.getSelection().index
+    // 插入图片  res.info为服务器返回的图片地址
+    quill.insertEmbed(length, 'image', link)
+    // 调整光标到最后
+    quill.setSelection(length + 1)
+  } else {
+    message.error('图片插入失败')
+  }
+  // loading 动画消失
+  loading.value = false
+}
+
+// 富文本图片上传失败
+const uploadError = () => {
+  // loading 动画消失
+  loading.value = false
+  message.error('图片插入失败')
+}
+</script>
+
+<template>
+  <div id="wxEditor">
+    <div v-loading="loading" element-loading-text="请稍等,图片上传中">
+      <!-- 图片上传组件辅助-->
+      <el-upload
+        class="avatar-uploader"
+        name="file"
+        :action="actionUrl"
+        :headers="headers"
+        :show-file-list="false"
+        :data="uploadData"
+        :on-success="uploadSuccess"
+        :on-error="uploadError"
+        :before-upload="beforeUpload"
+      />
+      <QuillEditor
+        class="editor"
+        v-model="content"
+        ref="quillEditorRef"
+        :options="editorOptions"
+        @change="onEditorChange($event)"
+      />
+    </div>
+  </div>
+</template>
+
+<style>
+.editor {
+  line-height: normal !important;
+  height: 500px;
+}
+
+.ql-snow .ql-tooltip[data-mode='link']::before {
+  content: '请输入链接地址:';
+}
+
+.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
+  border-right: 0;
+  content: '保存';
+  padding-right: 0;
+}
+
+.ql-snow .ql-tooltip[data-mode='video']::before {
+  content: '请输入视频地址:';
+}
+
+.ql-snow .ql-picker.ql-size .ql-picker-label::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item::before {
+  content: '14px';
+}
+
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
+  content: '10px';
+}
+
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
+  content: '18px';
+}
+
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
+  content: '32px';
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item::before {
+  content: '文本';
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
+  content: '标题1';
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
+  content: '标题2';
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
+  content: '标题3';
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
+  content: '标题4';
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
+  content: '标题5';
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
+  content: '标题6';
+}
+
+.ql-snow .ql-picker.ql-font .ql-picker-label::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item::before {
+  content: '标准字体';
+}
+
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
+  content: '衬线字体';
+}
+
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
+  content: '等宽字体';
+}
+</style>

+ 45 - 0
src/views/mp/components/wx-editor/quill-options.js

@@ -0,0 +1,45 @@
+const toolbarOptions = [
+  ['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
+  ['blockquote', 'code-block'], // 引用  代码块
+  [{ header: 1 }, { header: 2 }], // 1、2 级标题
+  [{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
+  [{ script: 'sub' }, { script: 'super' }], // 上标/下标
+  [{ indent: '-1' }, { indent: '+1' }], // 缩进
+  // [{'direction': 'rtl'}],                         // 文本方向
+  [{ size: ['small', false, 'large', 'huge'] }], // 字体大小
+  [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
+  [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
+  [{ font: [] }], // 字体种类
+  [{ align: [] }], // 对齐方式
+  ['clean'], // 清除文本格式
+  ['link', 'image', 'video'] // 链接、图片、视频
+]
+
+export default {
+  theme: 'snow',
+  placeholder: '请输入文章内容',
+  modules: {
+    toolbar: {
+      container: toolbarOptions,
+      // container: "#toolbar",
+      handlers: {
+        image: function (value) {
+          if (value) {
+            // 触发input框选择图片文件
+            document.querySelector('.avatar-uploader input').click()
+          } else {
+            this.quill.format('image', false)
+          }
+        },
+        link: function (value) {
+          if (value) {
+            const href = prompt('注意!只支持公众号图文链接')
+            this.quill.format('link', href)
+          } else {
+            this.quill.format('link', false)
+          }
+        }
+      }
+    }
+  }
+}

+ 117 - 119
src/views/mp/components/wx-material-select/main.vue

@@ -5,127 +5,125 @@
   ① 移除 avue 组件,使用 ElementUI 原生组件
 -->
 <template>
-  <!-- 类型:图片 -->
-  <div v-if="objData.type === 'image'">
-    <div class="waterfall" v-loading="loading">
-      <div class="waterfall-item" v-for="item in list" :key="item.mediaId">
-        <img class="material-img" :src="item.url" />
-        <p class="item-name">{{ item.name }}</p>
-        <el-row class="ope-row">
-          <el-button type="success" @click="selectMaterialFun(item)"
-            >选择
-            <i class="el-icon-circle-check el-icon--right"></i>
-          </el-button>
-        </el-row>
-      </div>
-    </div>
-    <!-- 分页组件 -->
-    <pagination
-      v-show="total > 0"
-      :total="total"
-      v-model:page="queryParams.pageNo"
-      v-model:limit="queryParams.pageSize"
-      @pagination="getMaterialPageFun"
-    />
-  </div>
-  <!-- 类型:语音 -->
-  <div v-else-if="objData.type === 'voice'">
-    <!-- 列表 -->
-    <el-table v-loading="loading" :data="list">
-      <el-table-column label="编号" align="center" prop="mediaId" />
-      <el-table-column label="文件名" align="center" prop="name" />
-      <el-table-column label="语音" align="center">
-        <template #default="scope">
-          <wx-voice-player :url="scope.row.url" />
-        </template>
-      </el-table-column>
-      <el-table-column label="上传时间" align="center" prop="createTime" width="180">
-        <template #default="scope">
-          <span>{{ formatDate(scope.row.createTime) }}</span>
-        </template>
-      </el-table-column>
-      <el-table-column
-        label="操作"
-        align="center"
-        fixed="right"
-        class-name="small-padding fixed-width"
-      >
-        <template #default="scope">
-          <el-button type="text" icon="el-icon-circle-plus" @click="selectMaterialFun(scope.row)"
-            >选择
-          </el-button>
-        </template>
-      </el-table-column>
-    </el-table>
-    <!-- 分页组件 -->
-    <pagination
-      v-show="total > 0"
-      :total="total"
-      v-model:page="queryParams.pageNo"
-      v-model:limit="queryParams.pageSize"
-      @pagination="getPage"
-    />
-  </div>
-  <div v-else-if="objData.type === 'video'">
-    <!-- 列表 -->
-    <el-table v-loading="loading" :data="list">
-      <el-table-column label="编号" align="center" prop="mediaId" />
-      <el-table-column label="文件名" align="center" prop="name" />
-      <el-table-column label="标题" align="center" prop="title" />
-      <el-table-column label="介绍" align="center" prop="introduction" />
-      <el-table-column label="视频" align="center">
-        <template #default="scope">
-          <wx-video-player :url="scope.row.url" />
-        </template>
-      </el-table-column>
-      <el-table-column label="上传时间" align="center" prop="createTime" width="180">
-        <template #default="scope">
-          <span>{{ formatDate(scope.row.createTime) }}</span>
-        </template>
-      </el-table-column>
-      <el-table-column
-        label="操作"
-        align="center"
-        fixed="right"
-        class-name="small-padding fixed-width"
-      >
-        <template #default="scope">
-          <el-button type="text" icon="el-icon-circle-plus" @click="selectMaterialFun(scope.row)"
-            >选择
-          </el-button>
-        </template>
-      </el-table-column>
-    </el-table>
-    <!-- 分页组件 -->
-    <pagination
-      v-show="total > 0"
-      :total="total"
-      v-model:page="queryParams.pageNo"
-      v-model:limit="queryParams.pageSize"
-      @pagination="getMaterialPageFun"
-    />
-  </div>
-  <div v-else-if="objData.type === 'news'">
-    <div class="waterfall" v-loading="loading">
-      <div class="waterfall-item" v-for="item in list" :key="item.mediaId">
-        <div v-if="item.content && item.content.newsItem">
-          <wx-news :articles="item.content.newsItem" />
+  <div class="pb-30px">
+    <!-- 类型:image -->
+    <div v-if="objData.type === 'image'">
+      <div class="waterfall" v-loading="loading">
+        <div class="waterfall-item" v-for="item in list" :key="item.mediaId">
+          <img class="material-img" :src="item.url" />
+          <p class="item-name">{{ item.name }}</p>
           <el-row class="ope-row">
             <el-button type="success" @click="selectMaterialFun(item)">
-              选择<i class="el-icon-circle-check el-icon--right"></i>
+              选择 <Icon icon="ep:circle-check" />
             </el-button>
           </el-row>
         </div>
       </div>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getMaterialPageFun"
+      />
+    </div>
+    <!-- 类型:voice -->
+    <div v-else-if="objData.type === 'voice'">
+      <!-- 列表 -->
+      <el-table v-loading="loading" :data="list">
+        <el-table-column label="编号" align="center" prop="mediaId" />
+        <el-table-column label="文件名" align="center" prop="name" />
+        <el-table-column label="语音" align="center">
+          <template #default="scope">
+            <WxVoicePlayer :url="scope.row.url" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="上传时间"
+          align="center"
+          prop="createTime"
+          width="180"
+          :formatter="dateFormatter"
+        />
+        <el-table-column label="操作" align="center" fixed="right">
+          <template #default="scope">
+            <el-button type="primary" link @click="selectMaterialFun(scope.row)"
+              >选择<Icon icon="ep:plus" />
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getPage"
+      />
+    </div>
+    <!-- 类型:video -->
+    <div v-else-if="objData.type === 'video'">
+      <!-- 列表 -->
+      <el-table v-loading="loading" :data="list">
+        <el-table-column label="编号" align="center" prop="mediaId" />
+        <el-table-column label="文件名" align="center" prop="name" />
+        <el-table-column label="标题" align="center" prop="title" />
+        <el-table-column label="介绍" align="center" prop="introduction" />
+        <el-table-column label="视频" align="center">
+          <template #default="scope">
+            <WxVideoPlayer :url="scope.row.url" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="上传时间"
+          align="center"
+          prop="createTime"
+          width="180"
+          :formatter="dateFormatter"
+        />
+        <el-table-column
+          label="操作"
+          align="center"
+          fixed="right"
+          class-name="small-padding fixed-width"
+        >
+          <template #default="scope">
+            <el-button type="primary" link @click="selectMaterialFun(scope.row)"
+              >选择<Icon icon="akar-icons:circle-plus" />
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getMaterialPageFun"
+      />
+    </div>
+    <!-- 类型:news -->
+    <div v-else-if="objData.type === 'news'">
+      <div class="waterfall" v-loading="loading">
+        <div class="waterfall-item" v-for="item in list" :key="item.mediaId">
+          <div v-if="item.content && item.content.newsItem">
+            <WxNews :articles="item.content.newsItem" />
+            <el-row class="ope-row">
+              <el-button type="success" @click="selectMaterialFun(item)">
+                选择<Icon icon="ep:circle-check" />
+              </el-button>
+            </el-row>
+          </div>
+        </div>
+      </div>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getMaterialPageFun"
+      />
     </div>
-    <!-- 分页组件 -->
-    <pagination
-      v-show="total > 0"
-      :total="total"
-      v-model:page="queryParams.pageNo"
-      v-model:limit="queryParams.pageSize"
-      @pagination="getMaterialPageFun"
-    />
   </div>
 </template>
 
@@ -136,7 +134,7 @@ import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
 import { getMaterialPage } from '@/api/mp/material'
 import { getFreePublishPage } from '@/api/mp/freePublish'
 import { getDraftPage } from '@/api/mp/draft'
-import { dateFormatter, formatDate } from '@/utils/formatTime'
+import { dateFormatter } from '@/utils/formatTime'
 import { defineComponent, PropType } from 'vue'
 
 export default defineComponent({
@@ -173,7 +171,7 @@ export default defineComponent({
     const newsTypeRef = ref(props.newsType)
 
     const selectMaterialFun = (item) => {
-      ctx.emit('selectMaterial', item)
+      ctx.emit('select-material', item)
     }
     /** 搜索按钮操作 */
     const handleQuery = () => {
@@ -203,9 +201,10 @@ export default defineComponent({
       total.value = data.total
       loading.value = false
     }
+
     const getFreePublishPageFun = async () => {
       let data = await getFreePublishPage(queryParams)
-      data.list.foreach((item) => {
+      data.list.forEach((item) => {
         const newsItem = item.content.newsItem
         newsItem.forEach((article) => {
           article.picUrl = article.thumbUrl
@@ -232,6 +231,7 @@ export default defineComponent({
     onMounted(async () => {
       getPage()
     })
+
     return {
       handleQuery,
       dateFormatter,
@@ -239,7 +239,6 @@ export default defineComponent({
       getMaterialPageFun,
       getPage,
       formatDate,
-      newsTypeRef,
       queryParams,
       objDataRef,
       list,
@@ -249,7 +248,6 @@ export default defineComponent({
   }
 })
 </script>
-
 <style lang="scss" scoped>
 /*瀑布流样式*/
 .waterfall {

+ 1 - 1
src/views/mp/components/wx-msg/main.vue

@@ -139,7 +139,7 @@ import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
 import WxNews from '@/views/mp/components/wx-news/main.vue'
 import WxLocation from '@/views/mp/components/wx-location/main.vue'
 import WxMusic from '@/views/mp/components/wx-music/main.vue'
-import { getUser } from '@/api/mp/mpuser'
+import { getUser } from '@/api/mp/user'
 import { defineComponent } from 'vue'
 
 const message = useMessage() // 消息弹窗

+ 35 - 62
src/views/mp/components/wx-reply/main.vue

@@ -12,10 +12,7 @@
     <!-- 类型 1:文本 -->
     <el-tab-pane name="text">
       <template #label>
-        <el-row align="middle">
-          <icon icon="ep:document" />
-          文本
-        </el-row>
+        <el-row align="middle"><Icon icon="ep:document" /> 文本</el-row>
       </template>
       <el-input
         type="textarea"
@@ -28,18 +25,15 @@
     <!-- 类型 2:图片 -->
     <el-tab-pane name="image">
       <template #label>
-        <el-row align="middle">
-          <icon icon="ep:picture" class="mr-5px" />
-          图片
-        </el-row>
+        <el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
       </template>
       <!-- 情况一:已经选择好素材、或者上传好图片 -->
       <div class="select-item" v-if="objDataRef.url">
         <img class="material-img" :src="objDataRef.url" />
         <p class="item-name" v-if="objDataRef.name">{{ objDataRef.name }}</p>
-        <el-row class="ope-row">
+        <el-row class="ope-row" justify="center">
           <el-button type="danger" circle @click="deleteObj">
-            <icon icon="ep:delete" />
+            <Icon icon="ep:delete" />
           </el-button>
         </el-row>
       </div>
@@ -48,11 +42,10 @@
         <!-- 选择素材 -->
         <el-col :span="12" class="col-select">
           <el-button type="success" @click="openMaterial">
-            素材库选择
-            <icon icon="ep:circle-check" />
+            素材库选择 <Icon icon="ep:circle-check" />
           </el-button>
           <el-dialog title="选择图片" v-model="dialogImageVisible" width="90%" append-to-body>
-            <wx-material-select :obj-data="objDataRef" @selectMaterial="selectMaterial" />
+            <WxMaterialSelect :obj-data="objDataRef" @select-material="selectMaterial" />
           </el-dialog>
         </el-col>
         <!-- 文件上传 -->
@@ -70,10 +63,8 @@
             <el-button type="primary">上传图片</el-button>
             <template #tip>
               <span>
-                <div class="el-upload__tip"
-                  >支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div
-                ></span
-              >
+                <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div>
+              </span>
             </template>
           </el-upload>
         </el-col>
@@ -82,29 +73,25 @@
     <!-- 类型 3:语音 -->
     <el-tab-pane name="voice">
       <template #label>
-        <el-row align="middle">
-          <icon icon="ep:phone" />
-          语音
-        </el-row>
+        <el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row>
       </template>
-
       <div class="select-item2" v-if="objDataRef.url">
         <p class="item-name">{{ objDataRef.name }}</p>
         <div class="item-infos">
-          <wx-voice-player :url="objDataRef.url" />
+          <WxVoicePlayer :url="objDataRef.url" />
         </div>
-        <el-row class="ope-row">
-          <el-button type="danger" icon="el-icon-delete" circle @click="deleteObj" />
+        <el-row class="ope-row" justify="center">
+          <el-button type="danger" circle @click="deleteObj"><Icon icon="ep:delete" /></el-button>
         </el-row>
       </div>
       <el-row v-else style="text-align: center">
         <!-- 选择素材 -->
         <el-col :span="12" class="col-select">
           <el-button type="success" @click="openMaterial">
-            素材库选择<i class="el-icon-circle-check el-icon--right"></i>
+            素材库选择<Icon icon="ep:circle-check" />
           </el-button>
           <el-dialog title="选择语音" v-model="dialogVoiceVisible" width="90%" append-to-body>
-            <WxMaterialSelect :objData="objData" @selectMaterial="selectMaterial" />
+            <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
           </el-dialog>
         </el-col>
         <!-- 文件上传 -->
@@ -121,8 +108,8 @@
           >
             <el-button type="primary">点击上传</el-button>
             <template #tip>
-              <div class="el-upload__tip"
-                >格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
+              <div class="el-upload__tip">
+                格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
               </div>
             </template>
           </el-upload>
@@ -132,10 +119,7 @@
     <!-- 类型 4:视频 -->
     <el-tab-pane name="video">
       <template #label>
-        <el-row align="middle">
-          <icon icon="ep:share" />
-          视频
-        </el-row>
+        <el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
       </template>
       <el-row>
         <el-input
@@ -151,18 +135,17 @@
           @input="inputContent"
         />
         <div style="text-align: center">
-          <wx-video-player v-if="objDataRef.url" :url="objDataRef.url" />
+          <WxVideoPlayer v-if="objDataRef.url" :url="objDataRef.url" />
         </div>
         <el-col>
           <el-row style="text-align: center" align="middle">
             <!-- 选择素材 -->
             <el-col :span="12">
               <el-button type="success" @click="openMaterial">
-                素材库选择
-                <icon icon="ep:circle-check" />
+                素材库选择 <Icon icon="ep:circle-check" />
               </el-button>
               <el-dialog title="选择视频" v-model="dialogVideoVisible" width="90%" append-to-body>
-                <wx-material-select :objData="objDataRef" @selectMaterial="selectMaterial" />
+                <WxMaterialSelect :objData="objDataRef" @select-material="selectMaterial" />
               </el-dialog>
             </el-col>
             <!-- 文件上传 -->
@@ -177,10 +160,7 @@
                 :before-upload="beforeVideoUpload"
                 :on-success="handleUploadSuccess"
               >
-                <el-button type="primary"
-                  >新建视频
-                  <icon icon="ep:upload" />
-                </el-button>
+                <el-button type="primary">新建视频 <Icon icon="ep:upload" /></el-button>
               </el-upload>
             </el-col>
           </el-row>
@@ -190,17 +170,14 @@
     <!-- 类型 5:图文 -->
     <el-tab-pane name="news">
       <template #label>
-        <el-row align="middle">
-          <icon icon="ep:reading" />
-          图文
-        </el-row>
+        <el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
       </template>
       <el-row>
-        <div class="select-item" v-if="objDataRef.articles.size > 0">
-          <wx-news :articles="objDataRef.articles" />
+        <div class="select-item" v-if="objDataRef.articles?.length > 0">
+          <WxNews :articles="objDataRef.articles" />
           <el-col class="ope-row">
             <el-button type="danger" circle @click="deleteObj">
-              <icon icon="ep:delete" />
+              <Icon icon="ep:delete" />
             </el-button>
           </el-col>
         </div>
@@ -208,17 +185,17 @@
         <el-col :span="24" v-if="!objDataRef.content">
           <el-row style="text-align: center" align="middle">
             <el-col :span="24">
-              <el-button type="success" @click="openMaterial"
-                >{{ newsType === '1' ? '选择已发布图文' : '选择草稿箱图文' }}
+              <el-button type="success" @click="openMaterial">
+                {{ newsType === '1' ? '选择已发布图文' : '选择草稿箱图文' }}
                 <icon icon="ep:circle-check" />
               </el-button>
             </el-col>
           </el-row>
         </el-col>
         <el-dialog title="选择图文" v-model="dialogNewsVisible" width="90%" append-to-body>
-          <wx-material-select
+          <WxMaterialSelect
             :objData="objDataRef"
-            @selectMaterial="selectMaterial"
+            @select-material="selectMaterial"
             :newsType="newsType"
           />
         </el-dialog>
@@ -227,10 +204,7 @@
     <!-- 类型 6:音乐 -->
     <el-tab-pane name="music">
       <template #label>
-        <el-row align="middle">
-          <icon icon="ep:service" />
-          音乐
-        </el-row>
+        <el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
       </template>
       <el-row align="middle" justify="center">
         <el-col :span="6">
@@ -259,7 +233,7 @@
                     <template #trigger>
                       <el-button type="text">本地上传</el-button>
                     </template>
-                    <el-button type="text" @click="openMaterial" style="margin-left: 5px"
+                    <el-button type="primary" link @click="openMaterial" style="margin-left: 5px"
                       >素材库选择
                     </el-button>
                   </el-upload>
@@ -268,9 +242,9 @@
             </el-col>
           </el-row>
           <el-dialog title="选择图片" v-model="dialogThumbVisible" width="80%" append-to-body>
-            <wx-material-select
+            <WxMaterialSelect
               :objData="{ type: 'image', accountId: objDataRef.accountId }"
-              @selectMaterial="selectMaterial"
+              @select-material="selectMaterial"
             />
           </el-dialog>
         </el-col>
@@ -295,7 +269,6 @@
     </el-tab-pane>
   </el-tabs>
 </template>
-
 <script lang="ts" name="WxReplySelect">
 import WxNews from '@/views/mp/components/wx-news/main.vue'
 import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
@@ -482,7 +455,7 @@ export default defineComponent({
       // 创建 tempObjItem 对象,并设置对应的值
       let tempObjItem = {
         type: '',
-        articles: '',
+        articles: [],
         thumbMediaId: '',
         thumbMediaUrl: '',
         introduction: '',
@@ -560,7 +533,7 @@ export default defineComponent({
     }
     const deleteObj = () => {
       if (objDataRef.type === 'news') {
-        objDataRef.articles = ''
+        objDataRef.articles = []
       } else if (objDataRef.type === 'image') {
         objDataRef.mediaId = null
         objDataRef.url = null

+ 1 - 1
src/views/mp/components/wx-voice-play/main.vue

@@ -17,7 +17,7 @@
       <span class="amr-duration" v-if="duration">{{ duration }} 秒</span>
     </el-icon>
     <div v-if="content">
-      <el-tag type="success" size="mini">语音识别</el-tag>
+      <el-tag type="success" size="small">语音识别</el-tag>
       {{ content }}
     </div>
   </div>

+ 811 - 1
src/views/mp/draft/index.vue

@@ -1,3 +1,813 @@
 <template>
-  <span>开发中</span>
+  <doc-alert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" />
+
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="公众号" prop="accountId">
+        <el-select v-model="queryParams.accountId" placeholder="请选择公众号">
+          <el-option
+            v-for="item in accountList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
+        <el-button type="primary" plain @click="handleAdd" v-hasPermi="['mp:draft:create']">
+          <Icon icon="ep:plus" />新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <div class="waterfall" v-loading="loading">
+      <template v-for="item in list" :key="item.articleId">
+        <div class="waterfall-item" v-if="item.content && item.content.newsItem">
+          <wx-news :articles="item.content.newsItem" />
+          <!-- 操作按钮 -->
+          <el-row class="ope-row">
+            <el-button
+              type="success"
+              circle
+              @click="handlePublish(item)"
+              v-hasPermi="['mp:free-publish:submit']"
+            >
+              <Icon icon="fa:upload" />
+            </el-button>
+            <el-button
+              type="primary"
+              circle
+              @click="handleUpdate(item)"
+              v-hasPermi="['mp:draft:update']"
+            >
+              <Icon icon="ep:edit" />
+            </el-button>
+            <el-button
+              type="danger"
+              circle
+              @click="handleDelete(item)"
+              v-hasPermi="['mp:draft:delete']"
+            >
+              <Icon icon="ep:delete" />
+            </el-button>
+          </el-row>
+        </div>
+      </template>
+    </div>
+    <!-- 分页记录 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- TODO @Dhb52:迁移成独立路由 -->
+  <div class="app-container">
+    <!-- 添加或修改草稿对话框 -->
+    <Teleport to="body">
+      <el-dialog
+        :title="operateMaterial === 'add' ? '新建图文' : '修改图文'"
+        width="80%"
+        top="20px"
+        v-model="dialogNewsVisible"
+        :before-close="dialogNewsClose"
+        :close-on-click-modal="false"
+      >
+        <div class="left">
+          <div class="select-item">
+            <div v-for="(news, index) in articlesAdd" :key="news.id">
+              <div
+                class="news-main father"
+                v-if="index === 0"
+                :class="{ activeAddNews: isActiveAddNews === index }"
+                @click="activeNews(index)"
+              >
+                <div class="news-content">
+                  <img class="material-img" v-if="news.thumbUrl" :src="news.thumbUrl" />
+                  <div class="news-content-title">{{ news.title }}</div>
+                </div>
+                <div class="child" v-if="articlesAdd.length > 1">
+                  <el-button size="small" @click="downNews(index)"
+                    ><Icon icon="ep:sort-down" />下移</el-button
+                  >
+                  <el-button v-if="operateMaterial === 'add'" size="small" @click="minusNews(index)"
+                    ><Icon icon="ep:delete" />删除
+                  </el-button>
+                </div>
+              </div>
+              <div
+                class="news-main-item father"
+                v-if="index > 0"
+                :class="{ activeAddNews: isActiveAddNews === index }"
+                @click="activeNews(index)"
+              >
+                <div class="news-content-item">
+                  <div class="news-content-item-title">{{ news.title }}</div>
+                  <div class="news-content-item-img">
+                    <img
+                      class="material-img"
+                      v-if="news.thumbUrl"
+                      :src="news.thumbUrl"
+                      height="100%"
+                    />
+                  </div>
+                </div>
+                <div class="child">
+                  <el-button
+                    v-if="articlesAdd.length > index + 1"
+                    size="small"
+                    @click="downNews(index)"
+                    ><Icon icon="ep:sort-down" />下移
+                  </el-button>
+                  <el-button size="small" @click="upNews(index)"
+                    ><Icon icon="ep:sort-up" />上移</el-button
+                  >
+                  <el-button
+                    v-if="operateMaterial === 'add'"
+                    type="danger"
+                    size="small"
+                    @click="minusNews(index)"
+                    ><Icon icon="ep:delete" />删除
+                  </el-button>
+                </div>
+              </div>
+            </div>
+            <el-row justify="center" class="ope-row">
+              <el-button
+                type="primary"
+                circle
+                @click="plusNews(item)"
+                v-if="articlesAdd.length < 8 && operateMaterial === 'add'"
+              >
+                <Icon icon="ep:plus" />
+              </el-button>
+            </el-row>
+          </div>
+        </div>
+        <div class="right" v-loading="addMaterialLoading" v-if="articlesAdd.length > 0">
+          <br />
+          <br />
+          <br />
+          <br />
+          <!-- 标题、作者、原文地址 -->
+          <el-input v-model="articlesAdd[isActiveAddNews].title" placeholder="请输入标题(必填)" />
+          <el-input
+            v-model="articlesAdd[isActiveAddNews].author"
+            placeholder="请输入作者"
+            style="margin-top: 5px"
+          />
+          <el-input
+            v-model="articlesAdd[isActiveAddNews].contentSourceUrl"
+            placeholder="请输入原文地址"
+            style="margin-top: 5px"
+          />
+          <!-- 封面和摘要 -->
+          <div class="input-tt">封面和摘要:</div>
+          <div>
+            <div class="thumb-div">
+              <img
+                class="material-img"
+                v-if="articlesAdd[isActiveAddNews].thumbUrl"
+                :src="articlesAdd[isActiveAddNews].thumbUrl"
+                :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"
+              />
+              <Icon
+                v-else
+                icon="ep:plus"
+                class="avatar-uploader-icon"
+                :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"
+              />
+              <div class="thumb-but">
+                <el-upload
+                  :action="actionUrl"
+                  :headers="headers"
+                  multiple
+                  :limit="1"
+                  :file-list="fileList"
+                  :data="uploadData"
+                  :before-upload="beforeThumbImageUpload"
+                  :on-success="handleUploadSuccess"
+                >
+                  <template #trigger>
+                    <el-button size="small" type="primary">本地上传</el-button>
+                  </template>
+                  <el-button
+                    size="small"
+                    type="primary"
+                    @click="openMaterial"
+                    style="margin-left: 5px"
+                    >素材库选择</el-button
+                  >
+                  <template #tip>
+                    <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div>
+                  </template>
+                </el-upload>
+              </div>
+              <Teleport to="body">
+                <el-dialog title="选择图片" v-model="dialogImageVisible" width="80%">
+                  <WxMaterialSelect
+                    ref="materialSelectRef"
+                    :objData="{ type: 'image', accountId: queryParams.accountId }"
+                    @select-material="selectMaterial"
+                  />
+                </el-dialog>
+              </Teleport>
+            </div>
+            <el-input
+              :rows="8"
+              type="textarea"
+              v-model="articlesAdd[isActiveAddNews].digest"
+              placeholder="请输入摘要"
+              class="digest"
+              maxlength="120"
+              style="float: right"
+            />
+          </div>
+          <!--富文本编辑器组件-->
+          <el-row>
+            <wx-editor
+              v-model="articlesAdd[isActiveAddNews].content"
+              :account-id="uploadData.accountId"
+              v-if="hackResetEditor"
+            />
+          </el-row>
+        </div>
+        <template #footer>
+          <el-button @click="dialogNewsVisible = false">取 消</el-button>
+          <el-button type="primary" @click="submitForm">提 交</el-button>
+        </template>
+      </el-dialog>
+    </Teleport>
+  </div>
 </template>
+<script setup name="MpDraft">
+import WxEditor from '@/views/mp/components/wx-editor/WxEditor.vue'
+import WxNews from '@/views/mp/components/wx-news/main.vue'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
+import { getAccessToken } from '@/utils/auth'
+import * as MpAccountApi from '@/api/mp/account'
+import * as MpDraftApi from '@/api/mp/draft'
+import * as MpFreePublishApi from '@/api/mp/freePublish'
+const message = useMessage() // 消息
+// 可以用改本地数据模拟,避免API调用超限
+// import drafts from './mock'
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  accountId: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const accountList = ref([]) // 公众号账号列表
+
+// ========== 文件上传 ==========
+const materialSelectRef = ref()
+const BASE_URL = import.meta.env.VITE_BASE_URL
+const actionUrl = ref(BASE_URL + '/admin-api/mp/material/upload-permanent') // 上传永久素材的地址
+const headers = ref({ Authorization: 'Bearer ' + getAccessToken() }) // 设置上传的请求头部
+const fileList = ref([])
+const uploadData = reactive({
+  type: 'image',
+  accountId: 1
+})
+
+// ========== 草稿新建 or 修改 ==========
+const dialogNewsVisible = ref(false)
+const addMaterialLoading = ref(false) // 添加草稿的 loading 标识
+const articlesAdd = ref([])
+const isActiveAddNews = ref(0)
+const dialogImageVisible = ref(false)
+const operateMaterial = ref('add')
+const articlesMediaId = ref('')
+const hackResetEditor = ref(false)
+
+/** 初始化 **/
+onMounted(async () => {
+  accountList.value = await MpAccountApi.getSimpleAccountList()
+  // 选中第一个
+  if (accountList.value.length > 0) {
+    // @ts-ignore
+    queryParams.accountId = accountList.value[0].id
+  }
+  await getList()
+})
+
+// ======================== 列表查询 ========================
+/** 设置账号编号 */
+const setAccountId = (accountId) => {
+  queryParams.accountId = accountId
+  uploadData.accountId = accountId
+}
+
+/** 查询列表 */
+const getList = async () => {
+  // 如果没有选中公众号账号,则进行提示。
+  if (!queryParams.accountId) {
+    message.error('未选中公众号,无法查询草稿箱')
+    return false
+  }
+
+  loading.value = true
+  try {
+    const drafts = await MpDraftApi.getDraftPage(queryParams)
+    drafts.list.forEach((item) => {
+      const newsItem = item.content.newsItem
+      // 将 thumbUrl 转成 picUrl,保证 wx-news 组件可以预览封面
+      newsItem.forEach((article) => {
+        article.picUrl = article.thumbUrl
+      })
+    })
+    list.value = drafts.list
+    total.value = drafts.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  // 默认选中第一个
+  if (queryParams.accountId) {
+    setAccountId(queryParams.accountId)
+  }
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  // 默认选中第一个
+  if (accountList.value.length > 0) {
+    setAccountId(accountList.value[0].id)
+  }
+  handleQuery()
+}
+
+// ======================== 新增/修改草稿 ========================
+/** 新增按钮操作 */
+const handleAdd = () => {
+  resetEditor()
+  reset()
+  // 打开表单,并设置初始化
+  operateMaterial.value = 'add'
+  dialogNewsVisible.value = true
+}
+
+/** 更新按钮操作 */
+const handleUpdate = (item) => {
+  resetEditor()
+  reset()
+  articlesMediaId.value = item.mediaId
+  articlesAdd.value = JSON.parse(JSON.stringify(item.content.newsItem))
+  // 打开表单,并设置初始化
+  operateMaterial.value = 'edit'
+  dialogNewsVisible.value = true
+}
+
+/** 提交按钮 */
+const submitForm = () => {
+  // TODO @Dhb52: 参考别的模块写法,改成 await 方式
+  addMaterialLoading.value = true
+  if (operateMaterial.value === 'add') {
+    MpDraftApi.createDraft(queryParams.accountId, articlesAdd.value)
+      .then(() => {
+        message.notifySuccess('新增成功')
+        dialogNewsVisible.value = false
+        getList()
+      })
+      .finally(() => {
+        addMaterialLoading.value = false
+      })
+  } else {
+    MpDraftApi.updateDraft(queryParams.accountId, articlesMediaId.value, articlesAdd.value)
+      .then(() => {
+        message.notifySuccess('更新成功')
+        dialogNewsVisible.value = false
+        getList()
+      })
+      .finally(() => {
+        addMaterialLoading.value = false
+      })
+  }
+}
+
+// 关闭弹窗
+const dialogNewsClose = async (done) => {
+  try {
+    await message.confirm('修改内容可能还未保存,确定关闭吗?')
+    reset()
+    resetEditor()
+    done()
+  } catch {}
+}
+
+// 表单重置
+const reset = () => {
+  isActiveAddNews.value = 0
+  articlesAdd.value = [buildEmptyArticle()]
+}
+
+// 表单 Editor 重置
+const resetEditor = () => {
+  hackResetEditor.value = false // 销毁组件
+  nextTick(() => {
+    hackResetEditor.value = true // 重建组件
+  })
+}
+
+// 将图文向下移动
+const downNews = (index) => {
+  let temp = articlesAdd.value[index]
+  articlesAdd.value[index] = articlesAdd.value[index + 1]
+  articlesAdd.value[index + 1] = temp
+  isActiveAddNews.value = index + 1
+}
+
+// 将图文向上移动
+const upNews = (index) => {
+  let temp = articlesAdd.value[index]
+  articlesAdd.value[index] = articlesAdd.value[index - 1]
+  articlesAdd.value[index - 1] = temp
+  isActiveAddNews.value = index - 1
+}
+
+// 选中指定 index 的图文
+const activeNews = (index) => {
+  resetEditor()
+  isActiveAddNews.value = index
+}
+
+// 删除指定 index 的图文
+const minusNews = async (index) => {
+  try {
+    await message.confirm('确定删除该图文吗?')
+    articlesAdd.value.splice(index, 1)
+    if (isActiveAddNews.value === index) {
+      isActiveAddNews.value = 0
+    }
+  } catch {}
+}
+
+// 添加一个图文
+const plusNews = () => {
+  articlesAdd.value.push(buildEmptyArticle())
+  isActiveAddNews.value = articlesAdd.value.length - 1
+}
+
+// 创建空的 article
+const buildEmptyArticle = () => {
+  return {
+    title: '',
+    thumbMediaId: '',
+    author: '',
+    digest: '',
+    showCoverPic: '',
+    content: '',
+    contentSourceUrl: '',
+    needOpenComment: '',
+    onlyFansCanComment: '',
+    thumbUrl: ''
+  }
+}
+
+// ======================== 文件上传 ========================
+const beforeThumbImageUpload = (file) => {
+  addMaterialLoading.value = true
+  const isType =
+    file.type === 'image/jpeg' ||
+    file.type === 'image/png' ||
+    file.type === 'image/gif' ||
+    file.type === 'image/bmp' ||
+    file.type === 'image/jpg'
+  if (!isType) {
+    message.error('上传图片格式不对!')
+    addMaterialLoading.value = false
+    return false
+  }
+  const isLt = file.size / 1024 / 1024 < 2
+  if (!isLt) {
+    message.error('上传图片大小不能超过 2M!')
+    addMaterialLoading.value = false
+    return false
+  }
+  // 校验通过
+  return true
+}
+
+const handleUploadSuccess = (response, file, fileList) => {
+  addMaterialLoading.value = false
+  if (response.code !== 0) {
+    message.error('上传出错:' + response.msg)
+    return false
+  }
+
+  // 重置上传文件的表单
+  fileList.value = []
+
+  // 设置草稿的封面字段
+  articlesAdd.value[isActiveAddNews.value].thumbMediaId = response.data.mediaId
+  articlesAdd.value[isActiveAddNews.value].thumbUrl = response.data.url
+}
+
+// 选择 or 上传完素材,设置回草稿
+const selectMaterial = (item) => {
+  dialogImageVisible.value = false
+  articlesAdd.value[isActiveAddNews.value].thumbMediaId = item.mediaId
+  articlesAdd.value[isActiveAddNews.value].thumbUrl = item.url
+}
+
+// 打开素材选择
+const openMaterial = () => {
+  dialogImageVisible.value = true
+  try {
+    materialSelectRef.value.queryParams.accountId = queryParams.accountId // 强制设置下 accountId,避免二次查询不对
+    materialSelectRef.value.handleQuery() // 刷新列表,失败也无所谓
+  } catch (e) {}
+}
+
+// ======================== 草稿箱发布 ========================
+const handlePublish = async (item) => {
+  const accountId = queryParams.accountId
+  const mediaId = item.mediaId
+  const content =
+    '你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。已发布内容不会推送给用户,也不会展示在公众号主页中。 发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。'
+  try {
+    await message.confirm(content)
+    await MpFreePublishApi.submitFreePublish(accountId, mediaId)
+    message.notifySuccess('发布成功')
+    await getList()
+  } catch {}
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (item) => {
+  const accountId = queryParams.accountId
+  const mediaId = item.mediaId
+  try {
+    await message.confirm('此操作将永久删除该草稿, 是否继续?')
+    await MpDraftApi.deleteDraft(accountId, mediaId)
+    message.notifySuccess('删除成功')
+    await getList()
+  } catch {}
+}
+</script>
+<style lang="scss" scoped>
+.pagination {
+  float: right;
+  margin-right: 25px;
+}
+
+.add_but {
+  padding: 10px;
+}
+
+.ope-row {
+  margin-top: 5px;
+  text-align: center;
+  border-top: 1px solid #eaeaea;
+  padding-top: 5px;
+}
+
+.item-name {
+  font-size: 12px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-align: center;
+}
+
+.el-upload__tip {
+  margin-left: 5px;
+}
+
+/*新增图文*/
+.left {
+  display: inline-block;
+  width: 35%;
+  vertical-align: top;
+  margin-top: 200px;
+}
+
+.right {
+  display: inline-block;
+  width: 60%;
+  margin-top: -40px;
+}
+
+.avatar-uploader {
+  width: 20%;
+  display: inline-block;
+}
+
+.avatar-uploader .el-upload {
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+  text-align: unset !important;
+}
+
+.avatar-uploader .el-upload:hover {
+  border-color: #165dff;
+}
+
+.avatar-uploader-icon {
+  border: 1px solid #d9d9d9;
+  font-size: 28px;
+  color: #8c939d;
+  width: 120px;
+  height: 120px;
+  line-height: 120px;
+  text-align: center;
+}
+
+.avatar {
+  width: 230px;
+  height: 120px;
+}
+
+.avatar1 {
+  width: 120px;
+  height: 120px;
+}
+
+.digest {
+  width: 60%;
+  display: inline-block;
+  vertical-align: top;
+}
+
+/*新增图文*/
+/*瀑布流样式*/
+.waterfall {
+  width: 100%;
+  column-gap: 10px;
+  column-count: 5;
+  margin: 0 auto;
+}
+
+.waterfall-item {
+  padding: 10px;
+  margin-bottom: 10px;
+  break-inside: avoid;
+  border: 1px solid #eaeaea;
+}
+
+p {
+  line-height: 30px;
+}
+
+@media (min-width: 992px) and (max-width: 1300px) {
+  .waterfall {
+    column-count: 3;
+  }
+
+  p {
+    color: red;
+  }
+}
+
+@media (min-width: 768px) and (max-width: 991px) {
+  .waterfall {
+    column-count: 2;
+  }
+
+  p {
+    color: orange;
+  }
+}
+
+@media (max-width: 767px) {
+  .waterfall {
+    column-count: 1;
+  }
+}
+
+/*瀑布流样式*/
+.news-main {
+  background-color: #ffffff;
+  width: 100%;
+  margin: auto;
+  height: 120px;
+}
+
+.news-content {
+  background-color: #acadae;
+  width: 100%;
+  height: 120px;
+  position: relative;
+}
+
+.news-content-title {
+  display: inline-block;
+  font-size: 15px;
+  color: #ffffff;
+  position: absolute;
+  left: 0px;
+  bottom: 0px;
+  background-color: black;
+  width: 98%;
+  padding: 1%;
+  opacity: 0.65;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  height: 25px;
+}
+
+.news-main-item {
+  background-color: #ffffff;
+  padding: 5px 0px;
+  border-top: 1px solid #eaeaea;
+  width: 100%;
+  margin: auto;
+}
+
+.news-content-item {
+  position: relative;
+  margin-left: -3px;
+}
+
+.news-content-item-title {
+  display: inline-block;
+  font-size: 12px;
+  width: 70%;
+}
+
+.news-content-item-img {
+  display: inline-block;
+  width: 25%;
+  background-color: #acadae;
+}
+
+.input-tt {
+  padding: 5px;
+}
+
+.activeAddNews {
+  border: 5px solid #2bb673;
+}
+
+.news-main-plus {
+  width: 280px;
+  text-align: center;
+  margin: auto;
+  height: 50px;
+}
+
+.icon-plus {
+  margin: 10px;
+  font-size: 25px;
+}
+
+.select-item {
+  width: 60%;
+  padding: 10px;
+  margin: 0 auto 10px auto;
+  border: 1px solid #eaeaea;
+}
+
+.father .child {
+  display: none;
+  text-align: center;
+  position: relative;
+  bottom: 25px;
+}
+
+.father:hover .child {
+  display: block;
+}
+
+.thumb-div {
+  display: inline-block;
+  width: 30%;
+  text-align: center;
+}
+
+.thumb-but {
+  margin: 5px;
+}
+
+.material-img {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 151 - 0
src/views/mp/draft/mock.js

@@ -0,0 +1,151 @@
+export default {
+  list: [
+    {
+      mediaId: 'r6ryvl6LrxBU0miaST4Y-q-G9pdsmZw0OYG4FzHQkKfpLfEwIH51wy2bxisx8PvW',
+      content: {
+        newsItem: [
+          {
+            title: '我是标题(OOO)',
+            author: '我是作者',
+            digest: '我是摘要',
+            content: '我是内容',
+            contentSourceUrl: 'https://www.iocoder.cn',
+            thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
+            showCoverPic: 0,
+            needOpenComment: 0,
+            onlyFansCanComment: 0,
+            url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9XaFphcmtJVFh3VEc4Q1MxQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN2QxTE56SFBCYXc2RE9NcUxIeS1CQjJuUHhTWjBlN2VOeGRpRi1fZUhwN1FNQjdrQV9yRU9EU0hibHREZmZoVW5acnZrN3ZjaWsxejR3RGpKczBzTHFIM0dFNFZWVkpBc0dWWlAzUEhlVmpnfn4%3D&chksm=1f6354802814dd969ef83c0f3babe555c614270b30bc383beaf7ffd13b0257f0fe5ced9af694#rd',
+            thumbUrl:
+              'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
+          },
+          {
+            title: '我是标题(XXX)',
+            author: '我是作者',
+            digest: '我是摘要',
+            content: '我是内容',
+            contentSourceUrl: 'https://www.iocoder.cn',
+            thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
+            showCoverPic: 0,
+            needOpenComment: 0,
+            onlyFansCanComment: 0,
+            url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9yTlYwOEs1clpwcE5OUEhCQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN0NSMjFqN3N1aUZMbFNVLTZHN2ZDME9qOGp2THk2RFNlSTlKZ3Y1czFVZDdQQm5IeUg3dEppSUtpQUh5SExOOTRkT3dHNUdBdHdWSWlOendlREV3dS1jUEVQbFpiVTZmVW5iRWhZcGdkNTFRfn4%3D&chksm=1f6354802814dd96a403151cd44c7da4eecf0e475d25423e46ecd795b513bafd829a75daef9b#rd',
+            thumbUrl:
+              'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
+          }
+        ]
+      },
+      updateTime: 1673655730
+    },
+    {
+      mediaId: 'r6ryvl6LrxBU0miaST4Y-jGpXnO73ihN0lsNXknCRQHapp2xgHMRxHKG50LituFe',
+      content: {
+        newsItem: [
+          {
+            title: '我是标题(修改)',
+            author: '我是作者',
+            digest: '我是摘要',
+            content: '我是内容',
+            contentSourceUrl: 'https://www.iocoder.cn',
+            thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
+            showCoverPic: 0,
+            needOpenComment: 0,
+            onlyFansCanComment: 0,
+            url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl95WVFXYndIZnZJd0t5cjgvQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN1dlNURPbWswbEF4RDd5dVJTdjQ4cm9Cc0Q1TWhpMUh6SE1hVEE3ZHljaHhlZjZYSGF5N2JNSHpDTlh6ajNZbkpGTGpTcUQ4M3NMdW41ZUpXNFZZQ1VKbVlaMVp5ekxEV1czREdsY1dOYTZnfn4%3D&chksm=1f6354be2814dda8e6238037c2ebd52b1c8e80e93249a861ad80e4d40e5ca7207233475ca689#rd',
+            thumbUrl:
+              'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
+          }
+        ]
+      },
+      updateTime: 1673655584
+    },
+    {
+      mediaId: 'r6ryvl6LrxBU0miaST4Y-v5SrbNCPpD6M_p3TmSrYwTjKogs-0DMJgmjMyNZPeMO',
+      content: {
+        newsItem: [
+          {
+            title: '1321',
+            author: '3232',
+            digest: '1333',
+            content: '<p>444</p>',
+            contentSourceUrl: 'http://www.iocoder.cn',
+            thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-tlQmcl3RdC-Jcgns6IQtf7zenGy3b86WLT7GzUcrb1T',
+            showCoverPic: 0,
+            needOpenComment: 0,
+            onlyFansCanComment: 0,
+            url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9jelJiaDAzbmdpSkJOZ2M2QWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNDNXVVc2ZDRYeTY0Zm1weXR6dE9vQWh1TzEwbEpUVnRfVzJyaGFDNXBkZ0ZXM2JFOTNaRHNhOHRUeFdEanhMeS01X01kMUNWQ1BpRER3cjYwTl9pMnpFLUJhZXFucVVfM1pDUXlTUEl1S25nfn4%3D&chksm=1f6354bc2814ddaa56a90ad5bc3d078601c8d1589ba01827a8170587bc830ff9747b5f59c3a0#rd',
+            thumbUrl:
+              'http://mmbiz.qpic.cn/mmbiz_png/btUmCVHwbJUoicwBiacjVeQbu6QxgBVrukfSJXz509boa21SpH8OVHAqXCJiaiaAaHQJNxwwsa0gHRXVr0G5EZYamw/0?wx_fmt=png'
+          }
+        ]
+      },
+      updateTime: 1673628969
+    },
+    {
+      mediaId: 'r6ryvl6LrxBU0miaST4Y-vdWrisK5EZbk4Y3tzh8P0PG0eEUbnQrh0BcsEb3WNP0',
+      content: {
+        newsItem: [
+          {
+            title: 'tudou',
+            author: 'haha',
+            digest: '312',
+            content: '<p>132312</p>',
+            contentSourceUrl: 'http://www.iocoder.cn',
+            thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG',
+            showCoverPic: 0,
+            needOpenComment: 0,
+            onlyFansCanComment: 0,
+            url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qdkJ1ZjBoUmg2Uk9TS3RlQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNVg2aTJsaC1fMkU2eXNacUplN3VDTTZFZkhtMjhuTUZvWkxsNDBRSXExY2tiVXRHb09TaHgtREhzY3doZ0JYeC1TSTZ5eWZldXJsOWtfbV8yMi1aYkcyZ2pOY0haM0Ntb3VSWEtxUGVFRlNBfn4%3D&chksm=1f6354ba2814ddacf0184b24d310483641ef190b1faac098c285eb416c70017e2f54decfa1af#rd',
+            thumbUrl:
+              'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG.png'
+          }
+        ]
+      },
+      updateTime: 1673628760
+    },
+    {
+      mediaId: 'r6ryvl6LrxBU0miaST4Y-u9kTIm1DhWZDdXyxsxUVv2Z5DAB99IPxkIRTUUD206k',
+      content: {
+        newsItem: [
+          {
+            title: '12',
+            author: '333',
+            digest: '123',
+            content: '123',
+            contentSourceUrl: 'https://www.iocoder.cn',
+            thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8',
+            showCoverPic: 0,
+            needOpenComment: 0,
+            onlyFansCanComment: 0,
+            url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qVVhpSDZUaFJWTzBBWWRVQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNWRnTDJWYmF2NER0clV1bThmQ0xUR3hqQnJkZ3BJSUNmNDJmc0lCZ1dadkVnZ3Z5bkN4YWtVUjhoaWZWYzZURUR4NnpMd0Y4Z3U5aUdib0lkMzI4Rjg3SG9JX2FycTMxbUctOHplaTlQVVhnfn4%3D&chksm=1f6354b62814dda076c778af33f06580165d8aa81f7798d55cfabb1886b5c74d9b2124a3535c#rd',
+            thumbUrl:
+              'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8.jpg'
+          }
+        ]
+      },
+      updateTime: 1673626494
+    },
+    {
+      mediaId: 'r6ryvl6LrxBU0miaST4Y-sO24upobaENDmeByfBTfaozB3aOqSMAV0lGy-UkHXE7',
+      content: {
+        newsItem: [
+          {
+            title: '我是标题',
+            author: '我是作者',
+            digest: '我是摘要',
+            content: '我是内容',
+            contentSourceUrl: 'https://www.iocoder.cn',
+            thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
+            showCoverPic: 0,
+            needOpenComment: 0,
+            onlyFansCanComment: 0,
+            url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9LT2dqRnpMNUpsR0hjYWtBQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNGNmazZTdlE5WkxvU0tfX2V5cjV2WjJiR0xjQUhyREFSZWo2eWNrUW9EYVh6ZkpWRXBLR3FmTEV6YldBMno3Q2ZvVXBSdzlaVDc3aFhndEpQWUwzWmFMUWt0YVVURE1VZ1FsQTdPMlRtc3JBfn4%3D&chksm=1f6354aa2814ddbcc2637382f963a8742993ac38ebcebe6e3411df5ac82ac7bbdb391be6494a#rd',
+            thumbUrl:
+              'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
+          }
+        ]
+      },
+      updateTime: 1673534279
+    }
+  ],
+  total: 6
+}

+ 1 - 1
src/views/mp/freePublish/index.vue

@@ -59,7 +59,7 @@
   </ContentWrap>
 </template>
 
-<script setup lang="ts" name="freePublish">
+<script setup lang="ts" name="MpFreePublish">
 import * as FreePublishApi from '@/api/mp/freePublish'
 import * as MpAccountApi from '@/api/mp/account'
 import WxNews from '@/views/mp/components/wx-news/main.vue'

+ 526 - 1
src/views/mp/material/index.vue

@@ -1,3 +1,528 @@
 <template>
-  <span>开发中</span>
+  <doc-alert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" />
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="公众号" prop="accountId">
+        <el-select v-model="queryParams.accountId" placeholder="请选择公众号" class="!w-240px">
+          <el-option
+            v-for="item in accountList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <el-tabs v-model="type" @tab-change="handleTabChange">
+      <!-- tab 1:图片  -->
+      <el-tab-pane name="image">
+        <template #label>
+          <span><Icon icon="ep:picture" />图片</span>
+        </template>
+        <div class="add_but" v-hasPermi="['mp:material:upload-permanent']">
+          <el-upload
+            :action="actionUrl"
+            :headers="headers"
+            multiple
+            :limit="1"
+            :file-list="fileList"
+            :data="uploadData"
+            :before-upload="beforeImageUpload"
+            :on-success="handleUploadSuccess"
+          >
+            <el-button type="primary" plain>点击上传</el-button>
+            <template #tip>
+              <span class="el-upload__tip" style="margin-left: 5px">
+                支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M
+              </span>
+            </template>
+          </el-upload>
+        </div>
+        <div class="waterfall" v-loading="loading">
+          <div class="waterfall-item" v-for="item in list" :key="item.id">
+            <a target="_blank" :href="item.url">
+              <img class="material-img" :src="item.url" />
+              <div class="item-name">{{ item.name }}</div>
+            </a>
+            <el-row class="ope-row" justify="center">
+              <el-button
+                type="danger"
+                circle
+                @click="handleDelete(item)"
+                v-hasPermi="['mp:material:delete']"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </el-row>
+          </div>
+        </div>
+        <!-- 分页组件 -->
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </el-tab-pane>
+
+      <!-- tab 2:语音  -->
+      <el-tab-pane name="voice">
+        <template #label>
+          <span><Icon icon="ep:microphone" />语音</span>
+        </template>
+        <div class="add_but" v-hasPermi="['mp:material:upload-permanent']">
+          <el-upload
+            :action="actionUrl"
+            :headers="headers"
+            multiple
+            :limit="1"
+            :file-list="fileList"
+            :data="uploadData"
+            :on-success="handleUploadSuccess"
+            :before-upload="beforeVoiceUpload"
+          >
+            <el-button type="primary" plain>点击上传</el-button>
+            <template #tip>
+              <span class="el-upload__tip" style="margin-left: 5px">
+                格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
+              </span>
+            </template>
+          </el-upload>
+        </div>
+        <el-table :data="list" stripe border v-loading="loading" style="margin-top: 10px">
+          <el-table-column label="编号" align="center" prop="mediaId" />
+          <el-table-column label="文件名" align="center" prop="name" />
+          <el-table-column label="语音" align="center">
+            <template #default="scope">
+              <WxVoicePlayer :url="scope.row.url" />
+            </template>
+          </el-table-column>
+          <el-table-column label="上传时间" align="center" prop="createTime" width="180">
+            <template #default="scope">
+              <span>{{ formatDate(scope.row.createTime) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+            <template #default="scope">
+              <el-button type="primary" link plain @click="handleDownload(scope.row)">
+                <Icon icon="ep:download" />下载
+              </el-button>
+              <el-button
+                type="primary"
+                link
+                plain
+                @click="handleDelete(scope.row)"
+                v-hasPermi="['mp:material:delete']"
+              >
+                <Icon icon="ep:delete" />删除
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <!-- 分页组件 -->
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </el-tab-pane>
+
+      <!-- tab 3:视频 -->
+      <el-tab-pane name="video">
+        <template #label>
+          <span><Icon icon="ep:video-play" /> 视频</span>
+        </template>
+        <div class="add_but" v-hasPermi="['mp:material:upload-permanent']">
+          <el-button type="primary" plain @click="handleAddVideo">新建视频</el-button>
+        </div>
+        <!-- 新建视频的弹窗 -->
+        <el-dialog
+          title="新建视频"
+          v-model="dialogVideoVisible"
+          width="600px"
+          v-loading="addMaterialLoading"
+        >
+          <el-upload
+            :action="actionUrl"
+            :headers="headers"
+            multiple
+            :limit="1"
+            :file-list="fileList"
+            :data="uploadData"
+            :before-upload="beforeVideoUpload"
+            :on-success="handleUploadSuccess"
+            ref="uploadVideoRef"
+            :auto-upload="false"
+          >
+            <template #trigger>
+              <el-button size="small" type="primary">选择视频</el-button>
+            </template>
+            <span class="el-upload__tip" style="margin-left: 10px"
+              >格式支持 MP4,文件大小不超过 10MB</span
+            >
+          </el-upload>
+          <el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef" label-width="80px">
+            <el-row>
+              <el-form-item label="标题" prop="title">
+                <el-input
+                  v-model="uploadData.title"
+                  placeholder="标题将展示在相关播放页面,建议填写清晰、准确、生动的标题"
+                />
+              </el-form-item>
+            </el-row>
+            <el-row>
+              <el-form-item label="描述" prop="introduction">
+                <el-input
+                  :rows="3"
+                  type="textarea"
+                  v-model="uploadData.introduction"
+                  placeholder="介绍语将展示在相关播放页面,建议填写简洁明确、有信息量的内容"
+                />
+              </el-form-item>
+            </el-row>
+          </el-form>
+          <template #footer>
+            <div class="dialog-footer">
+              <el-button @click="cancelVideo">取 消</el-button>
+              <el-button type="primary" @click="submitVideo">提 交</el-button>
+            </div>
+          </template>
+        </el-dialog>
+
+        <el-table :data="list" stripe border v-loading="loading" style="margin-top: 10px">
+          <el-table-column label="编号" align="center" prop="mediaId" />
+          <el-table-column label="文件名" align="center" prop="name" />
+          <el-table-column label="标题" align="center" prop="title" />
+          <el-table-column label="介绍" align="center" prop="introduction" />
+          <el-table-column label="视频" align="center">
+            <template #default="scope">
+              <WxVideoPlayer :url="scope.row.url" />
+            </template>
+          </el-table-column>
+          <el-table-column label="上传时间" align="center" prop="createTime" width="180">
+            <template #default="scope">
+              <span>{{ formatDate(scope.row.createTime) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" align="center" fixed="right">
+            <template #default="scope">
+              <el-button type="primary" link plain @click="handleDownload(scope.row)"
+                ><Icon icon="ep:download" />下载</el-button
+              >
+              <el-button
+                type="primary"
+                link
+                size="small"
+                plain
+                @click="handleDelete(scope.row)"
+                v-hasPermi="['mp:material:delete']"
+              >
+                <Icon icon="ep:delete" />删除
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <!-- 分页组件 -->
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
 </template>
+<script setup name="MpMaterial">
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
+import { getSimpleAccountList } from '@/api/mp/account'
+import { getMaterialPage, deletePermanentMaterial } from '@/api/mp/material'
+import { getAccessToken } from '@/utils/auth'
+import { formatDate } from '@/utils/formatTime'
+
+const BASE_URL = import.meta.env.VITE_BASE_URL
+
+const message = useMessage()
+
+const queryFormRef = ref()
+const uploadFormRef = ref()
+const uploadVideoRef = ref()
+
+const type = ref('image')
+// 遮罩层
+const loading = ref(false)
+// 总条数
+const total = ref(0)
+// 数据列表
+const list = ref([])
+// 查询参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  accountId: undefined,
+  permanent: true
+})
+
+const actionUrl = BASE_URL + '/admin-api/mp/material/upload-permanent'
+const headers = { Authorization: 'Bearer ' + getAccessToken() }
+const fileList = ref([])
+const uploadData = reactive({
+  type: 'image',
+  title: '',
+  introduction: ''
+})
+
+// === 视频上传,独有变量 ===
+const dialogVideoVisible = ref(false)
+const addMaterialLoading = ref(false)
+const uploadRules = reactive({
+  // 视频上传的校验规则
+  title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
+  introduction: [{ required: true, message: '请输入描述', trigger: 'blur' }]
+})
+
+// 公众号账号列表
+const accountList = ref([])
+
+onMounted(() => {
+  getSimpleAccountList().then((data) => {
+    accountList.value = data
+    // 默认选中第一个
+    if (accountList.value.length > 0) {
+      setAccountId(accountList.value[0].id)
+    }
+    // 加载数据
+    getList()
+  })
+})
+
+// ======================== 列表查询 ========================
+/** 设置账号编号 */
+const setAccountId = (accountId) => {
+  queryParams.accountId = accountId
+  uploadData.accountId = accountId
+}
+
+/** 查询列表 */
+const getList = () => {
+  // 如果没有选中公众号账号,则进行提示。
+  if (!queryParams.accountId) {
+    message.error('未选中公众号,无法查询草稿箱')
+    return false
+  }
+
+  loading.value = true
+  getMaterialPage({
+    ...queryParams,
+    type: type.value
+  })
+    .then((data) => {
+      list.value = data.list
+      total.value = data.total
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  // 默认选中第一个
+  if (queryParams.accountId) {
+    setAccountId(queryParams.accountId)
+  }
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  // 默认选中第一个
+  if (accountList.value.length > 0) {
+    setAccountId(accountList.value[0].id)
+  }
+  handleQuery()
+}
+
+const handleTabChange = (tabName) => {
+  // 设置 type
+  uploadData.type = tabName
+  // 从第一页开始查询
+  handleQuery()
+}
+
+// ======================== 文件上传 ========================
+const beforeImageUpload = (file) => {
+  const isType =
+    file.type === 'image/jpeg' ||
+    file.type === 'image/png' ||
+    file.type === 'image/gif' ||
+    file.type === 'image/bmp' ||
+    file.type === 'image/jpg'
+  if (!isType) {
+    message.error('上传图片格式不对!')
+    return false
+  }
+  const isLt = file.size / 1024 / 1024 < 2
+  if (!isLt) {
+    message.error('上传图片大小不能超过 2M!')
+    return false
+  }
+  loading.value = true
+  return true
+}
+
+const beforeVoiceUpload = (file) => {
+  const isType =
+    file.type === 'audio/mp3' ||
+    file.type === 'audio/wma' ||
+    file.type === 'audio/wav' ||
+    file.type === 'audio/amr'
+  const isLt = file.size / 1024 / 1024 < 2
+  if (!isType) {
+    message.error('上传语音格式不对!')
+    return false
+  }
+  if (!isLt) {
+    message.error('上传语音大小不能超过 2M!')
+    return false
+  }
+  loading.value = true
+  return true
+}
+
+const beforeVideoUpload = (file) => {
+  const isType = file.type === 'video/mp4'
+  if (!isType) {
+    message.error('上传视频格式不对!')
+    return false
+  }
+  const isLt = file.size / 1024 / 1024 < 10
+  if (!isLt) {
+    message.error('上传视频大小不能超过 10M!')
+    return false
+  }
+  addMaterialLoading.value = true
+  return true
+}
+
+const handleUploadSuccess = (response, file, fileList) => {
+  loading.value = false
+  addMaterialLoading.value = false
+  if (response.code !== 0) {
+    message.error('上传出错:' + response.msg)
+    return false
+  }
+
+  // 清空上传时的各种数据
+  dialogVideoVisible.value = false
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+
+  // 加载数据
+  getList()
+}
+
+// 下载文件
+const handleDownload = (row) => {
+  window.open(row.url, '_blank')
+}
+
+// 提交 video 新建的表单
+const submitVideo = () => {
+  uploadFormRef.value.validate((valid) => {
+    if (!valid) {
+      return false
+    }
+    uploadVideoRef.value.submit()
+  })
+}
+
+const handleAddVideo = () => {
+  resetVideo()
+  dialogVideoVisible.value = true
+}
+
+/** 取消按钮 */
+const cancelVideo = () => {
+  dialogVideoVisible.value = false
+  resetVideo()
+}
+
+/** 表单重置 */
+const resetVideo = () => {
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+  uploadFormRef.value?.resetFields()
+}
+
+// ======================== 其它操作 ========================
+const handleDelete = async (item) => {
+  await message.confirm('此操作将永久删除该文件, 是否继续?')
+  await deletePermanentMaterial(item.id)
+  message.alertSuccess('删除成功')
+}
+</script>
+
+<style lang="scss" scoped>
+/*瀑布流样式*/
+.waterfall {
+  width: 100%;
+  column-gap: 10px;
+  column-count: 5;
+  margin-top: 10px; /* 芋道源码:增加 10px,避免顶着上面 */
+}
+.waterfall-item {
+  padding: 10px;
+  margin-bottom: 10px;
+  break-inside: avoid;
+  border: 1px solid #eaeaea;
+}
+.material-img {
+  width: 100%;
+}
+p {
+  line-height: 30px;
+}
+@media (min-width: 992px) and (max-width: 1300px) {
+  .waterfall {
+    column-count: 3;
+  }
+  p {
+    color: red;
+  }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+  .waterfall {
+    column-count: 2;
+  }
+  p {
+    color: orange;
+  }
+}
+@media (max-width: 767px) {
+  .waterfall {
+    column-count: 1;
+  }
+}
+/*瀑布流样式*/
+</style>

BIN
src/views/mp/menu/assets/iphone_backImg.png


BIN
src/views/mp/menu/assets/menu_foot.png


BIN
src/views/mp/menu/assets/menu_head.png


+ 780 - 1
src/views/mp/menu/index.vue

@@ -1,3 +1,782 @@
 <template>
-  <span>开发中</span>
+  <doc-alert title="公众号菜单" url="https://doc.iocoder.cn/mp/menu/" />
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form class="-mb-15px" ref="queryFormRef" :inline="true" label-width="68px">
+      <el-form-item label="公众号" prop="accountId">
+        <el-select v-model="accountId" placeholder="请选择公众号" class="!w-240px">
+          <el-option
+            v-for="item in accountList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <div class="public-account-management clearfix" v-loading="loading">
+      <!--左边配置菜单-->
+      <div class="left">
+        <div class="weixin-hd">
+          <div class="weixin-title">{{ name }}</div>
+        </div>
+        <div class="weixin-menu menu_main clearfix">
+          <div class="menu_bottom" v-for="(item, i) of menuList" :key="i">
+            <!-- 一级菜单 -->
+            <div @click="menuClick(i, item)" class="menu_item" :class="{ active: isActive === i }"
+              ><Icon icon="ep:fold" color="black" />{{ item.name }}
+            </div>
+            <!-- 以下为二级菜单-->
+            <div class="submenu" v-if="isSubMenuFlag === i">
+              <div class="subtitle menu_bottom" v-for="(subItem, k) in item.children" :key="k">
+                <div
+                  class="menu_subItem"
+                  v-if="item.children"
+                  :class="{ active: isSubMenuActive === i + '' + k }"
+                  @click="subMenuClick(subItem, i, k)"
+                >
+                  {{ subItem.name }}
+                </div>
+              </div>
+              <!-- 二级菜单加号, 当长度 小于 5 才显示二级菜单的加号  -->
+              <div
+                class="menu_bottom menu_addicon"
+                v-if="!item.children || item.children.length < 5"
+                @click="addSubMenu(i, item)"
+              >
+                <Icon icon="ep:plus" />
+              </div>
+            </div>
+          </div>
+          <!-- 一级菜单加号 -->
+          <div class="menu_bottom menu_addicon" v-if="menuList.length < 3" @click="addMenu">
+            <Icon icon="ep:plus" />
+          </div>
+        </div>
+        <div class="save_div">
+          <el-button
+            class="save_btn"
+            type="success"
+            @click="handleSave"
+            v-hasPermi="['mp:menu:save']"
+            >保存并发布菜单</el-button
+          >
+          <el-button
+            class="save_btn"
+            type="danger"
+            @click="handleDelete"
+            v-hasPermi="['mp:menu:delete']"
+            >清空菜单</el-button
+          >
+        </div>
+      </div>
+      <!--右边配置-->
+      <div v-if="showRightFlag" class="right">
+        <div class="configure_page">
+          <div class="delete_btn">
+            <el-button size="small" type="danger" @click="handleDeleteMenu(tempObj)">
+              删除当前菜单<Icon icon="ep:delete" />
+            </el-button>
+          </div>
+          <div>
+            <span>菜单名称:</span>
+            <el-input
+              class="input_width"
+              v-model="tempObj.name"
+              placeholder="请输入菜单名称"
+              :maxlength="nameMaxLength"
+              clearable
+            />
+          </div>
+          <div v-if="showConfigureContent">
+            <div class="menu_content">
+              <span>菜单标识:</span>
+              <el-input
+                class="input_width"
+                v-model="tempObj.menuKey"
+                placeholder="请输入菜单 KEY"
+                clearable
+              />
+            </div>
+            <div class="menu_content">
+              <span>菜单内容:</span>
+              <el-select v-model="tempObj.type" clearable placeholder="请选择" class="menu_option">
+                <el-option
+                  v-for="item in menuOptions"
+                  :label="item.label"
+                  :value="item.value"
+                  :key="item.value"
+                />
+              </el-select>
+            </div>
+            <div class="configur_content" v-if="tempObj.type === 'view'">
+              <span>跳转链接:</span>
+              <el-input
+                class="input_width"
+                v-model="tempObj.url"
+                placeholder="请输入链接"
+                clearable
+              />
+            </div>
+            <div class="configur_content" v-if="tempObj.type === 'miniprogram'">
+              <div class="applet">
+                <span>小程序的 appid :</span>
+                <el-input
+                  class="input_width"
+                  v-model="tempObj.miniProgramAppId"
+                  placeholder="请输入小程序的appid"
+                  clearable
+                />
+              </div>
+              <div class="applet">
+                <span>小程序的页面路径:</span>
+                <el-input
+                  class="input_width"
+                  v-model="tempObj.miniProgramPagePath"
+                  placeholder="请输入小程序的页面路径,如:pages/index"
+                  clearable
+                />
+              </div>
+              <div class="applet">
+                <span>小程序的备用网页:</span>
+                <el-input
+                  class="input_width"
+                  v-model="tempObj.url"
+                  placeholder="不支持小程序的老版本客户端将打开本网页"
+                  clearable
+                />
+              </div>
+              <p class="blue">tips:需要和公众号进行关联才可以把小程序绑定带微信菜单上哟!</p>
+            </div>
+            <div class="configur_content" v-if="tempObj.type === 'article_view_limited'">
+              <el-row>
+                <div class="select-item" v-if="tempObj && tempObj.replyArticles">
+                  <WxNews :articles="tempObj.replyArticles" />
+                  <el-row class="ope-row" justify="center" align="middle">
+                    <el-button type="danger" circle @click="deleteMaterial">
+                      <icon icon="ep:delete" />
+                    </el-button>
+                  </el-row>
+                </div>
+                <div v-else>
+                  <el-row justify="center">
+                    <el-col :span="24" style="text-align: center">
+                      <el-button type="success" @click="openMaterial">
+                        素材库选择<Icon icon="ep:circle-check" />
+                      </el-button>
+                    </el-col>
+                  </el-row>
+                </div>
+                <el-dialog title="选择图文" v-model="dialogNewsVisible" width="90%">
+                  <WxMaterialSelect
+                    :objData="{ type: 'news', accountId: accountId }"
+                    @select-material="selectMaterial"
+                  />
+                </el-dialog>
+              </el-row>
+            </div>
+            <div
+              class="configur_content"
+              v-if="tempObj.type === 'click' || tempObj.type === 'scancode_waitmsg'"
+            >
+              <WxReplySelect :objData="tempObj.reply" v-if="hackResetWxReplySelect" />
+            </div>
+          </div>
+        </div>
+      </div>
+      <!-- 一进页面就显示的默认页面,当点击左边按钮的时候,就不显示了-->
+      <div v-else class="right">
+        <p>请选择菜单配置</p>
+      </div>
+    </div>
+  </ContentWrap>
 </template>
+<script setup name="MpMenu">
+import { handleTree } from '@/utils/tree'
+import WxReplySelect from '@/views/mp/components/wx-reply/main.vue'
+import WxNews from '@/views/mp/components/wx-news/main.vue'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
+import { deleteMenu, getMenuList, saveMenu } from '@/api/mp/menu'
+import * as MpAccountApi from '@/api/mp/account'
+import menuOptions from './menuOptions'
+const message = useMessage() // 消息
+
+// ======================== 列表查询 ========================
+const loading = ref(true) // 遮罩层
+const accountId = ref(undefined) // 公众号Id
+const name = ref('') // 公众号名
+const menuList = ref({ children: [] })
+const accountList = ref([]) // 公众号账号列表
+
+// ======================== 菜单操作 ========================
+const isActive = ref(-1) // 一级菜单点中样式
+const isSubMenuActive = ref(-1) // 一级菜单点中样式
+const isSubMenuFlag = ref(-1) // 二级菜单显示标志
+
+// ======================== 菜单编辑 ========================
+const showRightFlag = ref(false) // 右边配置显示默认详情还是配置详情
+const nameMaxLength = ref(0) // 菜单名称最大长度;1 级是 4 字符;2 级是 7 字符;
+const showConfigureContent = ref(true) // 是否展示配置内容;如果有子菜单,就不显示配置内容
+const hackResetWxReplySelect = ref(false) // 重置 WxReplySelect 组件
+const tempObj = ref({}) // 右边临时变量,作为中间值牵引关系
+
+const tempSelfObj = ref({
+  // 一些临时值放在这里进行判断,如果放在 tempObj,由于引用关系,menu 也会多了多余的参数
+})
+const dialogNewsVisible = ref(false) // 跳转图文时的素材选择弹窗
+
+onMounted(async () => {
+  accountList.value = await MpAccountApi.getSimpleAccountList()
+  // 选中第一个
+  if (accountList.value.length > 0) {
+    // @ts-ignore
+    setAccountId(accountList.value[0].id)
+  }
+  await getList()
+})
+
+// ======================== 列表查询 ========================
+/** 设置账号编号 */
+const setAccountId = (id) => {
+  accountId.value = id
+  name.value = accountList.value.find((item) => item.id === accountId.value)?.name
+}
+
+const getList = async () => {
+  loading.value = false
+  getMenuList(accountId.value)
+    .then((response) => {
+      const menuData = convertMenuList(response)
+      menuList.value = handleTree(menuData, 'id')
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  resetForm()
+  // 默认选中第一个
+  if (accountId.value) {
+    setAccountId(accountId.value)
+  }
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  resetForm()
+  // 默认选中第一个
+  if (accountList.value.length > 0) {
+    setAccountId(accountList.value[0].id)
+  }
+  handleQuery()
+}
+
+// 将后端返回的 menuList,转换成前端的 menuList
+const convertMenuList = (list) => {
+  if (!list) return []
+
+  const menuList = []
+  list.forEach((item) => {
+    const menu = {
+      ...item
+    }
+    if (item.type === 'click' || item.type === 'scancode_waitmsg') {
+      delete menu.replyMessageType
+      delete menu.replyContent
+      delete menu.replyMediaId
+      delete menu.replyMediaUrl
+      delete menu.replyDescription
+      delete menu.replyArticles
+      menu.reply = {
+        type: item.replyMessageType,
+        accountId: item.accountId,
+        content: item.replyContent,
+        mediaId: item.replyMediaId,
+        url: item.replyMediaUrl,
+        title: item.replyTitle,
+        description: item.replyDescription,
+        thumbMediaId: item.replyThumbMediaId,
+        thumbMediaUrl: item.replyThumbMediaUrl,
+        articles: item.replyArticles,
+        musicUrl: item.replyMusicUrl,
+        hqMusicUrl: item.replyHqMusicUrl
+      }
+    }
+    menuList.push(menu)
+  })
+  return menuList
+}
+
+// 重置表单,清空表单数据
+const resetForm = () => {
+  // 菜单操作
+  isActive.value = -1
+  isSubMenuActive.value = -1
+  isSubMenuFlag.value = -1
+
+  // 菜单编辑
+  showRightFlag.value = false
+  nameMaxLength.value = 0
+  showConfigureContent.value = 0
+  hackResetWxReplySelect.value = false
+  tempObj.value = {}
+  tempSelfObj.value = {}
+  dialogNewsVisible.value = false
+}
+
+// ======================== 菜单操作 ========================
+// 一级菜单点击事件
+const menuClick = (i, item) => {
+  // 右侧的表单相关
+  resetEditor()
+  showRightFlag.value = true // 右边菜单
+  tempObj.value = item // 这个如果放在顶部,flag 会没有。因为重新赋值了。
+  tempSelfObj.value.grand = '1' // 表示一级菜单
+  tempSelfObj.value.index = i // 表示一级菜单索引
+  nameMaxLength.value = 4
+  showConfigureContent.value = !(item.children && item.children.length > 0) // 有子菜单,就不显示配置内容
+
+  // 左侧的选中
+  isActive.value = i // 一级菜单选中样式
+  isSubMenuFlag.value = i // 二级菜单显示标志
+  isSubMenuActive.value = -1 // 二级菜单去除选中样式
+}
+
+// 二级菜单点击事件
+const subMenuClick = (subItem, index, k) => {
+  // 右侧的表单相关
+  resetEditor()
+  showRightFlag.value = true // 右边菜单
+  console.log(subItem)
+  tempObj.value = subItem // 将点击的数据放到临时变量,对象有引用作用
+  tempSelfObj.value.grand = '2' // 表示二级菜单
+  tempSelfObj.value.index = index // 表示一级菜单索引
+  tempSelfObj.value.secondIndex = k // 表示二级菜单索引
+  nameMaxLength.value = 7
+  showConfigureContent.value = true
+
+  // 左侧的选中
+  isActive.value = -1 // 一级菜单去除样式
+  isSubMenuActive.value = index + '' + k // 二级菜单选中样式
+}
+
+// 添加横向一级菜单
+const addMenu = () => {
+  const menuKeyLength = menuList.value.length
+  const addButton = {
+    name: '菜单名称',
+    children: [],
+    reply: {
+      // 用于存储回复内容
+      type: 'text',
+      accountId: accountId.value // 保证组件里,可以使用到对应的公众号
+    }
+  }
+  menuList.value[menuKeyLength] = addButton
+  menuClick(menuKeyLength.value - 1, addButton)
+}
+// 添加横向二级菜单;item 表示要操作的父菜单
+const addSubMenu = (i, item) => {
+  // 清空父菜单的属性,因为它只需要 name 属性即可
+  if (!item.children || item.children.length <= 0) {
+    item.children = []
+    delete item['type']
+    delete item['menuKey']
+    delete item['miniProgramAppId']
+    delete item['miniProgramPagePath']
+    delete item['url']
+    delete item['reply']
+    delete item['articleId']
+    delete item['replyArticles']
+    // 关闭配置面板
+    showConfigureContent.value = false
+  }
+
+  let subMenuKeyLength = item.children.length // 获取二级菜单key长度
+  let addButton = {
+    name: '子菜单名称',
+    reply: {
+      // 用于存储回复内容
+      type: 'text',
+      accountId: accountId.value // 保证组件里,可以使用到对应的公众号
+    }
+  }
+  item.children[subMenuKeyLength] = addButton
+  subMenuClick(item.children[subMenuKeyLength], i, subMenuKeyLength)
+}
+
+// 删除当前菜单
+const handleDeleteMenu = async () => {
+  try {
+    await message.confirm('确定要删除吗?')
+    if (tempSelfObj.value.grand === '1') {
+      // 一级菜单的删除方法
+      menuList.value.splice(tempSelfObj.value.index, 1)
+    } else if (tempSelfObj.value.grand === '2') {
+      // 二级菜单的删除方法
+      menuList.value[tempSelfObj.value.index].children.splice(tempSelfObj.value.secondIndex, 1)
+    }
+    // 提示
+    message.notifySuccess('删除成功')
+
+    // 处理菜单的选中
+    tempObj.value = {}
+    showRightFlag.value = false
+    isActive.value = -1
+    isSubMenuActive.value = -1
+  } catch {}
+}
+
+// ======================== 菜单编辑 ========================
+const handleSave = async () => {
+  try {
+    await message.confirm('确定要删除吗?')
+    loading.value = true
+    await saveMenu(accountId.value, convertMenuFormList())
+    getList()
+    message.notifySuccess('发布成功')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 表单 Editor 重置
+const resetEditor = () => {
+  hackResetWxReplySelect.value = false // 销毁组件
+  nextTick(() => {
+    console.log('nextTick')
+    hackResetWxReplySelect.value = true // 重建组件
+  })
+}
+
+const handleDelete = async () => {
+  try {
+    await message.confirm('确定要删除吗?')
+    loading.value = true
+    await deleteMenu(accountId.value)
+    handleQuery()
+    message.notifySuccess('清空成功')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 将前端的 menuList,转换成后端接收的 menuList
+const convertMenuFormList = () => {
+  const result = []
+  menuList.value.forEach((item) => {
+    let menu = convertMenuForm(item)
+    result.push(menu)
+
+    // 处理子菜单
+    if (!item.children || item.children.length <= 0) {
+      return
+    }
+    menu.children = []
+    item.children.forEach((subItem) => {
+      menu.children.push(convertMenuForm(subItem))
+    })
+  })
+  return result
+}
+
+// 将前端的 menu,转换成后端接收的 menu
+const convertMenuForm = (menu) => {
+  let result = {
+    ...menu,
+    children: undefined, // 不处理子节点
+    reply: undefined // 稍后复制
+  }
+  if (menu.type === 'click' || menu.type === 'scancode_waitmsg') {
+    result.replyMessageType = menu.reply.type
+    result.replyContent = menu.reply.content
+    result.replyMediaId = menu.reply.mediaId
+    result.replyMediaUrl = menu.reply.url
+    result.replyTitle = menu.reply.title
+    result.replyDescription = menu.reply.description
+    result.replyThumbMediaId = menu.reply.thumbMediaId
+    result.replyThumbMediaUrl = menu.reply.thumbMediaUrl
+    result.replyArticles = menu.reply.articles
+    result.replyMusicUrl = menu.reply.musicUrl
+    result.replyHqMusicUrl = menu.reply.hqMusicUrl
+  }
+  return result
+}
+
+// ======================== 菜单编辑(素材选择) ========================
+const openMaterial = () => {
+  dialogNewsVisible.value = true
+}
+
+const selectMaterial = (item) => {
+  const articleId = item.articleId
+  const articles = item.content.newsItem
+  // 提示,针对多图文
+  if (articles.length > 1) {
+    message.alertWarning('您选择的是多图文,将默认跳转第一篇')
+  }
+  dialogNewsVisible.value = false
+
+  // 设置菜单的回复
+  tempObj.value.articleId = articleId
+  tempObj.value.replyArticles = []
+  articles.forEach((article) => {
+    tempObj.value.replyArticles.push({
+      title: article.title,
+      description: article.digest,
+      picUrl: article.picUrl,
+      url: article.url
+    })
+  })
+}
+
+const deleteMaterial = () => {
+  delete tempObj.value['articleId']
+  delete tempObj.value['replyArticles']
+}
+</script>
+<!--本组件样式-->
+<style lang="scss" scoped="scoped">
+/* 公共颜色变量 */
+.clearfix {
+  *zoom: 1;
+}
+
+.clearfix::after {
+  content: '';
+  display: table;
+  clear: both;
+}
+
+div {
+  text-align: left;
+}
+
+.weixin-hd {
+  color: #fff;
+  text-align: center;
+  position: relative;
+  bottom: 426px;
+  left: 0px;
+  width: 300px;
+  height: 64px;
+  background: transparent url('./assets/menu_head.png') no-repeat 0 0;
+  background-position: 0 0;
+  background-size: 100%;
+}
+
+.weixin-title {
+  color: #fff;
+  font-size: 14px;
+  width: 100%;
+  text-align: center;
+  position: absolute;
+  top: 33px;
+  left: 0px;
+}
+
+.weixin-menu {
+  background: transparent url('./assets/menu_foot.png') no-repeat 0 0;
+  padding-left: 43px;
+  font-size: 12px;
+}
+
+.menu_option {
+  width: 40% !important;
+}
+
+.public-account-management {
+  min-width: 1200px;
+  width: 1200px;
+  margin: 0 auto;
+
+  .left {
+    float: left;
+    display: inline-block;
+    width: 350px;
+    height: 715px;
+    background: url('./assets/iphone_backImg.png') no-repeat;
+    background-size: 100% auto;
+    padding: 518px 25px 88px;
+    position: relative;
+    box-sizing: border-box;
+
+    /*第一级菜单*/
+    .menu_main {
+      .menu_bottom {
+        position: relative;
+        float: left;
+        display: inline-block;
+        box-sizing: border-box;
+        width: 85.5px;
+        text-align: center;
+        border: 1px solid #ebedee;
+        background-color: #fff;
+        cursor: pointer;
+
+        &.menu_addicon {
+          height: 46px;
+          line-height: 46px;
+        }
+
+        .menu_item {
+          height: 44px;
+          line-height: 44px;
+          // text-align: center;
+          box-sizing: border-box;
+          width: 100%;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+
+          &.active {
+            border: 1px solid #2bb673;
+          }
+        }
+
+        .menu_subItem {
+          height: 44px;
+          line-height: 44px;
+          text-align: center;
+          box-sizing: border-box;
+
+          &.active {
+            border: 1px solid #2bb673;
+          }
+        }
+      }
+
+      i {
+        color: #2bb673;
+      }
+
+      /*第二级菜单*/
+      .submenu {
+        position: absolute;
+        width: 85.5px;
+        bottom: 45px;
+
+        .subtitle {
+          background-color: #fff;
+          box-sizing: border-box;
+        }
+      }
+    }
+
+    .save_div {
+      margin-top: 15px;
+      text-align: center;
+
+      .save_btn {
+        bottom: 20px;
+        left: 100px;
+      }
+    }
+  }
+
+  /*右边菜单内容*/
+  .right {
+    float: left;
+    width: 63%;
+    background-color: #e8e7e7;
+    padding: 20px;
+    margin-left: 20px;
+    -webkit-box-sizing: border-box;
+    box-sizing: border-box;
+
+    .configure_page {
+      .delete_btn {
+        text-align: right;
+        margin-bottom: 15px;
+      }
+
+      .menu_content {
+        margin-top: 20px;
+      }
+
+      .configur_content {
+        margin-top: 20px;
+        background-color: #fff;
+        padding: 20px 10px;
+        border-radius: 5px;
+      }
+
+      .blue {
+        color: #29b6f6;
+        margin-top: 10px;
+      }
+
+      .applet {
+        margin-bottom: 20px;
+
+        span {
+          width: 20%;
+        }
+      }
+
+      .input_width {
+        width: 40%;
+      }
+
+      .material {
+        .input_width {
+          width: 30%;
+        }
+
+        .el-textarea {
+          width: 80%;
+        }
+      }
+    }
+  }
+
+  .el-input {
+    width: 70%;
+    margin-right: 2%;
+  }
+}
+</style>
+<!--素材样式-->
+<style lang="scss" scoped>
+.pagination {
+  text-align: right;
+  margin-right: 25px;
+}
+
+.select-item {
+  width: 280px;
+  padding: 10px;
+  margin: 0 auto 10px auto;
+  border: 1px solid #eaeaea;
+}
+
+.select-item2 {
+  padding: 10px;
+  margin: 0 auto 10px auto;
+  border: 1px solid #eaeaea;
+}
+
+.ope-row {
+  padding-top: 10px;
+  text-align: center;
+}
+
+.item-name {
+  font-size: 12px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-align: center;
+}
+</style>

+ 42 - 0
src/views/mp/menu/menuOptions.ts

@@ -0,0 +1,42 @@
+export default [
+  {
+    value: 'view',
+    label: '跳转网页'
+  },
+  {
+    value: 'miniprogram',
+    label: '跳转小程序'
+  },
+  {
+    value: 'click',
+    label: '点击回复'
+  },
+  {
+    value: 'article_view_limited',
+    label: '跳转图文消息'
+  },
+  {
+    value: 'scancode_push',
+    label: '扫码直接返回结果'
+  },
+  {
+    value: 'scancode_waitmsg',
+    label: '扫码回复'
+  },
+  {
+    value: 'pic_sysphoto',
+    label: '系统拍照发图'
+  },
+  {
+    value: 'pic_photo_or_album',
+    label: '拍照或者相册'
+  },
+  {
+    value: 'pic_weixin',
+    label: '微信相册'
+  },
+  {
+    value: 'location_select',
+    label: '选择地理位置'
+  }
+]

+ 0 - 3
src/views/mp/mpuser/index.vue

@@ -1,3 +0,0 @@
-<template>
-  <span>开发中</span>
-</template>

+ 99 - 0
src/views/mp/user/UserForm.vue

@@ -0,0 +1,99 @@
+<template>
+  <Dialog title="修改" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="昵称" prop="nickname">
+        <el-input v-model="formData.nickname" placeholder="请输入昵称" />
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输入备注" />
+      </el-form-item>
+      <el-form-item label="标签" prop="tagIds">
+        <el-select v-model="formData.tagIds" multiple clearable placeholder="请选择标签">
+          <el-option
+            v-for="item in tagList"
+            :key="item.tagId"
+            :label="item.name"
+            :value="item.tagId"
+          />
+        </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 * as MpTagApi from '@/api/mp/tag'
+import * as MpUserApi from '@/api/mp/user'
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+const formData = ref({
+  id: undefined,
+  nickname: undefined,
+  remark: undefined,
+  tagIds: []
+})
+const formRules = reactive({}) // 表单的校验
+const formRef = ref() // 表单 Ref
+const tagList = ref([]) // 公众号标签列表
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await MpUserApi.getUser(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 加载标签
+  tagList.value = await MpTagApi.getSimpleTagList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    await MpUserApi.updateUser(formData.value)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    nickname: undefined,
+    remark: undefined,
+    tagIds: []
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 187 - 0
src/views/mp/user/index.vue

@@ -0,0 +1,187 @@
+<template>
+  <doc-alert title="公众号粉丝" url="https://doc.iocoder.cn/mp/user/" />
+
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="公众号" prop="accountId">
+        <el-select v-model="queryParams.accountId" placeholder="请选择公众号" class="!w-240px">
+          <el-option
+            v-for="item in accountList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="用户标识" prop="openid">
+        <el-input
+          v-model="queryParams.openid"
+          placeholder="请输入用户标识"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="昵称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          placeholder="请输入昵称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
+        <el-button type="success" plain @click="handleSync" v-hasPermi="['mp:user:sync']">
+          <Icon icon="ep:refresh" class="mr-5px" /> 同步
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="用户标识" align="center" prop="openid" width="260" />
+      <el-table-column label="昵称" align="center" prop="nickname" />
+      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column label="标签" align="center" prop="tagIds" width="200">
+        <template #default="scope">
+          <span v-for="(tagId, index) in scope.row.tagIds" :key="index">
+            <el-tag>{{ tagList.find((tag) => tag.tagId === tagId)?.name }} </el-tag>&nbsp;
+          </span>
+        </template>
+      </el-table-column>
+      <el-table-column label="订阅状态" align="center" prop="subscribeStatus">
+        <template #default="scope">
+          <el-tag v-if="scope.row.subscribeStatus === 0" type="success">已订阅</el-tag>
+          <el-tag v-else type="danger">未订阅</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="订阅时间"
+        align="center"
+        prop="subscribeTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            type="primary"
+            link
+            @click="openForm(scope.row.id)"
+            v-hasPermi="['mp:user:update']"
+          >
+            修改
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:修改 -->
+  <UserForm ref="formRef" @success="getList" />
+</template>
+<script lang="ts" setup name="MpUser">
+import { dateFormatter } from '@/utils/formatTime'
+import * as MpAccountApi from '@/api/mp/account'
+import * as MpUserApi from '@/api/mp/user'
+import * as MpTagApi from '@/api/mp/tag'
+import UserForm from './UserForm.vue'
+const message = useMessage() // 消息
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  accountId: null,
+  openid: null,
+  nickname: null
+})
+const queryFormRef = ref() // 搜索的表单
+const accountList = ref([]) // 公众号账号列表
+const tagList = ref([]) // 公众号标签列表
+
+/** 查询列表 */
+const getList = async () => {
+  // 如果没有选中公众号账号,则进行提示。
+  if (!queryParams.accountId) {
+    message.error('未选中公众号,无法查询用户')
+    return false
+  }
+  try {
+    loading.value = true
+    const data = await MpUserApi.getUserPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  // 默认选中第一个
+  if (accountList.value.length > 0) {
+    queryParams.accountId = accountList.value[0].id
+  }
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (id: number) => {
+  formRef.value.open(id)
+}
+
+/** 同步标签 */
+const handleSync = async () => {
+  const accountId = queryParams.accountId
+  try {
+    await message.confirm('是否确认同步粉丝?')
+    await MpUserApi.syncUser(accountId)
+    message.success('开始从微信公众号同步粉丝信息,同步需要一段时间,建议稍后再查询')
+    await getList()
+  } catch {}
+}
+
+/** 初始化 */
+onMounted(async () => {
+  // 加载标签
+  tagList.value = await MpTagApi.getSimpleTagList()
+
+  // 加载账号
+  accountList.value = await MpAccountApi.getSimpleAccountList()
+  if (accountList.value.length > 0) {
+    queryParams.accountId = accountList.value[0].id
+  }
+  await getList()
+})
+</script>

+ 1 - 1
src/views/pay/app/index.vue

@@ -75,7 +75,7 @@
     </template>
   </XModal>
 </template>
-<script setup lang="ts" name="App">
+<script setup lang="ts" name="PayApp">
 import type { FormExpose } from '@/components/Form'
 import { rules, allSchemas } from './app.data'
 import * as AppApi from '@/api/pay/app'

+ 1 - 1
src/views/pay/merchant/index.vue

@@ -137,7 +137,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <MerchantForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="Merchant">
+<script setup lang="ts" name="PayMerchant">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { CommonStatusEnum } from '@/utils/constants'
 import { dateFormatter } from '@/utils/formatTime'

+ 1 - 1
src/views/pay/order/index.vue

@@ -41,7 +41,7 @@
     </template>
   </XModal>
 </template>
-<script setup lang="ts" name="Order">
+<script setup lang="ts" name="PayOrder">
 import { allSchemas } from './order.data'
 import * as OrderApi from '@/api/pay/order'
 

+ 1 - 1
src/views/pay/refund/index.vue

@@ -33,7 +33,7 @@
     </template>
   </XModal>
 </template>
-<script setup lang="ts" name="Refund">
+<script setup lang="ts" name="PayRefund">
 import { allSchemas } from './refund.data'
 import * as RefundApi from '@/api/pay/refund'
 

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

@@ -30,7 +30,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <AreaForm ref="formRef" />
 </template>
-<script setup lang="tsx" name="Area">
+<script setup lang="tsx" name="SystemArea">
 import type { Column } from 'element-plus'
 import AreaForm from './AreaForm.vue'
 import * as AreaApi from '@/api/system/area'

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

@@ -103,7 +103,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <DeptForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="Dept">
+<script setup lang="ts" name="SystemDept">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import { handleTree } from '@/utils/tree'

+ 1 - 1
src/views/system/dict/data/index.vue

@@ -115,7 +115,7 @@
   <!-- 表单弹窗:添加/修改 -->
   <DictDataForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="DictData">
+<script setup lang="ts" name="SystemDictData">
 import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'

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

@@ -132,7 +132,7 @@
   <DictTypeForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts" name="DictType">
+<script setup lang="ts" name="SystemDictType">
 import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as DictTypeApi from '@/api/system/dict/dict.type'

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

@@ -137,7 +137,7 @@
   <ErrorCodeForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts" name="ErrorCode">
+<script setup lang="ts" name="SystemErrorCode">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'

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

@@ -104,7 +104,7 @@
   <!-- 表单弹窗:详情 -->
   <LoginLogDetail ref="detailRef" />
 </template>
-<script setup lang="ts" name="LoginLog">
+<script setup lang="ts" name="SystemLoginLog">
 import { DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'

+ 26 - 0
src/views/system/mail/account/MailAccountDetail.vue

@@ -0,0 +1,26 @@
+<template>
+  <Dialog title="详情" v-model="dialogVisible">
+    <Descriptions :schema="allSchemas.detailSchema" :data="detailData" />
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as MailAccountApi from '@/api/system/mail/account'
+import { allSchemas } from './account.data'
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref() // 详情数据
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = await MailAccountApi.getMailAccount(id)
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 6 - 2
src/views/system/mail/account/account.data.ts

@@ -61,12 +61,16 @@ const crudSchemas = reactive<CrudSchema[]>([
     label: '创建时间',
     field: 'createTime',
     isForm: false,
-    formatter: dateFormatter
+    formatter: dateFormatter,
+    detail: {
+      dateFormat: 'YYYY-MM-DD HH:mm:ss'
+    }
   },
   {
     label: '操作',
     field: 'action',
-    isForm: false
+    isForm: false,
+    isDetail: false
   }
 ])
 export const { allSchemas } = useCrudSchemas(crudSchemas)

+ 18 - 1
src/views/system/mail/account/index.vue

@@ -39,6 +39,14 @@
         >
           编辑
         </el-button>
+        <el-button
+          link
+          type="primary"
+          @click="openDetail(row.id)"
+          v-hasPermi="['system:mail-account:query']"
+        >
+          详情
+        </el-button>
         <el-button
           link
           type="danger"
@@ -53,11 +61,14 @@
 
   <!-- 表单弹窗:添加/修改 -->
   <MailAccountForm ref="formRef" @success="getList" />
+  <!-- 详情弹窗 -->
+  <MailAccountDetail ref="detailRef" />
 </template>
-<script setup lang="ts" name="MailAccount">
+<script setup lang="ts" name="SystemMailAccount">
 import { allSchemas } from './account.data'
 import * as MailAccountApi from '@/api/system/mail/account'
 import MailAccountForm from './MailAccountForm.vue'
+import MailAccountDetail from './MailAccountDetail.vue'
 
 // tableObject:表格的属性对象,可获得分页大小、条数等属性
 // tableMethods:表格的操作对象,可进行获得分页、删除记录等操作
@@ -75,6 +86,12 @@ const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
 
+/** 详情操作 */
+const detailRef = ref()
+const openDetail = (id: number) => {
+  detailRef.value.open(id)
+}
+
 /** 删除按钮操作 */
 const handleDelete = (id: number) => {
   tableMethods.delList(id, false)

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott