Procházet zdrojové kódy

!135 MP模块重构,功能增强
Merge pull request !135 from dhb52/dev

芋道源码 před 2 roky
rodič
revize
4cf734d244
49 změnil soubory, kde provedl 842 přidání a 670 odebrání
  1. 1 1
      src/views/mp/account/index.vue
  2. 4 4
      src/views/mp/autoReply/components/ReplyTable.vue
  3. 0 41
      src/views/mp/autoReply/components/types.ts
  4. 28 27
      src/views/mp/autoReply/index.vue
  5. 3 0
      src/views/mp/components/wx-account-select/index.ts
  6. 9 5
      src/views/mp/components/wx-account-select/main.vue
  7. 3 0
      src/views/mp/components/wx-location/index.ts
  8. 6 0
      src/views/mp/components/wx-material-select/index.ts
  9. 49 54
      src/views/mp/components/wx-material-select/main.vue
  10. 11 0
      src/views/mp/components/wx-material-select/types.ts
  11. 47 32
      src/views/mp/components/wx-msg/card.scss
  12. 38 20
      src/views/mp/components/wx-msg/comment.scss
  13. 6 0
      src/views/mp/components/wx-msg/index.ts
  14. 14 14
      src/views/mp/components/wx-msg/main.vue
  15. 3 0
      src/views/mp/components/wx-music/index.ts
  16. 1 1
      src/views/mp/components/wx-music/main.vue
  17. 3 0
      src/views/mp/components/wx-news/index.ts
  18. 19 17
      src/views/mp/components/wx-news/main.vue
  19. 35 35
      src/views/mp/components/wx-reply/components/TabImage.vue
  20. 19 22
      src/views/mp/components/wx-reply/components/TabMusic.vue
  21. 17 19
      src/views/mp/components/wx-reply/components/TabNews.vue
  22. 3 8
      src/views/mp/components/wx-reply/components/TabText.vue
  23. 24 28
      src/views/mp/components/wx-reply/components/TabVideo.vue
  24. 37 37
      src/views/mp/components/wx-reply/components/TabVoice.vue
  25. 44 15
      src/views/mp/components/wx-reply/components/types.ts
  26. 7 0
      src/views/mp/components/wx-reply/index.ts
  27. 100 41
      src/views/mp/components/wx-reply/main.vue
  28. 3 0
      src/views/mp/components/wx-video-play/index.ts
  29. 3 0
      src/views/mp/components/wx-voice-play/index.ts
  30. 6 6
      src/views/mp/components/wx-voice-play/main.vue
  31. 10 26
      src/views/mp/draft/components/CoverSelect.vue
  32. 1 1
      src/views/mp/draft/components/DraftTable.vue
  33. 4 5
      src/views/mp/draft/index.vue
  34. 67 64
      src/views/mp/freePublish/index.vue
  35. 6 6
      src/views/mp/hooks/useUpload.ts
  36. 24 24
      src/views/mp/material/components/ImageTable.vue
  37. 5 10
      src/views/mp/material/components/UploadFile.vue
  38. 12 16
      src/views/mp/material/components/UploadVideo.vue
  39. 1 1
      src/views/mp/material/components/VideoTable.vue
  40. 1 1
      src/views/mp/material/components/VoiceTable.vue
  41. 6 6
      src/views/mp/material/components/upload.ts
  42. 14 14
      src/views/mp/material/index.vue
  43. 7 7
      src/views/mp/menu/components/MenuEditor.vue
  44. 88 28
      src/views/mp/menu/components/MenuPreviewer.vue
  45. 4 5
      src/views/mp/menu/index.vue
  46. 5 5
      src/views/mp/message/MessageTable.vue
  47. 10 10
      src/views/mp/message/index.vue
  48. 23 9
      src/views/mp/tag/index.vue
  49. 11 5
      src/views/mp/user/index.vue

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

@@ -46,7 +46,7 @@
             v-if="scope.row.qrCodeUrl"
             :src="scope.row.qrCodeUrl"
             alt="二维码"
-            style="height: 100px; display: inline-block"
+            style="display: inline-block; height: 100px"
           />
           <el-button
             link

+ 4 - 4
src/views/mp/autoReply/components/ReplyTable.vue

@@ -94,10 +94,10 @@
   </el-table>
 </template>
 <script setup lang="ts">
-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 WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import WxMusic from '@/views/mp/components/wx-music'
+import WxNews from '@/views/mp/components/wx-news'
 import { dateFormatter } from '@/utils/formatTime'
 import { DICT_TYPE } from '@/utils/dict'
 import { MsgType } from './types'

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

@@ -5,44 +5,3 @@ export enum MsgType {
   Message = 2,
   Keyword = 3
 }
-
-type ReplyType = 'text' | 'image' | 'voice' | 'video' | 'shortvideo' | 'location' | 'link'
-
-export interface ReplyForm {
-  // relation:
-  id?: number
-  accountId?: number
-  type?: MsgType
-  // request:
-  requestMessageType?: ReplyType
-  requestMatch?: number
-  requestKeyword?: string
-  // response:
-  responseMessageType?: ReplyType
-  responseContent?: string
-  responseMediaId?: number
-  responseMediaUrl?: string
-  responseTitle?: string
-  responseDescription?: number
-  responseThumbMediaId?: string
-  responseThumbMediaUrl?: string
-  responseArticles?: any[]
-  responseMusicUrl?: string
-  responseHqMusicUrl?: string
-}
-
-// TODO @Dhb52:ObjData 这个类名可以在看看,ObjData 有点通用
-export interface ObjData {
-  type: ReplyType
-  accountId?: number
-  content?: string
-  mediaId?: number
-  url?: string
-  title?: string
-  description?: string
-  thumbMediaId?: number
-  thumbMediaUrl?: string
-  articles?: any[]
-  musicUrl?: string
-  hqMusicUrl?: string
-}

+ 28 - 27
src/views/mp/autoReply/index.vue

@@ -82,7 +82,7 @@
           <el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable />
         </el-form-item>
         <el-form-item label="回复消息">
-          <WxReplySelect :objData="objData" />
+          <WxReplySelect v-model="reply" />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -93,14 +93,14 @@
   </ContentWrap>
 </template>
 <script setup lang="ts" name="MpAutoReply">
-import WxReplySelect from '@/views/mp/components/wx-reply/main.vue'
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxReplySelect, { type Reply, ReplyType } from '@/views/mp/components/wx-reply'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 import * as MpAutoReplyApi from '@/api/mp/autoReply'
 import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict'
 import { ContentWrap } from '@/components/ContentWrap'
-import type { TabPaneName } from 'element-plus'
+import type { FormInstance, TabPaneName } from 'element-plus'
 import ReplyTable from './components/ReplyTable.vue'
-import { MsgType, ReplyForm, ObjData } from './components/types'
+import { MsgType } from './components/types'
 const message = useMessage() // 消息
 
 const msgType = ref<MsgType>(MsgType.Keyword) // 消息类型
@@ -108,26 +108,26 @@ const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'l
 const loading = ref(true) // 遮罩层
 const total = ref(0) // 总条数
 const list = ref<any[]>([]) // 自动回复列表
-const formRef = ref() // 表单 ref
+const formRef = ref<FormInstance | null>(null) // 表单 ref
 // 查询参数
 interface QueryParams {
   pageNo: number
   pageSize: number
-  accountId?: number
+  accountId: number
 }
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: undefined
+  accountId: 0
 })
 
 const dialogTitle = ref('') // 弹出层标题
 const showFormDialog = ref(false) // 是否显示弹出层
-const replyForm = ref<ReplyForm>({}) // 表单参数
+const replyForm = ref<any>({}) // 表单参数
 // 回复消息
