diff --git a/src/views/mp/autoReply/components/ReplyTable.vue b/src/views/mp/autoReply/components/ReplyTable.vue
new file mode 100644
index 00000000..ef17dfe8
--- /dev/null
+++ b/src/views/mp/autoReply/components/ReplyTable.vue
@@ -0,0 +1,118 @@
+<template>
+  <el-table v-loading="props.loading" :data="props.list">
+    <el-table-column
+      label="请求消息类型"
+      align="center"
+      prop="requestMessageType"
+      v-if="msgType === MsgType.Message"
+    />
+    <el-table-column
+      label="关键词"
+      align="center"
+      prop="requestKeyword"
+      v-if="msgType === MsgType.Keyword"
+    />
+    <el-table-column
+      label="匹配类型"
+      align="center"
+      prop="requestMatch"
+      v-if="msgType === MsgType.Keyword"
+    >
+      <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 v-if="scope.row.responseMediaUrl" :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
+            v-if="scope.row.responseMediaUrl"
+            :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="emit('on-update', scope.row.id)"
+          v-hasPermi="['mp:auto-reply:update']"
+        >
+          修改
+        </el-button>
+        <el-button
+          type="danger"
+          link
+          @click="emit('on-delete', scope.row.id)"
+          v-hasPermi="['mp:auto-reply:delete']"
+        >
+          删除
+        </el-button>
+      </template>
+    </el-table-column>
+  </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 { dateFormatter } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+import { MsgType } from './types'
+
+const props = defineProps<{
+  loading: boolean
+  list: any[]
+  msgType: MsgType
+}>()
+
+const emit = defineEmits<{
+  (e: 'on-update', v: number)
+  (e: 'on-delete', v: number)
+}>()
+</script>
+
+<style scoped></style>
diff --git a/src/views/mp/autoReply/components/types.ts b/src/views/mp/autoReply/components/types.ts
new file mode 100644
index 00000000..d4d79ca4
--- /dev/null
+++ b/src/views/mp/autoReply/components/types.ts
@@ -0,0 +1,47 @@
+// 消息类型(Follow: 关注时回复;Message: 消息回复;Keyword: 关键词回复)
+// 作为tab.name,enum的数字不能随意修改,与api参数相关
+export enum MsgType {
+  Follow = 1,
+  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
+}
+
+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
+}
diff --git a/src/views/mp/autoReply/index.vue b/src/views/mp/autoReply/index.vue
index 0fd6001d..a28d23d5 100644
--- a/src/views/mp/autoReply/index.vue
+++ b/src/views/mp/autoReply/index.vue
@@ -12,14 +12,14 @@
 
   <!-- tab 切换 -->
   <ContentWrap>
-    <el-tabs v-model="msgType" @tab-change="handleTabChange">
+    <el-tabs v-model="msgType" @tab-change="onTabChange">
       <!-- 操作工具栏 -->
       <el-row :gutter="10" class="mb8">
         <el-col :span="1.5">
           <el-button
             type="primary"
             plain
-            @click="handleAdd"
+            @click="onCreate"
             v-hasPermi="['mp:auto-reply:create']"
             v-if="msgType !== MsgType.Follow || list.length <= 0"
           >
@@ -30,117 +30,31 @@
       <!-- tab 项 -->
       <el-tab-pane :name="MsgType.Follow">
         <template #label>
-          <span><Icon icon="ep:star" /> 关注时回复</span>
+          <el-row align="middle"><Icon icon="ep:star" class="mr-2px" /> 关注时回复</el-row>
         </template>
       </el-tab-pane>
       <el-tab-pane :name="MsgType.Message">
         <template #label>
-          <span><Icon icon="ep:chat-line-round" /> 消息回复</span>
+          <el-row align="middle"><Icon icon="ep:chat-line-round" class="mr-2px" /> 消息回复</el-row>
         </template>
       </el-tab-pane>
       <el-tab-pane :name="MsgType.Keyword">
         <template #label>
-          <span><Icon icon="fa:newspaper-o" /> 关键词回复</span>
+          <el-row align="middle"><Icon icon="fa:newspaper-o" class="mr-2px" /> 关键词回复</el-row>
         </template>
       </el-tab-pane>
     </el-tabs>
     <!-- 列表 -->
-    <el-table v-loading="loading" :data="list">
-      <el-table-column
-        label="请求消息类型"
-        align="center"
-        prop="requestMessageType"
-        v-if="msgType === MsgType.Message"
-      />
-      <el-table-column
-        label="关键词"
-        align="center"
-        prop="requestKeyword"
-        v-if="msgType === MsgType.Keyword"
-      />
-      <el-table-column
-        label="匹配类型"
-        align="center"
-        prop="requestMatch"
-        v-if="msgType === MsgType.Keyword"
-      >
-        <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 v-if="scope.row.responseMediaUrl" :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
-              v-if="scope.row.responseMediaUrl"
-              :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>
+    <ReplyTable
+      :loading="loading"
+      :list="list"
+      :msg-type="msgType"
+      @on-update="(id) => onUpdate(id)"
+      @on-delete="(id) => onDelete(id)"
+    />
 
     <!-- 添加或修改自动回复的对话框 -->
-    <el-dialog :title="title" v-model="showReplyFormDialog" width="800px" destroy-on-close>
+    <el-dialog :title="dialogTitle" v-model="showFormDialog" width="800px" destroy-on-close>
       <el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px">
         <el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message">
           <el-select v-model="replyForm.requestMessageType" placeholder="请选择">
@@ -172,35 +86,25 @@
       </el-form>
       <template #footer>
         <el-button @click="cancel">取 消</el-button>
-        <el-button type="primary" @click="handleSubmit">确 定</el-button>
+        <el-button type="primary" @click="onSubmit">确 定</el-button>
       </template>
     </el-dialog>
   </ContentWrap>
 </template>
 <script setup lang="ts" name="MpAutoReply">
-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 WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
 import * as MpAutoReplyApi from '@/api/mp/autoReply'
 import { DICT_TYPE, getDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
 import { ContentWrap } from '@/components/ContentWrap'
 import type { TabPaneName } from 'element-plus'
+import ReplyTable from './components/ReplyTable.vue'
+import { MsgType, ReplyForm, ObjData } from './components/types'
 
 const message = useMessage()
 
 const formRef = ref()
 
-// 消息类型(Follow: 关注时回复;Message: 消息回复;Keyword: 关键词回复)
-// 作为tab.name,enum的数字不能随意修改,与api参数相关
-enum MsgType {
-  Follow = 1,
-  Message = 2,
-  Keyword = 3
-}
 const msgType = ref<MsgType>(MsgType.Keyword)
 // 允许选择的请求消息类型
 const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link']
@@ -224,47 +128,11 @@ const queryParams: QueryParams = reactive({
 })
 
 // 弹出层标题
-const title = ref('')
+const dialogTitle = ref('')
 // 是否显示弹出层
-const showReplyFormDialog = ref(false)
+const showFormDialog = ref(false)
 // 表单参数
-type ReplyType = 'text' | 'image' | 'voice' | 'video' | 'shortvideo' | 'location' | 'link'
-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
-}
-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
-}
+
 const replyForm = ref<ReplyForm>({})
 // 回复消息
 const objData = ref<ObjData>({
@@ -277,6 +145,7 @@ const rules = {
   requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }]
 }
 
