diff --git a/src/views/mp/components/wx-editor/WxEditor.vue b/src/views/mp/components/wx-editor/WxEditor.vue new file mode 100644 index 00000000..50c65c6e --- /dev/null +++ b/src/views/mp/components/wx-editor/WxEditor.vue @@ -0,0 +1,201 @@ +<script setup> +import { ref, reactive } from 'vue' +import { QuillEditor } from '@vueup/vue-quill' +import '@vueup/vue-quill/dist/vue-quill.snow.css' +import { getAccessToken } from '@/utils/auth' +import editorOptions from './quill-options' + +const BASE_URL = import.meta.env.VITE_BASE_URL + +const message = useMessage() + +const props = defineProps({ + /* 公众号账号编号 */ + accountId: { + type: Number, + required: true + }, + /* 编辑器的内容 */ + value: { + type: String, + default: '' + }, + /* 图片大小 */ + maxSize: { + type: Number, + default: 4000 // kb + } +}) + +const emit = defineEmits(['input']) + +const myQuillEditorRef = ref() + +const content = ref(props.value.replace(/data-src/g, 'src')) + +const loading = ref(false) // 根据图片上传状态来确定是否显示loading动画,刚开始是false,不显示 + +const actionUrl = ref(BASE_URL + '/admin-api/mp/material/upload-news-image') // 这里写你要上传的图片服务器地址 +const headers = ref({ Authorization: 'Bearer ' + getAccessToken() }) // 设置上传的请求头部 +const uploadData = reactive({ + type: 'image', // TODO 芋艿:试试要不要换成 thumb + accountId: props.accountId +}) + +const onEditorChange = () => { + //内容改变事件 + emit('input', content.value) +} + +// 富文本图片上传前 +const beforeUpload = () => { + // 显示 loading 动画 + loading.value = true +} + +// 图片上传成功 +// 注意!由于微信公众号的图片有访问限制,所以会显示“此图片来自微信公众号,未经允许不可引用” +const uploadSuccess = (res) => { + // res为图片服务器返回的数据 + // 获取富文本组件实例 + const quill = myQuillEditorRef.value.quill + // 如果上传成功 + const link = res.data + if (link) { + // 获取光标所在位置 + let length = quill.getSelection().index + // 插入图片 res.info为服务器返回的图片地址 + quill.insertEmbed(length, 'image', link) + // 调整光标到最后 + quill.setSelection(length + 1) + } else { + message.error('图片插入失败') + } + // loading 动画消失 + loading.value = false +} + +// 富文本图片上传失败 +const uploadError = () => { + // loading 动画消失 + loading.value = false + message.error('图片插入失败') +} +</script> + +<template> + <div id="wxEditor"> + <div v-loading="loading" element-loading-text="请稍等,图片上传中"> + <!-- 图片上传组件辅助--> + <el-upload + class="avatar-uploader" + name="file" + :action="actionUrl" + :headers="headers" + :show-file-list="false" + :data="uploadData" + :on-success="uploadSuccess" + :on-error="uploadError" + :before-upload="beforeUpload" + /> + <QuillEditor + class="editor" + v-model="content" + ref="quillEditorRef" + :options="editorOptions" + @change="onEditorChange($event)" + /> + </div> + </div> +</template> + +<style> +.editor { + line-height: normal !important; + height: 500px; +} + +.ql-snow .ql-tooltip[data-mode='link']::before { + content: '请输入链接地址:'; +} + +.ql-snow .ql-tooltip.ql-editing a.ql-action::after { + border-right: 0; + content: '保存'; + padding-right: 0; +} + +.ql-snow .ql-tooltip[data-mode='video']::before { + content: '请输入视频地址:'; +} + +.ql-snow .ql-picker.ql-size .ql-picker-label::before, +.ql-snow .ql-picker.ql-size .ql-picker-item::before { + content: '14px'; +} + +.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before, +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before { + content: '10px'; +} + +.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before, +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before { + content: '18px'; +} + +.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before, +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before { + content: '32px'; +} + +.ql-snow .ql-picker.ql-header .ql-picker-label::before, +.ql-snow .ql-picker.ql-header .ql-picker-item::before { + content: '文本'; +} + +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before { + content: '标题1'; +} + +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before { + content: '标题2'; +} + +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before { + content: '标题3'; +} + +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before { + content: '标题4'; +} + +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before { + content: '标题5'; +} + +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before { + content: '标题6'; +} + +.ql-snow .ql-picker.ql-font .ql-picker-label::before, +.ql-snow .ql-picker.ql-font .ql-picker-item::before { + content: '标准字体'; +} + +.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before, +.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before { + content: '衬线字体'; +} + +.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before, +.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before { + content: '等宽字体'; +} +</style> diff --git a/src/views/mp/components/wx-editor/quill-options.js b/src/views/mp/components/wx-editor/quill-options.js new file mode 100644 index 00000000..5ec211ae --- /dev/null +++ b/src/views/mp/components/wx-editor/quill-options.js @@ -0,0 +1,45 @@ +const toolbarOptions = [ + ['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线 + ['blockquote', 'code-block'], // 引用 代码块 + [{ header: 1 }, { header: 2 }], // 1、2 级标题 + [{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表 + [{ script: 'sub' }, { script: 'super' }], // 上标/下标 + [{ indent: '-1' }, { indent: '+1' }], // 缩进 + // [{'direction': 'rtl'}], // 文本方向 + [{ size: ['small', false, 'large', 'huge'] }], // 字体大小 + [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题 + [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色 + [{ font: [] }], // 字体种类 + [{ align: [] }], // 对齐方式 + ['clean'], // 清除文本格式 + ['link', 'image', 'video'] // 链接、图片、视频 +] + +export default { + theme: 'snow', + placeholder: '请输入文章内容', + modules: { + toolbar: { + container: toolbarOptions, + // container: "#toolbar", + handlers: { + image: function (value) { + if (value) { + // 触发input框选择图片文件 + document.querySelector('.avatar-uploader input').click() + } else { + this.quill.format('image', false) + } + }, + link: function (value) { + if (value) { + const href = prompt('注意!只支持公众号图文链接') + this.quill.format('link', href) + } else { + this.quill.format('link', false) + } + } + } + } + } +} diff --git a/src/views/mp/components/wx-material-select/main.vue b/src/views/mp/components/wx-material-select/main.vue index b1bdd5aa..87a19dad 100644 --- a/src/views/mp/components/wx-material-select/main.vue +++ b/src/views/mp/components/wx-material-select/main.vue @@ -29,6 +29,7 @@ </div> <!-- 类型:voice --> <div v-else-if="objData.type === 'voice'"> + <!-- 列表 --> <el-table v-loading="loading" :data="list"> <el-table-column label="编号" align="center" prop="mediaId" /> <el-table-column label="文件名" align="center" prop="name" /> @@ -47,7 +48,7 @@ <el-table-column label="操作" align="center" fixed="right"> <template #default="scope"> <el-button type="text" @click="selectMaterialFun(scope.row)"> - 选择 <Icon icon="ep:plus" /> + 选择<Icon icon="ep:plus" /> </el-button> </template> </el-table-column> @@ -80,10 +81,15 @@ width="180" :formatter="dateFormatter" /> - <el-table-column label="操作" align="center"> + <el-table-column + label="操作" + align="center" + fixed="right" + class-name="small-padding fixed-width" + > <template #default="scope"> - <el-button type="text" @click="selectMaterialFun(scope.row)" - >选择<Icon icon="ep:circle-plus" /> + <el-button type="text" @click="selectMaterialFun(scope.row)"> + 选择<Icon icon="akar-icons:circle-plus" /> </el-button> </template> </el-table-column> @@ -104,7 +110,7 @@ <WxNews :articles="item.content.newsItem" /> <el-row class="ope-row"> <el-button type="success" @click="selectMaterialFun(item)"> - 选择 <Icon icon="ep:circle-check" /> + 选择<Icon icon="ep:circle-check" /> </el-button> </el-row> </div> @@ -120,6 +126,7 @@ </div> </div> </template> + <script lang="ts" name="WxMaterialSelect"> import WxNews from '@/views/mp/components/wx-news/main.vue' import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue' @@ -231,7 +238,7 @@ export default defineComponent({ selectMaterialFun, getMaterialPageFun, getPage, - newsTypeRef, + formatDate, queryParams, objDataRef, list, @@ -241,7 +248,6 @@ export default defineComponent({ } }) </script> - <style lang="scss" scoped> /*瀑布流样式*/ .waterfall { diff --git a/src/views/mp/draft/index.vue b/src/views/mp/draft/index.vue index 497f72ec..604f8faf 100644 --- a/src/views/mp/draft/index.vue +++ b/src/views/mp/draft/index.vue @@ -1,3 +1,815 @@ <template> - <span>开发中</span> + <doc-alert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="公众号" prop="accountId"> + <el-select v-model="queryParams.accountId" placeholder="请选择公众号"> + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button> + <el-button type="primary" plain @click="handleAdd" v-hasPermi="['mp:draft:create']"> + <Icon icon="ep:plus" />新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <div class="waterfall" v-loading="loading"> + <template v-for="item in list" :key="item.articleId"> + <div class="waterfall-item" v-if="item.content && item.content.newsItem"> + <wx-news :articles="item.content.newsItem" /> + <!-- 操作按钮 --> + <el-row class="ope-row"> + <el-button + type="success" + circle + @click="handlePublish(item)" + v-hasPermi="['mp:free-publish:submit']" + > + <Icon icon="fa:upload" /> + </el-button> + <el-button + type="primary" + circle + @click="handleUpdate(item)" + v-hasPermi="['mp:draft:update']" + > + <Icon icon="ep:edit" /> + </el-button> + <el-button + type="danger" + circle + @click="handleDelete(item)" + v-hasPermi="['mp:draft:delete']" + > + <Icon icon="ep:delete" /> + </el-button> + </el-row> + </div> + </template> + </div> + <!-- 分页记录 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- TODO @Dhb52:迁移成独立路由 --> + <div class="app-container"> + <!-- 添加或修改草稿对话框 --> + <Teleport to="body"> + <el-dialog + :title="operateMaterial === 'add' ? '新建图文' : '修改图文'" + width="80%" + top="20px" + v-model="dialogNewsVisible" + :before-close="dialogNewsClose" + :close-on-click-modal="false" + > + <div class="left"> + <div class="select-item"> + <div v-for="(news, index) in articlesAdd" :key="news.id"> + <div + class="news-main father" + v-if="index === 0" + :class="{ activeAddNews: isActiveAddNews === index }" + @click="activeNews(index)" + > + <div class="news-content"> + <img class="material-img" v-if="news.thumbUrl" :src="news.thumbUrl" /> + <div class="news-content-title">{{ news.title }}</div> + </div> + <div class="child" v-if="articlesAdd.length > 1"> + <el-button size="small" @click="downNews(index)" + ><Icon icon="ep:sort-down" />下移</el-button + > + <el-button v-if="operateMaterial === 'add'" size="small" @click="minusNews(index)" + ><Icon icon="ep:delete" />删除 + </el-button> + </div> + </div> + <div + class="news-main-item father" + v-if="index > 0" + :class="{ activeAddNews: isActiveAddNews === index }" + @click="activeNews(index)" + > + <div class="news-content-item"> + <div class="news-content-item-title">{{ news.title }}</div> + <div class="news-content-item-img"> + <img + class="material-img" + v-if="news.thumbUrl" + :src="news.thumbUrl" + height="100%" + /> + </div> + </div> + <div class="child"> + <el-button + v-if="articlesAdd.length > index + 1" + size="small" + @click="downNews(index)" + ><Icon icon="ep:sort-down" />下移 + </el-button> + <el-button size="small" @click="upNews(index)" + ><Icon icon="ep:sort-up" />上移</el-button + > + <el-button + v-if="operateMaterial === 'add'" + type="danger" + size="small" + @click="minusNews(index)" + ><Icon icon="ep:delete" />删除 + </el-button> + </div> + </div> + </div> + <el-row justify="center" class="ope-row"> + <el-button + type="primary" + circle + @click="plusNews(item)" + v-if="articlesAdd.length < 8 && operateMaterial === 'add'" + > + <Icon icon="ep:plus" /> + </el-button> + </el-row> + </div> + </div> + <div class="right" v-loading="addMaterialLoading" v-if="articlesAdd.length > 0"> + <br /> + <br /> + <br /> + <br /> + <!-- 标题、作者、原文地址 --> + <el-input v-model="articlesAdd[isActiveAddNews].title" placeholder="请输入标题(必填)" /> + <el-input + v-model="articlesAdd[isActiveAddNews].author" + placeholder="请输入作者" + style="margin-top: 5px" + /> + <el-input + v-model="articlesAdd[isActiveAddNews].contentSourceUrl" + placeholder="请输入原文地址" + style="margin-top: 5px" + /> + <!-- 封面和摘要 --> + <div class="input-tt">封面和摘要:</div> + <div> + <div class="thumb-div"> + <img + class="material-img" + v-if="articlesAdd[isActiveAddNews].thumbUrl" + :src="articlesAdd[isActiveAddNews].thumbUrl" + :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'" + /> + <Icon + v-else + icon="ep:plus" + class="avatar-uploader-icon" + :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'" + /> + <div class="thumb-but"> + <el-upload + :action="actionUrl" + :headers="headers" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :before-upload="beforeThumbImageUpload" + :on-success="handleUploadSuccess" + > + <template #trigger> + <el-button size="small" type="primary">本地上传</el-button> + </template> + <el-button + size="small" + type="primary" + @click="openMaterial" + style="margin-left: 5px" + >素材库选择</el-button + > + <template #tip> + <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div> + </template> + </el-upload> + </div> + <Teleport to="body"> + <el-dialog title="选择图片" v-model="dialogImageVisible" width="80%"> + <WxMaterialSelect + ref="materialSelectRef" + :objData="{ type: 'image', accountId: queryParams.accountId }" + @select-material="selectMaterial" + /> + </el-dialog> + </Teleport> + </div> + <el-input + :rows="8" + type="textarea" + v-model="articlesAdd[isActiveAddNews].digest" + placeholder="请输入摘要" + class="digest" + maxlength="120" + style="float: right" + /> + </div> + <!--富文本编辑器组件--> + <el-row> + <wx-editor + v-model="articlesAdd[isActiveAddNews].content" + :account-id="uploadData.accountId" + v-if="hackResetEditor" + /> + </el-row> + </div> + <template #footer> + <div class="dialog-footer"> + <el-button @click="dialogNewsVisible = false">取 消</el-button> + <el-button type="primary" @click="submitForm">提 交</el-button> + </div> + </template> + </el-dialog> + </Teleport> + </div> </template> +<script setup name="MpDraft"> +import WxEditor from '@/views/mp/components/wx-editor/WxEditor.vue' +import WxNews from '@/views/mp/components/wx-news/main.vue' +import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue' +import { getAccessToken } from '@/utils/auth' +import { createDraft, deleteDraft, getDraftPage, updateDraft } from '@/api/mp/draft' +import { getSimpleAccountList } from '@/api/mp/account' +import { submitFreePublish } from '@/api/mp/freePublish' +const message = useMessage() // 消息 +// 可以用改本地数据模拟,避免API调用超限 +// import drafts from './mock' + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const accountList = ref([]) // 公众号账号列表 + +// ========== 文件上传 ========== +const materialSelectRef = ref() +const BASE_URL = import.meta.env.VITE_BASE_URL +const actionUrl = ref(BASE_URL + '/admin-api/mp/material/upload-permanent') // 上传永久素材的地址 +const headers = ref({ Authorization: 'Bearer ' + getAccessToken() }) // 设置上传的请求头部 +const fileList = ref([]) +const uploadData = reactive({ + type: 'image', + accountId: 1 +}) + +// ========== 草稿新建 or 修改 ========== +const dialogNewsVisible = ref(false) +const addMaterialLoading = ref(false) // 添加草稿的 loading 标识 +const articlesAdd = ref([]) +const isActiveAddNews = ref(0) +const dialogImageVisible = ref(false) +const operateMaterial = ref('add') +const articlesMediaId = ref('') +const hackResetEditor = ref(false) + +/** 初始化 **/ +onMounted(async () => { + accountList.value = await getSimpleAccountList() + // 选中第一个 + if (accountList.value.length > 0) { + // @ts-ignore + queryParams.accountId = accountList.value[0].id + } + await getList() +}) + +// ======================== 列表查询 ======================== +/** 设置账号编号 */ +const setAccountId = (accountId) => { + queryParams.accountId = accountId + uploadData.accountId = accountId +} + +/** 查询列表 */ +const getList = async () => { + // 如果没有选中公众号账号,则进行提示。 + if (!queryParams.accountId) { + message.error('未选中公众号,无法查询草稿箱') + return false + } + + loading.value = true + try { + const drafts = await getDraftPage(queryParams) + drafts.list.forEach((item) => { + const newsItem = item.content.newsItem + // 将 thumbUrl 转成 picUrl,保证 wx-news 组件可以预览封面 + newsItem.forEach((article) => { + article.picUrl = article.thumbUrl + }) + }) + list.value = drafts.list + total.value = drafts.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + // 默认选中第一个 + if (queryParams.accountId) { + setAccountId(queryParams.accountId) + } + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + // 默认选中第一个 + if (accountList.value.length > 0) { + setAccountId(accountList.value[0].id) + } + handleQuery() +} + +// ======================== 新增/修改草稿 ======================== +/** 新增按钮操作 */ +const handleAdd = () => { + resetEditor() + reset() + // 打开表单,并设置初始化 + operateMaterial.value = 'add' + dialogNewsVisible.value = true +} + +/** 更新按钮操作 */ +const handleUpdate = (item) => { + resetEditor() + reset() + articlesMediaId.value = item.mediaId + articlesAdd.value = JSON.parse(JSON.stringify(item.content.newsItem)) + // 打开表单,并设置初始化 + operateMaterial.value = 'edit' + dialogNewsVisible.value = true +} + +/** 提交按钮 */ +const submitForm = () => { + // TODO @Dhb52: 参考别的模块写法,改成 await 方式 + addMaterialLoading.value = true + if (operateMaterial.value === 'add') { + createDraft(queryParams.accountId, articlesAdd.value) + .then(() => { + message.notifySuccess('新增成功') + dialogNewsVisible.value = false + getList() + }) + .finally(() => { + addMaterialLoading.value = false + }) + } else { + updateDraft(queryParams.accountId, articlesMediaId.value, articlesAdd.value) + .then(() => { + message.notifySuccess('更新成功') + dialogNewsVisible.value = false + getList() + }) + .finally(() => { + addMaterialLoading.value = false + }) + } +} + +// 关闭弹窗 +const dialogNewsClose = async (done) => { + try { + await message.confirm('修改内容可能还未保存,确定关闭吗?') + reset() + resetEditor() + done() + } catch {} +} + +// 表单重置 +const reset = () => { + isActiveAddNews.value = 0 + articlesAdd.value = [buildEmptyArticle()] +} + +// 表单 Editor 重置 +const resetEditor = () => { + hackResetEditor.value = false // 销毁组件 + nextTick(() => { + hackResetEditor.value = true // 重建组件 + }) +} + +// 将图文向下移动 +const downNews = (index) => { + let temp = articlesAdd.value[index] + articlesAdd.value[index] = articlesAdd.value[index + 1] + articlesAdd.value[index + 1] = temp + isActiveAddNews.value = index + 1 +} + +// 将图文向上移动 +const upNews = (index) => { + let temp = articlesAdd.value[index] + articlesAdd.value[index] = articlesAdd.value[index - 1] + articlesAdd.value[index - 1] = temp + isActiveAddNews.value = index - 1 +} + +// 选中指定 index 的图文 +const activeNews = (index) => { + resetEditor() + isActiveAddNews.value = index +} + +// 删除指定 index 的图文 +const minusNews = async (index) => { + try { + await message.confirm('确定删除该图文吗?') + articlesAdd.value.splice(index, 1) + if (isActiveAddNews.value === index) { + isActiveAddNews.value = 0 + } + } catch {} +} + +// 添加一个图文 +const plusNews = () => { + articlesAdd.value.push(buildEmptyArticle()) + isActiveAddNews.value = articlesAdd.value.length - 1 +} + +// 创建空的 article +const buildEmptyArticle = () => { + return { + title: '', + thumbMediaId: '', + author: '', + digest: '', + showCoverPic: '', + content: '', + contentSourceUrl: '', + needOpenComment: '', + onlyFansCanComment: '', + thumbUrl: '' + } +} + +// ======================== 文件上传 ======================== +const beforeThumbImageUpload = (file) => { + addMaterialLoading.value = true + const isType = + file.type === 'image/jpeg' || + file.type === 'image/png' || + file.type === 'image/gif' || + file.type === 'image/bmp' || + file.type === 'image/jpg' + if (!isType) { + message.error('上传图片格式不对!') + addMaterialLoading.value = false + return false + } + const isLt = file.size / 1024 / 1024 < 2 + if (!isLt) { + message.error('上传图片大小不能超过 2M!') + addMaterialLoading.value = false + return false + } + // 校验通过 + return true +} + +const handleUploadSuccess = (response, file, fileList) => { + addMaterialLoading.value = false + if (response.code !== 0) { + message.error('上传出错:' + response.msg) + return false + } + + // 重置上传文件的表单 + fileList.value = [] + + // 设置草稿的封面字段 + articlesAdd.value[isActiveAddNews.value].thumbMediaId = response.data.mediaId + articlesAdd.value[isActiveAddNews.value].thumbUrl = response.data.url +} + +// 选择 or 上传完素材,设置回草稿 +const selectMaterial = (item) => { + dialogImageVisible.value = false + articlesAdd.value[isActiveAddNews.value].thumbMediaId = item.mediaId + articlesAdd.value[isActiveAddNews.value].thumbUrl = item.url +} + +// 打开素材选择 +const openMaterial = () => { + dialogImageVisible.value = true + try { + materialSelectRef.value.queryParams.accountId = queryParams.accountId // 强制设置下 accountId,避免二次查询不对 + materialSelectRef.value.handleQuery() // 刷新列表,失败也无所谓 + } catch (e) {} +} + +// ======================== 草稿箱发布 ======================== +const handlePublish = async (item) => { + const accountId = queryParams.accountId + const mediaId = item.mediaId + const content = + '你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。已发布内容不会推送给用户,也不会展示在公众号主页中。 发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。' + try { + await message.confirm(content) + await submitFreePublish(accountId, mediaId) + message.notifySuccess('发布成功') + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (item) => { + const accountId = queryParams.accountId + const mediaId = item.mediaId + try { + await message.confirm('此操作将永久删除该草稿, 是否继续?') + await deleteDraft(accountId, mediaId) + message.notifySuccess('删除成功') + await getList() + } catch {} +} +</script> +<style lang="scss" scoped> +.pagination { + float: right; + margin-right: 25px; +} + +.add_but { + padding: 10px; +} + +.ope-row { + margin-top: 5px; + text-align: center; + border-top: 1px solid #eaeaea; + padding-top: 5px; +} + +.item-name { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.el-upload__tip { + margin-left: 5px; +} + +/*新增图文*/ +.left { + display: inline-block; + width: 35%; + vertical-align: top; + margin-top: 200px; +} + +.right { + display: inline-block; + width: 60%; + margin-top: -40px; +} + +.avatar-uploader { + width: 20%; + display: inline-block; +} + +.avatar-uploader .el-upload { + border-radius: 6px; + cursor: pointer; + position: relative; + overflow: hidden; + text-align: unset !important; +} + +.avatar-uploader .el-upload:hover { + border-color: #165dff; +} + +.avatar-uploader-icon { + border: 1px solid #d9d9d9; + font-size: 28px; + color: #8c939d; + width: 120px; + height: 120px; + line-height: 120px; + text-align: center; +} + +.avatar { + width: 230px; + height: 120px; +} + +.avatar1 { + width: 120px; + height: 120px; +} + +.digest { + width: 60%; + display: inline-block; + vertical-align: top; +} + +/*新增图文*/ +/*瀑布流样式*/ +.waterfall { + width: 100%; + column-gap: 10px; + column-count: 5; + margin: 0 auto; +} + +.waterfall-item { + padding: 10px; + margin-bottom: 10px; + break-inside: avoid; + border: 1px solid #eaeaea; +} + +p { + line-height: 30px; +} + +@media (min-width: 992px) and (max-width: 1300px) { + .waterfall { + column-count: 3; + } + + p { + color: red; + } +} + +@media (min-width: 768px) and (max-width: 991px) { + .waterfall { + column-count: 2; + } + + p { + color: orange; + } +} + +@media (max-width: 767px) { + .waterfall { + column-count: 1; + } +} + +/*瀑布流样式*/ +.news-main { + background-color: #ffffff; + width: 100%; + margin: auto; + height: 120px; +} + +.news-content { + background-color: #acadae; + width: 100%; + height: 120px; + position: relative; +} + +.news-content-title { + display: inline-block; + font-size: 15px; + color: #ffffff; + position: absolute; + left: 0px; + bottom: 0px; + background-color: black; + width: 98%; + padding: 1%; + opacity: 0.65; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + height: 25px; +} + +.news-main-item { + background-color: #ffffff; + padding: 5px 0px; + border-top: 1px solid #eaeaea; + width: 100%; + margin: auto; +} + +.news-content-item { + position: relative; + margin-left: -3px; +} + +.news-content-item-title { + display: inline-block; + font-size: 12px; + width: 70%; +} + +.news-content-item-img { + display: inline-block; + width: 25%; + background-color: #acadae; +} + +.input-tt { + padding: 5px; +} + +.activeAddNews { + border: 5px solid #2bb673; +} + +.news-main-plus { + width: 280px; + text-align: center; + margin: auto; + height: 50px; +} + +.icon-plus { + margin: 10px; + font-size: 25px; +} + +.select-item { + width: 60%; + padding: 10px; + margin: 0 auto 10px auto; + border: 1px solid #eaeaea; +} + +.father .child { + display: none; + text-align: center; + position: relative; + bottom: 25px; +} + +.father:hover .child { + display: block; +} + +.thumb-div { + display: inline-block; + width: 30%; + text-align: center; +} + +.thumb-but { + margin: 5px; +} + +.material-img { + width: 100%; + height: 100%; +} +</style> diff --git a/src/views/mp/draft/mock.js b/src/views/mp/draft/mock.js new file mode 100644 index 00000000..e8493f6c --- /dev/null +++ b/src/views/mp/draft/mock.js @@ -0,0 +1,151 @@ +export default { + list: [ + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-q-G9pdsmZw0OYG4FzHQkKfpLfEwIH51wy2bxisx8PvW', + content: { + newsItem: [ + { + title: '我是标题(OOO)', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9XaFphcmtJVFh3VEc4Q1MxQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN2QxTE56SFBCYXc2RE9NcUxIeS1CQjJuUHhTWjBlN2VOeGRpRi1fZUhwN1FNQjdrQV9yRU9EU0hibHREZmZoVW5acnZrN3ZjaWsxejR3RGpKczBzTHFIM0dFNFZWVkpBc0dWWlAzUEhlVmpnfn4%3D&chksm=1f6354802814dd969ef83c0f3babe555c614270b30bc383beaf7ffd13b0257f0fe5ced9af694#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + }, + { + title: '我是标题(XXX)', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9yTlYwOEs1clpwcE5OUEhCQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN0NSMjFqN3N1aUZMbFNVLTZHN2ZDME9qOGp2THk2RFNlSTlKZ3Y1czFVZDdQQm5IeUg3dEppSUtpQUh5SExOOTRkT3dHNUdBdHdWSWlOendlREV3dS1jUEVQbFpiVTZmVW5iRWhZcGdkNTFRfn4%3D&chksm=1f6354802814dd96a403151cd44c7da4eecf0e475d25423e46ecd795b513bafd829a75daef9b#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + } + ] + }, + updateTime: 1673655730 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-jGpXnO73ihN0lsNXknCRQHapp2xgHMRxHKG50LituFe', + content: { + newsItem: [ + { + title: '我是标题(修改)', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl95WVFXYndIZnZJd0t5cjgvQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN1dlNURPbWswbEF4RDd5dVJTdjQ4cm9Cc0Q1TWhpMUh6SE1hVEE3ZHljaHhlZjZYSGF5N2JNSHpDTlh6ajNZbkpGTGpTcUQ4M3NMdW41ZUpXNFZZQ1VKbVlaMVp5ekxEV1czREdsY1dOYTZnfn4%3D&chksm=1f6354be2814dda8e6238037c2ebd52b1c8e80e93249a861ad80e4d40e5ca7207233475ca689#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + } + ] + }, + updateTime: 1673655584 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-v5SrbNCPpD6M_p3TmSrYwTjKogs-0DMJgmjMyNZPeMO', + content: { + newsItem: [ + { + title: '1321', + author: '3232', + digest: '1333', + content: '<p>444</p>', + contentSourceUrl: 'http://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-tlQmcl3RdC-Jcgns6IQtf7zenGy3b86WLT7GzUcrb1T', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9jelJiaDAzbmdpSkJOZ2M2QWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNDNXVVc2ZDRYeTY0Zm1weXR6dE9vQWh1TzEwbEpUVnRfVzJyaGFDNXBkZ0ZXM2JFOTNaRHNhOHRUeFdEanhMeS01X01kMUNWQ1BpRER3cjYwTl9pMnpFLUJhZXFucVVfM1pDUXlTUEl1S25nfn4%3D&chksm=1f6354bc2814ddaa56a90ad5bc3d078601c8d1589ba01827a8170587bc830ff9747b5f59c3a0#rd', + thumbUrl: + 'http://mmbiz.qpic.cn/mmbiz_png/btUmCVHwbJUoicwBiacjVeQbu6QxgBVrukfSJXz509boa21SpH8OVHAqXCJiaiaAaHQJNxwwsa0gHRXVr0G5EZYamw/0?wx_fmt=png' + } + ] + }, + updateTime: 1673628969 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-vdWrisK5EZbk4Y3tzh8P0PG0eEUbnQrh0BcsEb3WNP0', + content: { + newsItem: [ + { + title: 'tudou', + author: 'haha', + digest: '312', + content: '<p>132312</p>', + contentSourceUrl: 'http://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qdkJ1ZjBoUmg2Uk9TS3RlQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNVg2aTJsaC1fMkU2eXNacUplN3VDTTZFZkhtMjhuTUZvWkxsNDBRSXExY2tiVXRHb09TaHgtREhzY3doZ0JYeC1TSTZ5eWZldXJsOWtfbV8yMi1aYkcyZ2pOY0haM0Ntb3VSWEtxUGVFRlNBfn4%3D&chksm=1f6354ba2814ddacf0184b24d310483641ef190b1faac098c285eb416c70017e2f54decfa1af#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG.png' + } + ] + }, + updateTime: 1673628760 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-u9kTIm1DhWZDdXyxsxUVv2Z5DAB99IPxkIRTUUD206k', + content: { + newsItem: [ + { + title: '12', + author: '333', + digest: '123', + content: '123', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qVVhpSDZUaFJWTzBBWWRVQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNWRnTDJWYmF2NER0clV1bThmQ0xUR3hqQnJkZ3BJSUNmNDJmc0lCZ1dadkVnZ3Z5bkN4YWtVUjhoaWZWYzZURUR4NnpMd0Y4Z3U5aUdib0lkMzI4Rjg3SG9JX2FycTMxbUctOHplaTlQVVhnfn4%3D&chksm=1f6354b62814dda076c778af33f06580165d8aa81f7798d55cfabb1886b5c74d9b2124a3535c#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8.jpg' + } + ] + }, + updateTime: 1673626494 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-sO24upobaENDmeByfBTfaozB3aOqSMAV0lGy-UkHXE7', + content: { + newsItem: [ + { + title: '我是标题', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9LT2dqRnpMNUpsR0hjYWtBQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNGNmazZTdlE5WkxvU0tfX2V5cjV2WjJiR0xjQUhyREFSZWo2eWNrUW9EYVh6ZkpWRXBLR3FmTEV6YldBMno3Q2ZvVXBSdzlaVDc3aFhndEpQWUwzWmFMUWt0YVVURE1VZ1FsQTdPMlRtc3JBfn4%3D&chksm=1f6354aa2814ddbcc2637382f963a8742993ac38ebcebe6e3411df5ac82ac7bbdb391be6494a#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + } + ] + }, + updateTime: 1673534279 + } + ], + total: 6 +} diff --git a/src/views/mp/menu/assets/iphone_backImg.png b/src/views/mp/menu/assets/iphone_backImg.png new file mode 100644 index 00000000..bb09591a Binary files /dev/null and b/src/views/mp/menu/assets/iphone_backImg.png differ diff --git a/src/views/mp/menu/assets/menu_foot.png b/src/views/mp/menu/assets/menu_foot.png new file mode 100644 index 00000000..4a89d4bd Binary files /dev/null and b/src/views/mp/menu/assets/menu_foot.png differ diff --git a/src/views/mp/menu/assets/menu_head.png b/src/views/mp/menu/assets/menu_head.png new file mode 100644 index 00000000..248cfb76 Binary files /dev/null and b/src/views/mp/menu/assets/menu_head.png differ diff --git a/src/views/mp/menu/index.vue b/src/views/mp/menu/index.vue index 497f72ec..a8e3d880 100644 --- a/src/views/mp/menu/index.vue +++ b/src/views/mp/menu/index.vue @@ -1,3 +1,793 @@ <template> - <span>开发中</span> + <div class="app-container"> + <doc-alert title="公众号菜单" url="https://doc.iocoder.cn/mp/menu/" /> + + <!-- 搜索工作栏 --> + <el-form ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px"> + <el-form-item label="公众号" prop="accountId"> + <el-select v-model="accountId" placeholder="请选择公众号"> + <el-option + v-for="item in accountList" + :key="parseInt(item.id)" + :label="item.name" + :value="parseInt(item.id)" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button> + <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button> + </el-form-item> + </el-form> + + <div class="public-account-management clearfix" v-loading="loading"> + <!--左边配置菜单--> + <div class="left"> + <div class="weixin-hd"> + <div class="weixin-title">{{ name }}</div> + </div> + <div class="weixin-menu menu_main clearfix"> + <div class="menu_bottom" v-for="(item, i) of menuList" :key="i"> + <!-- 一级菜单 --> + <div @click="menuClick(i, item)" class="menu_item" :class="{ active: isActive === i }" + ><Icon icon="ep:fold" color="black" />{{ item.name }} + </div> + <!-- 以下为二级菜单--> + <div class="submenu" v-if="isSubMenuFlag === i"> + <div class="subtitle menu_bottom" v-for="(subItem, k) in item.children" :key="k"> + <div + class="menu_subItem" + v-if="item.children" + :class="{ active: isSubMenuActive === i + '' + k }" + @click="subMenuClick(subItem, i, k)" + > + {{ subItem.name }} + </div> + </div> + <!-- 二级菜单加号, 当长度 小于 5 才显示二级菜单的加号 --> + <div + class="menu_bottom menu_addicon" + v-if="!item.children || item.children.length < 5" + @click="addSubMenu(i, item)" + > + <Icon icon="ep:plus" /> + </div> + </div> + </div> + <!-- 一级菜单加号 --> + <div class="menu_bottom menu_addicon" v-if="menuList.length < 3" @click="addMenu"> + <Icon icon="ep:plus" /> + </div> + </div> + <div class="save_div"> + <el-button + class="save_btn" + type="success" + size="small" + @click="handleSave" + v-hasPermi="['mp:menu:save']" + >保存并发布菜单</el-button + > + <el-button + class="save_btn" + type="danger" + size="small" + @click="handleDelete" + v-hasPermi="['mp:menu:delete']" + >清空菜单</el-button + > + </div> + </div> + <!--右边配置--> + <div v-if="showRightFlag" class="right"> + <div class="configure_page"> + <div class="delete_btn"> + <el-button size="small" type="danger" @click="handleDeleteMenu(tempObj)" + >删除当前菜单<Icon icon="ep:delete" + /></el-button> + </div> + <div> + <span>菜单名称:</span> + <el-input + class="input_width" + v-model="tempObj.name" + placeholder="请输入菜单名称" + :maxlength="nameMaxLength" + clearable + /> + </div> + <div v-if="showConfigureContent"> + <div class="menu_content"> + <span>菜单标识:</span> + <el-input + class="input_width" + v-model="tempObj.menuKey" + placeholder="请输入菜单 KEY" + clearable + /> + </div> + <div class="menu_content"> + <span>菜单内容:</span> + <el-select v-model="tempObj.type" clearable placeholder="请选择" class="menu_option"> + <el-option + v-for="item in menuOptions" + :label="item.label" + :value="item.value" + :key="item.value" + /> + </el-select> + </div> + <div class="configur_content" v-if="tempObj.type === 'view'"> + <span>跳转链接:</span> + <el-input + class="input_width" + v-model="tempObj.url" + placeholder="请输入链接" + clearable + /> + </div> + <div class="configur_content" v-if="tempObj.type === 'miniprogram'"> + <div class="applet"> + <span>小程序的 appid :</span> + <el-input + class="input_width" + v-model="tempObj.miniProgramAppId" + placeholder="请输入小程序的appid" + clearable + /> + </div> + <div class="applet"> + <span>小程序的页面路径:</span> + <el-input + class="input_width" + v-model="tempObj.miniProgramPagePath" + placeholder="请输入小程序的页面路径,如:pages/index" + clearable + /> + </div> + <div class="applet"> + <span>小程序的备用网页:</span> + <el-input + class="input_width" + v-model="tempObj.url" + placeholder="不支持小程序的老版本客户端将打开本网页" + clearable + /> + </div> + <p class="blue">tips:需要和公众号进行关联才可以把小程序绑定带微信菜单上哟!</p> + </div> + <div class="configur_content" v-if="tempObj.type === 'article_view_limited'"> + <el-row> + <div class="select-item" v-if="tempObj && tempObj.replyArticles"> + <WxNews :articles="tempObj.replyArticles" /> + <el-row class="ope-row" justify="center" align="middle"> + <el-button type="danger" circle @click="deleteMaterial" + ><icon icon="ep:delete" + /></el-button> + </el-row> + </div> + <div v-else> + <el-row justify="center"> + <el-col :span="24" style="text-align: center"> + <el-button type="success" @click="openMaterial"> + 素材库选择<Icon icon="ep:circle-check" /> + </el-button> + </el-col> + </el-row> + </div> + <el-dialog title="选择图文" v-model="dialogNewsVisible" width="90%"> + <WxMaterialSelect + :objData="{ type: 'news', accountId: accountId }" + @select-material="selectMaterial" + /> + </el-dialog> + </el-row> + </div> + <div + class="configur_content" + v-if="tempObj.type === 'click' || tempObj.type === 'scancode_waitmsg'" + > + <WxReplySelect :objData="tempObj.reply" v-if="hackResetWxReplySelect" /> + </div> + </div> + </div> + </div> + <!-- 一进页面就显示的默认页面,当点击左边按钮的时候,就不显示了--> + <div v-else class="right"> + <p>请选择菜单配置</p> + </div> + </div> + </div> </template> + +<script setup> +import { ref, nextTick } from 'vue' +import WxReplySelect from '@/views/mp/components/wx-reply/main.vue' +import WxNews from '@/views/mp/components/wx-news/main.vue' +import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue' +import { deleteMenu, getMenuList, saveMenu } from '@/api/mp/menu' +import { getSimpleAccountList } from '@/api/mp/account' +import { handleTree } from '@/utils/tree' +import menuOptions from './menuOptions' + +const message = useMessage() + +// ======================== 列表查询 ======================== +// 遮罩层 +const loading = ref(true) +// 显示搜索条件 +const showSearch = ref(true) +// 公众号Id +const accountId = ref(undefined) +// 公众号名 +const name = ref('') +const menuList = ref({ children: [] }) + +// const menuList = ref(menuListData) +// ======================== 菜单操作 ======================== +const isActive = ref(-1) // 一级菜单点中样式 +const isSubMenuActive = ref(-1) // 一级菜单点中样式 +const isSubMenuFlag = ref(-1) // 二级菜单显示标志 + +// ======================== 菜单编辑 ======================== +const showRightFlag = ref(false) // 右边配置显示默认详情还是配置详情 +const nameMaxLength = ref(0) // 菜单名称最大长度;1 级是 4 字符;2 级是 7 字符; +const showConfigureContent = ref(true) // 是否展示配置内容;如果有子菜单,就不显示配置内容 +const hackResetWxReplySelect = ref(false) // 重置 WxReplySelect 组件 +const tempObj = ref({}) // 右边临时变量,作为中间值牵引关系 + +const tempSelfObj = ref({ + // 一些临时值放在这里进行判断,如果放在 tempObj,由于引用关系,menu 也会多了多余的参数 +}) +const dialogNewsVisible = ref(false) // 跳转图文时的素材选择弹窗 + +// 公众号账号列表 +const accountList = ref([]) + +onMounted(async () => { + accountList.value = await getSimpleAccountList() + // 选中第一个 + if (accountList.value.length > 0) { + // @ts-ignore + setAccountId(accountList.value[0].id) + } + await getList() +}) + +// ======================== 列表查询 ======================== +/** 设置账号编号 */ +const setAccountId = (id) => { + accountId.value = id + name.value = accountList.value.find((item) => item.id === accountId.value)?.name +} + +const getList = async () => { + loading.value = false + getMenuList(accountId.value) + .then((response) => { + const menuData = convertMenuList(response) + menuList.value = handleTree(menuData, 'id') + }) + .finally(() => { + loading.value = false + }) +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + resetForm() + // 默认选中第一个 + if (accountId.value) { + setAccountId(accountId.value) + } + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + resetForm() + // 默认选中第一个 + if (accountList.value.length > 0) { + setAccountId(accountList.value[0].id) + } + handleQuery() +} + +// 将后端返回的 menuList,转换成前端的 menuList +const convertMenuList = (list) => { + if (!list) return [] + + const menuList = [] + list.forEach((item) => { + const menu = { + ...item + } + if (item.type === 'click' || item.type === 'scancode_waitmsg') { + delete menu.replyMessageType + delete menu.replyContent + delete menu.replyMediaId + delete menu.replyMediaUrl + delete menu.replyDescription + delete menu.replyArticles + menu.reply = { + type: item.replyMessageType, + accountId: item.accountId, + content: item.replyContent, + mediaId: item.replyMediaId, + url: item.replyMediaUrl, + title: item.replyTitle, + description: item.replyDescription, + thumbMediaId: item.replyThumbMediaId, + thumbMediaUrl: item.replyThumbMediaUrl, + articles: item.replyArticles, + musicUrl: item.replyMusicUrl, + hqMusicUrl: item.replyHqMusicUrl + } + } + menuList.push(menu) + }) + return menuList +} + +// 重置表单,清空表单数据 +const resetForm = () => { + // 菜单操作 + isActive.value = -1 + isSubMenuActive.value = -1 + isSubMenuFlag.value = -1 + + // 菜单编辑 + showRightFlag.value = false + nameMaxLength.value = 0 + showConfigureContent.value = 0 + hackResetWxReplySelect.value = false + tempObj.value = {} + tempSelfObj.value = {} + dialogNewsVisible.value = false +} + +// ======================== 菜单操作 ======================== +// 一级菜单点击事件 +const menuClick = (i, item) => { + // 右侧的表单相关 + resetEditor() + showRightFlag.value = true // 右边菜单 + tempObj.value = item // 这个如果放在顶部,flag 会没有。因为重新赋值了。 + tempSelfObj.value.grand = '1' // 表示一级菜单 + tempSelfObj.value.index = i // 表示一级菜单索引 + nameMaxLength.value = 4 + showConfigureContent.value = !(item.children && item.children.length > 0) // 有子菜单,就不显示配置内容 + + // 左侧的选中 + isActive.value = i // 一级菜单选中样式 + isSubMenuFlag.value = i // 二级菜单显示标志 + isSubMenuActive.value = -1 // 二级菜单去除选中样式 +} + +// 二级菜单点击事件 +const subMenuClick = (subItem, index, k) => { + // 右侧的表单相关 + resetEditor() + showRightFlag.value = true // 右边菜单 + console.log(subItem) + tempObj.value = subItem // 将点击的数据放到临时变量,对象有引用作用 + tempSelfObj.value.grand = '2' // 表示二级菜单 + tempSelfObj.value.index = index // 表示一级菜单索引 + tempSelfObj.value.secondIndex = k // 表示二级菜单索引 + nameMaxLength.value = 7 + showConfigureContent.value = true + + // 左侧的选中 + isActive.value = -1 // 一级菜单去除样式 + isSubMenuActive.value = index + '' + k // 二级菜单选中样式 +} + +// 添加横向一级菜单 +const addMenu = () => { + const menuKeyLength = menuList.value.length + const addButton = { + name: '菜单名称', + children: [], + reply: { + // 用于存储回复内容 + type: 'text', + accountId: accountId.value // 保证组件里,可以使用到对应的公众号 + } + } + menuList.value[menuKeyLength] = addButton + menuClick(menuKeyLength.value - 1, addButton) +} +// 添加横向二级菜单;item 表示要操作的父菜单 +const addSubMenu = (i, item) => { + // 清空父菜单的属性,因为它只需要 name 属性即可 + if (!item.children || item.children.length <= 0) { + item.children = [] + delete item['type'] + delete item['menuKey'] + delete item['miniProgramAppId'] + delete item['miniProgramPagePath'] + delete item['url'] + delete item['reply'] + delete item['articleId'] + delete item['replyArticles'] + // 关闭配置面板 + showConfigureContent.value = false + } + + let subMenuKeyLength = item.children.length // 获取二级菜单key长度 + let addButton = { + name: '子菜单名称', + reply: { + // 用于存储回复内容 + type: 'text', + accountId: accountId.value // 保证组件里,可以使用到对应的公众号 + } + } + item.children[subMenuKeyLength] = addButton + subMenuClick(item.children[subMenuKeyLength], i, subMenuKeyLength) +} + +// 删除当前菜单 +const handleDeleteMenu = async () => { + try { + await message.confirm('确定要删除吗?') + if (tempSelfObj.value.grand === '1') { + // 一级菜单的删除方法 + menuList.value.splice(tempSelfObj.value.index, 1) + } else if (tempSelfObj.value.grand === '2') { + // 二级菜单的删除方法 + menuList.value[tempSelfObj.value.index].children.splice(tempSelfObj.value.secondIndex, 1) + } + // 提示 + message.notifySuccess('删除成功') + + // 处理菜单的选中 + tempObj.value = {} + showRightFlag.value = false + isActive.value = -1 + isSubMenuActive.value = -1 + } catch {} +} + +// ======================== 菜单编辑 ======================== +const handleSave = async () => { + try { + await message.confirm('确定要删除吗?') + loading.value = true + await saveMenu(accountId.value, convertMenuFormList()) + getList() + message.notifySuccess('发布成功') + } finally { + loading.value = false + } +} + +// 表单 Editor 重置 +const resetEditor = () => { + hackResetWxReplySelect.value = false // 销毁组件 + nextTick(() => { + console.log('nextTick') + hackResetWxReplySelect.value = true // 重建组件 + }) +} + +const handleDelete = async () => { + try { + await message.confirm('确定要删除吗?') + loading.value = true + await deleteMenu(accountId.value) + handleQuery() + message.notifySuccess('清空成功') + } finally { + loading.value = false + } +} + +// 将前端的 menuList,转换成后端接收的 menuList +const convertMenuFormList = () => { + const result = [] + menuList.value.forEach((item) => { + let menu = convertMenuForm(item) + result.push(menu) + + // 处理子菜单 + if (!item.children || item.children.length <= 0) { + return + } + menu.children = [] + item.children.forEach((subItem) => { + menu.children.push(convertMenuForm(subItem)) + }) + }) + return result +} + +// 将前端的 menu,转换成后端接收的 menu +const convertMenuForm = (menu) => { + let result = { + ...menu, + children: undefined, // 不处理子节点 + reply: undefined // 稍后复制 + } + if (menu.type === 'click' || menu.type === 'scancode_waitmsg') { + result.replyMessageType = menu.reply.type + result.replyContent = menu.reply.content + result.replyMediaId = menu.reply.mediaId + result.replyMediaUrl = menu.reply.url + result.replyTitle = menu.reply.title + result.replyDescription = menu.reply.description + result.replyThumbMediaId = menu.reply.thumbMediaId + result.replyThumbMediaUrl = menu.reply.thumbMediaUrl + result.replyArticles = menu.reply.articles + result.replyMusicUrl = menu.reply.musicUrl + result.replyHqMusicUrl = menu.reply.hqMusicUrl + } + return result +} + +// ======================== 菜单编辑(素材选择) ======================== +const openMaterial = () => { + dialogNewsVisible.value = true +} + +const selectMaterial = (item) => { + const articleId = item.articleId + const articles = item.content.newsItem + // 提示,针对多图文 + if (articles.length > 1) { + message.alertWarning('您选择的是多图文,将默认跳转第一篇') + } + dialogNewsVisible.value = false + + // 设置菜单的回复 + tempObj.value.articleId = articleId + tempObj.value.replyArticles = [] + articles.forEach((article) => { + tempObj.value.replyArticles.push({ + title: article.title, + description: article.digest, + picUrl: article.picUrl, + url: article.url + }) + }) +} + +const deleteMaterial = () => { + delete tempObj.value['articleId'] + delete tempObj.value['replyArticles'] +} +</script> +<!--本组件样式--> +<style lang="scss" scoped="scoped"> +/* 公共颜色变量 */ +.clearfix { + *zoom: 1; +} + +.clearfix::after { + content: ''; + display: table; + clear: both; +} + +div { + text-align: left; +} + +.weixin-hd { + color: #fff; + text-align: center; + position: relative; + bottom: 426px; + left: 0px; + width: 300px; + height: 64px; + background: transparent url('./assets/menu_head.png') no-repeat 0 0; + background-position: 0 0; + background-size: 100%; +} + +.weixin-title { + color: #fff; + font-size: 14px; + width: 100%; + text-align: center; + position: absolute; + top: 33px; + left: 0px; +} + +.weixin-menu { + background: transparent url('./assets/menu_foot.png') no-repeat 0 0; + padding-left: 43px; + font-size: 12px; +} + +.menu_option { + width: 40% !important; +} + +.public-account-management { + min-width: 1200px; + width: 1200px; + margin: 0 auto; + + .left { + float: left; + display: inline-block; + width: 350px; + height: 715px; + background: url('./assets/iphone_backImg.png') no-repeat; + background-size: 100% auto; + padding: 518px 25px 88px; + position: relative; + box-sizing: border-box; + + /*第一级菜单*/ + .menu_main { + .menu_bottom { + position: relative; + float: left; + display: inline-block; + box-sizing: border-box; + width: 85.5px; + text-align: center; + border: 1px solid #ebedee; + background-color: #fff; + cursor: pointer; + + &.menu_addicon { + height: 46px; + line-height: 46px; + } + + .menu_item { + height: 44px; + line-height: 44px; + // text-align: center; + box-sizing: border-box; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + &.active { + border: 1px solid #2bb673; + } + } + + .menu_subItem { + height: 44px; + line-height: 44px; + text-align: center; + box-sizing: border-box; + + &.active { + border: 1px solid #2bb673; + } + } + } + + i { + color: #2bb673; + } + + /*第二级菜单*/ + .submenu { + position: absolute; + width: 85.5px; + bottom: 45px; + + .subtitle { + background-color: #fff; + box-sizing: border-box; + } + } + } + + .save_div { + margin-top: 15px; + text-align: center; + + .save_btn { + bottom: 20px; + left: 100px; + } + } + } + + /*右边菜单内容*/ + .right { + float: left; + width: 63%; + background-color: #e8e7e7; + padding: 20px; + margin-left: 20px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + + .configure_page { + .delete_btn { + text-align: right; + margin-bottom: 15px; + } + + .menu_content { + margin-top: 20px; + } + + .configur_content { + margin-top: 20px; + background-color: #fff; + padding: 20px 10px; + border-radius: 5px; + } + + .blue { + color: #29b6f6; + margin-top: 10px; + } + + .applet { + margin-bottom: 20px; + + span { + width: 20%; + } + } + + .input_width { + width: 40%; + } + + .material { + .input_width { + width: 30%; + } + + .el-textarea { + width: 80%; + } + } + } + } + + .el-input { + width: 70%; + margin-right: 2%; + } +} +</style> +<!--素材样式--> +<style lang="scss" scoped> +.pagination { + text-align: right; + margin-right: 25px; +} + +.select-item { + width: 280px; + padding: 10px; + margin: 0 auto 10px auto; + border: 1px solid #eaeaea; +} + +.select-item2 { + padding: 10px; + margin: 0 auto 10px auto; + border: 1px solid #eaeaea; +} + +.ope-row { + padding-top: 10px; + text-align: center; +} + +.item-name { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} +</style> diff --git a/src/views/mp/menu/menuOptions.js b/src/views/mp/menu/menuOptions.js new file mode 100644 index 00000000..d86dd789 --- /dev/null +++ b/src/views/mp/menu/menuOptions.js @@ -0,0 +1,42 @@ +export default [ + { + value: 'view', + label: '跳转网页' + }, + { + value: 'miniprogram', + label: '跳转小程序' + }, + { + value: 'click', + label: '点击回复' + }, + { + value: 'article_view_limited', + label: '跳转图文消息' + }, + { + value: 'scancode_push', + label: '扫码直接返回结果' + }, + { + value: 'scancode_waitmsg', + label: '扫码回复' + }, + { + value: 'pic_sysphoto', + label: '系统拍照发图' + }, + { + value: 'pic_photo_or_album', + label: '拍照或者相册' + }, + { + value: 'pic_weixin', + label: '微信相册' + }, + { + value: 'location_select', + label: '选择地理位置' + } +]