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 {