+/** 侦听账号变化 */
 const onAccountChanged = (id?: number) => {
   queryParams.accountId = id
   getList()
@@ -284,7 +153,7 @@ const onAccountChanged = (id?: number) => {
 
 /** 查询列表 */
 const getList = async () => {
-  loading.value = false
+  loading.value = true
   try {
     const data = await MpAutoReplyApi.getAutoReplyPage({
       ...queryParams,
@@ -303,13 +172,13 @@ const handleQuery = () => {
   getList()
 }
 
-const handleTabChange = (tabName: TabPaneName) => {
+const onTabChange = (tabName: TabPaneName) => {
   msgType.value = tabName as MsgType
   handleQuery()
 }
 
 /** 新增按钮操作 */
-const handleAdd = () => {
+const onCreate = () => {
   reset()
   // 打开表单,并设置初始化
   objData.value = {
@@ -317,15 +186,15 @@ const handleAdd = () => {
     accountId: queryParams.accountId
   }
 
-  title.value = '新增自动回复'
-  showReplyFormDialog.value = true
+  dialogTitle.value = '新增自动回复'
+  showFormDialog.value = true
 }
 
 /** 修改按钮操作 */
-const handleUpdate = async (row: any) => {
+const onUpdate = async (id: number) => {
   reset()
 
-  const data = await MpAutoReplyApi.getAutoReply(row.id)
+  const data = await MpAutoReplyApi.getAutoReply(id)
   // 设置属性
   replyForm.value = { ...data }
   delete replyForm.value['responseMessageType']
@@ -350,11 +219,19 @@ const handleUpdate = async (row: any) => {
   }
 
   // 打开表单
-  title.value = '修改自动回复'
-  showReplyFormDialog.value = true
+  dialogTitle.value = '修改自动回复'
+  showFormDialog.value = true
 }
 
-const handleSubmit = async () => {
+/** 删除按钮操作 */
+const onDelete = async (id: number) => {
+  await message.confirm('是否确认删除此数据?')
+  await MpAutoReplyApi.deleteAutoReply(id)
+  await getList()
+  message.success('删除成功')
+}
+
+const onSubmit = async () => {
   const valid = await formRef.value?.validate()
   if (!valid) return
 
@@ -380,7 +257,7 @@ const handleSubmit = async () => {
     message.success('新增成功')
   }
 
-  showReplyFormDialog.value = false
+  showFormDialog.value = false
   await getList()
 }
 
@@ -399,14 +276,7 @@ const reset = () => {
 
 // 取消按钮
 const cancel = () => {
-  showReplyFormDialog.value = false
+  showFormDialog.value = false
   reset()
 }
-
-const handleDelete = async (row) => {
-  await message.confirm('是否确认删除此数据?')
-  await MpAutoReplyApi.deleteAutoReply(row.id)
-  await getList()
-  message.success('删除成功')
-}
 </script>
diff --git a/src/views/mp/components/wx-account-select/main.vue b/src/views/mp/components/wx-account-select/main.vue
index 110fc8a5..5359b366 100644
--- a/src/views/mp/components/wx-account-select/main.vue
+++ b/src/views/mp/components/wx-account-select/main.vue
@@ -22,11 +22,14 @@ 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)
   }
 }
 
-const onChanged = () => {
+const onChanged = (id?: number) => {
+  const found = accountList.value.find((v) => v.id === id)
+  account.name = found ? found.name : ''
   emit('change', account.id, account.name)
 }
 
diff --git a/src/views/mp/components/wx-msg/main.vue b/src/views/mp/components/wx-msg/main.vue
index ad37c1f1..c7354628 100644
--- a/src/views/mp/components/wx-msg/main.vue
+++ b/src/views/mp/components/wx-msg/main.vue
@@ -126,7 +126,7 @@
     </div>
     <div class="msg-send" v-loading="sendLoading">
       <WxReplySelect ref="replySelectRef" :objData="objData" />
-      <el-button type="success" size="small" class="send-but" @click="sendMsg">发送(S)</el-button>
+      <el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
     </div>
   </ContentWrap>
 </template>
@@ -231,12 +231,8 @@ const sendMsg = async () => {
   list.value = [...list.value, ...[data]]
   scrollToBottom()
 
-  //ts检查的時候会判断这个组件可能是空的,所以需要进行断言。
-  //避免 tab 的数据未清理
-  const deleteObj = replySelectRef.value?.deleteObj
-  if (deleteObj) {
-    deleteObj()
-  }
+  // 发送后清空数据
+  replySelectRef.value?.clear()
 }
 
 const loadingMore = () => {
@@ -333,6 +329,7 @@ const scrollToBottom = () => {
 
 .send-but {
   float: right;
-  margin-top: 8px !important;
+  margin-top: 8px;
+  margin-bottom: 8px;
 }
 </style>
diff --git a/src/views/mp/components/wx-reply/components/TabImage.vue b/src/views/mp/components/wx-reply/components/TabImage.vue
new file mode 100644
index 00000000..827ef11c
--- /dev/null
+++ b/src/views/mp/components/wx-reply/components/TabImage.vue
@@ -0,0 +1,172 @@
+<template>
+  <el-tab-pane name="image">
+    <template #label>
+      <el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
+    </template>
+    <!-- 情况一:已经选择好素材、或者上传好图片 -->
+    <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>
+      <el-row class="ope-row" justify="center">
+        <el-button type="danger" circle @click="onDelete">
+          <Icon icon="ep:delete" />
+        </el-button>
+      </el-row>
+    </div>
+    <!-- 情况二:未做完上述操作 -->
+    <el-row v-else style="text-align: center" align="middle">
+      <!-- 选择素材 -->
+      <el-col :span="12" class="col-select">
+        <el-button type="success" @click="showDialog = true">
+          素材库选择 <Icon icon="ep:circle-check" />
+        </el-button>
+        <el-dialog
+          title="选择图片"
+          v-model="showDialog"
+          width="90%"
+          append-to-body
+          destroy-on-close
+        >
+          <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
+        </el-dialog>
+      </el-col>
+      <!-- 文件上传 -->
+      <el-col :span="12" class="col-add">
+        <el-upload
+          :action="UPLOAD_URL"
+          :headers="HEADERS"
+          multiple
+          :limit="1"
+          :file-list="fileList"
+          :data="uploadData"
+          :before-upload="beforeImageUpload"
+          :on-success="onUploadSuccess"
+        >
+          <el-button type="primary">上传图片</el-button>
+          <template #tip>
+            <span>
+              <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div>
+            </span>
+          </template>
+        </el-upload>
+      </el-col>
+    </el-row>
+  </el-tab-pane>
+</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 type { UploadRawFile } from 'element-plus'
+import { getAccessToken } from '@/utils/auth'
+import { ObjData } 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
+}>()
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: ObjData)
+}>()
+const objData = computed<ObjData>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const showDialog = ref(false)
+const fileList = ref([])
+const uploadData = reactive({
+  accountId: objData.value.accountId,
+  type: 'image',
+  title: '',
+  introduction: ''
+})
+
+const beforeImageUpload = (rawFile: UploadRawFile) =>
+  useBeforeUpload(MaterialType.Image, 2)(rawFile)
+
+const onUploadSuccess = (res: any) => {
+  if (res.code !== 0) {
+    message.error('上传出错:' + res.msg)
+    return false
+  }
+
+  // 清空上传时的各种数据
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+
+  // 上传好的文件,本质是个素材,所以可以进行选中
+  selectMaterial(res.data)
+}
+
+const onDelete = () => {
+  objData.value.mediaId = null
+  objData.value.url = null
+  objData.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
+}
+</script>
+
+<style lang="scss" scoped>
+.select-item {
+  width: 280px;
+  padding: 10px;
+  margin: 0 auto 10px auto;
+  border: 1px solid #eaeaea;
+
+  .material-img {
+    width: 100%;
+  }
+
+  .item-name {
+    font-size: 12px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    text-align: center;
+
+    .item-infos {
+      width: 30%;
+      margin: auto;
+    }
+
+    .ope-row {
+      padding-top: 10px;
+      text-align: center;
+    }
+  }
+
+  .col-select {
+    border: 1px solid rgb(234, 234, 234);
+    padding: 50px 0px;
+    height: 160px;
+    width: 49.5%;
+  }
+
+  .col-add {
+    border: 1px solid rgb(234, 234, 234);
+    padding: 50px 0px;
+    height: 160px;
+    width: 49.5%;
+    float: right;
+
+    .el-upload__tip {
+      line-height: 18px;
+      text-align: center;
+    }
+  }
+}
+</style>
diff --git a/src/views/mp/components/wx-reply/components/TabMusic.vue b/src/views/mp/components/wx-reply/components/TabMusic.vue
new file mode 100644
index 00000000..5a50cbf0
--- /dev/null
+++ b/src/views/mp/components/wx-reply/components/TabMusic.vue
@@ -0,0 +1,121 @@
+<template>
+  <el-tab-pane name="music">
+    <template #label>
+      <el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
+    </template>
+    <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" />
+              <icon v-else icon="ep:plus" />
+            </el-row>
+            <el-row align="middle" justify="center" style="margin-top: 2%">
+              <div class="thumb-but">
+                <el-upload
+                  :action="UPLOAD_URL"
+                  :headers="HEADERS"
+                  multiple
+                  :limit="1"
+                  :file-list="fileList"
+                  :data="uploadData"
+                  :before-upload="beforeImageUpload"
+                  :on-success="onUploadSuccess"
+                >
+                  <template #trigger>
+                    <el-button type="primary" link>本地上传</el-button>
+                  </template>
+                  <el-button type="primary" link @click="showDialog = true" style="margin-left: 5px"
+                    >素材库选择
+                  </el-button>
+                </el-upload>
+              </div>
+            </el-row>
+          </el-col>
+        </el-row>
+        <el-dialog
+          title="选择图片"
+          v-model="showDialog"
+          width="80%"
+          append-to-body
+          destroy-on-close
+        >
+          <WxMaterialSelect
+            :objData="{ type: 'image', accountId: objData.accountId }"
+            @select-material="selectMaterial"
+          />
+        </el-dialog>
+      </el-col>
+      <el-col :span="18">
+        <el-input v-model="objData.title" placeholder="请输入标题" />
+        <div style="margin: 20px 0"></div>
+        <el-input v-model="objData.description" placeholder="请输入描述" />
+      </el-col>
+    </el-row>
+    <div style="margin: 20px 0"></div>
+    <el-input v-model="objData.musicUrl" placeholder="请输入音乐链接" />
+    <div style="margin: 20px 0"></div>
+    <el-input v-model="objData.hqMusicUrl" placeholder="请输入高质量音乐链接" />
+  </el-tab-pane>
+</template>
+
+<script setup lang="ts">
+import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
+import type { UploadRawFile } from 'element-plus'
+import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import { getAccessToken } from '@/utils/auth'
+import { ObjData } 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
+}>()
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: ObjData)
+}>()
+const objData = computed<ObjData>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const showDialog = ref(false)
+const fileList = ref([])
+const uploadData = reactive({
+  accountId: objData.value.accountId,
+  type: 'thumb', // 音乐类型为thumb
+  title: '',
+  introduction: ''
+})
+
+const beforeImageUpload = (rawFile: UploadRawFile) =>
+  useBeforeUpload(MaterialType.Image, 2)(rawFile)
+
+const onUploadSuccess = (res: any) => {
+  if (res.code !== 0) {
+    message.error('上传出错:' + res.msg)
+    return false
+  }
+
+  // 清空上传时的各种数据
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+
+  // 上传好的文件,本质是个素材,所以可以进行选中
+  selectMaterial(res.data)
+}
+
+const selectMaterial = (item: any) => {
+  showDialog.value = false
+
+  objData.value.thumbMediaId = item.mediaId
+  objData.value.thumbMediaUrl = item.url
+}
+</script>
+
+<style scoped></style>
diff --git a/src/views/mp/components/wx-reply/components/TabNews.vue b/src/views/mp/components/wx-reply/components/TabNews.vue
new file mode 100644
index 00000000..13ae9570
--- /dev/null
+++ b/src/views/mp/components/wx-reply/components/TabNews.vue
@@ -0,0 +1,78 @@
+<template>
+  <el-tab-pane name="news">
+    <template #label>
+      <el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
+    </template>
+    <el-row>
+      <div class="select-item" v-if="objData.articles?.length > 0">
+        <WxNews :articles="objData.articles" />
+        <el-col class="ope-row">
+          <el-button type="danger" circle @click="onDelete">
+            <Icon icon="ep:delete" />
+          </el-button>
+        </el-col>
+      </div>
+      <!-- 选择素材 -->
+      <el-col :span="24" v-if="!objData.content">
+        <el-row style="text-align: center" align="middle">
+          <el-col :span="24">
+            <el-button type="success" @click="showDialog = true">
+              {{ newsType === NewsType.Published ? '选择已发布图文' : '选择草稿箱图文' }}
+              <icon icon="ep:circle-check" />
+            </el-button>
+          </el-col>
+        </el-row>
+      </el-col>
+      <el-dialog title="选择图文" v-model="showDialog" width="90%" append-to-body destroy-on-close>
+        <WxMaterialSelect
+          :objData="objData"
+          @select-material="selectMaterial"
+          :newsType="newsType"
+        />
+      </el-dialog>
+    </el-row>
+  </el-tab-pane>
+</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'
+
+const props = defineProps<{
+  modelValue: ObjData
+  newsType: NewsType
+}>()
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: ObjData)
+}>()
+const objData = computed<ObjData>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const showDialog = ref(false)
+
+const selectMaterial = (item: any) => {
+  showDialog.value = false
+  objData.value.articles = item.content.newsItem
+}
+
+const onDelete = () => {
+  objData.value.articles = []
+}
+</script>
+
+<style lang="scss" scoped>
+.select-item {
+  width: 280px;
+  padding: 10px;
+  margin: 0 auto 10px auto;
+  border: 1px solid #eaeaea;
+
+  .ope-row {
+    padding-top: 10px;
+    text-align: center;
+  }
+}
+</style>
diff --git a/src/views/mp/components/wx-reply/components/TabText.vue b/src/views/mp/components/wx-reply/components/TabText.vue
new file mode 100644
index 00000000..f6981a73
--- /dev/null
+++ b/src/views/mp/components/wx-reply/components/TabText.vue
@@ -0,0 +1,29 @@
+<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>
+</template>
+
+<script setup lang="ts">
+const props = defineProps<{
+  modelValue: string | null
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: string | null)
+  (e: 'input', v: string | null)
+}>()
+
+const content = computed<string | null>({
+  get: () => props.modelValue,
+  set: (val: string | null) => {
+    emit('update:modelValue', val)
+    emit('input', val)
+  }
+})
+</script>
+
+<style scoped></style>
diff --git a/src/views/mp/components/wx-reply/components/TabVideo.vue b/src/views/mp/components/wx-reply/components/TabVideo.vue
new file mode 100644
index 00000000..c924bc2a
--- /dev/null
+++ b/src/views/mp/components/wx-reply/components/TabVideo.vue
@@ -0,0 +1,132 @@
+<template>
+  <el-tab-pane name="video">
+    <template #label>
+      <el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
+    </template>
+    <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-row class="ope-row" justify="center">
+        <WxVideoPlayer v-if="objData.url" :url="objData.url" />
+      </el-row>
+      <el-col>
+        <el-row style="text-align: center" align="middle">
+          <!-- 选择素材 -->
+          <el-col :span="12">
+            <el-button type="success" @click="showDialog = true">
+              素材库选择 <Icon icon="ep:circle-check" />
+            </el-button>
+            <el-dialog
+              title="选择视频"
+              v-model="showDialog"
+              width="90%"
+              append-to-body
+              destroy-on-close
+            >
+              <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
+            </el-dialog>
+          </el-col>
+          <!-- 文件上传 -->
+          <el-col :span="12">
+            <el-upload
+              :action="UPLOAD_URL"
+              :headers="HEADERS"
+              multiple
+              :limit="1"
+              :file-list="fileList"
+              :data="uploadData"
+              :before-upload="beforeVideoUpload"
+              :on-success="onUploadSuccess"
+            >
+              <el-button type="primary">新建视频 <Icon icon="ep:upload" /></el-button>
+            </el-upload>
+          </el-col>
+        </el-row>
+      </el-col>
+    </el-row>
+  </el-tab-pane>
+</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 type { UploadRawFile } from 'element-plus'
+import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import { getAccessToken } from '@/utils/auth'
+import { ObjData } 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
+}>()
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: ObjData)
+}>()
+const objData = computed<ObjData>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const showDialog = ref(false)
+const fileList = ref([])
+const uploadData = reactive({
+  accountId: objData.value.accountId,
+  type: 'video',
+  title: '',
+  introduction: ''
+})
+
+const beforeVideoUpload = (rawFile: UploadRawFile) =>
+  useBeforeUpload(MaterialType.Video, 10)(rawFile)
+
+const onUploadSuccess = (res: any) => {
+  if (res.code !== 0) {
+    message.error('上传出错:' + res.msg)
+    return false
+  }
+
+  // 清空上传时的各种数据
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+
+  selectMaterial(res.data)
+}
+
+/** 选择素材后设置 */
+const selectMaterial = (item: any) => {
+  showDialog.value = false
+
+  objData.value.mediaId = item.mediaId
+  objData.value.url = item.url
+  objData.value.name = item.name
+
+  // title、introduction:从 item 到 tempObjItem,因为素材里有 title、introduction
+  if (item.title) {
+    objData.value.title = item.title || ''
+  }
+  if (item.introduction) {
+    objData.value.description = item.introduction || ''
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.input-margin-bottom {
+  margin-bottom: 2%;
+}
+
+.ope-row {
+  width: 100%;
+  padding-top: 10px;
+  text-align: center;
+}
+</style>
diff --git a/src/views/mp/components/wx-reply/components/TabVoice.vue b/src/views/mp/components/wx-reply/components/TabVoice.vue
new file mode 100644
index 00000000..7bcefced
--- /dev/null
+++ b/src/views/mp/components/wx-reply/components/TabVoice.vue
@@ -0,0 +1,162 @@
+<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>
+      <el-row class="ope-row" justify="center">
+        <WxVoicePlayer :url="objData.url" />
+      </el-row>
+      <el-row class="ope-row" justify="center">
+        <el-button type="danger" circle @click="onDelete"><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="showDialog = true">
+          素材库选择<Icon icon="ep:circle-check" />
+        </el-button>
+        <el-dialog
+          title="选择语音"
+          v-model="showDialog"
+          width="90%"
+          append-to-body
+          destroy-on-close
+        >
+          <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
+        </el-dialog>
+      </el-col>
+      <!-- 文件上传 -->
+      <el-col :span="12" class="col-add">
+        <el-upload
+          :action="UPLOAD_URL"
+          :headers="HEADERS"
+          multiple
+          :limit="1"
+          :file-list="fileList"
+          :data="uploadData"
+          :before-upload="beforeVoiceUpload"
+          :on-success="onUploadSuccess"
+        >
+          <el-button type="primary">点击上传</el-button>
+          <template #tip>
+            <div class="el-upload__tip">
+              格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
+            </div>
+          </template>
+        </el-upload>
+      </el-col>
+    </el-row>
+  </el-tab-pane>
+</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 type { UploadRawFile } from 'element-plus'
+import { getAccessToken } from '@/utils/auth'
+import { ObjData } 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
+}>()
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: ObjData)
+}>()
+const objData = computed<ObjData>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const showDialog = ref(false)
+const fileList = ref([])
+const uploadData = reactive({
+  accountId: objData.value.accountId,
+  type: 'voice',
+  title: '',
+  introduction: ''
+})
+
+const beforeVoiceUpload = (rawFile: UploadRawFile) =>
+  useBeforeUpload(MaterialType.Voice, 10)(rawFile)
+
+const onUploadSuccess = (res: any) => {
+  if (res.code !== 0) {
+    message.error('上传出错:' + res.msg)
+    return false
+  }
+
+  // 清空上传时的各种数据
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+
+  // 上传好的文件,本质是个素材,所以可以进行选中
+  selectMaterial(res.data)
+}
+
+const onDelete = () => {
+  objData.value.mediaId = null
+  objData.value.url = null
+  objData.value.name = null
+}
+
+const selectMaterial = (item: ObjData) => {
+  showDialog.value = false
+
+  objData.value.type = 'voice'
+  objData.value.mediaId = item.mediaId
+  objData.value.url = item.url
+  objData.value.name = item.name
+}
+</script>
+
+<style lang="scss" scoped>
+.select-item2 {
+  padding: 10px;
+  margin: 0 auto 10px auto;
+  border: 1px solid #eaeaea;
+
+  .item-name {
+    font-size: 12px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    text-align: center;
+
+    .ope-row {
+      width: 100%;
+      padding-top: 10px;
+      text-align: center;
+    }
+  }
+
+  .col-select {
+    border: 1px solid rgb(234, 234, 234);
+    padding: 50px 0px;
+    height: 160px;
+    width: 49.5%;
+  }
+
+  .col-add {
+    border: 1px solid rgb(234, 234, 234);
+    padding: 50px 0px;
+    height: 160px;
+    width: 49.5%;
+    float: right;
+
+    .el-upload__tip {
+      line-height: 18px;
+      text-align: center;
+    }
+  }
+}
+</style>
diff --git a/src/views/mp/components/wx-reply/components/types.ts b/src/views/mp/components/wx-reply/components/types.ts
new file mode 100644
index 00000000..d5273334
--- /dev/null
+++ b/src/views/mp/components/wx-reply/components/types.ts
@@ -0,0 +1,25 @@
+type ReplyType = '' | 'news' | 'image' | 'voice' | 'video' | 'music' | 'text'
+
+interface ObjData {
+  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[]
+}
+
+enum NewsType {
+  Published = '1',
+  Draft = '2'
+}
+
+export { ObjData, NewsType }
diff --git a/src/views/mp/components/wx-reply/main.vue b/src/views/mp/components/wx-reply/main.vue
index 4768eb31..da523b6c 100644
--- a/src/views/mp/components/wx-reply/main.vue
+++ b/src/views/mp/components/wx-reply/main.vue
@@ -8,607 +8,63 @@
   ④ 支持发送【视频】消息时,支持新建视频
 -->
 <template>