-const objData = ref<ObjData>({
-  type: 'text',
-  accountId: undefined
+const reply = ref<Reply>({
+  type: ReplyType.Text,
+  accountId: 0
 })
 // 表单校验
 const rules = {
@@ -136,8 +136,9 @@ const rules = {
 }
 
 /** 侦听账号变化 */
-const onAccountChanged = (id?: number) => {
+const onAccountChanged = (id: number) => {
   queryParams.accountId = id
+  reply.value.accountId = id
   getList()
 }
 
@@ -171,8 +172,8 @@ const onTabChange = (tabName: TabPaneName) => {
 const onCreate = () => {
   reset()
   // 打开表单,并设置初始化
-  objData.value = {
-    type: 'text',
+  reply.value = {
+    type: ReplyType.Text,
     accountId: queryParams.accountId
   }
 
@@ -193,7 +194,7 @@ const onUpdate = async (id: number) => {
   delete replyForm.value['responseMediaUrl']
   delete replyForm.value['responseDescription']
   delete replyForm.value['responseArticles']
-  objData.value = {
+  reply.value = {
     type: data.responseMessageType,
     accountId: queryParams.accountId,
     content: data.responseContent,
@@ -227,17 +228,17 @@ const onSubmit = async () => {
 
   // 处理回复消息
   const submitForm: any = { ...replyForm.value }
-  submitForm.responseMessageType = objData.value.type
-  submitForm.responseContent = objData.value.content
-  submitForm.responseMediaId = objData.value.mediaId
-  submitForm.responseMediaUrl = objData.value.url
-  submitForm.responseTitle = objData.value.title
-  submitForm.responseDescription = objData.value.description
-  submitForm.responseThumbMediaId = objData.value.thumbMediaId
-  submitForm.responseThumbMediaUrl = objData.value.thumbMediaUrl
-  submitForm.responseArticles = objData.value.articles
-  submitForm.responseMusicUrl = objData.value.musicUrl
-  submitForm.responseHqMusicUrl = objData.value.hqMusicUrl
+  submitForm.responseMessageType = reply.value.type
+  submitForm.responseContent = reply.value.content
+  submitForm.responseMediaId = reply.value.mediaId
+  submitForm.responseMediaUrl = reply.value.url
+  submitForm.responseTitle = reply.value.title
+  submitForm.responseDescription = reply.value.description
+  submitForm.responseThumbMediaId = reply.value.thumbMediaId
+  submitForm.responseThumbMediaUrl = reply.value.thumbMediaUrl
+  submitForm.responseArticles = reply.value.articles
+  submitForm.responseMusicUrl = reply.value.musicUrl
+  submitForm.responseHqMusicUrl = reply.value.hqMusicUrl
 
   if (replyForm.value.id !== undefined) {
     await MpAutoReplyApi.updateAutoReply(submitForm)

+ 3 - 0
src/views/mp/components/wx-account-select/index.ts

@@ -0,0 +1,3 @@
+import WxAccountSelect from './main.vue'
+
+export default WxAccountSelect

+ 9 - 5
src/views/mp/components/wx-account-select/main.vue

@@ -14,7 +14,7 @@ const account: MpAccountApi.AccountVO = reactive({
 const accountList: Ref<MpAccountApi.AccountVO[]> = ref([])
 
 const emit = defineEmits<{
-  (e: 'change', id?: number, name?: string): void
+  (e: 'change', id: number, name: string): void
 }>()
 
 const handleQuery = async () => {
@@ -22,15 +22,19 @@ const handleQuery = async () => {
   // 默认选中第一个
   if (accountList.value.length > 0) {
     account.id = accountList.value[0].id
-    account.name = accountList.value[0].name
-    emit('change', account.id, account.name)
+    if (account.id) {
+      account.name = accountList.value[0].name
+      emit('change', account.id, account.name)
+    }
   }
 }
 
 const onChanged = (id?: number) => {
   const found = accountList.value.find((v) => v.id === id)
-  account.name = found ? found.name : ''
-  emit('change', account.id, account.name)
+  if (account.id) {
+    account.name = found ? found.name : ''
+    emit('change', account.id, account.name)
+  }
 }
 
 /** 初始化 */

+ 3 - 0
src/views/mp/components/wx-location/index.ts

@@ -0,0 +1,3 @@
+import WxLocation from './main.vue'
+
+export default WxLocation

+ 6 - 0
src/views/mp/components/wx-material-select/index.ts

@@ -0,0 +1,6 @@
+import WxMaterialSelect from './main.vue'
+import { NewsType, MaterialType } from './types'
+
+export { NewsType, MaterialType }
+
+export default WxMaterialSelect

+ 49 - 54
src/views/mp/components/wx-material-select/main.vue

@@ -7,7 +7,7 @@
 <template>
   <div class="pb-30px">
     <!-- 类型:image -->
-    <div v-if="objData.type === 'image'">
+    <div v-if="props.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" />
@@ -29,7 +29,7 @@
       />
     </div>
     <!-- 类型:voice -->
-    <div v-else-if="objData.type === 'voice'">
+    <div v-else-if="props.type === 'voice'">
       <!-- 列表 -->
       <el-table v-loading="loading" :data="list">
         <el-table-column label="编号" align="center" prop="mediaId" />
@@ -64,7 +64,7 @@
       />
     </div>
     <!-- 类型:video -->
-    <div v-else-if="objData.type === 'video'">
+    <div v-else-if="props.type === 'video'">
       <!-- 列表 -->
       <el-table v-loading="loading" :data="list">
         <el-table-column label="编号" align="center" prop="mediaId" />
@@ -106,7 +106,7 @@
       />
     </div>
     <!-- 类型:news -->
-    <div v-else-if="objData.type === 'news'">
+    <div v-else-if="props.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">
@@ -132,25 +132,25 @@
 </template>
 
 <script lang="ts" setup name="WxMaterialSelect">
-import WxNews from '@/views/mp/components/wx-news/main.vue'
-import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
-import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
+import WxNews from '@/views/mp/components/wx-news'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import { NewsType } from './types'
 import * as MpMaterialApi from '@/api/mp/material'
 import * as MpFreePublishApi from '@/api/mp/freePublish'
 import * as MpDraftApi from '@/api/mp/draft'
 import { dateFormatter } from '@/utils/formatTime'
 
-const props = defineProps({
-  objData: {
-    type: Object, // type - 类型;accountId - 公众号账号编号
-    required: true
-  },
-  newsType: {
-    // 图文类型:1、已发布图文;2、草稿箱图文
-    type: String as PropType<string>,
-    default: '1'
+const props = withDefaults(
+  defineProps<{
+    type: string
+    accountId: number
+    newsType?: NewsType
+  }>(),
+  {
+    newsType: NewsType.Published
   }
-})
+)
 
 const emit = defineEmits(['select-material'])
 
@@ -159,15 +159,13 @@ const loading = ref(false)
 // 总条数
 const total = ref(0)
 // 数据列表
-const list = ref([])
+const list = ref<any[]>([])
 // 查询参数
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: props.objData.accountId
+  accountId: props.accountId
 })
-const objDataRef = reactive(props.objData)
-const newsTypeRef = ref(props.newsType)
 
 const selectMaterialFun = (item) => {
   emit('select-material', item)
@@ -176,10 +174,10 @@ const selectMaterialFun = (item) => {
 const getPage = async () => {
   loading.value = true
   try {
-    if (objDataRef.type === 'news' && newsTypeRef.value === '1') {
+    if (props.type === 'news' && props.newsType === NewsType.Published) {
       // 【图文】+ 【已发布】
       await getFreePublishPageFun()
-    } else if (objDataRef.type === 'news' && newsTypeRef.value === '2') {
+    } else if (props.type === 'news' && props.newsType === NewsType.Draft) {
       // 【图文】+ 【草稿】
       await getDraftPageFun()
     } else {
@@ -194,7 +192,7 @@ const getPage = async () => {
 const getMaterialPageFun = async () => {
   const data = await MpMaterialApi.getMaterialPage({
     ...queryParams,
-    type: objDataRef.type
+    type: props.type
   })
   list.value = data.list
   total.value = data.total
@@ -202,9 +200,9 @@ const getMaterialPageFun = async () => {
 
 const getFreePublishPageFun = async () => {
   const data = await MpFreePublishApi.getFreePublishPage(queryParams)
-  data.list.forEach((item) => {
-    const newsItem = item.content.newsItem
-    newsItem.forEach((article) => {
+  data.list.forEach((item: any) => {
+    const articles = item.content.newsItem
+    articles.forEach((article: any) => {
       article.picUrl = article.thumbUrl
     })
   })
@@ -214,9 +212,9 @@ const getFreePublishPageFun = async () => {
 
 const getDraftPageFun = async () => {
   const data = await MpDraftApi.getDraftPage(queryParams)
-  data.list.forEach((item) => {
-    const newsItem = item.content.newsItem
-    newsItem.forEach((article) => {
+  data.list.forEach((draft: any) => {
+    const articles = draft.content.newsItem
+    articles.forEach((article: any) => {
       article.picUrl = article.thumbUrl
     })
   })
@@ -229,29 +227,6 @@ onMounted(async () => {
 })
 </script>
 <style lang="scss" scoped>
-/*瀑布流样式*/
-.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;
-}
-
-.material-img {
-  width: 100%;
-}
-
-p {
-  line-height: 30px;
-}
-
 @media (min-width: 992px) and (max-width: 1300px) {
   .waterfall {
     column-count: 3;
@@ -278,5 +253,25 @@ p {
   }
 }
 
-/*瀑布流样式*/
+.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;
+}
+
+.material-img {
+  width: 100%;
+}
+
+p {
+  line-height: 30px;
+}
 </style>

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

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

+ 47 - 32
src/views/mp/components/wx-msg/card.scss

@@ -1,25 +1,27 @@
-.avue-card{
-  &__item{
+.avue-card {
+  &__item {
     margin-bottom: 16px;
     border: 1px solid #e8e8e8;
     background-color: #fff;
     box-sizing: border-box;
-    color: rgba(0,0,0,.65);
+    color: rgba(0, 0, 0, 0.65);
     font-size: 14px;
     font-variant: tabular-nums;
     line-height: 1.5;
     list-style: none;
-    font-feature-settings: "tnum";
+    font-feature-settings: 'tnum';
     cursor: pointer;
-    height:200px;
-    &:hover{
-      border-color: rgba(0,0,0,.09);
-      box-shadow: 0 2px 8px rgba(0,0,0,.09);
+    height: 200px;
+
+    &:hover {
+      border-color: rgba(0, 0, 0, 0.09);
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
     }
-    &--add{
-      border:1px dashed #000;
+
+    &--add {
+      border: 1px dashed #000;
       width: 100%;
-      color: rgba(0,0,0,.45);
+      color: rgba(0, 0, 0, 0.45);
       background-color: #fff;
       border-color: #d9d9d9;
       border-radius: 2px;
@@ -27,74 +29,87 @@
       align-items: center;
       justify-content: center;
       font-size: 16px;
-      i{
+
+      i {
         margin-right: 10px;
       }
-      &:hover{
+
+      &:hover {
         color: #40a9ff;
         background-color: #fff;
         border-color: #40a9ff;
       }
     }
   }
-  &__body{
+
+  &__body {
     display: flex;
     padding: 24px;
   }
-  &__detail{
-    flex:1
+
+  &__detail {
+    flex: 1;
   }
-  &__avatar{
+
+  &__avatar {
     width: 48px;
     height: 48px;
     border-radius: 48px;
     overflow: hidden;
     margin-right: 12px;
-    img{
+
+    img {
       width: 100%;
       height: 100%;
     }
   }
-  &__title{
-    color: rgba(0,0,0,.85);
+
+  &__title {
+    color: rgba(0, 0, 0, 0.85);
     margin-bottom: 12px;
     font-size: 16px;
-    &:hover{
-      color:#1890ff;
+
+    &:hover {
+      color: #1890ff;
     }
   }
-  &__info{
-    color: rgba(0,0,0,.45);
+
+  &__info {
+    color: rgba(0, 0, 0, 0.45);
     display: -webkit-box;
     -webkit-box-orient: vertical;
     -webkit-line-clamp: 3;
     overflow: hidden;
     height: 64px;
   }
-  &__menu{
+
+  &__menu {
     display: flex;
-    justify-content:space-around;
+    justify-content: space-around;
     height: 50px;
     background: #f7f9fa;
-    color: rgba(0,0,0,.45);
+    color: rgba(0, 0, 0, 0.45);
     text-align: center;
     line-height: 50px;
-    &:hover{
-      color:#1890ff;
+
+    &:hover {
+      color: #1890ff;
     }
   }
 }
 
 /** joolun 额外加的 */
 .avue-comment__main {
-  flex: unset!important;
-  border-radius: 5px!important;
-  margin: 0 8px!important;
+  flex: unset !important;
+  border-radius: 5px !important;
+  margin: 0 8px !important;
 }
+
 .avue-comment__header {
   border-top-left-radius: 5px;
   border-top-right-radius: 5px;
 }
+
 .avue-comment__body {
   border-bottom-right-radius: 5px;
   border-bottom-left-radius: 5px;

+ 38 - 20
src/views/mp/components/wx-msg/comment.scss

@@ -1,27 +1,33 @@
 /* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss  */
-.avue-comment{
+.avue-comment {
   margin-bottom: 30px;
   display: flex;
   align-items: flex-start;
-  &--reverse{
-    flex-direction:row-reverse;
-    .avue-comment__main{
-      &:before,&:after{
+
+  &--reverse {
+    flex-direction: row-reverse;
+
+    .avue-comment__main {
+      &:before,
+      &:after {
         left: auto;
         right: -8px;
         border-width: 8px 0 8px 8px;
       }
-      &:before{
+
+      &:before {
         border-left-color: #dedede;
       }
-      &:after{
+
+      &:after {
         border-left-color: #f8f8f8;
         margin-right: 1px;
         margin-left: auto;
       }
     }
   }
-  &__avatar{
+
+  &__avatar {
     width: 48px;
     height: 48px;
     border-radius: 50%;
@@ -29,7 +35,8 @@
     box-sizing: border-box;
     vertical-align: middle;
   }
-  &__header{
+
+  &__header {
     padding: 5px 15px;
     background: #f8f8f8;
     border-bottom: 1px solid #eee;
@@ -37,18 +44,22 @@
     align-items: center;
     justify-content: space-between;
   }
-  &__author{
+
+  &__author {
     font-weight: 700;
     font-size: 14px;
     color: #999;
   }
-  &__main{
-    flex:1;
+
+  &__main {
+    flex: 1;
     margin: 0 20px;
     position: relative;
     border: 1px solid #dedede;
     border-radius: 2px;
-    &:before,&:after{
+
+    &:before,
+    &:after {
       position: absolute;
       top: 10px;
       left: -8px;
@@ -56,32 +67,39 @@
       width: 0;
       height: 0;
       display: block;
-      content: " ";
+      content: ' ';
       border-color: transparent;
       border-style: solid solid outset;
       border-width: 8px 8px 8px 0;
       pointer-events: none;
     }
+
     &:before {
       border-right-color: #dedede;
       z-index: 1;
     }
-    &:after{
+
+    &:after {
       border-right-color: #f8f8f8;
       margin-left: 1px;
       z-index: 2;
     }
   }
-  &__body{
+
+  &__body {
     padding: 15px;
     overflow: hidden;
     background: #fff;
-    font-family: Segoe UI,Lucida Grande,Helvetica,Arial,Microsoft YaHei,FreeSans,Arimo,Droid Sans,wenquanyi micro hei,Hiragino Sans GB,Hiragino Sans GB W3,FontAwesome,sans-serif;color: #333;
+    font-family: Segoe UI, Lucida Grande, Helvetica, Arial, Microsoft YaHei, FreeSans, Arimo,
+      Droid Sans, wenquanyi micro hei, Hiragino Sans GB, Hiragino Sans GB W3, FontAwesome,
+      sans-serif;
+    color: #333;
     font-size: 14px;
   }
-  blockquote{
-    margin:0;
-    font-family: Georgia,Times New Roman,Times,Kai,Kaiti SC,KaiTi,BiauKai,FontAwesome,serif;
+
+  blockquote {
+    margin: 0;
+    font-family: Georgia, Times New Roman, Times, Kai, Kaiti SC, KaiTi, BiauKai, FontAwesome, serif;
     padding: 1px 0 1px 15px;
     border-left: 4px solid #ddd;
   }

+ 6 - 0
src/views/mp/components/wx-msg/index.ts

@@ -0,0 +1,6 @@
+import WxMsg from './main.vue'
+import { MsgType } from './types'
+
+export { MsgType }
+
+export default WxMsg

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

@@ -125,19 +125,19 @@
       </div>
     </div>
     <div class="msg-send" v-loading="sendLoading">
-      <WxReplySelect ref="replySelectRef" :objData="objData" />
+      <WxReplySelect ref="replySelectRef" v-model="reply" />
       <el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
     </div>
   </ContentWrap>
 </template>
 
 <script setup lang="ts" name="WxMsg">
-import WxReplySelect from '@/views/mp/components/wx-reply/main.vue'
-import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
-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 WxReplySelect from '@/views/mp/components/wx-reply'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import WxNews from '@/views/mp/components/wx-news'
+import WxLocation from '@/views/mp/components/wx-location'
+import WxMusic from '@/views/mp/components/wx-music'
 import { getMessagePage, sendMessage } from '@/api/mp/message'
 import { getUser } from '@/api/mp/user'
 import { formatDate } from '@/utils/formatTime'
@@ -187,14 +187,14 @@ const mp: Mp = reactive({
 
 // ========= 消息发送 =========
 const sendLoading = ref(false) // 发送消息是否加载中
-interface ObjData {
+interface Reply {
   type: MsgType
   accountId: number | null
   articles: any[]
 }
 
 // 微信发送消息
-const objData: ObjData = reactive({
+const reply = ref<Reply>({
   type: MsgType.Text,
   accountId: null,
   articles: []
@@ -209,23 +209,23 @@ onMounted(async () => {
   user.avatar = user.avatar?.length > 0 ? data.avatar : user.avatar
   user.accountId = data.accountId
   queryParams.accountId = data.accountId
-  objData.accountId = data.accountId
+  reply.value.accountId = data.accountId
 
   refreshChange()
 })
 
 // 执行发送
 const sendMsg = async () => {
-  if (!objData) {
+  if (!reply) {
     return
   }
   // 公众号限制:客服消息,公众号只允许发送一条
-  if (objData.type === MsgType.News && objData.articles.length > 1) {
-    objData.articles = [objData.articles[0]]
+  if (reply.value.type === MsgType.News && reply.value.articles.length > 1) {
+    reply.value.articles = [reply.value.articles[0]]
     message.success('图文消息条数限制在 1 条以内,已默认发送第一条')
   }
 
-  const data = await sendMessage(Object.assign({ userId: props.userId }, { ...objData }))
+  const data = await sendMessage({ userId: props.userId, ...reply.value })
   sendLoading.value = false
 
   list.value = [...list.value, ...[data]]

+ 3 - 0
src/views/mp/components/wx-music/index.ts

@@ -0,0 +1,3 @@
+import WxMusic from './main.vue'
+
+export default WxMusic

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

@@ -56,5 +56,5 @@ defineExpose({
 
 <style lang="scss" scoped>
 /* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scc  */
-@import '../wx-msg/card.scss';
+@import url('../wx-msg/card.scss');
 </style>

+ 3 - 0
src/views/mp/components/wx-news/index.ts

@@ -0,0 +1,3 @@
+import WxNews from './main.vue'
+
+export default WxNews

+ 19 - 17
src/views/mp/components/wx-news/main.vue

@@ -39,12 +39,14 @@
 </template>
 
 <script lang="ts" name="WxNews" setup>
-const props = defineProps({
-  articles: {
-    type: Array,
-    default: () => null
+const props = withDefaults(
+  defineProps<{
+    articles: any[] | null
+  }>(),
+  {
+    articles: null
   }
-})
+)
 
 defineExpose({
   articles: props.articles
@@ -53,9 +55,9 @@ defineExpose({
 
 <style lang="scss" scoped>
 .news-home {
-  background-color: #ffffff;
   width: 100%;
   margin: auto;
+  background-color: #fff;
 }
 
 .news-main {
@@ -64,29 +66,29 @@ defineExpose({
 }
 
 .news-content {
-  background-color: #acadae;
-  width: 100%;
   position: relative;
+  width: 100%;
+  background-color: #acadae;
 }
 
 .news-content-title {
-  display: inline-block;
-  font-size: 12px;
-  color: #ffffff;
   position: absolute;
-  left: 0;
   bottom: 0;
-  background-color: black;
+  left: 0;
+  display: inline-block;
   width: 98%;
   padding: 1%;
-  opacity: 0.65;
+  font-size: 12px;
+  color: #fff;
   white-space: normal;
+  background-color: black;
+  opacity: 0.65;
   box-sizing: unset !important;
 }
 
 .news-main-item {
-  background-color: #ffffff;
   padding: 5px 0;
+  background-color: #fff;
   border-top: 1px solid #eaeaea;
 }
 
@@ -96,17 +98,17 @@ defineExpose({
 
 .news-content-item-title {
   display: inline-block;
-  font-size: 10px;
   width: 70%;
   margin-left: 1%;
+  font-size: 10px;
   white-space: normal;
 }
 
 .news-content-item-img {
   display: inline-block;
   width: 25%;
-  background-color: #acadae;
   margin-right: 1%;
+  background-color: #acadae;
 }
 
 .material-img {

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

@@ -1,12 +1,9 @@
 <template>
-  <el-tab-pane name="image">
-    <template #label>
-      <el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
-    </template>
+  <div>
     <!-- 情况一:已经选择好素材、或者上传好图片 -->
-    <div class="select-item" v-if="objData.url">
-      <img class="material-img" :src="objData.url" />
-      <p class="item-name" v-if="objData.name">{{ objData.name }}</p>
+    <div class="select-item" v-if="reply.url">
+      <img class="material-img" :src="reply.url" />
+      <p class="item-name" v-if="reply.name">{{ reply.name }}</p>
       <el-row class="ope-row" justify="center">
         <el-button type="danger" circle @click="onDelete">
           <Icon icon="ep:delete" />
@@ -27,7 +24,11 @@
           append-to-body
           destroy-on-close
         >
-          <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
+          <WxMaterialSelect
+            type="image"
+            :account-id="reply.accountId"
+            @select-material="selectMaterial"
+          />
         </el-dialog>
       </el-col>
       <!-- 文件上传 -->
@@ -51,27 +52,27 @@
         </el-upload>
       </el-col>
     </el-row>
-  </el-tab-pane>
+  </div>
 </template>
 
 <script setup lang="ts">
-import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
-import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
+import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
 import type { UploadRawFile } from 'element-plus'
 import { getAccessToken } from '@/utils/auth'
-import { ObjData } from './types'
+import { Reply } from './types'
 const message = useMessage()
 
 const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/upload-temporary'
 const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
 
 const props = defineProps<{
-  modelValue: ObjData
+  modelValue: Reply
 }>()
 const emit = defineEmits<{
-  (e: 'update:modelValue', v: ObjData)
+  (e: 'update:modelValue', v: Reply)
 }>()
-const objData = computed<ObjData>({
+const reply = computed<Reply>({
   get: () => props.modelValue,
   set: (val) => emit('update:modelValue', val)
 })
@@ -79,14 +80,13 @@ const objData = computed<ObjData>({
 const showDialog = ref(false)
 const fileList = ref([])
 const uploadData = reactive({
-  accountId: objData.value.accountId,
+  accountId: reply.value.accountId,
   type: 'image',
   title: '',
   introduction: ''
 })
 
-const beforeImageUpload = (rawFile: UploadRawFile) =>
-  useBeforeUpload(MaterialType.Image, 2)(rawFile)
+const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Image, 2)(rawFile)
 
 const onUploadSuccess = (res: any) => {
   if (res.code !== 0) {
@@ -104,18 +104,18 @@ const onUploadSuccess = (res: any) => {
 }
 
 const onDelete = () => {
-  objData.value.mediaId = null
-  objData.value.url = null
-  objData.value.name = null
+  reply.value.mediaId = null
+  reply.value.url = null
+  reply.value.name = null
 }
 
 const selectMaterial = (item) => {
   showDialog.value = false
 
-  objData.value.type = 'image'
-  objData.value.mediaId = item.mediaId
-  objData.value.url = item.url
-  objData.value.name = item.name
+  // reply.value.type = 'image'
+  reply.value.mediaId = item.mediaId
+  reply.value.url = item.url
+  reply.value.name = item.name
 }
 </script>
 
@@ -123,7 +123,7 @@ const selectMaterial = (item) => {
 .select-item {
   width: 280px;
   padding: 10px;
-  margin: 0 auto 10px auto;
+  margin: 0 auto 10px;
   border: 1px solid #eaeaea;
 
   .material-img {
@@ -131,11 +131,11 @@ const selectMaterial = (item) => {
   }
 
   .item-name {
-    font-size: 12px;
     overflow: hidden;
+    font-size: 12px;
+    text-align: center;
     text-overflow: ellipsis;
     white-space: nowrap;
-    text-align: center;
 
     .item-infos {
       width: 30%;
@@ -149,18 +149,18 @@ const selectMaterial = (item) => {
   }
 
   .col-select {
-    border: 1px solid rgb(234, 234, 234);
-    padding: 50px 0px;
-    height: 160px;
     width: 49.5%;
+    height: 160px;
+    padding: 50px 0;
+    border: 1px solid rgb(234 234 234);
   }
 
   .col-add {
-    border: 1px solid rgb(234, 234, 234);
-    padding: 50px 0px;
-    height: 160px;
-    width: 49.5%;
     float: right;
+    width: 49.5%;
+    height: 160px;
+    padding: 50px 0;
+    border: 1px solid rgb(234 234 234);
 
     .el-upload__tip {
       line-height: 18px;

+ 19 - 22
src/views/mp/components/wx-reply/components/TabMusic.vue

@@ -1,14 +1,11 @@
 <template>
-  <el-tab-pane name="music">
-    <template #label>
-      <el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
-    </template>
+  <div>
     <el-row align="middle" justify="center">
       <el-col :span="6">
         <el-row align="middle" justify="center" class="thumb-div">
           <el-col :span="24">
             <el-row align="middle" justify="center">
-              <img style="width: 100px" v-if="objData.thumbMediaUrl" :src="objData.thumbMediaUrl" />
+              <img style="width: 100px" v-if="reply.thumbMediaUrl" :src="reply.thumbMediaUrl" />
               <icon v-else icon="ep:plus" />
             </el-row>
             <el-row align="middle" justify="center" style="margin-top: 2%">
@@ -42,30 +39,31 @@
           destroy-on-close
         >
           <WxMaterialSelect
-            :objData="{ type: 'image', accountId: objData.accountId }"
+            type="image"
+            :account-id="reply.accountId"
             @select-material="selectMaterial"
           />
         </el-dialog>
       </el-col>
       <el-col :span="18">
-        <el-input v-model="objData.title" placeholder="请输入标题" />
+        <el-input v-model="reply.title" placeholder="请输入标题" />
         <div style="margin: 20px 0"></div>
-        <el-input v-model="objData.description" placeholder="请输入描述" />
+        <el-input v-model="reply.description" placeholder="请输入描述" />
       </el-col>
     </el-row>
     <div style="margin: 20px 0"></div>
-    <el-input v-model="objData.musicUrl" placeholder="请输入音乐链接" />
+    <el-input v-model="reply.musicUrl" placeholder="请输入音乐链接" />
     <div style="margin: 20px 0"></div>
-    <el-input v-model="objData.hqMusicUrl" placeholder="请输入高质量音乐链接" />
-  </el-tab-pane>
+    <el-input v-model="reply.hqMusicUrl" placeholder="请输入高质量音乐链接" />
+  </div>
 </template>
 
 <script setup lang="ts">
-import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
 import type { UploadRawFile } from 'element-plus'
-import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
 import { getAccessToken } from '@/utils/auth'
-import { ObjData } from './types'
+import { Reply } from './types'
 
 const message = useMessage()
 
@@ -73,12 +71,12 @@ const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/u
 const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
 
 const props = defineProps<{
-  modelValue: ObjData
+  modelValue: Reply
 }>()
 const emit = defineEmits<{
-  (e: 'update:modelValue', v: ObjData)
+  (e: 'update:modelValue', v: Reply)
 }>()
-const objData = computed<ObjData>({
+const reply = computed<Reply>({
   get: () => props.modelValue,
   set: (val) => emit('update:modelValue', val)
 })
@@ -86,14 +84,13 @@ const objData = computed<ObjData>({
 const showDialog = ref(false)
 const fileList = ref([])
 const uploadData = reactive({
-  accountId: objData.value.accountId,
+  accountId: reply.value.accountId,
   type: 'thumb', // 音乐类型为thumb
   title: '',
   introduction: ''
 })
 
-const beforeImageUpload = (rawFile: UploadRawFile) =>
-  useBeforeUpload(MaterialType.Image, 2)(rawFile)
+const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Image, 2)(rawFile)
 
 const onUploadSuccess = (res: any) => {
   if (res.code !== 0) {
@@ -113,7 +110,7 @@ const onUploadSuccess = (res: any) => {
 const selectMaterial = (item: any) => {
   showDialog.value = false
 
-  objData.value.thumbMediaId = item.mediaId
-  objData.value.thumbMediaUrl = item.url
+  reply.value.thumbMediaId = item.mediaId
+  reply.value.thumbMediaUrl = item.url
 }
 </script>

+ 17 - 19
src/views/mp/components/wx-reply/components/TabNews.vue

@@ -1,11 +1,8 @@
 <template>
-  <el-tab-pane name="news">
-    <template #label>
-      <el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
-    </template>
+  <div>
     <el-row>
-      <div class="select-item" v-if="objData.articles?.length > 0">
-        <WxNews :articles="objData.articles" />
+      <div class="select-item" v-if="reply.articles && reply.articles.length > 0">
+        <WxNews :articles="reply.articles" />
         <el-col class="ope-row">
           <el-button type="danger" circle @click="onDelete">
             <Icon icon="ep:delete" />
@@ -13,7 +10,7 @@
         </el-col>
       </div>
       <!-- 选择素材 -->
-      <el-col :span="24" v-if="!objData.content">
+      <el-col :span="24" v-if="!reply.content">
         <el-row style="text-align: center" align="middle">
           <el-col :span="24">
             <el-button type="success" @click="showDialog = true">
@@ -25,28 +22,29 @@
       </el-col>
       <el-dialog title="选择图文" v-model="showDialog" width="90%" append-to-body destroy-on-close>
         <WxMaterialSelect
-          :objData="objData"
-          @select-material="selectMaterial"
+          type="news"
+          :account-id="reply.accountId"
           :newsType="newsType"
+          @select-material="selectMaterial"
         />
       </el-dialog>
     </el-row>
-  </el-tab-pane>
+  </div>
 </template>
 
 <script setup lang="ts">
-import WxNews from '@/views/mp/components/wx-news/main.vue'
-import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
-import { ObjData, NewsType } from './types'
+import WxNews from '@/views/mp/components/wx-news'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
+import { Reply, NewsType } from './types'
 
 const props = defineProps<{
-  modelValue: ObjData
+  modelValue: Reply
   newsType: NewsType
 }>()
 const emit = defineEmits<{
-  (e: 'update:modelValue', v: ObjData)
+  (e: 'update:modelValue', v: Reply)
 }>()
-const objData = computed<ObjData>({
+const reply = computed<Reply>({
   get: () => props.modelValue,
   set: (val) => emit('update:modelValue', val)
 })
@@ -55,11 +53,11 @@ const showDialog = ref(false)
 
 const selectMaterial = (item: any) => {
   showDialog.value = false
-  objData.value.articles = item.content.newsItem
+  reply.value.articles = item.content.newsItem
 }
 
 const onDelete = () => {
-  objData.value.articles = []
+  reply.value.articles = []
 }
 </script>
 
@@ -67,7 +65,7 @@ const onDelete = () => {
 .select-item {
   width: 280px;
   padding: 10px;
-  margin: 0 auto 10px auto;
+  margin: 0 auto 10px;
   border: 1px solid #eaeaea;
 
   .ope-row {

+ 3 - 8
src/views/mp/components/wx-reply/components/TabText.vue

@@ -1,15 +1,10 @@
 <template>
-  <el-tab-pane name="text">
-    <template #label>
-      <el-row align="middle"><Icon icon="ep:document" /> 文本</el-row>
-    </template>
-    <el-input type="textarea" :rows="5" placeholder="请输入内容" v-model="content" />
-  </el-tab-pane>
+  <el-input type="textarea" :rows="5" placeholder="请输入内容" v-model="content" />
 </template>
 
 <script setup lang="ts">
 const props = defineProps<{
-  modelValue: string | null
+  modelValue?: string | null
 }>()
 
 const emit = defineEmits<{
@@ -17,7 +12,7 @@ const emit = defineEmits<{
   (e: 'input', v: string | null)
 }>()
 
-const content = computed<string | null>({
+const content = computed<string | null | undefined>({
   get: () => props.modelValue,
   set: (val: string | null) => {
     emit('update:modelValue', val)

+ 24 - 28
src/views/mp/components/wx-reply/components/TabVideo.vue

@@ -1,17 +1,10 @@
 <template>
-  <el-tab-pane name="video">
-    <template #label>
-      <el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
-    </template>
+  <div>
     <el-row>
-      <el-input v-model="objData.title" class="input-margin-bottom" placeholder="请输入标题" />
-      <el-input
-        class="input-margin-bottom"
-        v-model="objData.description"
-        placeholder="请输入描述"
-      />
+      <el-input v-model="reply.title" class="input-margin-bottom" placeholder="请输入标题" />
+      <el-input class="input-margin-bottom" v-model="reply.description" placeholder="请输入描述" />
       <el-row class="ope-row" justify="center">
-        <WxVideoPlayer v-if="objData.url" :url="objData.url" />
+        <WxVideoPlayer v-if="reply.url" :url="reply.url" />
       </el-row>
       <el-col>
         <el-row style="text-align: center" align="middle">
@@ -27,7 +20,11 @@
               append-to-body
               destroy-on-close
             >
-              <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
+              <WxMaterialSelect
+                type="video"
+                :account-id="reply.accountId"
+                @select-material="selectMaterial"
+              />
             </el-dialog>
           </el-col>
           <!-- 文件上传 -->
@@ -48,16 +45,16 @@
         </el-row>
       </el-col>
     </el-row>
-  </el-tab-pane>
+  </div>
 </template>
 
 <script setup lang="ts">
-import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
-import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
 import type { UploadRawFile } from 'element-plus'
-import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
 import { getAccessToken } from '@/utils/auth'
-import { ObjData } from './types'
+import { Reply } from './types'
 
 const message = useMessage()
 
@@ -65,12 +62,12 @@ const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/u
 const HEADERS = { Authorization: 'Bearer ' + getAccessToken() }
 
 const props = defineProps<{
-  modelValue: ObjData
+  modelValue: Reply
 }>()
 const emit = defineEmits<{
-  (e: 'update:modelValue', v: ObjData)
+  (e: 'update:modelValue', v: Reply)
 }>()
-const objData = computed<ObjData>({
+const reply = computed<Reply>({
   get: () => props.modelValue,
   set: (val) => emit('update:modelValue', val)
 })
@@ -78,14 +75,13 @@ const objData = computed<ObjData>({
 const showDialog = ref(false)
 const fileList = ref([])
 const uploadData = reactive({
-  accountId: objData.value.accountId,
+  accountId: reply.value.accountId,
   type: 'video',
   title: '',
   introduction: ''
 })
 
-const beforeVideoUpload = (rawFile: UploadRawFile) =>
-  useBeforeUpload(MaterialType.Video, 10)(rawFile)
+const beforeVideoUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Video, 10)(rawFile)
 
 const onUploadSuccess = (res: any) => {
   if (res.code !== 0) {
@@ -105,16 +101,16 @@ const onUploadSuccess = (res: any) => {
 const selectMaterial = (item: any) => {
   showDialog.value = false
 
-  objData.value.mediaId = item.mediaId
-  objData.value.url = item.url
-  objData.value.name = item.name
+  reply.value.mediaId = item.mediaId
+  reply.value.url = item.url
+  reply.value.name = item.name
 
   // title、introduction:从 item 到 tempObjItem,因为素材里有 title、introduction
   if (item.title) {
-    objData.value.title = item.title || ''
+    reply.value.title = item.title || ''
   }
   if (item.introduction) {
-    objData.value.description = item.introduction || ''
+    reply.value.description = item.introduction || ''
   }
 }
 </script>

+ 37 - 37
src/views/mp/components/wx-reply/components/TabVoice.vue

@@ -1,12 +1,9 @@
 <template>
-  <el-tab-pane name="voice">
-    <template #label>
-      <el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row>
-    </template>
-    <div class="select-item2" v-if="objData.url">
-      <p class="item-name">{{ objData.name }}</p>
+  <div>
+    <div class="select-item2" v-if="reply.url">
+      <p class="item-name">{{ reply.name }}</p>
       <el-row class="ope-row" justify="center">
-        <WxVoicePlayer :url="objData.url" />
+        <WxVoicePlayer :url="reply.url" />
       </el-row>
       <el-row class="ope-row" justify="center">
         <el-button type="danger" circle @click="onDelete"><Icon icon="ep:delete" /></el-button>
@@ -25,7 +22,11 @@
           append-to-body
           destroy-on-close
         >
-          <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
+          <WxMaterialSelect
+            type="voice"
+            :account-id="reply.accountId"
+            @select-material="selectMaterial"
+          />
         </el-dialog>
       </el-col>
       <!-- 文件上传 -->
@@ -49,27 +50,27 @@
         </el-upload>
       </el-col>
     </el-row>
-  </el-tab-pane>
+  </div>
 </template>
 <script setup lang="ts">
-import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
-import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
-import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
 import type { UploadRawFile } from 'element-plus'
 import { getAccessToken } from '@/utils/auth'
-import { ObjData } from './types'
+import { Reply } from './types'
 const message = useMessage()
 
 const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/upload-temporary'
 const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
 
 const props = defineProps<{
-  modelValue: ObjData
+  modelValue: Reply
 }>()
 const emit = defineEmits<{
-  (e: 'update:modelValue', v: ObjData)
+  (e: 'update:modelValue', v: Reply)
 }>()
-const objData = computed<ObjData>({
+const reply = computed<Reply>({
   get: () => props.modelValue,
   set: (val) => emit('update:modelValue', val)
 })
@@ -77,14 +78,13 @@ const objData = computed<ObjData>({
 const showDialog = ref(false)
 const fileList = ref([])
 const uploadData = reactive({
-  accountId: objData.value.accountId,
+  accountId: reply.value.accountId,
   type: 'voice',
   title: '',
   introduction: ''
 })
 
-const beforeVoiceUpload = (rawFile: UploadRawFile) =>
-  useBeforeUpload(MaterialType.Voice, 10)(rawFile)
+const beforeVoiceUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Voice, 10)(rawFile)
 
 const onUploadSuccess = (res: any) => {
   if (res.code !== 0) {
@@ -102,33 +102,33 @@ const onUploadSuccess = (res: any) => {
 }
 
 const onDelete = () => {
-  objData.value.mediaId = null
-  objData.value.url = null
-  objData.value.name = null
+  reply.value.mediaId = null
+  reply.value.url = null
+  reply.value.name = null
 }
 
-const selectMaterial = (item: ObjData) => {
+const selectMaterial = (item: Reply) => {
   showDialog.value = false
 
-  objData.value.type = 'voice'
-  objData.value.mediaId = item.mediaId
-  objData.value.url = item.url
-  objData.value.name = item.name
+  // reply.value.type = ReplyType.Voice
+  reply.value.mediaId = item.mediaId
+  reply.value.url = item.url
+  reply.value.name = item.name
 }
 </script>
 
 <style lang="scss" scoped>
 .select-item2 {
   padding: 10px;
-  margin: 0 auto 10px auto;
+  margin: 0 auto 10px;
   border: 1px solid #eaeaea;
 
   .item-name {
-    font-size: 12px;
     overflow: hidden;
+    font-size: 12px;
+    text-align: center;
     text-overflow: ellipsis;
     white-space: nowrap;
-    text-align: center;
 
     .ope-row {
       width: 100%;
@@ -138,18 +138,18 @@ const selectMaterial = (item: ObjData) => {
   }
 
   .col-select {
-    border: 1px solid rgb(234, 234, 234);
-    padding: 50px 0px;
-    height: 160px;
     width: 49.5%;
+    height: 160px;
+    padding: 50px 0;
+    border: 1px solid rgb(234 234 234);
   }
 
   .col-add {
-    border: 1px solid rgb(234, 234, 234);
-    padding: 50px 0px;
-    height: 160px;
-    width: 49.5%;
     float: right;
+    width: 49.5%;
+    height: 160px;
+    padding: 50px 0;
+    border: 1px solid rgb(234 234 234);
 
     .el-upload__tip {
       line-height: 18px;

+ 44 - 15
src/views/mp/components/wx-reply/components/types.ts

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

+ 7 - 0
src/views/mp/components/wx-reply/index.ts

@@ -0,0 +1,7 @@
+import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types'
+
+import WxReplySelect from './main.vue'
+
+export type { Reply }
+export { createEmptyReply, NewsType, ReplyType }
+export default WxReplySelect

+ 100 - 41
src/views/mp/components/wx-reply/main.vue

@@ -8,24 +8,59 @@
   ④ 支持发送【视频】消息时,支持新建视频
 -->
 <template>
-  <el-tabs type="border-card" v-model="objData.type" @tab-click="onTabClick">
+  <el-tabs type="border-card" v-model="currentTab">
     <!-- 类型 1:文本 -->
-    <TabText v-model="objData.content" />
+    <el-tab-pane :name="ReplyType.Text">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:document" /> 文本</el-row>
+      </template>
+      <TabText v-model="reply.content" />
+    </el-tab-pane>
+
     <!-- 类型 2:图片 -->
-    <TabImage v-model="objData" />
+    <el-tab-pane :name="ReplyType.Image">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
+      </template>
+      <TabImage v-model="reply" />
+    </el-tab-pane>
+
     <!-- 类型 3:语音 -->
-    <TabVoice v-model="objData" />
+    <el-tab-pane :name="ReplyType.Voice">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row>
+      </template>
+      <TabVoice v-model="reply" />
+    </el-tab-pane>
+
     <!-- 类型 4:视频 -->
-    <TabVideo v-model="objData" />
+    <el-tab-pane :name="ReplyType.Video">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
+      </template>
+      <TabVideo v-model="reply" />
+    </el-tab-pane>
+
     <!-- 类型 5:图文 -->
-    <TabNews v-model="objData" :news-type="newsType" />
+    <el-tab-pane :name="ReplyType.News">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
+      </template>
+      <TabNews v-model="reply" :news-type="newsType" />
+    </el-tab-pane>
+
     <!-- 类型 6:音乐 -->
-    <TabMusic v-model="objData" />
+    <el-tab-pane :name="ReplyType.Music">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
+      </template>
+      <TabMusic v-model="reply" />
+    </el-tab-pane>
   </el-tabs>
 </template>
 
 <script setup lang="ts" name="WxReplySelect">
-import { ObjData, NewsType } from './components/types'
+import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types'
 import TabText from './components/TabText.vue'
 import TabImage from './components/TabImage.vue'
 import TabVoice from './components/TabVoice.vue'
@@ -34,30 +69,54 @@ import TabNews from './components/TabNews.vue'
 import TabMusic from './components/TabMusic.vue'
 
 interface Props {
-  objData: ObjData
+  modelValue: Reply
   newsType?: NewsType
 }
 const props = withDefaults(defineProps<Props>(), {
   newsType: () => NewsType.Published
 })
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: Reply)
+}>()
 
-const objData = reactive(props.objData)
-// TODO @Dhb52:Tab 切换的时候,应该表单还保留着;清除只有两个时机:1)发送成功后;2)关闭窗口后;我捉摸,是不是每个 TabXXX 组件,是个独立的 Form,然后有自己的对象,不粘在 objData 一起。这样最终就是 MusicMessageForm、ImageMessageForm
-// const tempObj = new Map().set(objData.type, Object.assign({}, objData))
-
-/** 切换消息类型的 tab */
-const onTabClick = () => {
-  clear()
-}
-
-/** 清除除了`type`的字段 */
+const reply = computed<Reply>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+// 作为多个标签保存各自Reply的缓存
+const tabCache = new Map<ReplyType, Reply>()
+// 采用独立的ref来保存当前tab,避免在watch标签变化,对reply进行赋值会产生了循环调用
+const currentTab = ref<ReplyType>(props.modelValue.type || ReplyType.Text)
+
+watch(
+  currentTab,
+  (newTab, oldTab) => {
+    // 第一次进入:oldTab 为 undefined
+    // 判断 newTab 是因为 Reply 为 Partial
+    if (oldTab === undefined || newTab === undefined) {
+      return
+    }
+
+    tabCache.set(oldTab, unref(reply))
+
+    // 从缓存里面取出新tab内容,有则覆盖Reply,没有则创建空Reply
+    const temp = tabCache.get(newTab)
+    if (temp) {
+      reply.value = temp
+    } else {
+      let newData = createEmptyReply(reply)
+      newData.type = newTab
+      reply.value = newData
+    }
+  },
+  {
+    immediate: true
+  }
+)
+
+/** 清除除了`type`, `accountId`的字段 */
 const clear = () => {
-  objData.content = ''
-  objData.mediaId = ''
-  objData.url = ''
-  objData.title = ''
-  objData.description = ''
-  objData.articles = []
+  reply.value = createEmptyReply(reply)
 }
 
 defineExpose({
@@ -69,13 +128,13 @@ defineExpose({
 .select-item {
   width: 280px;
   padding: 10px;
-  margin: 0 auto 10px auto;
+  margin: 0 auto 10px;
   border: 1px solid #eaeaea;
 }
 
 .select-item2 {
   padding: 10px;
-  margin: 0 auto 10px auto;
+  margin: 0 auto 10px;
   border: 1px solid #eaeaea;
 }
 
@@ -89,11 +148,11 @@ defineExpose({
 }
 
 .item-name {
-  font-size: 12px;
   overflow: hidden;
+  font-size: 12px;
+  text-align: center;
   text-overflow: ellipsis;
   white-space: nowrap;
-  text-align: center;
 }
 
 .el-form-item__content {
@@ -101,34 +160,34 @@ defineExpose({
 }
 
 .col-select {
-  border: 1px solid rgb(234, 234, 234);
-  padding: 50px 0px;
-  height: 160px;
   width: 49.5%;
+  height: 160px;
+  padding: 50px 0;
+  border: 1px solid rgb(234 234 234);
 }
 
 .col-select2 {
-  border: 1px solid rgb(234, 234, 234);
-  padding: 50px 0px;
   height: 160px;
+  padding: 50px 0;
+  border: 1px solid rgb(234 234 234);
 }
 
 .col-add {
-  border: 1px solid rgb(234, 234, 234);
-  padding: 50px 0px;
-  height: 160px;
-  width: 49.5%;
   float: right;
+  width: 49.5%;
+  height: 160px;
+  padding: 50px 0;
+  border: 1px solid rgb(234 234 234);
 }
 
 .avatar-uploader-icon {
-  border: 1px solid #d9d9d9;
-  font-size: 28px;
-  color: #8c939d;
   width: 100px !important;
   height: 100px !important;
+  font-size: 28px;
   line-height: 100px !important;
+  color: #8c939d;
   text-align: center;
+  border: 1px solid #d9d9d9;
 }
 
 .material-img {

+ 3 - 0
src/views/mp/components/wx-video-play/index.ts

@@ -0,0 +1,3 @@
+import WxVideoPlayer from './main.vue'
+
+export default WxVideoPlayer

+ 3 - 0
src/views/mp/components/wx-voice-play/index.ts

@@ -0,0 +1,3 @@
+import WxVoicePlayer from './main.vue'
+
+export default WxVoicePlayer

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

@@ -7,7 +7,7 @@
     1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容;
       存在的问题:mediaId 有效期是 3 天,超过时间后无法播放
     2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。
-  ② 代码优化:将 props 中的 objData 调成为 data 中对应的属性,并补充相关注释
+  ② 代码优化:将 props 中的 reply 调成为 data 中对应的属性,并补充相关注释
 -->
 <template>
   <div class="wx-voice-div" @click="playVoice">
@@ -86,18 +86,18 @@ const amrStop = () => {
 </script>
 <style lang="scss" scoped>
 .wx-voice-div {
+  display: flex;
+  width: 120px;
+  height: 50px;
   padding: 5px;
   background-color: #eaeaea;
   border-radius: 10px;
-  width: 40px;
-  height: 40px;
-
-  display: flex;
   justify-content: center;
   align-items: center;
 }
+
 .amr-duration {
-  font-size: 11px;
   margin-left: 5px;
+  font-size: 11px;
 }
 </style>

+ 10 - 26
src/views/mp/draft/components/CoverSelect.vue

@@ -27,9 +27,7 @@
           :on-success="onUploadSuccess"
         >
           <template #trigger>
-            <el-button size="small" type="primary" :loading="isUploading" disabled="isUploading">
-              {{ isUploading ? '正在上传' : '本地上传' }}
-            </el-button>
+            <el-button size="small" type="primary">本地上传</el-button>
           </template>
           <el-button
             size="small"
@@ -52,7 +50,8 @@
         destroy-on-close
       >
         <WxMaterialSelect
-          :objData="{ type: 'image', accountId: accountId }"
+          type="image"
+          :account-id="accountId"
           @select-material="onMaterialSelected"
         />
       </el-dialog>
@@ -61,13 +60,13 @@
 </template>
 
 <script setup lang="ts">
-import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
 import { getAccessToken } from '@/utils/auth'
 import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus'
+import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
 import { NewsItem } from './types'
 const message = useMessage()
 
-// const UPLOAD_URL = 'http://localhost:8000/upload/' // 上传永久素材的地址
 const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址
 const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
 
@@ -93,14 +92,13 @@ const showImageDialog = ref(false)
 
 const fileList = ref<UploadFiles>([])
 interface UploadData {
-  type: 'image' | 'video' | 'audio'
-  accountId?: number
+  type: UploadType
+  accountId: number | undefined
 }
 const uploadData: UploadData = reactive({
-  type: 'image',
+  type: UploadType.Image,
   accountId: accountId
 })
-const isUploading = ref(false)
 
 /** 素材选择完成事件*/
 const onMaterialSelected = (item: any) => {
@@ -109,22 +107,8 @@ const onMaterialSelected = (item: any) => {
   newsItem.value.thumbUrl = item.url
 }
 
-const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => {
-  const isType = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg'].includes(
-    rawFile.type
-  )
-  if (!isType) {
-    message.error('上传图片格式不对!')
-    return false
-  }
-
-  if (rawFile.size / 1024 / 1024 > 2) {
-    message.error('上传图片大小不能超过 2M!')
-    return false
-  }
-  // 校验通过
-  return true
-}
+const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
+  useBeforeUpload(UploadType.Image, 2)(rawFile)
 
 const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
   if (res.code !== 0) {

+ 1 - 1
src/views/mp/draft/components/DraftTable.vue

@@ -36,7 +36,7 @@
 </template>
 
 <script setup lang="ts">
-import WxNews from '@/views/mp/components/wx-news/main.vue'
+import WxNews from '@/views/mp/components/wx-news'
 
 import { Article } from './types'
 

+ 4 - 5
src/views/mp/draft/index.vue

@@ -46,7 +46,6 @@
   </ContentWrap>
 
   <!-- 添加或修改草稿对话框 -->
-  <!-- TODO @Dhb52:是不是整个做成一个组件 -->
   <el-dialog
     :title="isCreating ? '新建图文' : '修改图文'"
     width="80%"
@@ -63,7 +62,7 @@
 </template>
 
 <script setup lang="ts" name="MpDraft">
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 import * as MpDraftApi from '@/api/mp/draft'
 import * as MpFreePublishApi from '@/api/mp/freePublish'
 import {
@@ -77,7 +76,7 @@ import {
 
 const message = useMessage() // 消息
 
-const accountId = ref(0)
+const accountId = ref<number>(0)
 provide('accountId', accountId)
 
 const loading = ref(true) // 列表的加载中
@@ -91,7 +90,7 @@ interface QueryParams {
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: accountId.value
+  accountId: 0
 })
 
 interface UploadData {
@@ -100,7 +99,7 @@ interface UploadData {
 }
 const uploadData: UploadData = reactive({
   type: 'image',
-  accountId: accountId.value
+  accountId: 0
 })
 
 // ========== 草稿新建 or 修改 ==========

+ 67 - 64
src/views/mp/freePublish/index.vue

@@ -50,8 +50,8 @@
 
 <script lang="ts" setup name="MpFreePublish">
 import * as FreePublishApi from '@/api/mp/freePublish'
-import WxNews from '@/views/mp/components/wx-news/main.vue'
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxNews from '@/views/mp/components/wx-news'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
@@ -62,16 +62,16 @@ const list = ref<any[]>([]) // 列表的数据
 interface QueryParams {
   pageNo: number
   pageSize: number
-  accountId?: number
+  accountId: number
 }
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: undefined
+  accountId: 0
 })
 
 /** 侦听公众号变化 **/
-const onAccountChanged = (id: number | undefined) => {
+const onAccountChanged = (id: number) => {
   queryParams.accountId = id
   getList()
 }
@@ -102,19 +102,45 @@ const handleDelete = async (item: any) => {
 }
 </script>
 <style lang="scss" scoped>
+@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;
+  }
+}
+
 .ope-row {
+  padding-top: 5px;
   margin-top: 5px;
   text-align: center;
   border-top: 1px solid #eaeaea;
-  padding-top: 5px;
 }
 
 .item-name {
-  font-size: 12px;
   overflow: hidden;
+  font-size: 12px;
+  text-align: center;
   text-overflow: ellipsis;
   white-space: nowrap;
-  text-align: center;
 }
 
 .el-upload__tip {
@@ -125,8 +151,8 @@ const handleDelete = async (item: any) => {
 .left {
   display: inline-block;
   width: 35%;
-  vertical-align: top;
   margin-top: 200px;
+  vertical-align: top;
 }
 
 .right {
@@ -136,16 +162,16 @@ const handleDelete = async (item: any) => {
 }
 
 .avatar-uploader {
-  width: 20%;
   display: inline-block;
+  width: 20%;
 }
 
 .avatar-uploader .el-upload {
-  border-radius: 6px;
-  cursor: pointer;
   position: relative;
   overflow: hidden;
   text-align: unset !important;
+  cursor: pointer;
+  border-radius: 6px;
 }
 
 .avatar-uploader .el-upload:hover {
@@ -153,13 +179,13 @@ const handleDelete = async (item: any) => {
 }
 
 .avatar-uploader-icon {
-  border: 1px solid #d9d9d9;
-  font-size: 28px;
-  color: #8c939d;
   width: 120px;
   height: 120px;
+  font-size: 28px;
   line-height: 120px;
+  color: #8c939d;
   text-align: center;
+  border: 1px solid #d9d9d9;
 }
 
 .avatar {
@@ -173,13 +199,14 @@ const handleDelete = async (item: any) => {
 }
 
 .digest {
-  width: 60%;
   display: inline-block;
+  width: 60%;
   vertical-align: top;
 }
 
-/*新增图文*/
-/*瀑布流样式*/
+/* 新增图文 */
+
+/* 瀑布流样式 */
 .waterfall {
   width: 100%;
   column-gap: 10px;
@@ -198,68 +225,44 @@ 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;
+  margin: auto;
+  background-color: #fff;
 }
 
 .news-content {
-  background-color: #acadae;
+  position: relative;
   width: 100%;
   height: 120px;
-  position: relative;
+  background-color: #acadae;
 }
 
 .news-content-title {
-  display: inline-block;
-  font-size: 15px;
-  color: #ffffff;
   position: absolute;
-  left: 0px;
-  bottom: 0px;
-  background-color: black;
+  bottom: 0;
+  left: 0;
+  display: inline-block;
   width: 98%;
+  height: 25px;
   padding: 1%;
-  opacity: 0.65;
   overflow: hidden;
+  font-size: 15px;
+  color: #fff;
   text-overflow: ellipsis;
   white-space: nowrap;
-  height: 25px;
+  background-color: black;
+  opacity: 0.65;
 }
 
 .news-main-item {
-  background-color: #ffffff;
-  padding: 5px 0px;
-  border-top: 1px solid #eaeaea;
   width: 100%;
+  padding: 5px 0;
   margin: auto;
+  background-color: #fff;
+  border-top: 1px solid #eaeaea;
 }
 
 .news-content-item {
@@ -269,8 +272,8 @@ p {
 
 .news-content-item-title {
   display: inline-block;
-  font-size: 12px;
   width: 70%;
+  font-size: 12px;
 }
 
 .news-content-item-img {
@@ -289,9 +292,9 @@ p {
 
 .news-main-plus {
   width: 280px;
-  text-align: center;
-  margin: auto;
   height: 50px;
+  margin: auto;
+  text-align: center;
 }
 
 .icon-plus {
@@ -302,15 +305,15 @@ p {
 .select-item {
   width: 60%;
   padding: 10px;
-  margin: 0 auto 10px auto;
+  margin: 0 auto 10px;
   border: 1px solid #eaeaea;
 }
 
 .father .child {
-  display: none;
-  text-align: center;
   position: relative;
   bottom: 25px;
+  display: none;
+  text-align: center;
 }
 
 .father:hover .child {

+ 6 - 6
src/views/mp/hooks/useUpload.ts

@@ -2,29 +2,29 @@ import type { UploadRawFile } from 'element-plus'
 
 const message = useMessage() // 消息
 
-enum MaterialType {
+enum UploadType {
   Image = 'image',
   Voice = 'voice',
   Video = 'video'
 }
 
-const useBeforeUpload = (type: MaterialType, maxSizeMB: number) => {
+const useBeforeUpload = (type: UploadType, maxSizeMB: number) => {
   const fn = (rawFile: UploadRawFile): boolean => {
     let allowTypes: string[] = []
     let name = ''
 
     switch (type) {
-      case MaterialType.Image:
+      case UploadType.Image:
         allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg']
         maxSizeMB = 2
         name = '图片'
         break
-      case MaterialType.Voice:
+      case UploadType.Voice:
         allowTypes = ['audio/mp3', 'audio/mpeg', 'audio/wma', 'audio/wav', 'audio/amr']
         maxSizeMB = 2
         name = '语音'
         break
-      case MaterialType.Video:
+      case UploadType.Video:
         allowTypes = ['video/mp4']
         maxSizeMB = 10
         name = '视频'
@@ -47,4 +47,4 @@ const useBeforeUpload = (type: MaterialType, maxSizeMB: number) => {
   return fn
 }
 
-export { MaterialType, useBeforeUpload }
+export { UploadType, useBeforeUpload }

+ 24 - 24
src/views/mp/material/components/ImageTable.vue

@@ -31,30 +31,6 @@ const emit = defineEmits<{
 </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;
@@ -80,4 +56,28 @@ p {
     column-count: 1;
   }
 }
+
+.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;
+}
 </style>

+ 5 - 10
src/views/mp/material/components/UploadFile.vue

@@ -6,14 +6,11 @@
     :limit="1"
     :file-list="fileList"
     :data="uploadData"
-    :on-progress="(isUploading = true)"
     :on-error="onUploadError"
     :before-upload="onBeforeUpload"
     :on-success="onUploadSuccess"
   >
-    <el-button type="primary" plain :loading="isUploading" :disabled="isUploading">
-      {{ isUploading ? '正在上传' : '点击上传' }}
-    </el-button>
+    <el-button type="primary" plain> 点击上传 </el-button>
     <template #tip>
       <span class="el-upload__tip" style="margin-left: 5px">
         <slot></slot>
@@ -27,14 +24,14 @@ import {
   HEADERS,
   UPLOAD_URL,
   UploadData,
-  MaterialType,
+  UploadType,
   beforeImageUpload,
   beforeVoiceUpload
 } from './upload'
 
 const message = useMessage()
 
-const props = defineProps<{ type: MaterialType }>()
+const props = defineProps<{ type: UploadType }>()
 
 const fileList = ref<UploadUserFile[]>([])
 const emit = defineEmits<{
@@ -42,14 +39,13 @@ const emit = defineEmits<{
 }>()
 
 const uploadData: UploadData = reactive({
-  type: MaterialType.Image,
+  type: UploadType.Image,
   title: '',
   introduction: ''
 })
-const isUploading = ref(false)
 
 /** 上传前检查 */
-const onBeforeUpload = props.type === MaterialType.Image ? beforeImageUpload : beforeVoiceUpload
+const onBeforeUpload = props.type === UploadType.Image ? beforeImageUpload : beforeVoiceUpload
 
 /** 上传成功处理 */
 const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
@@ -64,7 +60,6 @@ const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
   uploadData.introduction = ''
 
   message.notifySuccess('上传成功')
-  isUploading.value = false
   emit('uploaded')
 }
 

+ 12 - 16
src/views/mp/material/components/UploadVideo.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-dialog title="新建视频" v-model="showDialog" width="600px" destroy-on-close>
+  <el-dialog title="新建视频" v-model="showDialog" width="600px">
     <el-upload
       :action="UPLOAD_URL"
       :headers="HEADERS"
@@ -8,7 +8,6 @@
       :file-list="fileList"
       :data="uploadData"
       :before-upload="beforeVideoUpload"
-      :on-progress="(isUploading = true)"
       :on-error="onUploadError"
       :on-success="onUploadSuccess"
       ref="uploadVideoRef"
@@ -18,12 +17,14 @@
       <template #trigger>
         <el-button type="primary" plain>选择视频</el-button>
       </template>
-      <span class="el-upload__tip" style="margin-left: 10px"
-        >格式支持 MP4,文件大小不超过 10MB</span
-      >
+      <template #tip>
+        <span class="el-upload__tip" style="margin-left: 10px"
+          >格式支持 MP4,文件大小不超过 10MB</span
+        >
+      </template>
     </el-upload>
     <el-divider />
-    <el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef" v-loading="isUploading">
+    <el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef">
       <el-form-item label="标题" prop="title">
         <el-input
           v-model="uploadData.title"
@@ -41,9 +42,7 @@
     </el-form>
     <template #footer>
       <el-button @click="showDialog = false">取 消</el-button>
-      <el-button type="primary" @click="submitVideo" :loading="isUploading" :disabled="isUploading"
-        >提 交</el-button
-      >
+      <el-button type="primary" @click="submitVideo">提 交</el-button>
     </template>
   </el-dialog>
 </template>
@@ -56,7 +55,7 @@ import type {
   UploadProps,
   UploadUserFile
 } from 'element-plus'
-import { HEADERS, UploadData, UPLOAD_URL, beforeVideoUpload, MaterialType } from './upload'
+import { HEADERS, UploadData, UPLOAD_URL, UploadType, beforeVideoUpload } from './upload'
 
 const message = useMessage()
 
@@ -85,18 +84,16 @@ const showDialog = computed<boolean>({
   }
 })
 
-const isUploading = ref(false)
-
 const fileList = ref<UploadUserFile[]>([])
 
 const uploadData: UploadData = reactive({
-  type: MaterialType.Video,
+  type: UploadType.Video,
   title: '',
   introduction: ''
 })
 
-const uploadFormRef = ref<FormInstance>()
-const uploadVideoRef = ref<UploadInstance>()
+const uploadFormRef = ref<FormInstance | null>(null)
+const uploadVideoRef = ref<UploadInstance | null>(null)
 
 const submitVideo = () => {
   uploadFormRef.value?.validate((valid) => {
@@ -109,7 +106,6 @@ const submitVideo = () => {
 
 /** 上传成功处理 */
 const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
-  isUploading.value = false
   if (res.code !== 0) {
     message.error('上传出错:' + res.msg)
     return false

+ 1 - 1
src/views/mp/material/components/VideoTable.vue

@@ -39,7 +39,7 @@
 </template>
 
 <script setup lang="ts">
-import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
 import { dateFormatter } from '@/utils/formatTime'
 
 const props = defineProps<{

+ 1 - 1
src/views/mp/material/components/VoiceTable.vue

@@ -37,7 +37,7 @@
 </template>
 
 <script setup lang="ts">
-import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
 import { dateFormatter } from '@/utils/formatTime'
 
 const props = defineProps<{

+ 6 - 6
src/views/mp/material/components/upload.ts

@@ -1,29 +1,29 @@
 import type { UploadProps, UploadRawFile } from 'element-plus'
 import { getAccessToken } from '@/utils/auth'
-import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
 
 const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 请求头
 const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传地址
 
 interface UploadData {
-  type: MaterialType
+  type: UploadType
   title: string
   introduction: string
 }
 
 const beforeImageUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
-  useBeforeUpload(MaterialType.Image, 2)(rawFile)
+  useBeforeUpload(UploadType.Image, 2)(rawFile)
 
 const beforeVoiceUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
-  useBeforeUpload(MaterialType.Voice, 2)(rawFile)
+  useBeforeUpload(UploadType.Voice, 2)(rawFile)
 
 const beforeVideoUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
-  useBeforeUpload(MaterialType.Video, 10)(rawFile)
+  useBeforeUpload(UploadType.Video, 10)(rawFile)
 
 export {
   HEADERS,
   UPLOAD_URL,
-  MaterialType,
+  UploadType,
   UploadData,
   beforeImageUpload,
   beforeVoiceUpload,

+ 14 - 14
src/views/mp/material/index.vue

@@ -12,13 +12,13 @@
   <ContentWrap>
     <el-tabs v-model="type" @tab-change="onTabChange">
       <!-- tab 1:图片  -->
-      <el-tab-pane :name="MaterialType.Image">
+      <el-tab-pane :name="UploadType.Image">
         <template #label>
-          <span> <Icon icon="ep:picture" />图片 </span>
+          <el-row align="middle"> <Icon icon="ep:picture" />图片 </el-row>
         </template>
         <UploadFile
           v-hasPermi="['mp:material:upload-permanent']"
-          :type="MaterialType.Image"
+          :type="UploadType.Image"
           @uploaded="getList"
         >
           支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M
@@ -35,13 +35,13 @@
       </el-tab-pane>
 
       <!-- tab 2:语音  -->
-      <el-tab-pane :name="MaterialType.Voice">
+      <el-tab-pane :name="UploadType.Voice">
         <template #label>
-          <span> <Icon icon="ep:microphone" />语音 </span>
+          <el-row align="middle"> <Icon icon="ep:microphone" />语音 </el-row>
         </template>
         <UploadFile
           v-hasPermi="['mp:material:upload-permanent']"
-          :type="MaterialType.Voice"
+          :type="UploadType.Voice"
           @uploaded="getList"
         >
           格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
@@ -58,9 +58,9 @@
       </el-tab-pane>
 
       <!-- tab 3:视频 -->
-      <el-tab-pane :name="MaterialType.Video">
+      <el-tab-pane :name="UploadType.Video">
         <template #label>
-          <span> <Icon icon="ep:video-play" /> 视频 </span>
+          <el-row align="middle"> <Icon icon="ep:video-play" /> 视频 </el-row>
         </template>
         <el-button
           v-hasPermi="['mp:material:upload-permanent']"
@@ -85,17 +85,17 @@
   </ContentWrap>
 </template>
 <script lang="ts" setup name="MpMaterial">
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 import ImageTable from './components/ImageTable.vue'
 import VoiceTable from './components/VoiceTable.vue'
 import VideoTable from './components/VideoTable.vue'
 import UploadFile from './components/UploadFile.vue'
 import UploadVideo from './components/UploadVideo.vue'
-import { MaterialType } from './components/upload'
+import { UploadType } from './components/upload'
 import * as MpMaterialApi from '@/api/mp/material'
 const message = useMessage() // 消息
 
-const type = ref<MaterialType>(MaterialType.Image) // 素材类型
+const type = ref<UploadType>(UploadType.Image) // 素材类型
 const loading = ref(false) // 遮罩层
 const list = ref<any[]>([]) // 总条数
 const total = ref(0) // 数据列表
@@ -103,19 +103,19 @@ const total = ref(0) // 数据列表
 interface QueryParams {
   pageNo: number
   pageSize: number
-  accountId?: number
+  accountId: number
   permanent: boolean
 }
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: undefined,
+  accountId: 0,
   permanent: true
 })
 const showCreateVideo = ref(false) // 是否新建视频的弹窗
 
 /** 侦听公众号变化 **/
-const onAccountChanged = (id?: number) => {
+const onAccountChanged = (id: number) => {
   queryParams.accountId = id
   getList()
 }

+ 7 - 7
src/views/mp/menu/components/MenuEditor.vue

@@ -94,7 +94,8 @@
             </div>
             <el-dialog title="选择图文" v-model="showNewsDialog" width="80%" destroy-on-close>
               <WxMaterialSelect
-                :objData="{ type: 'news', accountId: props.accountId }"
+                type="news"
+                :account-id="props.accountId"
                 @select-material="selectMaterial"
               />
             </el-dialog>
@@ -104,7 +105,7 @@
           class="configur_content"
           v-if="menu.type === 'click' || menu.type === 'scancode_waitmsg'"
         >
-          <WxReplySelect v-if="hackResetWxReplySelect" :objData="menu.reply" />
+          <WxReplySelect v-if="hackResetWxReplySelect" v-model="menu.reply" />
         </div>
       </div>
     </div>
@@ -112,15 +113,15 @@
 </template>
 
 <script setup lang="ts">
-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 WxReplySelect from '@/views/mp/components/wx-reply'
+import WxNews from '@/views/mp/components/wx-news'
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
 import menuOptions from './menuOptions'
 
 const message = useMessage()
 
 const props = defineProps<{
-  accountId?: number
+  accountId: number
   modelValue: any
   isParent: boolean
 }>()
@@ -130,7 +131,6 @@ const emit = defineEmits<{
   (e: 'update:modelValue', v: any)
 }>()
 
-// TODO @Dhb52 输入的 table 切换时,表单应该保留
 const menu = computed({
   get() {
     return props.modelValue

+ 88 - 28
src/views/mp/menu/components/MenuPreviewer.vue

@@ -1,35 +1,55 @@
 <template>
-  <div class="menu_bottom" v-for="(parent, x) of menuList" :key="x">
-    <!-- 一级菜单 -->
-    <div
-      @click="menuClicked(parent, x)"
-      class="menu_item"
-      :class="{ active: props.activeIndex === `${x}` }"
-    >
-      <Icon icon="ep:fold" color="black" />{{ parent.name }}
-    </div>
-    <!-- 以下为二级菜单-->
-    <div class="submenu" v-if="parentIndex === x && parent.children">
-      <div class="subtitle menu_bottom" v-for="(child, y) in parent.children" :key="y">
+  <draggable
+    v-model="menuList"
+    item-key="id"
+    ghost-class="draggable-ghost"
+    :animation="400"
+    @end="onDragEnd"
+  >
+    <template #item="{ element: parent, index: x }">
+      <div class="menu_bottom">
+        <!-- 一级菜单 -->
         <div
-          class="menu_subItem"
-          v-if="parent.children"
-          :class="{ active: props.activeIndex === `${x}-${y}` }"
-          @click="subMenuClicked(child, x, y)"
+          @click="menuClicked(parent, x)"
+          class="menu_item"
+          :class="{ active: props.activeIndex === `${x}` }"
         >
-          {{ child.name }}
+          <Icon icon="ep:fold" color="black" />{{ parent.name }}
+        </div>
+        <!-- 以下为二级菜单-->
+        <div class="submenu" v-if="props.parentIndex === x && parent.children">
+          <draggable
+            v-model="parent.children"
+            item-key="id"
+            ghost-class="draggable-ghost"
+            :animation="400"
+          >
+            <template #item="{ element: child, index: y }">
+              <div class="subtitle menu_bottom">
+                <div
+                  class="menu_subItem"
+                  v-if="parent.children"
+                  :class="{ active: props.activeIndex === `${x}-${y}` }"
+                  @click="subMenuClicked(child, x, y)"
+                >
+                  {{ child.name }}
+                </div>
+              </div>
+            </template>
+          </draggable>
+          <!-- 二级菜单加号, 当长度 小于 5 才显示二级菜单的加号  -->
+          <div
+            class="menu_bottom menu_addicon"
+            v-if="!parent.children || parent.children.length < 5"
+            @click="addSubMenu(x, parent)"
+          >
+            <Icon icon="ep:plus" class="plus" />
+          </div>
         </div>
       </div>
-      <!-- 二级菜单加号, 当长度 小于 5 才显示二级菜单的加号  -->
-      <div
-        class="menu_bottom menu_addicon"
-        v-if="!parent.children || parent.children.length < 5"
-        @click="addSubMenu(x, parent)"
-      >
-        <Icon icon="ep:plus" class="plus" />
-      </div>
-    </div>
-  </div>
+    </template>
+  </draggable>
+
   <!-- 一级菜单加号 -->
   <div class="menu_bottom menu_addicon" v-if="menuList.length < 3" @click="addMenu">
     <Icon icon="ep:plus" class="plus" />
@@ -38,12 +58,13 @@
 
 <script setup lang="ts">
 import { Menu } from './types'
+import draggable from 'vuedraggable'
 
 const props = defineProps<{
   modelValue: Menu[]
   activeIndex: string
   parentIndex: number
-  accountId?: number
+  accountId: number
 }>()
 
 const emit = defineEmits<{
@@ -91,9 +112,42 @@ const addSubMenu = (i: number, parent: any) => {
 const menuClicked = (parent: Menu, x: number) => {
   emit('menu-clicked', parent, x)
 }
+
 const subMenuClicked = (child: Menu, x: number, y: number) => {
   emit('submenu-clicked', child, x, y)
 }
+
+/**
+ * 处理一级菜单展开后被拖动
+ *
+ * @param oldIndex: 一级菜单拖动前的位置
+ * @param newIndex: 一级菜单拖动后的位置
+ */
+const onDragEnd = ({ oldIndex, newIndex }) => {
+  // 二级菜单没有展开,直接返回
+  if (props.activeIndex === '__MENU_NOT_SELECTED__') {
+    return
+  }
+
+  let newParent = props.parentIndex
+  if (props.parentIndex === oldIndex) {
+    newParent = newIndex
+  } else if (props.parentIndex === newIndex) {
+    newParent = oldIndex
+  } else {
+    // 如果展开的二级菜单下标`props.parentIndex`不是被移动的菜单的前后下标。
+    // 那么使用一个辅助素组来模拟菜单移动,然后找到展开的二级菜单的新下标`newParent`
+    let positions = new Array<boolean>(menuList.value.length).fill(false)
+    positions[props.parentIndex] = true
+    positions.splice(oldIndex, 1)
+    positions.splice(newIndex, 0, true)
+    newParent = positions.indexOf(true)
+  }
+
+  // 找到菜单元素,触发一级菜单点击
+  const parent = menuList.value[newParent]
+  emit('menu-clicked', parent, newParent)
+}
 </script>
 
 <style lang="scss" scoped>
@@ -155,4 +209,10 @@ const subMenuClicked = (child: Menu, x: number, y: number) => {
     box-sizing: border-box;
   }
 }
+
+.draggable-ghost {
+  opacity: 0.5;
+  background: #f7fafc;
+  border: 1px solid #4299e1;
+}
 </style>

+ 4 - 5
src/views/mp/menu/index.vue

@@ -53,7 +53,7 @@
 </template>
 
 <script lang="ts" setup name="MpMenu">
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 import MenuEditor from './components/MenuEditor.vue'
 import MenuPreviewer from './components/MenuPreviewer.vue'
 import * as MpMenuApi from '@/api/mp/menu'
@@ -65,8 +65,8 @@ const MENU_NOT_SELECTED = '__MENU_NOT_SELECTED__'
 
 // ======================== 列表查询 ========================
 const loading = ref(false) // 遮罩层
-const accountId = ref<number | undefined>()
-const accountName = ref<string | undefined>('')
+const accountId = ref<number>(0)
+const accountName = ref<string>('')
 const menuList = ref<Menu[]>([])
 
 // ======================== 菜单操作 ========================
@@ -103,7 +103,7 @@ const tempSelfObj = ref<{
 const dialogNewsVisible = ref(false) // 跳转图文时的素材选择弹窗
 
 /** 侦听公众号变化 **/
-const onAccountChanged = (id?: number, name?: string) => {
+const onAccountChanged = (id: number, name: string) => {
   accountId.value = id
   accountName.value = name
   getList()
@@ -367,7 +367,6 @@ div {
     margin-left: 20px;
     background-color: #e8e7e7;
     box-sizing: border-box;
-    box-sizing: border-box;
   }
 }
 </style>

+ 5 - 5
src/views/mp/message/MessageTable.vue

@@ -122,11 +122,11 @@
 </template>
 
 <script setup lang="ts">
-import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
-import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
-import WxLocation from '@/views/mp/components/wx-location/main.vue'
-import WxMusic from '@/views/mp/components/wx-music/main.vue'
-import WxNews from '@/views/mp/components/wx-news/main.vue'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import WxLocation from '@/views/mp/components/wx-location'
+import WxMusic from '@/views/mp/components/wx-music'
+import WxNews from '@/views/mp/components/wx-news'
 import { dateFormatter } from '@/utils/formatTime'
 import { MsgType } from '@/views/mp/components/wx-msg/types'
 

+ 10 - 10
src/views/mp/message/index.vue

@@ -81,8 +81,8 @@
 </template>
 <script setup lang="ts" name="MpMessage">
 import * as MpMessageApi from '@/api/mp/message'
-import WxMsg from '@/views/mp/components/wx-msg/main.vue'
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxMsg from '@/views/mp/components/wx-msg'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 import MessageTable from './MessageTable.vue'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import { MsgType } from '@/views/mp/components/wx-msg/types'
@@ -96,17 +96,17 @@ const list = ref<any[]>([]) // 当前页的列表数据
 interface QueryParams {
   pageNo: number
   pageSize: number
-  openid: string | null
-  accountId: number | null
-  type: MsgType | null
+  openid: string | undefined
+  accountId: number
+  type: MsgType | undefined
   createTime: string[] | []
 }
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  openid: null,
-  accountId: null,
-  type: null,
+  openid: undefined,
+  accountId: 0,
+  type: undefined,
   createTime: []
 })
 const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单
@@ -118,8 +118,8 @@ const messageBox = reactive({
 })
 
 /** 侦听accountId */
-const onAccountChanged = (id?: number) => {
-  queryParams.accountId = id as number
+const onAccountChanged = (id: number) => {
+  queryParams.accountId = id
   handleQuery()
 }
 

+ 23 - 9
src/views/mp/tag/index.vue

@@ -14,10 +14,22 @@
         <WxAccountSelect @change="onAccountChanged" />
       </el-form-item>
       <el-form-item>
-        <el-button type="primary" plain @click="openForm('create')" v-hasPermi="['mp:tag:create']">
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['mp:tag:create']"
+          :disabled="queryParams.accountId === 0"
+        >
           <Icon icon="ep:plus" class="mr-5px" /> 新增
         </el-button>
-        <el-button type="success" plain @click="handleSync" v-hasPermi="['mp:tag:sync']">
+        <el-button
+          type="success"
+          plain
+          @click="handleSync"
+          v-hasPermi="['mp:tag:sync']"
+          :disabled="queryParams.accountId === 0"
+        >
           <Icon icon="ep:refresh" class="mr-5px" /> 同步
         </el-button>
       </el-form-item>
@@ -74,28 +86,30 @@
 import { dateFormatter } from '@/utils/formatTime'
 import * as MpTagApi from '@/api/mp/tag'
 import TagForm from './TagForm.vue'
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
+
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
-const list = ref<any>([]) // 列表的数据
+const list = ref<any[]>([]) // 列表的数据
 
 interface QueryParams {
   pageNo: number
   pageSize: number
-  accountId?: number
+  accountId: number
 }
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: undefined
+  accountId: 0
 })
+
 const formRef = ref<InstanceType<typeof TagForm> | null>(null)
 
 /** 侦听公众号变化 **/
-const onAccountChanged = (id?: number) => {
+const onAccountChanged = (id: number) => {
   queryParams.pageNo = 1
   queryParams.accountId = id
   getList()
@@ -114,8 +128,8 @@ const getList = async () => {
 }
 
 /** 添加/修改操作 */
-const openForm = (type: string, id?: number) => {
-  formRef.value?.open(type, queryParams.accountId as number, id)
+const openForm = (type: 'create' | 'update', id?: number) => {
+  formRef.value?.open(type, queryParams.accountId, id)
 }
 
 /** 删除按钮操作 */

+ 11 - 5
src/views/mp/user/index.vue

@@ -34,7 +34,13 @@
       <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']">
+        <el-button
+          type="success"
+          plain
+          @click="handleSync"
+          v-hasPermi="['mp:user:sync']"
+          :disabled="queryParams.accountId === 0"
+        >
           <Icon icon="ep:refresh" class="mr-5px" /> 同步
         </el-button>
       </el-form-item>
@@ -97,7 +103,7 @@
 import { dateFormatter } from '@/utils/formatTime'
 import * as MpUserApi from '@/api/mp/user'
 import * as MpTagApi from '@/api/mp/tag'
-import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 import type { FormInstance } from 'element-plus'
 import UserForm from './UserForm.vue'
 
@@ -110,14 +116,14 @@ const list = ref<any[]>([]) // 列表的数据
 interface QueryParams {
   pageNo: number
   pageSize: number
-  accountId?: number
+  accountId: number
   openid: string | null
   nickname: string | null
 }
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: undefined,
+  accountId: 0,
   openid: null,
   nickname: null
 })
@@ -125,7 +131,7 @@ const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单
 const tagList = ref<any[]>([]) // 公众号标签列表
 
 /** 侦听公众号变化 **/
-const onAccountChanged = (id?: number) => {
+const onAccountChanged = (id: number) => {
   queryParams.pageNo = 1
   queryParams.accountId = id
   getList()