Parcourir la source

refactor: mp/draft拆分组件

dhb52 il y a 2 ans
Parent
commit
b5fb700e4e

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

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

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

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

+ 40 - 0
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 }

+ 82 - 631
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 onBeforeDialogClose = async (onDone: () => {}) => {
+  try {
+    await message.confirm('修改内容可能还未保存,确定关闭吗?')
+    onDone()
+  } catch {}
+}
+
 // ======================== 列表查询 ========================
 /** 设置账号编号 */
-const setAccountId = (id?: number) => {
+const setAccountId = (id: number) => {
   queryParams.accountId = id
   uploadData.accountId = id
-  editorConfig.value = createEditorConfig(uploadUrl, queryParams.accountId)
 }
 
-const editorConfig = ref<Partial<IEditorConfig>>({})
-
 /** 查询列表 */
 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>