-  <el-tabs type="border-card" v-model="objDataRef.type" @tab-click="handleClick">
+  <el-tabs type="border-card" v-model="objData.type" @tab-click="onTabClick">
     <!-- 类型 1:文本 -->
-    <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="objDataRef.content"
-        @input="inputContent"
-      />
-    </el-tab-pane>
+    <TabText v-model="objData.content" />
     <!-- 类型 2:图片 -->
-    <el-tab-pane name="image">
-      <template #label>
-        <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" 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" align="middle">
-        <!-- 选择素材 -->
-        <el-col :span="12" class="col-select">
-          <el-button type="success" @click="openMaterial">
-            素材库选择 <Icon icon="ep:circle-check" />
-          </el-button>
-          <el-dialog title="选择图片" v-model="dialogImageVisible" width="90%" append-to-body>
-            <WxMaterialSelect :obj-data="objDataRef" @select-material="selectMaterial" />
-          </el-dialog>
-        </el-col>
-        <!-- 文件上传 -->
-        <el-col :span="12" class="col-add">
-          <el-upload
-            :action="actionUrl"
-            :headers="headers"
-            multiple
-            :limit="1"
-            :file-list="fileList"
-            :data="uploadData"
-            :before-upload="beforeImageUpload"
-            :on-success="handleUploadSuccess"
-          >
-            <el-button type="primary">上传图片</el-button>
-            <template #tip>
-              <span>
-                <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div>
-              </span>
-            </template>
-          </el-upload>
-        </el-col>
-      </el-row>
-    </el-tab-pane>
+    <TabImage v-model="objData" />
     <!-- 类型 3:语音 -->
-    <el-tab-pane name="voice">
-      <template #label>
-        <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">
-          <WxVoicePlayer :url="objDataRef.url" />
-        </div>
-        <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">
-            素材库选择<Icon icon="ep:circle-check" />
-          </el-button>
-          <el-dialog title="选择语音" v-model="dialogVoiceVisible" width="90%" append-to-body>
-            <WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
-          </el-dialog>
-        </el-col>
-        <!-- 文件上传 -->
-        <el-col :span="12" class="col-add">
-          <el-upload
-            :action="actionUrl"
-            :headers="headers"
-            multiple
-            :limit="1"
-            :file-list="fileList"
-            :data="uploadData"
-            :before-upload="beforeVoiceUpload"
-            :on-success="handleUploadSuccess"
-          >
-            <el-button type="primary">点击上传</el-button>
-            <template #tip>
-              <div class="el-upload__tip">
-                格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
-              </div>
-            </template>
-          </el-upload>
-        </el-col>
-      </el-row>
-    </el-tab-pane>
+    <TabVoice v-model="objData" />
     <!-- 类型 4:视频 -->
-    <el-tab-pane name="video">
-      <template #label>
-        <el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
-      </template>
-      <el-row>
-        <el-input
-          v-model="objDataRef.title"
-          class="input-margin-bottom"
-          placeholder="请输入标题"
-          @input="inputContent"
-        />
-        <el-input
-          class="input-margin-bottom"
-          v-model="objDataRef.description"
-          placeholder="请输入描述"
-          @input="inputContent"
-        />
-        <div style="text-align: center">
-          <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" />
-              </el-button>
-              <el-dialog title="选择视频" v-model="dialogVideoVisible" width="90%" append-to-body>
-                <WxMaterialSelect :objData="objDataRef" @select-material="selectMaterial" />
-              </el-dialog>
-            </el-col>
-            <!-- 文件上传 -->
-            <el-col :span="12">
-              <el-upload
-                :action="actionUrl"
-                :headers="headers"
-                multiple
-                :limit="1"
-                :file-list="fileList"
-                :data="uploadData"
-                :before-upload="beforeVideoUpload"
-                :on-success="handleUploadSuccess"
-              >
-                <el-button type="primary">新建视频 <Icon icon="ep:upload" /></el-button>
-              </el-upload>
-            </el-col>
-          </el-row>
-        </el-col>
-      </el-row>
-    </el-tab-pane>
+    <TabVideo v-model="objData" />
     <!-- 类型 5:图文 -->
-    <el-tab-pane name="news">
-      <template #label>
-        <el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
-      </template>
-      <el-row>
-        <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" />
-            </el-button>
-          </el-col>
-        </div>
-        <!-- 选择素材 -->
-        <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' ? '选择已发布图文' : '选择草稿箱图文' }}
-                <icon icon="ep:circle-check" />
-              </el-button>
-            </el-col>
-          </el-row>
-        </el-col>
-        <el-dialog title="选择图文" v-model="dialogNewsVisible" width="90%" append-to-body>
-          <WxMaterialSelect
-            :objData="objDataRef"
-            @select-material="selectMaterial"
-            :newsType="newsType"
-          />
-        </el-dialog>
-      </el-row>
-    </el-tab-pane>
+    <TabNews v-model="objData" :news-type="newsType" />
     <!-- 类型 6:音乐 -->
-    <el-tab-pane name="music">
-      <template #label>
-        <el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
-      </template>
-      <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="objDataRef.thumbMediaUrl"
-                  :src="objDataRef.thumbMediaUrl"
-                />
-                <icon v-else icon="ep:plus" />
-              </el-row>
-              <el-row align="middle" justify="center" style="margin-top: 2%">
-                <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 type="primary" link>本地上传</el-button>
-                    </template>
-                    <el-button type="primary" link @click="openMaterial" style="margin-left: 5px"
-                      >素材库选择
-                    </el-button>
-                  </el-upload>
-                </div>
-              </el-row>
-            </el-col>
-          </el-row>
-          <el-dialog title="选择图片" v-model="dialogThumbVisible" width="80%" append-to-body>
-            <WxMaterialSelect
-              :objData="{ type: 'image', accountId: objDataRef.accountId }"
-              @select-material="selectMaterial"
-            />
-          </el-dialog>
-        </el-col>
-        <el-col :span="18">
-          <el-input v-model="objDataRef.title" placeholder="请输入标题" @input="inputContent" />
-          <div style="margin: 20px 0"></div>
-          <el-input
-            v-model="objDataRef.description"
-            placeholder="请输入描述"
-            @input="inputContent"
-          />
-        </el-col>
-      </el-row>
-      <div style="margin: 20px 0"></div>
-      <el-input v-model="objDataRef.musicUrl" placeholder="请输入音乐链接" @input="inputContent" />
-      <div style="margin: 20px 0"></div>
-      <el-input
-        v-model="objDataRef.hqMusicUrl"
-        placeholder="请输入高质量音乐链接"
-        @input="inputContent"
-      />
-    </el-tab-pane>
+    <TabMusic v-model="objData" />
   </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'
-import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
-import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
 
-import { getAccessToken } from '@/utils/auth'
-import { defineComponent } from 'vue'
+<script setup lang="ts" name="WxReplySelect">
+import { ObjData, NewsType } from './components/types'
+import TabText from './components/TabText.vue'
+import TabImage from './components/TabImage.vue'
+import TabVoice from './components/TabVoice.vue'
+import TabVideo from './components/TabVideo.vue'
+import TabNews from './components/TabNews.vue'
+import TabMusic from './components/TabMusic.vue'
 
-export default defineComponent({
-  components: {
-    WxNews,
-    WxMaterialSelect,
-    WxVoicePlayer,
-    WxVideoPlayer
-  },
-  props: {
-    objData: {
-      // 消息对象。
-      type: Object, // 设置为 Object 的原因,方便属性的传递
-      required: true
-    },
-    newsType: {
-      // 图文类型:1、已发布图文;2、草稿箱图文
-      type: String,
-      default: '1'
-    }
-  },
-  setup(props) {
-    const objDataRef = reactive(props.objData)
-    const message = useMessage() // 消息弹窗
-    const tempObj = new Map().set(objDataRef.type, Object.assign({}, objDataRef))
-    // ========== 素材选择的弹窗,是否可见 ==========
-    const dialogNewsVisible = ref(false) // 图文
-    const dialogImageVisible = ref(false) // 图片
-    const dialogVoiceVisible = ref(false) // 语音
-    const dialogVideoVisible = ref(false) // 视频
-    const dialogThumbVisible = ref(false) // 缩略图
-    // ========== 文件上传(图片、语音、视频) ==========
-    const fileList = ref([])
-    const uploadData = reactive({
-      accountId: undefined,
-      type: objDataRef.type,
-      title: '',
-      introduction: ''
-    })
-    const actionUrl = ref(
-      import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/upload-temporary'
-    )
-    const headers = ref({ Authorization: 'Bearer ' + getAccessToken() }) // 设置上传的请求头部
-    const beforeThumbImageUpload = (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
-      }
-      uploadData.accountId = objDataRef.accountId
-      return true
-    }
-    const beforeVoiceUpload = (file) => {
-      // 校验格式
-      const isType =
-        file.type === 'audio/mp3' ||
-        file.type === 'audio/mpeg' ||
-        file.type === 'audio/wma' ||
-        file.type === 'audio/wav' ||
-        file.type === 'audio/amr'
-      if (!isType) {
-        message.error('上传语音格式不对!' + file.type)
-        return false
-      }
-      // 校验大小
-      const isLt = file.size / 1024 / 1024 < 2
-      if (!isLt) {
-        message.error('上传语音大小不能超过 2M!')
-        return false
-      }
-      uploadData.accountId = objDataRef.accountId
-      return true
-    }
-    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
-      }
-      uploadData.accountId = objDataRef.accountId
-      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
-      }
-      uploadData.accountId = objDataRef.accountId
-      return true
-    }
-    const handleUploadSuccess = (response) => {
-      if (response.code !== 0) {
-        message.error('上传出错:' + response.msg)
-        return false
-      }
+interface Props {
+  objData: ObjData
+  newsType?: NewsType
+}
+const props = withDefaults(defineProps<Props>(), {
+  newsType: () => NewsType.Published
+})
 
-      // 清空上传时的各种数据
-      fileList.value = []
-      uploadData.title = ''
-      uploadData.introduction = ''
+const objData = reactive(props.objData)
+// const tempObj = new Map().set(objData.type, Object.assign({}, objData))
 
-      // 上传好的文件,本质是个素材,所以可以进行选中
-      let item = response.data
-      selectMaterial(item)
-    }
-    /**
-     * 切换消息类型的 tab
-     *
-     * @param tab tab  没用 暂时删了tab
-     */
-    const handleClick = () => {
-      // 设置后续文件上传的文件类型
-      uploadData.type = objDataRef.type
-      if (uploadData.type === 'music') {
-        // 【音乐】上传的是缩略图
-        uploadData.type = 'thumb'
-      }
+/** 切换消息类型的 tab */
+const onTabClick = () => {
+  clear()
+}
 
-      // 从 tempObj 临时缓存中,获取对应的数据,并设置回 objDataRef
-      let tempObjItem = tempObj.get(objDataRef.type)
-      if (tempObjItem) {
-        objDataRef.content = tempObjItem.content ? tempObjItem.content : null
-        objDataRef.mediaId = tempObjItem.mediaId ? tempObjItem.mediaId : null
-        objDataRef.url = tempObjItem.url ? tempObjItem.url : null
-        objDataRef.name = tempObjItem.url ? tempObjItem.name : null
-        objDataRef.title = tempObjItem.title ? tempObjItem.title : null
-        objDataRef.description = tempObjItem.description ? tempObjItem.description : null
-        return
-      }
-      // 如果获取不到,需要把 objDataRef 复原
-      // 必须使用 $set 赋值,不然 input 无法输入内容
-      objDataRef.content = ''
-      objDataRef.mediaId = ''
-      objDataRef.url = ''
-      objDataRef.title = ''
-      objDataRef.description = ''
-    }
-    /**
-     * 选择素材,将设置设置到 objDataRef 变量
-     *
-     * @param item 素材
-     */
-    const selectMaterial = (item) => {
-      // 选择好素材,所以隐藏弹窗
-      closeMaterial()
+/** 清除除了`type`的字段 */
+const clear = () => {
+  objData.content = ''
+  objData.mediaId = ''
+  objData.url = ''
+  objData.title = ''
+  objData.description = ''
+  objData.articles = []
+}
 
-      // 创建 tempObjItem 对象,并设置对应的值
-      let tempObjItem = {
-        type: '',
-        articles: [],
-        thumbMediaId: '',
-        thumbMediaUrl: '',
-        introduction: '',
-        title: '',
-        musicUrl: '',
-        hqMusicUrl: '',
-        mediaId: '',
-        url: '',
-        name: '',
-        description: ''
-      }
-      tempObjItem.type = objDataRef.type
-      if (objDataRef.type === 'news') {
-        tempObjItem.articles = item.content.newsItem
-        objDataRef.articles = item.content.newsItem
-      } else if (objDataRef.type === 'music') {
-        // 音乐需要特殊处理,因为选择的是图片的缩略图
-        tempObjItem.thumbMediaId = item.mediaId
-        objDataRef.thumbMediaId = item.mediaId
-        tempObjItem.thumbMediaUrl = item.url
-        objDataRef.thumbMediaUrl = item.url
-        // title、introduction、musicUrl、hqMusicUrl:从 objDataRef 到 tempObjItem,避免上传素材后,被覆盖掉
-        tempObjItem.title = objDataRef.title || ''
-        tempObjItem.introduction = objDataRef.introduction || ''
-        tempObjItem.musicUrl = objDataRef.musicUrl || ''
-        tempObjItem.hqMusicUrl = objDataRef.hqMusicUrl || ''
-      } else if (objDataRef.type === 'image' || objDataRef.type === 'voice') {
-        tempObjItem.mediaId = item.mediaId
-        objDataRef.mediaId = item.mediaId
-        tempObjItem.url = item.url
-        objDataRef.url = item.url
-        tempObjItem.name = item.name
-        objDataRef.name = item.name
-      } else if (objDataRef.type === 'video') {
-        tempObjItem.mediaId = item.mediaId
-        objDataRef.mediaId = item.mediaId
-        tempObjItem.url = item.url
-        objDataRef.url = item.url
-        tempObjItem.name = item.name
-        objDataRef.name = item.name
-        // title、introduction:从 item 到 tempObjItem,因为素材里有 title、introduction
-        if (item.title) {
-          objDataRef.title = item.title || ''
-          tempObjItem.title = item.title || ''
-        }
-        if (item.introduction) {
-          objDataRef.description = item.introduction || '' // 消息使用的是 description,素材使用的是 introduction,所以转换下
-          tempObjItem.description = item.introduction || ''
-        }
-      } else if (objDataRef.type === 'text') {
-        objDataRef.content = item.content || ''
-      }
-      // 最终设置到临时缓存
-      tempObj.set(objDataRef.type, tempObjItem)
-    }
-    const openMaterial = () => {
-      if (objDataRef.type === 'news') {
-        dialogNewsVisible.value = true
-      } else if (objDataRef.type === 'image') {
-        dialogImageVisible.value = true
-      } else if (objDataRef.type === 'voice') {
-        dialogVoiceVisible.value = true
-      } else if (objDataRef.type === 'video') {
-        dialogVideoVisible.value = true
-      } else if (objDataRef.type === 'music') {
-        dialogThumbVisible.value = true
-      }
-    }
-    const closeMaterial = () => {
-      dialogNewsVisible.value = false
-      dialogImageVisible.value = false
-      dialogVoiceVisible.value = false
-      dialogVideoVisible.value = false
-      dialogThumbVisible.value = false
-    }
-    const deleteObj = () => {
-      if (objDataRef.type === 'news') {
-        objDataRef.articles = []
-      } else if (objDataRef.type === 'image') {
-        objDataRef.mediaId = null
-        objDataRef.url = null
-        objDataRef.name = null
-      } else if (objDataRef.type === 'voice') {
-        objDataRef.mediaId = null
-        objDataRef.url = null
-        objDataRef.name = null
-      } else if (objDataRef.type === 'video') {
-        objDataRef.mediaId = null
-        objDataRef.url = null
-        objDataRef.name = null
-        objDataRef.title = null
-        objDataRef.description = null
-      } else if (objDataRef.type === 'music') {
-        objDataRef.thumbMediaId = null
-        objDataRef.thumbMediaUrl = null
-        objDataRef.title = null
-        objDataRef.description = null
-        objDataRef.musicUrl = null
-        objDataRef.hqMusicUrl = null
-      } else if (objDataRef.type === 'text') {
-        objDataRef.content = null
-      }
-      // 覆盖缓存
-      tempObj.set(objDataRef.type, Object.assign({}, objDataRef))
-    }
-    /**
-     * 输入时,缓存每次 objDataRef 到 tempObj 中
-     *
-     * why?不确定为什么 v-model="objDataRef.content" 不能自动缓存,所以通过这样的方式
-     */
-    const inputContent = () => {
-      // 覆盖缓存
-      tempObj.set(objDataRef.type, Object.assign({}, objDataRef))
-    }
-    return {
-      inputContent,
-      dialogNewsVisible,
-      deleteObj,
-      openMaterial,
-      handleClick,
-      beforeImageUpload,
-      beforeVoiceUpload,
-      handleUploadSuccess,
-      beforeVideoUpload,
-      selectMaterial,
-      dialogImageVisible,
-      dialogVoiceVisible,
-      dialogThumbVisible,
-      actionUrl,
-      objDataRef,
-      headers,
-      fileList,
-      beforeThumbImageUpload,
-      uploadData,
-      dialogVideoVisible
-    }
-  }
+defineExpose({
+  clear
 })
 </script>
 
 <style lang="scss" scoped>
-.public-account-management {
-  .el-input {
-    width: 70%;
-    margin-right: 2%;
-  }
-}
-
-.pagination {
-  text-align: right;
-  margin-right: 25px;
-}
-
 .select-item {
   width: 280px;
   padding: 10px;
diff --git a/src/views/mp/components/wx-video-play/main.vue b/src/views/mp/components/wx-video-play/main.vue
index 7dc4347c..036446bc 100644
--- a/src/views/mp/components/wx-video-play/main.vue
+++ b/src/views/mp/components/wx-video-play/main.vue
@@ -14,26 +14,24 @@
   <div @click="playVideo()">
     <!-- 提示 -->
     <div>
-      <Icon icon="ep:video-play" class="mr-5px" />
-      <p>点击播放视频</p>
+      <Icon icon="ep:video-play" :size="32" class="mr-5px" />
+      <p class="text-sm">点击播放视频</p>
     </div>
 
     <!-- 弹窗播放 -->
-    <el-dialog v-model="dialogVideo" title="视频播放" width="40%" append-to-body>
-      <template #footer>
-        <video-player
-          v-if="dialogVideo"
-          class="video-player vjs-big-play-centered"
-          :src="url"
-          poster=""
-          crossorigin="anonymous"
-          playsinline
-          controls
-          :volume="0.6"
-          :height="320"
-          :playback-rates="[0.7, 1.0, 1.5, 2.0]"
-        />
-      </template>
+    <el-dialog v-model="dialogVideo" title="视频播放" append-to-body>
+      <video-player
+        v-if="dialogVideo"
+        class="video-player vjs-big-play-centered"
+        :src="props.url"
+        poster=""
+        crossorigin="anonymous"
+        playsinline
+        controls
+        :volume="0.6"
+        :width="800"
+        :playback-rates="[0.7, 1.0, 1.5, 2.0]"
+      />
       <!--     事件,暫時沒用
       @mounted="handleMounted"-->
       <!--        @ready="handleEvent($event)"-->
@@ -50,36 +48,24 @@
   </div>
 </template>
 
-<script lang="ts" name="WxVideoPlayer">
-//升级videojs6.0版本,重寫6.0版本
+<script setup lang="ts" name="WxVideoPlayer">
 import 'video.js/dist/video-js.css'
-import { defineComponent } from 'vue'
 import { VideoPlayer } from '@videojs-player/vue'
-import 'video.js/dist/video-js.css'
 
-export default defineComponent({
-  components: {
-    VideoPlayer
-  },
-  props: {
-    url: {
-      // 视频地址,例如说:https://vjs.zencdn.net/v/oceans.mp4
-      type: String,
-      required: true
-    }
-  },
-  setup() {
-    // const videoPlayerRef = ref(null)
-    const dialogVideo = ref(false)
-
-    const handleEvent = (log) => {
-      console.log('Basic player event', log)
-    }
-    const playVideo = () => {
-      dialogVideo.value = true
-    }
-
-    return { handleEvent, playVideo, dialogVideo }
+const props = defineProps({
+  url: {
+    type: String,
+    required: true
   }
 })
+
+const dialogVideo = ref(false)
+
+// const handleEvent = (log) => {
+//   console.log('Basic player event', log)
+// }
+
+const playVideo = () => {
+  dialogVideo.value = true
+}
 </script>
diff --git a/src/views/mp/components/wx-voice-play/main.vue b/src/views/mp/components/wx-voice-play/main.vue
index 5b9e0dfd..3ce970c1 100644
--- a/src/views/mp/components/wx-voice-play/main.vue
+++ b/src/views/mp/components/wx-voice-play/main.vue
@@ -12,8 +12,8 @@
 <template>
   <div class="wx-voice-div" @click="playVoice">
     <el-icon>
-      <Icon v-if="playing !== true" icon="ep:video-play" />
-      <Icon v-else icon="ep:video-pause" />
+      <Icon v-if="playing !== true" icon="ep:video-play" :size="32" />
+      <Icon v-else icon="ep:video-pause" :size="32" />
       <span class="amr-duration" v-if="duration">{{ duration }} 秒</span>
     </el-icon>
     <div v-if="content">
@@ -25,7 +25,6 @@
 
 <script setup lang="ts" name="WxVoicePlayer">
 // 因为微信语音是 amr 格式,所以需要用到 amr 解码器:https://www.npmjs.com/package/benz-amr-recorder
-
 import BenzAMRRecorder from 'benz-amr-recorder'
 
 const props = defineProps({
@@ -90,6 +89,12 @@ const amrStop = () => {
   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;
diff --git a/src/views/mp/draft/components/CoverSelect.vue b/src/views/mp/draft/components/CoverSelect.vue
new file mode 100644
index 00000000..2c9c64b1
--- /dev/null
+++ b/src/views/mp/draft/components/CoverSelect.vue
@@ -0,0 +1,183 @@
+<template>
+  <div>
+    <p>封面:</p>
+    <div class="thumb-div">
+      <el-image
+        v-if="newsItem.thumbUrl"
+        style="width: 300px; max-height: 300px"
+        :src="newsItem.thumbUrl"
+        fit="contain"
+      />
+      <Icon
+        v-else
+        icon="ep:plus"
+        class="avatar-uploader-icon"
+        :class="isFirst ? 'avatar' : 'avatar1'"
+      />
+      <div class="thumb-but">
+        <el-upload
+          :action="UPLOAD_URL"
+          :headers="HEADERS"
+          multiple
+          :limit="1"
+          :file-list="fileList"
+          :data="uploadData"
+          :before-upload="onBeforeUpload"
+          :on-error="onUploadError"
+          :on-success="onUploadSuccess"
+        >
+          <template #trigger>
+            <el-button size="small" type="primary" :loading="isUploading" disabled="isUploading">
+              {{ isUploading ? '正在上传' : '本地上传' }}
+            </el-button>
+          </template>
+          <el-button
+            size="small"
+            type="primary"
+            @click="showImageDialog = true"
+            style="margin-left: 5px"
+          >
+            素材库选择
+          </el-button>
+          <template #tip>
+            <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div>
+          </template>
+        </el-upload>
+      </div>
+      <el-dialog
+        title="选择图片"
+        v-model="showImageDialog"
+        width="80%"
+        append-to-body
+        destroy-on-close
+      >
+        <WxMaterialSelect
+          :objData="{ type: 'image', accountId: accountId }"
+          @select-material="onMaterialSelected"
+        />
+      </el-dialog>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
+import { getAccessToken } from '@/utils/auth'
+import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus'
+import { NewsItem } from './types'
+
+const message = useMessage()
+
+const UPLOAD_URL = 'http://localhost:8000/upload/' //import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址
+const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
+
+const props = defineProps<{
+  modelValue: NewsItem
+  isFirst: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: NewsItem)
+}>()
+const newsItem = computed<NewsItem>({
+  get() {
+    return props.modelValue
+  },
+  set(val) {
+    emit('update:modelValue', val)
+  }
+})
+
+const accountId = inject<number>('accountId')
+const showImageDialog = ref(false)
+
+const fileList = ref<UploadFiles>([])
+interface UploadData {
+  type: 'image' | 'video' | 'audio'
+  accountId?: number
+}
+const uploadData: UploadData = reactive({
+  type: 'image',
+  accountId: accountId
+})
+const isUploading = ref(false)
+
+/** 素材选择完成事件*/
+const onMaterialSelected = (item: any) => {
+  showImageDialog.value = false
+  newsItem.value.thumbMediaId = item.mediaId
+  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 onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
+  if (res.code !== 0) {
+    message.error('上传出错:' + res.msg)
+    return false
+  }
+
+  // 重置上传文件的表单
+  fileList.value = []
+
+  // 设置草稿的封面字段
+  newsItem.value.thumbMediaId = res.data.mediaId
+  newsItem.value.thumbUrl = res.data.url
+}
+
+const onUploadError = (err: Error) => {
+  message.error('上传失败: ' + err.message)
+}
+</script>
+
+<style lang="scss" scoped>
+.el-upload__tip {
+  margin-left: 5px;
+}
+
+.thumb-div {
+  display: inline-block;
+  width: 100%;
+  text-align: center;
+
+  .avatar-uploader-icon {
+    width: 120px;
+    height: 120px;
+    font-size: 28px;
+    line-height: 120px;
+    color: #8c939d;
+    text-align: center;
+    border: 1px solid #d9d9d9;
+  }
+
+  .avatar {
+    width: 230px;
+    height: 120px;
+  }
+
+  .avatar1 {
+    width: 120px;
+    height: 120px;
+  }
+
+  .thumb-but {
+    margin: 5px;
+  }
+}
+</style>
diff --git a/src/views/mp/draft/components/DraftTable.vue b/src/views/mp/draft/components/DraftTable.vue
new file mode 100644
index 00000000..63cee31f
--- /dev/null
+++ b/src/views/mp/draft/components/DraftTable.vue
@@ -0,0 +1,87 @@
+<template>
+  <div class="waterfall" v-loading="props.loading">
+    <template v-for="item in props.list" :key="item.articleId">
+      <div class="waterfall-item" v-if="item.content && item.content.newsItem">
+        <WxNews :articles="item.content.newsItem" />
+        <!-- 操作按钮 -->
+        <el-row>
+          <el-button
+            type="success"
+            circle
+            @click="emit('publish', item)"
+            v-hasPermi="['mp:free-publish:submit']"
+          >
+            <Icon icon="fa:upload" />
+          </el-button>
+          <el-button
+            type="primary"
+            circle
+            @click="emit('update', item)"
+            v-hasPermi="['mp:draft:update']"
+          >
+            <Icon icon="ep:edit" />
+          </el-button>
+          <el-button
+            type="danger"
+            circle
+            @click="emit('delete', item)"
+            v-hasPermi="['mp:draft:delete']"
+          >
+            <Icon icon="ep:delete" />
+          </el-button>
+        </el-row>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import WxNews from '@/views/mp/components/wx-news/main.vue'
+
+import { Article } from './types'
+
+const props = defineProps<{
+  list: Article[]
+  loading: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'publish', v: Article)
+  (e: 'update', v: Article)
+  (e: 'delete', v: Article)
+}>()
+</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;
+  }
+}
+
+@media (min-width: 992px) and (max-width: 1300px) {
+  .waterfall {
+    column-count: 3;
+  }
+}
+
+@media (min-width: 768px) and (max-width: 991px) {
+  .waterfall {
+    column-count: 2;
+  }
+}
+
+@media (max-width: 767px) {
+  .waterfall {
+    column-count: 1;
+  }
+}
+</style>
diff --git a/src/views/mp/draft/components/NewsForm.vue b/src/views/mp/draft/components/NewsForm.vue
new file mode 100644
index 00000000..a2b88a5b
--- /dev/null
+++ b/src/views/mp/draft/components/NewsForm.vue
@@ -0,0 +1,302 @@
+<template>
+  <el-container>
+    <el-aside width="40%">
+      <div class="select-item">
+        <div v-for="(news, index) in newsList" :key="index">
+          <div
+            class="news-main father"
+            v-if="index === 0"
+            :class="{ activeAddNews: activeNewsIndex === index }"
+            @click="activeNewsIndex = index"
+          >
+            <div class="news-content">
+              <img class="material-img" :src="news.thumbUrl" />
+              <div class="news-content-title">{{ news.title }}</div>
+            </div>
+            <div class="child" v-if="newsList.length > 1">
+              <el-button type="info" circle size="small" @click="() => moveDownNews(index)">
+                <Icon icon="ep:arrow-down-bold" />
+              </el-button>
+              <el-button
+                v-if="isCreating"
+                type="danger"
+                circle
+                size="small"
+                @click="() => removeNews(index)"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </div>
+          </div>
+          <div
+            class="news-main-item father"
+            v-if="index > 0"
+            :class="{ activeAddNews: activeNewsIndex === index }"
+            @click="activeNewsIndex = 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" :src="news.thumbUrl" width="100%" />
+              </div>
+            </div>
+            <div class="child">
+              <el-button
+                v-if="newsList.length > index + 1"
+                circle
+                type="info"
+                size="small"
+                @click="() => moveDownNews(index)"
+              >
+                <Icon icon="ep:arrow-down-bold" />
+              </el-button>
+              <el-button
+                v-if="index > 0"
+                type="info"
+                circle
+                size="small"
+                @click="() => moveUpNews(index)"
+              >
+                <Icon icon="ep:arrow-up-bold" />
+              </el-button>
+              <el-button
+                v-if="isCreating"
+                type="danger"
+                size="small"
+                circle
+                @click="() => removeNews(index)"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </div>
+          </div>
+        </div>
+        <el-row justify="center" class="ope-row">
+          <el-button
+            type="primary"
+            circle
+            @click="plusNews"
+            v-if="newsList.length < 8 && isCreating"
+          >
+            <Icon icon="ep:plus" />
+          </el-button>
+        </el-row>
+      </div>
+    </el-aside>
+    <el-main>
+      <div v-if="newsList.length > 0">
+        <!-- 标题、作者、原文地址 -->
+        <el-row :gutter="20">
+          <el-input v-model="activeNewsItem.title" placeholder="请输入标题(必填)" />
+          <el-input
+            v-model="activeNewsItem.author"
+            placeholder="请输入作者"
+            style="margin-top: 5px"
+          />
+          <el-input
+            v-model="activeNewsItem.contentSourceUrl"
+            placeholder="请输入原文地址"
+            style="margin-top: 5px"
+          />
+        </el-row>
+        <!-- 封面和摘要 -->
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <CoverSelect v-model="activeNewsItem" :is-first="activeNewsIndex === 0" />
+          </el-col>
+          <el-col :span="12">
+            <p>摘要:</p>
+            <el-input
+              :rows="8"
+              type="textarea"
+              v-model="activeNewsItem.digest"
+              placeholder="请输入摘要"
+              class="digest"
+              maxlength="120"
+            />
+          </el-col>
+        </el-row>
+        <!--富文本编辑器组件-->
+        <el-row>
+          <Editor v-model="activeNewsItem.content" :editor-config="editorConfig" />
+        </el-row>
+      </div>
+    </el-main>
+  </el-container>
+</template>
+
+<script setup lang="ts">
+import { Editor } from '@/components/Editor'
+import { createEditorConfig } from '../editor-config'
+import CoverSelect from './CoverSelect.vue'
+import { type NewsItem, createEmptyNewsItem } from './types'
+
+const message = useMessage()
+
+const props = defineProps<{
+  isCreating: boolean
+  modelValue: NewsItem[] | null
+}>()
+
+const accountId = inject<number>('accountId')
+
+// ========== 文件上传 ==========
+const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址
+const editorConfig = createEditorConfig(UPLOAD_URL, accountId)
+
+// v-model=newsList
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: NewsItem[])
+}>()
+const newsList = computed<NewsItem[]>({
+  get() {
+    return props.modelValue === null ? [createEmptyNewsItem()] : props.modelValue
+  },
+  set(val) {
+    emit('update:modelValue', val)
+  }
+})
+
+const activeNewsIndex = ref(0)
+const activeNewsItem = computed<NewsItem>(() => newsList.value[activeNewsIndex.value])
+
+// 将图文向下移动
+const moveDownNews = (index: number) => {
+  const temp = newsList.value[index]
+  newsList.value[index] = newsList.value[index + 1]
+  newsList.value[index + 1] = temp
+  activeNewsIndex.value = index + 1
+}
+
+// 将图文向上移动
+const moveUpNews = (index: number) => {
+  const temp = newsList.value[index]
+  newsList.value[index] = newsList.value[index - 1]
+  newsList.value[index - 1] = temp
+  activeNewsIndex.value = index - 1
+}
+
+// 删除指定 index 的图文
+const removeNews = async (index: number) => {
+  try {
+    await message.confirm('确定删除该图文吗?')
+    newsList.value.splice(index, 1)
+    if (activeNewsIndex.value === index) {
+      activeNewsIndex.value = 0
+    }
+  } catch {}
+}
+
+// 添加一个图文
+const plusNews = () => {
+  newsList.value.push(createEmptyNewsItem())
+  activeNewsIndex.value = newsList.value.length - 1
+}
+</script>
+
+<style lang="scss" scoped>
+.ope-row {
+  padding-top: 5px;
+  margin-top: 5px;
+  text-align: center;
+  border-top: 1px solid #eaeaea;
+}
+
+.el-row {
+  margin-bottom: 20px;
+}
+
+.el-row:last-child {
+  margin-bottom: 0;
+}
+
+.digest {
+  display: inline-block;
+  width: 100%;
+  vertical-align: top;
+}
+
+/* 新增图文 */
+.news-main {
+  width: 100%;
+  height: 120px;
+  margin: auto;
+  background-color: #fff;
+}
+
+.news-content {
+  position: relative;
+  width: 100%;
+  height: 120px;
+  background-color: #acadae;
+}
+
+.news-content-title {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  display: inline-block;
+  width: 98%;
+  height: 25px;
+  padding: 1%;
+  overflow: hidden;
+  font-size: 15px;
+  color: #fff;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  background-color: black;
+  opacity: 0.65;
+}
+
+.news-main-item {
+  width: 100%;
+  padding: 5px 0;
+  margin: auto;
+  background-color: #fff;
+  border-top: 1px solid #eaeaea;
+}
+
+.news-content-item {
+  position: relative;
+  margin-left: -3px;
+}
+
+.news-content-item-title {
+  display: inline-block;
+  width: 70%;
+  font-size: 12px;
+}
+
+.news-content-item-img {
+  display: inline-block;
+  width: 25%;
+  background-color: #acadae;
+}
+
+.select-item {
+  width: 60%;
+  padding: 10px;
+  margin: 0 auto 10px;
+  border: 1px solid #eaeaea;
+
+  .activeAddNews {
+    border: 5px solid #2bb673;
+  }
+}
+
+.father .child {
+  position: relative;
+  bottom: 25px;
+  display: none;
+  text-align: center;
+}
+
+.father:hover .child {
+  display: block;
+}
+
+.material-img {
+  width: 100%;
+  height: 100%;
+}
+</style>
diff --git a/src/views/mp/draft/components/index.ts b/src/views/mp/draft/components/index.ts
new file mode 100644
index 00000000..51e843d3
--- /dev/null
+++ b/src/views/mp/draft/components/index.ts
@@ -0,0 +1,7 @@
+import type { Article, NewsItem, NewsItemList } from './types'
+import { createEmptyNewsItem } from './types'
+import DraftTable from './DraftTable.vue'
+import NewsForm from './NewsForm.vue'
+
+export { DraftTable, NewsForm, createEmptyNewsItem }
+export type { Article, NewsItem, NewsItemList }
diff --git a/src/views/mp/draft/components/types.ts b/src/views/mp/draft/components/types.ts
new file mode 100644
index 00000000..a8cf00c3
--- /dev/null
+++ b/src/views/mp/draft/components/types.ts
@@ -0,0 +1,40 @@
+interface NewsItem {
+  title: string
+  thumbMediaId: string
+  author: string
+  digest: string
+  showCoverPic: string
+  content: string
+  contentSourceUrl: string
+  needOpenComment: string
+  onlyFansCanComment: string
+  thumbUrl: string
+}
+
+interface NewsItemList {
+  newsItem: NewsItem[]
+}
+
+interface Article {
+  mediaId: string
+  content: NewsItemList
+  updateTime: number
+}
+
+const createEmptyNewsItem = (): NewsItem => {
+  return {
+    title: '',
+    thumbMediaId: '',
+    author: '',
+    digest: '',
+    showCoverPic: '',
+    content: '',
+    contentSourceUrl: '',
+    needOpenComment: '',
+    onlyFansCanComment: '',
+    thumbUrl: ''
+  }
+}
+
+export type { Article, NewsItem, NewsItemList }
+export { createEmptyNewsItem }
diff --git a/src/views/mp/draft/index.vue b/src/views/mp/draft/index.vue
index cf0bb10d..9e188ec4 100644
--- a/src/views/mp/draft/index.vue
+++ b/src/views/mp/draft/index.vue
@@ -14,7 +14,13 @@
         <WxAccountSelect @change="onAccountChanged" />
       </el-form-item>
       <el-form-item>
-        <el-button type="primary" plain @click="handleAdd" v-hasPermi="['mp:draft:create']">
+        <el-button
+          type="primary"
+          plain
+          @click="handleAdd"
+          v-hasPermi="['mp:draft:create']"
+          :disabled="accountId === 0"
+        >
           <Icon icon="ep:plus" />新增
         </el-button>
       </el-form-item>
@@ -23,40 +29,13 @@
 
   <!-- 列表 -->
   <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">
-          <WxNews :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>
+    <DraftTable
+      :loading="loading"
+      :list="list"
+      @update="onUpdate"
+      @delete="onDelete"
+      @publish="onPublish"
+    />
     <!-- 分页记录 -->
     <Pagination
       :total="total"
@@ -66,287 +45,101 @@
     />
   </ContentWrap>
 
-  <div class="app-container">
-    <!-- 添加或修改草稿对话框 -->
-    <el-dialog
-      :title="operateMaterial === 'add' ? '新建图文' : '修改图文'"
-      width="80%"
-      v-model="dialogNewsVisible"
-      :before-close="dialogNewsClose"
-      destroy-on-close
-    >
-      <el-container>
-        <el-aside width="40%">
-          <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"
-                      width="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"
-                v-if="articlesAdd.length < 8 && operateMaterial === 'add'"
-              >
-                <Icon icon="ep:plus" />
-              </el-button>
-            </el-row>
-          </div>
-        </el-aside>
-        <el-main>
-          <div class="" v-loading="addMaterialLoading" v-if="articlesAdd.length > 0">
-            <!-- 标题、作者、原文地址 -->
-            <el-row :gutter="20">
-              <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"
-              />
-            </el-row>
-            <!-- 封面和摘要 -->
-            <el-row :gutter="20">
-              <el-col :span="12">
-                <p>封面:</p>
-                <div class="thumb-div">
-                  <el-image
-                    v-if="articlesAdd[isActiveAddNews].thumbUrl"
-                    style="width: 300px; max-height: 300px"
-                    :src="articlesAdd[isActiveAddNews].thumbUrl"
-                    fit="contain"
-                  />
-                  <Icon
-                    v-else
-                    icon="ep:plus"
-                    class="avatar-uploader-icon"
-                    :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"
-                  />
-                  <div class="thumb-but">
-                    <el-upload
-                      :action="uploadUrl"
-                      :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>
-                  <el-dialog
-                    title="选择图片"
-                    v-model="dialogImageVisible"
-                    width="80%"
-                    append-to-body
-                  >
-                    <WxMaterialSelect
-                      ref="materialSelectRef"
-                      :objData="{ type: 'image', accountId: queryParams.accountId }"
-                      @select-material="selectMaterial"
-                    />
-                  </el-dialog>
-                </div>
-              </el-col>
-              <el-col :span="12">
-                <p>摘要:</p>
-                <el-input
-                  :rows="8"
-                  type="textarea"
-                  v-model="articlesAdd[isActiveAddNews].digest"
-                  placeholder="请输入摘要"
-                  class="digest"
-                  maxlength="120"
-                />
-              </el-col>
-            </el-row>
-            <!--富文本编辑器组件-->
-            <el-row>
-              <Editor
-                v-model="articlesAdd[isActiveAddNews].content"
-                :editor-config="editorConfig"
-              />
-            </el-row>
-          </div>
-        </el-main>
-      </el-container>
-      <template #footer>
-        <el-button @click="dialogNewsVisible = false">取 消</el-button>
-        <el-button type="primary" @click="submitForm">提 交</el-button>
-      </template>
-    </el-dialog>
-  </div>
+  <!-- 添加或修改草稿对话框 -->
+  <el-dialog
+    :title="isCreating ? '新建图文' : '修改图文'"
+    width="80%"
+    v-model="showDialog"
+    :before-close="onBeforeDialogClose"
+    destroy-on-close
+  >
+    <NewsForm v-model="newsList" v-loading="isSubmitting" :is-creating="isCreating" />
+    <template #footer>
+      <el-button @click="showDialog = false">取 消</el-button>
+      <el-button type="primary" @click="onSubmitNewsItem">提 交</el-button>
+    </template>
+  </el-dialog>
 </template>
 
 <script setup lang="ts" name="MpDraft">
-import { Editor } from '@/components/Editor'
-import WxNews from '@/views/mp/components/wx-news/main.vue'
-import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
 import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
-import { getAccessToken } from '@/utils/auth'
 import * as MpDraftApi from '@/api/mp/draft'
 import * as MpFreePublishApi from '@/api/mp/freePublish'
-import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus'
-import { createEditorConfig } from './editor-config'
+import {
+  type Article,
+  type NewsItem,
+  NewsForm,
+  DraftTable,
+  createEmptyNewsItem
+} from './components/'
 // import drafts from './mock' // 可以用改本地数据模拟,避免API调用超限
-import { IEditorConfig } from '@wangeditor/editor'
 
 const message = useMessage() // 消息
 
+const accountId = ref(0)
+provide('accountId', accountId)
+
 const loading = ref(true) // 列表的加载中
 const list = ref<any[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
 interface QueryParams {
   pageNo: number
   pageSize: number
-  accountId?: number
+  accountId: number
 }
 const queryParams: QueryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: undefined
+  accountId: accountId.value
 })
 
-// ========== 文件上传 ==========
-const BASE_URL = import.meta.env.VITE_BASE_URL
-const uploadUrl = BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址
-const headers = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
-
-const materialSelectRef = ref<InstanceType<typeof WxMaterialSelect> | null>(null)
-const fileList = ref<UploadFiles>([])
 interface UploadData {
   type: 'image' | 'video' | 'audio'
-  accountId?: number
+  accountId: number
 }
 const uploadData: UploadData = reactive({
   type: 'image',
-  accountId: 1
+  accountId: accountId.value
 })
 
 // ========== 草稿新建 or 修改 ==========
-interface Article {
-  title: string
-  thumbMediaId: string
-  author: string
-  digest: string
-  showCoverPic: string
-  content: string
-  contentSourceUrl: string
-  needOpenComment: string
-  onlyFansCanComment: string
-  thumbUrl: string
-}
-const dialogNewsVisible = ref(false)
-const addMaterialLoading = ref(false) // 添加草稿的 loading 标识
-const articlesAdd = ref<Article[]>([])
-const isActiveAddNews = ref(0)
-const dialogImageVisible = ref(false)
-const operateMaterial = ref<'add' | 'edit'>('add')
-const articlesMediaId = ref('')
+const showDialog = ref(false)
+const newsList = ref<NewsItem[]>([])
+const mediaId = ref('')
+const isCreating = ref(true)
+const isSubmitting = ref(false)
 
 /** 侦听公众号变化 **/
-const onAccountChanged = (id?: number) => {
+const onAccountChanged = (id: number) => {
   setAccountId(id)
   getList()
 }
 
-// ======================== 列表查询 ========================
-/** 设置账号编号 */
-const setAccountId = (id?: number) => {
-  queryParams.accountId = id
-  uploadData.accountId = id
-  editorConfig.value = createEditorConfig(uploadUrl, queryParams.accountId)
+// 关闭弹窗
+const onBeforeDialogClose = async (onDone: () => {}) => {
+  try {
+    await message.confirm('修改内容可能还未保存,确定关闭吗?')
+    onDone()
+  } catch {}
 }
 
-const editorConfig = ref<Partial<IEditorConfig>>({})
+// ======================== 列表查询 ========================
+/** 设置账号编号 */
+const setAccountId = (id: number) => {
+  queryParams.accountId = id
+  uploadData.accountId = id
+}
 
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
   try {
     const drafts = await MpDraftApi.getDraftPage(queryParams)
-    drafts.list.forEach((item) => {
-      const newsItem = item.content.newsItem
+    drafts.list.forEach((draft) => {
+      const newsList = draft.content.newsItem
       // 将 thumbUrl 转成 picUrl,保证 wx-news 组件可以预览封面
-      newsItem.forEach((article) => {
-        article.picUrl = article.thumbUrl
+      newsList.forEach((item) => {
+        item.picUrl = item.thumbUrl
       })
     })
     list.value = drafts.list
@@ -359,163 +152,45 @@ const getList = async () => {
 // ======================== 新增/修改草稿 ========================
 /** 新增按钮操作 */
 const handleAdd = () => {
-  reset()
-  // 打开表单,并设置初始化
-  operateMaterial.value = 'add'
-  dialogNewsVisible.value = true
+  isCreating.value = true
+  newsList.value = [createEmptyNewsItem()]
+  showDialog.value = true
 }
 
 /** 更新按钮操作 */
-const handleUpdate = (item: any) => {
-  reset()
-  articlesMediaId.value = item.mediaId
-  articlesAdd.value = JSON.parse(JSON.stringify(item.content.newsItem))
-  // 打开表单,并设置初始化
-  operateMaterial.value = 'edit'
-  dialogNewsVisible.value = true
+const onUpdate = (item: Article) => {
+  mediaId.value = item.mediaId
+  newsList.value = JSON.parse(JSON.stringify(item.content.newsItem))
+  isCreating.value = false
+  showDialog.value = true
 }
 
 /** 提交按钮 */
-const submitForm = async () => {
-  addMaterialLoading.value = true
+const onSubmitNewsItem = async () => {
+  isSubmitting.value = true
   try {
-    if (operateMaterial.value === 'add') {
-      await MpDraftApi.createDraft(queryParams.accountId, articlesAdd.value)
+    if (isCreating.value) {
+      await MpDraftApi.createDraft(queryParams.accountId, newsList.value)
       message.notifySuccess('新增成功')
     } else {
-      await MpDraftApi.updateDraft(queryParams.accountId, articlesMediaId.value, articlesAdd.value)
+      await MpDraftApi.updateDraft(queryParams.accountId, mediaId.value, newsList.value)
       message.notifySuccess('更新成功')
     }
   } finally {
-    dialogNewsVisible.value = false
-    addMaterialLoading.value = false
+    showDialog.value = false
+    isSubmitting.value = false
     await getList()
   }
 }
 
-// 关闭弹窗
-const dialogNewsClose = async (onDone: () => {}) => {
-  try {
-    await message.confirm('修改内容可能还未保存,确定关闭吗?')
-    reset()
-    onDone()
-  } catch {}
-}
-
-// 表单重置
-const reset = () => {
-  isActiveAddNews.value = 0
-  articlesAdd.value = [buildEmptyArticle()]
-}
-
-// 将图文向下移动
-const downNews = (index: number) => {
-  let temp = articlesAdd.value[index]
-  articlesAdd.value[index] = articlesAdd.value[index + 1]
-  articlesAdd.value[index + 1] = temp
-  isActiveAddNews.value = index + 1
-}
-
-// 将图文向上移动
-const upNews = (index: number) => {
-  const temp = articlesAdd.value[index]
-  articlesAdd.value[index] = articlesAdd.value[index - 1]
-  articlesAdd.value[index - 1] = temp
-  isActiveAddNews.value = index - 1
-}
-
-// 选中指定 index 的图文
-const activeNews = (index: number) => {
-  isActiveAddNews.value = index
-}
-
-// 删除指定 index 的图文
-const minusNews = async (index: number) => {
-  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 = (): Article => {
-  return {
-    title: '',
-    thumbMediaId: '',
-    author: '',
-    digest: '',
-    showCoverPic: '',
-    content: '',
-    contentSourceUrl: '',
-    needOpenComment: '',
-    onlyFansCanComment: '',
-    thumbUrl: ''
-  }
-}
-
-// ======================== 文件上传 ========================
-const beforeThumbImageUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => {
-  addMaterialLoading.value = true
-  const isType = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg'].includes(
-    rawFile.type
-  )
-  if (!isType) {
-    message.error('上传图片格式不对!')
-    addMaterialLoading.value = false
-    return false
-  }
-
-  if (rawFile.size / 1024 / 1024 > 2) {
-    message.error('上传图片大小不能超过 2M!')
-    addMaterialLoading.value = false
-    return false
-  }
-  // 校验通过
-  return true
-}
-
-const handleUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
-  addMaterialLoading.value = false
-  if (res.code !== 0) {
-    message.error('上传出错:' + res.msg)
-    return false
-  }
-
-  // 重置上传文件的表单
-  fileList.value = []
-
-  // 设置草稿的封面字段
-  articlesAdd.value[isActiveAddNews.value].thumbMediaId = res.data.mediaId
-  articlesAdd.value[isActiveAddNews.value].thumbUrl = res.data.url
-}
-
-// 选择 or 上传完素材,设置回草稿
-const selectMaterial = (item: any) => {
-  dialogImageVisible.value = false
-  articlesAdd.value[isActiveAddNews.value].thumbMediaId = item.mediaId
-  articlesAdd.value[isActiveAddNews.value].thumbUrl = item.url
-}
-
-// 打开素材选择
-const openMaterial = () => {
-  dialogImageVisible.value = true
-}
-
 // ======================== 草稿箱发布 ========================
-const handlePublish = async (item: any) => {
+const onPublish = async (item: Article) => {
   const accountId = queryParams.accountId
   const mediaId = item.mediaId
   const content =
-    '你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。已发布内容不会推送给用户,也不会展示在公众号主页中。 发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。'
+    '你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' +
+    '已发布内容不会推送给用户,也不会展示在公众号主页中。 ' +
+    '发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。'
   try {
     await message.confirm(content)
     await MpFreePublishApi.submitFreePublish(accountId, mediaId)
@@ -525,7 +200,7 @@ const handlePublish = async (item: any) => {
 }
 
 /** 删除按钮操作 */
-const handleDelete = async (item: any) => {
+const onDelete = async (item: Article) => {
   const accountId = queryParams.accountId
   const mediaId = item.mediaId
   try {
@@ -536,234 +211,10 @@ const handleDelete = async (item: any) => {
   } 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;
-}
-
-.el-row {
-  margin-bottom: 20px;
-}
-.el-row:last-child {
-  margin-bottom: 0;
-}
-
-.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;
-//   vertical-align: top;
-//   margin-top: 200px;
-// }
-
-// .right {
-//   display: inline-block;
-//   width: 100%;
-// }
-
-// .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: 100%;
-  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;
-}
-
-@media (min-width: 992px) and (max-width: 1300px) {
-  .waterfall {
-    column-count: 3;
-  }
-}
-
-@media (min-width: 768px) and (max-width: 991px) {
-  .waterfall {
-    column-count: 2;
-  }
-}
-
-@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;
-}
-
-.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: 100%;
-  text-align: center;
-}
-
-.thumb-but {
-  margin: 5px;
-}
-
-.material-img {
-  width: 100%;
-  height: 100%;
-}
 </style>
diff --git a/src/views/mp/hooks/useUpload.ts b/src/views/mp/hooks/useUpload.ts
new file mode 100644
index 00000000..b2d32d71
--- /dev/null
+++ b/src/views/mp/hooks/useUpload.ts
@@ -0,0 +1,50 @@
+import type { UploadRawFile } from 'element-plus'
+
+const message = useMessage() // 消息
+
+enum MaterialType {
+  Image = 'image',
+  Voice = 'voice',
+  Video = 'video'
+}
+
+const useBeforeUpload = (type: MaterialType, maxSizeMB: number) => {
+  const fn = (rawFile: UploadRawFile): boolean => {
+    let allowTypes: string[] = []
+    let name = ''
+
+    switch (type) {
+      case MaterialType.Image:
+        allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg']
+        maxSizeMB = 2
+        name = '图片'
+        break
+      case MaterialType.Voice:
+        allowTypes = ['audio/mp3', 'audio/mpeg', 'audio/wma', 'audio/wav', 'audio/amr']
+        maxSizeMB = 2
+        name = '语音'
+        break
+      case MaterialType.Video:
+        allowTypes = ['video/mp4']
+        maxSizeMB = 10
+        name = '视频'
+        break
+    }
+    // 格式不正确
+    if (!allowTypes.includes(rawFile.type)) {
+      message.error(`上传${name}格式不对!`)
+      return false
+    }
+    // 大小不正确
+    if (rawFile.size / 1024 / 1024 > maxSizeMB) {
+      message.error(`上传${name}大小不能超过${maxSizeMB}M!`)
+      return false
+    }
+
+    return true
+  }
+
+  return fn
+}
+
+export { MaterialType, useBeforeUpload }
diff --git a/src/views/mp/material/components/UploadFile.vue b/src/views/mp/material/components/UploadFile.vue
index 3ab62224..be7e323b 100644
--- a/src/views/mp/material/components/UploadFile.vue
+++ b/src/views/mp/material/components/UploadFile.vue
@@ -6,12 +6,13 @@
     :limit="1"
     :file-list="fileList"
     :data="uploadData"
-    :on-progress="() => (uploading = true)"
-    :before-upload="beforeUpload"
-    :on-success="handleUploadSuccess"
+    :on-progress="(isUploading = true)"
+    :on-error="onUploadError"
+    :before-upload="onBeforeUpload"
+    :on-success="onUploadSuccess"
   >
-    <el-button type="primary" plain :loading="uploading" :disabled="uploading">
-      {{ uploading ? '正在上传' : '点击上传' }}
+    <el-button type="primary" plain :loading="isUploading" :disabled="isUploading">
+      {{ isUploading ? '正在上传' : '点击上传' }}
     </el-button>
     <template #tip>
       <span class="el-upload__tip" style="margin-left: 5px">
@@ -33,7 +34,7 @@ import {
 
 const message = useMessage()
 
-const props = defineProps<{ type: boolean }>()
+const props = defineProps<{ type: MaterialType }>()
 
 const fileList = ref<UploadUserFile[]>([])
 const emit = defineEmits<{
@@ -45,11 +46,13 @@ const uploadData: UploadData = reactive({
   title: '',
   introduction: ''
 })
-const uploading = ref(false)
+const isUploading = ref(false)
 
-const beforeUpload = props.type == MaterialType.Image ? beforeImageUpload : beforeVoiceUpload
+/** 上传前检查 */
+const onBeforeUpload = props.type === MaterialType.Image ? beforeImageUpload : beforeVoiceUpload
 
-const handleUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
+/** 上传成功处理 */
+const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
   if (res.code !== 0) {
     message.alertError('上传出错:' + res.msg)
     return false
@@ -61,7 +64,16 @@ const handleUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
   uploadData.introduction = ''
 
   message.notifySuccess('上传成功')
-  uploading.value = false
+  isUploading.value = false
   emit('uploaded')
 }
+
+/** 上传失败处理 */
+const onUploadError = (err: Error) => message.error('上传失败: ' + err.message)
 </script>
+
+<style lang="scss" scoped>
+.el-upload__tip {
+  margin-left: 5px;
+}
+</style>
diff --git a/src/views/mp/material/components/UploadVideo.vue b/src/views/mp/material/components/UploadVideo.vue
index 816711d1..8e374767 100644
--- a/src/views/mp/material/components/UploadVideo.vue
+++ b/src/views/mp/material/components/UploadVideo.vue
@@ -8,8 +8,9 @@
       :file-list="fileList"
       :data="uploadData"
       :before-upload="beforeVideoUpload"
-      :on-progress="() => (uploading = true)"
-      :on-success="handleUploadSuccess"
+      :on-progress="(isUploading = true)"
+      :on-error="onUploadError"
+      :on-success="onUploadSuccess"
       ref="uploadVideoRef"
       :auto-upload="false"
       class="mb-5"
@@ -22,7 +23,7 @@
       >
     </el-upload>
     <el-divider />
-    <el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef" v-loading="uploading">
+    <el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef" v-loading="isUploading">
       <el-form-item label="标题" prop="title">
         <el-input
           v-model="uploadData.title"
@@ -40,7 +41,7 @@
     </el-form>
     <template #footer>
       <el-button @click="showDialog = false">取 消</el-button>
-      <el-button type="primary" @click="submitVideo" :loading="uploading" :disabled="uploading"
+      <el-button type="primary" @click="submitVideo" :loading="isUploading" :disabled="isUploading"
         >提 交</el-button
       >
     </template>
@@ -75,7 +76,7 @@ const emit = defineEmits<{
   (e: 'uploaded', v: void)
 }>()
 
-const showDialog = computed({
+const showDialog = computed<boolean>({
   get() {
     return props.modelValue
   },
@@ -84,7 +85,7 @@ const showDialog = computed({
   }
 })
 
-const uploading = ref(false)
+const isUploading = ref(false)
 
 const fileList = ref<UploadUserFile[]>([])
 
@@ -106,8 +107,9 @@ const submitVideo = () => {
   })
 }
 
-const handleUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
-  uploading.value = false
+/** 上传成功处理 */
+const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
+  isUploading.value = false
   if (res.code !== 0) {
     message.error('上传出错:' + res.msg)
     return false
@@ -122,4 +124,7 @@ const handleUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
   message.notifySuccess('上传成功')
   emit('uploaded')
 }
+
+/** 上传失败处理 */
+const onUploadError = (err: Error) => message.error(`上传失败: ${err.message}`)
 </script>
diff --git a/src/views/mp/material/components/upload.ts b/src/views/mp/material/components/upload.ts
index 239665a2..dc52a9eb 100644
--- a/src/views/mp/material/components/upload.ts
+++ b/src/views/mp/material/components/upload.ts
@@ -1,15 +1,9 @@
 import type { UploadProps, UploadRawFile } from 'element-plus'
 import { getAccessToken } from '@/utils/auth'
-const message = useMessage() // 消息
-const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 请求头
-// const UPLOAD_URL = 'http://127.0.0.1:8000/upload/' // 上传地址
-const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传地址
+import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
 
-enum MaterialType {
-  Image = 'image',
-  Voice = 'voice',
-  Video = 'video'
-}
+const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 请求头
+const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传地址
 
 interface UploadData {
   type: MaterialType
@@ -17,48 +11,14 @@ interface UploadData {
   introduction: string
 }
 
-const beforeUpload = (rawFile: UploadRawFile, materialType: MaterialType): boolean => {
-  let allowTypes: string[] = []
-  let maxSizeMB = 0
-  let name = ''
-  switch (materialType) {
-    case MaterialType.Image:
-      allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg']
-      maxSizeMB = 2
-      name = '图片'
-      break
-    case MaterialType.Voice:
-      allowTypes = ['audio/mp3', 'audio/mpeg', 'audio/wma', 'audio/wav', 'audio/amr']
-      maxSizeMB = 2
-      name = '图片'
-      break
-    case MaterialType.Video:
-      allowTypes = ['video/mp4']
-      maxSizeMB = 10
-      name = '视频'
-      break
-  }
-  // 格式不正确
-  if (!allowTypes.includes(rawFile.type)) {
-    message.error(`上传${name}格式不对!`)
-    return false
-  }
-  // 大小不正确
-  if (rawFile.size / 1024 / 1024 > maxSizeMB) {
-    message.error(`上传${name}大小不能超过${maxSizeMB}M!`)
-    return false
-  }
-  return true
-}
-
 const beforeImageUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
-  beforeUpload(rawFile, MaterialType.Image)
+  useBeforeUpload(MaterialType.Image, 2)(rawFile)
 
 const beforeVoiceUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
-  beforeUpload(rawFile, MaterialType.Voice)
+  useBeforeUpload(MaterialType.Voice, 2)(rawFile)
 
 const beforeVideoUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
-  beforeUpload(rawFile, MaterialType.Video)
+  useBeforeUpload(MaterialType.Video, 10)(rawFile)
 
 export {
   HEADERS,
diff --git a/src/views/mp/menu/components/MenuEditor.vue b/src/views/mp/menu/components/MenuEditor.vue
new file mode 100644
index 00000000..b83fcddf
--- /dev/null
+++ b/src/views/mp/menu/components/MenuEditor.vue
@@ -0,0 +1,243 @@
+<template>
+  <div>
+    <div class="configure_page">
+      <div class="delete_btn">
+        <el-button type="danger" @click="emit('delete')">
+          <Icon icon="ep:delete" />
+          删除当前菜单
+        </el-button>
+      </div>
+      <div>
+        <span>菜单名称:</span>
+        <el-input
+          class="input_width"
+          v-model="menu.name"
+          placeholder="请输入菜单名称"
+          :maxlength="isParent ? 4 : 7"
+          clearable
+        />
+      </div>
+      <div v-if="isLeave">
+        <div class="menu_content">
+          <span>菜单标识:</span>
+          <el-input
+            class="input_width"
+            v-model="menu.menuKey"
+            placeholder="请输入菜单 KEY"
+            clearable
+          />
+        </div>
+        <div class="menu_content">
+          <span>菜单内容:</span>
+          <el-select v-model="menu.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="menu.type === 'view'">
+          <span>跳转链接:</span>
+          <el-input class="input_width" v-model="menu.url" placeholder="请输入链接" clearable />
+        </div>
+        <div class="configur_content" v-if="menu.type === 'miniprogram'">
+          <div class="applet">
+            <span>小程序的 appid :</span>
+            <el-input
+              class="input_width"
+              v-model="menu.miniProgramAppId"
+              placeholder="请输入小程序的appid"
+              clearable
+            />
+          </div>
+          <div class="applet">
+            <span>小程序的页面路径:</span>
+            <el-input
+              class="input_width"
+              v-model="menu.miniProgramPagePath"
+              placeholder="请输入小程序的页面路径,如:pages/index"
+              clearable
+            />
+          </div>
+          <div class="applet">
+            <span>小程序的备用网页:</span>
+            <el-input
+              class="input_width"
+              v-model="menu.url"
+              placeholder="不支持小程序的老版本客户端将打开本网页"
+              clearable
+            />
+          </div>
+          <p class="blue">tips:需要和公众号进行关联才可以把小程序绑定带微信菜单上哟!</p>
+        </div>
+        <div class="configur_content" v-if="menu.type === 'article_view_limited'">
+          <el-row>
+            <div class="select-item" v-if="menu && menu.replyArticles">
+              <WxNews :articles="menu.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="showNewsDialog = true">
+                    素材库选择
+                    <Icon icon="ep:circle-check" />
+                  </el-button>
+                </el-col>
+              </el-row>
+            </div>
+            <el-dialog title="选择图文" v-model="showNewsDialog" width="80%" destroy-on-close>
+              <WxMaterialSelect
+                :objData="{ type: 'news', accountId: props.accountId }"
+                @select-material="selectMaterial"
+              />
+            </el-dialog>
+          </el-row>
+        </div>
+        <div
+          class="configur_content"
+          v-if="menu.type === 'click' || menu.type === 'scancode_waitmsg'"
+        >
+          <WxReplySelect v-if="hackResetWxReplySelect" :objData="menu.reply" />
+        </div>
+      </div>
+    </div>
+  </div>
+</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 menuOptions from './menuOptions'
+
+const message = useMessage()
+
+const props = defineProps<{
+  accountId?: number
+  modelValue: any
+  isParent: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'delete', v: void)
+  (e: 'update:modelValue', v: any)
+}>()
+
+const menu = computed({
+  get() {
+    return props.modelValue
+  },
+  set(val) {
+    emit('update:modelValue', val)
+  }
+})
+const showNewsDialog = ref(false)
+const hackResetWxReplySelect = ref(false)
+const isLeave = computed<boolean>(() => !(menu.value.children?.length > 0))
+
+watch(menu, () => {
+  hackResetWxReplySelect.value = false // 销毁组件
+  nextTick(() => {
+    hackResetWxReplySelect.value = true // 重建组件
+  })
+})
+
+// ======================== 菜单编辑(素材选择) ========================
+const selectMaterial = (item: any) => {
+  const articleId = item.articleId
+  const articles = item.content.newsItem
+  // 提示,针对多图文
+  if (articles.length > 1) {
+    message.alertWarning('您选择的是多图文,将默认跳转第一篇')
+  }
+  showNewsDialog.value = false
+
+  // 设置菜单的回复
+  menu.value.articleId = articleId
+  menu.value.replyArticles = []
+  articles.forEach((article) => {
+    menu.value.replyArticles.push({
+      title: article.title,
+      description: article.digest,
+      picUrl: article.picUrl,
+      url: article.url
+    })
+  })
+}
+
+const deleteMaterial = () => {
+  delete menu.value['articleId']
+  delete menu.value['replyArticles']
+}
+</script>
+
+<style lang="scss" scoped>
+.el-input {
+  width: 70%;
+  margin-right: 2%;
+}
+
+.configure_page {
+  .delete_btn {
+    margin-bottom: 15px;
+    text-align: right;
+  }
+
+  .menu_content {
+    margin-top: 20px;
+  }
+
+  .configur_content {
+    padding: 20px 10px;
+    margin-top: 20px;
+    background-color: #fff;
+    border-radius: 5px;
+
+    .select-item {
+      width: 280px;
+      padding: 10px;
+      margin: 0 auto 10px;
+      border: 1px solid #eaeaea;
+
+      .ope-row {
+        padding-top: 10px;
+        text-align: center;
+      }
+    }
+  }
+
+  .blue {
+    margin-top: 10px;
+    color: #29b6f6;
+  }
+
+  .applet {
+    margin-bottom: 20px;
+
+    span {
+      width: 20%;
+    }
+  }
+
+  .input_width {
+    width: 40%;
+  }
+
+  .material {
+    .input_width {
+      width: 30%;
+    }
+
+    .el-textarea {
+      width: 80%;
+    }
+  }
+}
+</style>
diff --git a/src/views/mp/menu/components/MenuPreviewer.vue b/src/views/mp/menu/components/MenuPreviewer.vue
new file mode 100644
index 00000000..aca1c2ae
--- /dev/null
+++ b/src/views/mp/menu/components/MenuPreviewer.vue
@@ -0,0 +1,158 @@
+<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">
+        <div
+          class="menu_subItem"
+          v-if="parent.children"
+          :class="{ active: props.activeIndex === `${x}-${y}` }"
+          @click="subMenuClicked(child, x, y)"
+        >
+          {{ child.name }}
+        </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>
+  <!-- 一级菜单加号 -->
+  <div class="menu_bottom menu_addicon" v-if="menuList.length < 3" @click="addMenu">
+    <Icon icon="ep:plus" class="plus" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Menu } from './types'
+
+const props = defineProps<{
+  modelValue: Menu[]
+  activeIndex: string
+  parentIndex: number
+  accountId?: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: Menu[])
+  (e: 'menu-clicked', parent: Menu, x: number)
+  (e: 'submenu-clicked', child: Menu, x: number, y: number)
+}>()
+
+const menuList = computed<Menu[]>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+// 添加横向一级菜单
+const addMenu = () => {
+  const index = menuList.value.length
+  const menu = {
+    name: '菜单名称',
+    children: [],
+    reply: {
+      // 用于存储回复内容
+      type: 'text',
+      accountId: props.accountId // 保证组件里,可以使用到对应的公众号
+    }
+  }
+  menuList.value[index] = menu
+  menuClicked(menu, index - 1)
+}
+
+// 添加横向二级菜单;parent 表示要操作的父菜单
+const addSubMenu = (i: number, parent: any) => {
+  const subMenuKeyLength = parent.children.length // 获取二级菜单key长度
+  const addButton = {
+    name: '子菜单名称',
+    reply: {
+      // 用于存储回复内容
+      type: 'text',
+      accountId: props.accountId // 保证组件里,可以使用到对应的公众号
+    }
+  }
+  parent.children[subMenuKeyLength] = addButton
+  subMenuClicked(parent.children[subMenuKeyLength], i, subMenuKeyLength)
+}
+
+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)
+}
+</script>
+
+<style lang="scss" scoped>
+.menu_bottom {
+  position: relative;
+  display: inline-block;
+  float: left;
+  width: 85.5px;
+  text-align: center;
+  cursor: pointer;
+  background-color: #fff;
+  border: 1px solid #ebedee;
+  box-sizing: border-box;
+
+  &.menu_addicon {
+    height: 46px;
+    line-height: 46px;
+
+    .plus {
+      color: #2bb673;
+    }
+  }
+
+  .menu_item {
+    display: flex;
+    width: 100%;
+    height: 44px;
+    line-height: 44px;
+    // text-align: center;
+    box-sizing: border-box;
+    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;
+    }
+  }
+}
+
+/* 第二级菜单 */
+.submenu {
+  position: absolute;
+  bottom: 45px;
+  width: 85.5px;
+
+  .subtitle {
+    background-color: #fff;
+    box-sizing: border-box;
+  }
+}
+</style>
diff --git a/src/views/mp/menu/menuOptions.ts b/src/views/mp/menu/components/menuOptions.ts
similarity index 100%
rename from src/views/mp/menu/menuOptions.ts
rename to src/views/mp/menu/components/menuOptions.ts
diff --git a/src/views/mp/menu/components/types.ts b/src/views/mp/menu/components/types.ts
new file mode 100644
index 00000000..b9f76597
--- /dev/null
+++ b/src/views/mp/menu/components/types.ts
@@ -0,0 +1,73 @@
+export interface Replay {
+  title: string
+  description: string
+  picUrl: string
+  url: string
+}
+
+export type MenuType =
+  | ''
+  | 'click'
+  | 'view'
+  | 'scancode_waitmsg'
+  | 'scancode_push'
+  | 'pic_sysphoto'
+  | 'pic_photo_or_album'
+  | 'pic_weixin'
+  | 'location_select'
+  | 'article_view_limited'
+
+interface _RawMenu {
+  // db
+  id: number
+  parentId: number
+  accountId: number
+  appId: string
+  createTime: number
+
+  // mp-native
+  name: string
+  menuKey: string
+  type: MenuType
+  url: string
+  miniProgramAppId: string
+  miniProgramPagePath: string
+  articleId: string
+  replyMessageType: string
+  replyContent: string
+  replyMediaId: string
+  replyMediaUrl: string
+  replyThumbMediaId: string
+  replyThumbMediaUrl: string
+  replyTitle: string
+  replyDescription: string
+  replyArticles: Replay
+  replyMusicUrl: string
+  replyHqMusicUrl: string
+}
+
+export type RawMenu = Partial<_RawMenu>
+
+interface _Reply {
+  type: string
+  accountId: number
+  content: string
+  mediaId: string
+  url: string
+  thumbMediaId: string
+  thumbMediaUrl: string
+  title: string
+  description: string
+  articles: null | Replay[]
+  musicUrl: string
+  hqMusicUrl: string
+}
+
+export type Reply = Partial<_Reply>
+
+interface _Menu extends RawMenu {
+  children: _Menu[]
+  reply: Reply
+}
+
+export type Menu = Partial<_Menu>
diff --git a/src/views/mp/menu/index.vue b/src/views/mp/menu/index.vue
index 3d428f06..c053a47c 100644
--- a/src/views/mp/menu/index.vue
+++ b/src/views/mp/menu/index.vue
@@ -9,7 +9,6 @@
     </el-form>
   </ContentWrap>
 
-  <!-- 列表 -->
   <ContentWrap>
     <div class="public-account-management clearfix" v-loading="loading">
       <!--左边配置菜单-->
@@ -17,169 +16,33 @@
         <div class="weixin-hd">
           <div class="weixin-title">{{ accountName }}</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 class="weixin-menu clearfix">
+          <MenuPreviewer
+            v-model="menuList"
+            :account-id="accountId"
+            :active-index="activeIndex"
+            :parent-index="parentIndex"
+            @menu-clicked="(parent, x) => menuClicked(parent, x)"
+            @submenu-clicked="(child, x, y) => subMenuClicked(child, x, y)"
+          />
         </div>
         <div class="save_div">
-          <el-button
-            class="save_btn"
-            type="success"
-            @click="handleSave"
-            v-hasPermi="['mp:menu:save']"
+          <el-button class="save_btn" type="success" @click="onSave" v-hasPermi="['mp:menu:save']"
             >保存并发布菜单</el-button
           >
-          <el-button
-            class="save_btn"
-            type="danger"
-            @click="handleDelete"
-            v-hasPermi="['mp:menu:delete']"
+          <el-button class="save_btn" type="danger" @click="onClear" 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">
-              删除当前菜单<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="dialogNewsVisible = true">
-                        素材库选择<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 class="right" v-if="showRightPanel">
+        <MenuEditor
+          :account-id="accountId"
+          :is-parent="isParent"
+          v-model="activeMenu"
+          @delete="onDeleteMenu"
+        />
       </div>
       <!-- 一进页面就显示的默认页面,当点击左边按钮的时候,就不显示了-->
       <div v-else class="right">
@@ -188,37 +51,55 @@
     </div>
   </ContentWrap>
 </template>
+
 <script lang="ts" setup name="MpMenu">
-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 WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
+import MenuEditor from './components/MenuEditor.vue'
+import MenuPreviewer from './components/MenuPreviewer.vue'
 import * as MpMenuApi from '@/api/mp/menu'
-import { handleTree } from '@/utils/tree'
-import menuOptions from './menuOptions'
+import * as UtilsTree from '@/utils/tree'
+import { RawMenu, Menu } from './components/types'
 
 const message = useMessage() // 消息
+const MENU_NOT_SELECTED = '__MENU_NOT_SELECTED__'
 
 // ======================== 列表查询 ========================
 const loading = ref(false) // 遮罩层
 const accountId = ref<number | undefined>()
 const accountName = ref<string | undefined>('')
-const menuList = ref<any>({ children: [] })
+const menuList = ref<Menu[]>([])
 
 // ======================== 菜单操作 ========================
-const isActive = ref(-1) // 一级菜单点中样式
-const isSubMenuActive = ref<string | number>(-1) // 一级菜单点中样式
-const isSubMenuFlag = ref(-1) // 二级菜单显示标志
+// 当前选中菜单编码:
+//  * 一级('x')
+//  * 二级('x-y')
+//  * 未选中(MENU_NOT_SELECTED)
+const activeIndex = ref<string>(MENU_NOT_SELECTED)
+// 二级菜单显示标志: 归属的一级菜单index
+// * 未初始化:-1
+// * 初始化:x
+const parentIndex = 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<any>({}) // 右边临时变量,作为中间值牵引关系
+const showRightPanel = ref(false) // 右边配置显示默认详情还是配置详情
+const isParent = ref<boolean>(true) // 是否一级菜单,控制MenuEditor中name字段长度
+const activeMenu = ref<Menu>({}) // 选中菜单,MenuEditor的modelValue
 
-// 一些临时值放在这里进行判断,如果放在 tempObj,由于引用关系,menu 也会多了多余的参数
-const tempSelfObj = ref<any>({})
+// 一些临时值放在这里进行判断,如果放在 activeMenu,由于引用关系,menu 也会多了多余的参数
+enum Level {
+  Undefined = '0',
+  Parent = '1',
+  Child = '2'
+}
+const tempSelfObj = ref<{
+  grand: Level
+  x: number
+  y: number
+}>({
+  grand: Level.Undefined,
+  x: 0,
+  y: 0
+})
 const dialogNewsVisible = ref(false) // 跳转图文时的素材选择弹窗
 
 /** 侦听公众号变化 **/
@@ -233,8 +114,8 @@ const getList = async () => {
   loading.value = false
   try {
     const data = await MpMenuApi.getMenuList(accountId.value)
-    const menuData = convertMenuList(data)
-    menuList.value = handleTree(menuData, 'id')
+    const menuData = menuListToFrontend(data)
+    menuList.value = UtilsTree.handleTree(menuData, 'id')
   } finally {
     loading.value = false
   }
@@ -247,37 +128,29 @@ const handleQuery = () => {
 }
 
 // 将后端返回的 menuList,转换成前端的 menuList
-const convertMenuList = (list: any[]) => {
+const menuListToFrontend = (list: any[]) => {
   if (!list) return []
 
-  const result: any[] = []
-  list.forEach((item) => {
-    const menu = {
+  const result: RawMenu[] = []
+  list.forEach((item: RawMenu) => {
+    const menu: any = {
       ...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
-      }
+    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
     }
-    result.push(menu)
+    result.push(menu as RawMenu)
   })
   return result
 }
@@ -285,128 +158,72 @@ const convertMenuList = (list: any[]) => {
 // 重置表单,清空表单数据
 const resetForm = () => {
   // 菜单操作
-  isActive.value = -1
-  isSubMenuActive.value = -1
-  isSubMenuFlag.value = -1
+  activeIndex.value = MENU_NOT_SELECTED
+  parentIndex.value = -1
 
   // 菜单编辑
-  showRightFlag.value = false
-  nameMaxLength.value = 0
-  showConfigureContent.value = false
-  hackResetWxReplySelect.value = false
-  tempObj.value = {}
-  tempSelfObj.value = {}
+  showRightPanel.value = false
+  activeMenu.value = {}
+  tempSelfObj.value = { grand: Level.Undefined, x: 0, y: 0 }
   dialogNewsVisible.value = false
 }
 
 // ======================== 菜单操作 ========================
 // 一级菜单点击事件
-const menuClick = (i: number, item: any) => {
+const menuClicked = (parent: Menu, x: number) => {
   // 右侧的表单相关
-  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) // 有子菜单,就不显示配置内容
+  showRightPanel.value = true // 右边菜单
+  activeMenu.value = parent // 这个如果放在顶部,flag 会没有。因为重新赋值了。
+  tempSelfObj.value.grand = Level.Parent // 表示一级菜单
+  tempSelfObj.value.x = x // 表示一级菜单索引
+  isParent.value = true
 
   // 左侧的选中
-  isActive.value = i // 一级菜单选中样式
-  isSubMenuFlag.value = i // 二级菜单显示标志
-  isSubMenuActive.value = -1 // 二级菜单去除选中样式
+  activeIndex.value = `${x}` // 菜单选中样式
+  parentIndex.value = x // 二级菜单显示标志
 }
 
 // 二级菜单点击事件
-const subMenuClick = (subItem: any, index: number, k: number) => {
+const subMenuClicked = (child: Menu, x: number, y: number) => {
   // 右侧的表单相关
-  resetEditor()
-  showRightFlag.value = true // 右边菜单
-  tempObj.value = subItem // 将点击的数据放到临时变量,对象有引用作用
-  tempSelfObj.value.grand = '2' // 表示二级菜单
-  tempSelfObj.value.index = index // 表示一级菜单索引
-  tempSelfObj.value.secondIndex = k // 表示二级菜单索引
-  nameMaxLength.value = 7
-  showConfigureContent.value = true
+  showRightPanel.value = true // 右边菜单
+  activeMenu.value = child // 将点击的数据放到临时变量,对象有引用作用
+  tempSelfObj.value.grand = Level.Child // 表示二级菜单
+  tempSelfObj.value.x = x // 表示一级菜单索引
+  tempSelfObj.value.y = y // 表示二级菜单索引
+  isParent.value = false
 
   // 左侧的选中
-  isActive.value = -1 // 一级菜单去除样式
-  isSubMenuActive.value = index + '' + k // 二级菜单选中样式
-}
-
-// 添加横向一级菜单
-const addMenu = () => {
-  const menuKeyLength: number = menuList.value.length
-  const addButton = {
-    name: '菜单名称',
-    children: [],
-    reply: {
-      // 用于存储回复内容
-      type: 'text',
-      accountId: accountId.value // 保证组件里,可以使用到对应的公众号
-    }
-  }
-  menuList.value[menuKeyLength] = addButton
-  menuClick(menuKeyLength - 1, addButton)
-}
-// 添加横向二级菜单;item 表示要操作的父菜单
-const addSubMenu = (i: number, item: any) => {
-  // 清空父菜单的属性,因为它只需要 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
-  }
-
-  const subMenuKeyLength = item.children.length // 获取二级菜单key长度
-  const addButton = {
-    name: '子菜单名称',
-    reply: {
-      // 用于存储回复内容
-      type: 'text',
-      accountId: accountId.value // 保证组件里,可以使用到对应的公众号
-    }
-  }
-  item.children[subMenuKeyLength] = addButton
-  subMenuClick(item.children[subMenuKeyLength], i, subMenuKeyLength)
+  activeIndex.value = `${x}-${y}`
 }
 
 // 删除当前菜单
-const handleDeleteMenu = async () => {
+const onDeleteMenu = async () => {
   try {
     await message.confirm('确定要删除吗?')
-    if (tempSelfObj.value.grand === '1') {
+    if (tempSelfObj.value.grand === Level.Parent) {
       // 一级菜单的删除方法
-      menuList.value.splice(tempSelfObj.value.index, 1)
-    } else if (tempSelfObj.value.grand === '2') {
+      menuList.value.splice(tempSelfObj.value.x, 1)
+    } else if (tempSelfObj.value.grand === Level.Child) {
       // 二级菜单的删除方法
-      menuList.value[tempSelfObj.value.index].children.splice(tempSelfObj.value.secondIndex, 1)
+      menuList.value[tempSelfObj.value.x].children?.splice(tempSelfObj.value.y, 1)
     }
     // 提示
     message.notifySuccess('删除成功')
 
     // 处理菜单的选中
-    tempObj.value = {}
-    showRightFlag.value = false
-    isActive.value = -1
-    isSubMenuActive.value = -1
+    activeMenu.value = {}
+    showRightPanel.value = false
+    activeIndex.value = MENU_NOT_SELECTED
   } catch {}
 }
 
 // ======================== 菜单编辑 ========================
-const handleSave = async () => {
+const onSave = async () => {
   try {
     await message.confirm('确定要保存吗?')
     loading.value = true
-    await MpMenuApi.saveMenu(accountId.value, convertMenuFormList())
+    await MpMenuApi.saveMenu(accountId.value, menuListToBackend())
     getList()
     message.notifySuccess('发布成功')
   } finally {
@@ -414,15 +231,7 @@ const handleSave = async () => {
   }
 }
 
-// 表单 Editor 重置
-const resetEditor = () => {
-  hackResetWxReplySelect.value = false // 销毁组件
-  nextTick(() => {
-    hackResetWxReplySelect.value = true // 重建组件
-  })
-}
-
-const handleDelete = async () => {
+const onClear = async () => {
   try {
     await message.confirm('确定要删除吗?')
     loading.value = true
@@ -435,10 +244,10 @@ const handleDelete = async () => {
 }
 
 // 将前端的 menuList,转换成后端接收的 menuList
-const convertMenuFormList = () => {
+const menuListToBackend = () => {
   const result: any[] = []
   menuList.value.forEach((item) => {
-    const menu = convertMenuForm(item)
+    const menu = menuToBackend(item)
     result.push(menu)
 
     // 处理子菜单
@@ -447,62 +256,34 @@ const convertMenuFormList = () => {
     }
     menu.children = []
     item.children.forEach((subItem) => {
-      menu.children.push(convertMenuForm(subItem))
+      menu.children.push(menuToBackend(subItem))
     })
   })
   return result
 }
 
 // 将前端的 menu,转换成后端接收的 menu
-const convertMenuForm = (menu: any) => {
+// TODO: @芋艿,需要根据后台API删除不需要的字段
+const menuToBackend = (menu: any) => {
   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
-  }
+  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 selectMaterial = (item: any) => {
-  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>
 
 <!--本组件样式-->
@@ -513,9 +294,9 @@ const deleteMaterial = () => {
 }
 
 .clearfix::after {
-  content: '';
   display: table;
   clear: both;
+  content: '';
 }
 
 div {
@@ -523,116 +304,50 @@ div {
 }
 
 .weixin-hd {
-  color: #fff;
-  text-align: center;
   position: relative;
   bottom: 426px;
-  left: 0px;
+  left: 0;
   width: 300px;
   height: 64px;
+  color: #fff;
+  text-align: center;
   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;
+  left: 0;
+  width: 100%;
+  font-size: 14px;
+  color: #fff;
+  text-align: center;
 }
 
 .weixin-menu {
-  background: transparent url('./assets/menu_foot.png') no-repeat 0 0;
   padding-left: 43px;
   font-size: 12px;
-}
-
-.menu_option {
-  width: 40% !important;
+  background: transparent url('./assets/menu_foot.png') no-repeat 0 0;
 }
 
 .public-account-management {
-  min-width: 1200px;
   width: 1200px;
+  // min-width: 1200px;
   margin: 0 auto;
 
   .left {
-    float: left;
+    position: relative;
     display: inline-block;
+    float: left;
     width: 350px;
     height: 715px;
+    padding: 518px 25px 88px;
     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;
@@ -644,85 +359,29 @@ div {
     }
   }
 
-  /*右边菜单内容*/
+  /* 右边菜单内容 */
   .right {
     float: left;
     width: 63%;
-    background-color: #e8e7e7;
     padding: 20px;
     margin-left: 20px;
-    -webkit-box-sizing: border-box;
+    background-color: #e8e7e7;
+    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;
+  text-align: right;
 }
 
 .select-item {
   width: 280px;
   padding: 10px;
-  margin: 0 auto 10px auto;
-  border: 1px solid #eaeaea;
-}
-
-.select-item2 {
-  padding: 10px;
-  margin: 0 auto 10px auto;
+  margin: 0 auto 10px;
   border: 1px solid #eaeaea;
 }
 
@@ -732,10 +391,10 @@ div {
 }
 
 .item-name {
-  font-size: 12px;
   overflow: hidden;
+  font-size: 12px;
+  text-align: center;
   text-overflow: ellipsis;
   white-space: nowrap;
-  text-align: center;
 }
 </style>
diff --git a/src/views/system/notify/template/index.vue b/src/views/system/notify/template/index.vue
index 6d1938ad..12392b8a 100644
--- a/src/views/system/notify/template/index.vue
+++ b/src/views/system/notify/template/index.vue
@@ -212,7 +212,7 @@ const handleDelete = async (id: number) => {
     // 删除的二次确认
     await message.delConfirm()
     // 发起删除
-    await NotifyTemplateApi.deleteNotifyTemplateApi(id)
+    await NotifyTemplateApi.deleteNotifyTemplate(id)
     message.success('删除成功')
     // 刷新列表
     await getList()
diff --git a/src/views/system/user/index.vue b/src/views/system/user/index.vue
index 204e262f..d4d8d908 100644
--- a/src/views/system/user/index.vue
+++ b/src/views/system/user/index.vue
@@ -230,7 +230,7 @@ const queryFormRef = ref() // 搜索的表单
 const getList = async () => {
   loading.value = true
   try {
-    const data = await UserApi.getUserPage(queryParams.value)
+    const data = await UserApi.getUserPage(queryParams)
     list.value = data.list
     total.value = data.total
   } finally {