Commit 71ec8123 by licheng

chore: 删除mp模块

parent 4b2ad936
import request from '@/config/axios'
export interface AccountVO {
id: number
name: string
}
// 创建公众号账号
export const createAccount = async (data) => {
return await request.post({ url: '/mp/account/create', data })
}
// 更新公众号账号
export const updateAccount = async (data) => {
return request.put({ url: '/mp/account/update', data: data })
}
// 删除公众号账号
export const deleteAccount = async (id) => {
return request.delete({ url: '/mp/account/delete?id=' + id, method: 'delete' })
}
// 获得公众号账号
export const getAccount = async (id) => {
return request.get({ url: '/mp/account/get?id=' + id })
}
// 获得公众号账号分页
export const getAccountPage = async (query) => {
return request.get({ url: '/mp/account/page', params: query })
}
// 获取公众号账号精简信息列表
export const getSimpleAccountList = async () => {
return request.get({ url: '/mp/account/list-all-simple' })
}
// 生成公众号二维码
export const generateAccountQrCode = async (id) => {
return request.put({ url: '/mp/account/generate-qr-code?id=' + id })
}
// 清空公众号 API 配额
export const clearAccountQuota = async (id) => {
return request.put({ url: '/mp/account/clear-quota?id=' + id })
}
import request from '@/config/axios'
// 创建公众号的自动回复
export const createAutoReply = (data) => {
return request.post({
url: '/mp/auto-reply/create',
data: data
})
}
// 更新公众号的自动回复
export const updateAutoReply = (data) => {
return request.put({
url: '/mp/auto-reply/update',
data: data
})
}
// 删除公众号的自动回复
export const deleteAutoReply = (id) => {
return request.delete({
url: '/mp/auto-reply/delete?id=' + id
})
}
// 获得公众号的自动回复
export const getAutoReply = (id) => {
return request.get({
url: '/mp/auto-reply/get?id=' + id
})
}
// 获得公众号的自动回复分页
export const getAutoReplyPage = (query) => {
return request.get({
url: '/mp/auto-reply/page',
params: query
})
}
import request from '@/config/axios'
// 获得公众号草稿分页
export const getDraftPage = (query) => {
return request.get({
url: '/mp/draft/page',
params: query
})
}
// 创建公众号草稿
export const createDraft = (accountId, articles) => {
return request.post({
url: '/mp/draft/create?accountId=' + accountId,
data: {
articles
}
})
}
// 更新公众号草稿
export const updateDraft = (accountId, mediaId, articles) => {
return request.put({
url: '/mp/draft/update?accountId=' + accountId + '&mediaId=' + mediaId,
method: 'put',
data: articles
})
}
// 删除公众号草稿
export const deleteDraft = (accountId, mediaId) => {
return request.delete({
url: '/mp/draft/delete?accountId=' + accountId + '&mediaId=' + mediaId
})
}
import request from '@/config/axios'
// 获得公众号素材分页
export const getFreePublishPage = (query) => {
return request.get({
url: '/mp/free-publish/page',
params: query
})
}
// 删除公众号素材
export const deleteFreePublish = (accountId, articleId) => {
return request.delete({
url: '/mp/free-publish/delete?accountId=' + accountId + '&articleId=' + articleId
})
}
// 发布公众号素材
export const submitFreePublish = (accountId, mediaId) => {
return request.post({
url: '/mp/free-publish/submit?accountId=' + accountId + '&mediaId=' + mediaId
})
}
import request from '@/config/axios'
// 获得公众号素材分页
export const getMaterialPage = (query) => {
return request.get({
url: '/mp/material/page',
params: query
})
}
// 删除公众号永久素材
export const deletePermanentMaterial = (id) => {
return request.delete({
url: '/mp/material/delete-permanent?id=' + id
})
}
import request from '@/config/axios'
// 获得公众号菜单列表
export const getMenuList = (accountId) => {
return request.get({
url: '/mp/menu/list?accountId=' + accountId
})
}
// 保存公众号菜单
export const saveMenu = (accountId, menus) => {
return request.post({
url: '/mp/menu/save',
data: {
accountId,
menus
}
})
}
// 删除公众号菜单
export const deleteMenu = (accountId) => {
return request.delete({
url: '/mp/menu/delete?accountId=' + accountId
})
}
import request from '@/config/axios'
// 获得公众号消息分页
export const getMessagePage = (query: PageParam) => {
return request.get({
url: '/mp/message/page',
params: query
})
}
// 给粉丝发送消息
export const sendMessage = (data) => {
return request.post({
url: '/mp/message/send',
data: data
})
}
import request from '@/config/axios'
// 获取消息发送概况数据
export const getUpstreamMessage = (query) => {
return request.get({
url: '/mp/statistics/upstream-message',
params: query
})
}
// 用户增减数据
export const getUserSummary = (query) => {
return request.get({
url: '/mp/statistics/user-summary',
params: query
})
}
// 获得用户累计数据
export const getUserCumulate = (query) => {
return request.get({
url: '/mp/statistics/user-cumulate',
params: query
})
}
// 获得接口分析数据
export const getInterfaceSummary = (query) => {
return request.get({
url: '/mp/statistics/interface-summary',
params: query
})
}
import request from '@/config/axios'
export interface TagVO {
id?: number
name: string
accountId: number
createTime: Date
}
// 创建公众号标签
export const createTag = (data: TagVO) => {
return request.post({
url: '/mp/tag/create',
data: data
})
}
// 更新公众号标签
export const updateTag = (data: TagVO) => {
return request.put({
url: '/mp/tag/update',
data: data
})
}
// 删除公众号标签
export const deleteTag = (id: number) => {
return request.delete({
url: '/mp/tag/delete?id=' + id
})
}
// 获得公众号标签
export const getTag = (id: number) => {
return request.get({
url: '/mp/tag/get?id=' + id
})
}
// 获得公众号标签分页
export const getTagPage = (query: PageParam) => {
return request.get({
url: '/mp/tag/page',
params: query
})
}
// 获取公众号标签精简信息列表
export const getSimpleTagList = () => {
return request.get({
url: '/mp/tag/list-all-simple'
})
}
// 同步公众号标签
export const syncTag = (accountId: number) => {
return request.post({
url: '/mp/tag/sync?accountId=' + accountId
})
}
import request from '@/config/axios'
// 更新公众号粉丝
export const updateUser = (data) => {
return request.put({
url: '/mp/user/update',
data: data
})
}
// 获得公众号粉丝
export const getUser = (id) => {
return request.get({
url: '/mp/user/get?id=' + id
})
}
// 获得公众号粉丝分页
export const getUserPage = (query) => {
return request.get({
url: '/mp/user/page',
params: query
})
}
// 同步公众号粉丝
export const syncUser = (accountId) => {
return request.post({
url: '/mp/user/sync?accountId=' + accountId
})
}
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="rules"
label-width="120px"
>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="微信号" prop="account">
<template #label>
<span>
<el-tooltip
content="在微信公众平台(mp.weixin.qq.com)的菜单 [设置与开发 - 公众号设置 - 账号详情] 中能找到「微信号」"
placement="top"
>
<Icon icon="ep:question-filled" style="vertical-align: middle" />
</el-tooltip>
微信号
</span>
</template>
<el-input v-model="formData.account" placeholder="请输入微信号" />
</el-form-item>
<el-form-item label="appId" prop="appId">
<template #label>
<span>
<el-tooltip
content="在微信公众平台(mp.weixin.qq.com)的菜单 [设置与开发 - 公众号设置 - 基本设置] 中能找到「开发者ID(AppID)」"
placement="top"
>
<Icon icon="ep:question-filled" style="vertical-align: middle" />
</el-tooltip>
appId
</span>
</template>
<el-input v-model="formData.appId" placeholder="请输入公众号 appId" />
</el-form-item>
<el-form-item label="appSecret" prop="appSecret">
<template #label>
<span>
<el-tooltip
content="在微信公众平台(mp.weixin.qq.com)的菜单 [设置与开发 - 公众号设置 - 基本设置] 中能找到「开发者密码(AppSecret)」"
placement="top"
>
<Icon icon="ep:question-filled" style="vertical-align: middle" />
</el-tooltip>
appSecret
</span>
</template>
<el-input v-model="formData.appSecret" placeholder="请输入公众号 appSecret" />
</el-form-item>
<el-form-item label="token" prop="token">
<el-input v-model="formData.token" placeholder="请输入公众号token" />
</el-form-item>
<el-form-item label="消息加解密密钥" prop="aesKey">
<el-input v-model="formData.aesKey" placeholder="请输入消息加解密密钥" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import * as AccountApi from '@/api/mp/account'
defineOptions({ name: 'MpAccountForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formType = ref('') // 表单的类型:create - 新增;update - 修改
const formData = ref({
id: undefined,
name: '',
account: '',
appId: '',
appSecret: '',
token: '',
aesKey: '',
remark: ''
})
const rules = reactive({
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
account: [{ required: true, message: '公众号账号不能为空', trigger: 'blur' }],
appId: [{ required: true, message: '公众号 appId 不能为空', trigger: 'blur' }],
appSecret: [{ required: true, message: '公众号密钥不能为空', trigger: 'blur' }],
token: [{ required: true, message: '公众号 token 不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await AccountApi.getAccount(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
if (!formRef) return
const valid = await formRef.value.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
const data = formData.value
if (formType.value === 'create') {
await AccountApi.createAccount(data)
message.success(t('common.createSuccess'))
} else {
await AccountApi.updateAccount(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 表单重置 */
const resetForm = () => {
formData.value = {
id: undefined,
name: '',
account: '',
appId: '',
appSecret: '',
token: '',
aesKey: '',
remark: ''
}
formRef.value?.resetFields()
}
</script>
<template>
<doc-alert title="公众号接入" url="https://doc.iocoder.cn/mp/account/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button type="primary" @click="openForm('create')" v-hasPermi="['mp:account:create']">
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="名称" align="center" prop="name" />
<el-table-column label="微信号" align="center" prop="account" width="180" />
<el-table-column label="appId" align="center" prop="appId" width="180" />
<el-table-column label="服务器地址(URL)" align="center" prop="appId" width="360">
<template #default="scope">
{{ 'http://服务端地址/admin-api/mp/open/' + scope.row.appId }}
</template>
</el-table-column>
<el-table-column label="二维码" align="center" prop="qrCodeUrl">
<template #default="scope">
<img
v-if="scope.row.qrCodeUrl"
:src="scope.row.qrCodeUrl"
alt="二维码"
style="display: inline-block; height: 100px"
/>
<el-button
link
type="primary"
@click="handleGenerateQrCode(scope.row)"
v-hasPermi="['mp:account:qr-code']"
>
生成二维码
</el-button>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['mp:account:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['mp:account:delete']"
>
删除
</el-button>
<el-button
link
type="danger"
@click="handleCleanQuota(scope.row)"
v-hasPermi="['mp:account:clear-quota']"
>
清空 API 配额
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 对话框(添加 / 修改) -->
<AccountForm ref="formRef" @success="getList" />
</template>
<script lang="ts" setup>
import * as AccountApi from '@/api/mp/account'
import AccountForm from './AccountForm.vue'
defineOptions({ name: 'MpAccount' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref([]) // 列表的数据
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: null,
account: null,
appId: null
})
const queryFormRef = ref() // 搜索的表单
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await AccountApi.getAccountPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await AccountApi.deleteAccount(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 生成二维码的按钮操作 */
const handleGenerateQrCode = async (row) => {
try {
// 生成二维码的二次确认
await message.confirm('是否确认生成公众号账号编号为"' + row.name + '"的二维码?')
// 发起生成二维码
await AccountApi.generateAccountQrCode(row.id)
message.success('生成二维码成功')
// 刷新列表
await getList()
} catch {}
}
/** 清空二维码 API 配额的按钮操作 */
const handleCleanQuota = async (row) => {
try {
// 清空 API 配额的二次确认
await message.confirm('是否确认清空生成公众号账号编号为"' + row.name + '"的 API 配额?')
// 发起清空 API 配额
await AccountApi.clearAccountQuota(row.id)
message.success('清空 API 配额成功')
} catch {}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>
<template>
<div>
<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="请选择">
<template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value">
<el-option
v-if="RequestMessageTypes.includes(dict.value)"
:label="dict.label"
:value="dict.value"
/>
</template>
</el-select>
</el-form-item>
<el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword">
<el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword">
<el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable />
</el-form-item>
<el-form-item label="回复消息">
<WxReplySelect v-model="reply" />
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import WxReplySelect, { type Reply } from '@/views/mp/components/wx-reply'
import type { FormInstance } from 'element-plus'
import { MsgType } from './types'
import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict'
defineOptions({ name: 'ReplyForm' })
const props = defineProps<{
modelValue: any
reply: Reply
msgType: MsgType
}>()
const emit = defineEmits<{
(e: 'update:reply', v: Reply)
(e: 'update:modelValue', v: any)
}>()
const reply = computed<Reply>({
get: () => props.reply,
set: (val) => emit('update:reply', val)
})
const replyForm = computed<any>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const formRef = ref<FormInstance | null>(null) // 表单 ref
const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型
// 表单校验
const rules = {
requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }],
requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }]
}
defineExpose({
resetFields: () => formRef.value?.resetFields(),
validate: async () => formRef.value?.validate()
})
</script>
<style scoped></style>
<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 lang="ts" setup>
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import WxMusic from '@/views/mp/components/wx-music'
import WxNews from '@/views/mp/components/wx-news'
import { dateFormatter } from '@/utils/formatTime'
import { DICT_TYPE } from '@/utils/dict'
import { MsgType } from './types'
const props = defineProps<{
loading: boolean
list: any[]
msgType: MsgType
}>()
const emit = defineEmits<{
(e: 'on-update', v: number)
(e: 'on-delete', v: number)
}>()
</script>
// 消息类型(Follow: 关注时回复;Message: 消息回复;Keyword: 关键词回复)
// 作为 tab.name,enum 的数字不能随意修改,与 api 参数相关
export enum MsgType {
Follow = 1,
Message = 2,
Keyword = 3
}
<template>
<doc-alert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form class="-mb-15px" :model="queryParams" :inline="true" label-width="68px">
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
</el-form>
</ContentWrap>
<!-- tab 切换 -->
<ContentWrap>
<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="onCreate"
v-hasPermi="['mp:auto-reply:create']"
v-if="msgType !== MsgType.Follow || list.length <= 0"
>
<Icon icon="ep:plus" />新增
</el-button>
</el-col>
</el-row>
<!-- tab 项 -->
<el-tab-pane :name="MsgType.Follow">
<template #label>
<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>
<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>
<el-row align="middle"><Icon icon="fa:newspaper-o" class="mr-2px" /> 关键词回复</el-row>
</template>
</el-tab-pane>
</el-tabs>
<!-- 列表 -->
<ReplyTable
:loading="loading"
:list="list"
:msg-type="msgType"
@on-update="onUpdate"
@on-delete="onDelete"
/>
<el-dialog
:title="isCreating ? '新增自动回复' : '修改自动回复'"
v-model="showDialog"
width="800px"
destroy-on-close
>
<ReplyForm v-model="replyForm" v-model:reply="reply" :msg-type="msgType" ref="formRef" />
<template #footer>
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" @click="onSubmit">确 定</el-button>
</template>
</el-dialog>
</ContentWrap>
</template>
<script lang="ts" setup>
import ReplyForm from '@/views/mp/autoReply/components/ReplyForm.vue'
import { type Reply, ReplyType } from '@/views/mp/components/wx-reply'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import * as MpAutoReplyApi from '@/api/mp/autoReply'
import { ContentWrap } from '@/components/ContentWrap'
import type { TabPaneName } from 'element-plus'
import ReplyTable from './components/ReplyTable.vue'
import { MsgType } from './components/types'
defineOptions({ name: 'MpAutoReply' })
const message = useMessage() // 消息
const accountId = ref(-1) // 公众号ID
const msgType = ref<MsgType>(MsgType.Keyword) // 消息类型
const loading = ref(true) // 遮罩层
const total = ref(0) // 总条数
const list = ref<any[]>([]) // 自动回复列表
const formRef = ref<InstanceType<typeof ReplyForm> | null>(null) // 表单 ref
// 查询参数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: accountId
})
const isCreating = ref(false) // 是否新建(否则编辑)
const showDialog = ref(false) // 是否显示弹出层
const replyForm = ref<any>({}) // 表单参数
// 回复消息
const reply = ref<Reply>({
type: ReplyType.Text,
accountId: -1
})
/** 侦听账号变化 */
const onAccountChanged = (id: number) => {
accountId.value = id
reply.value.accountId = id
queryParams.pageNo = 1
getList()
}
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await MpAutoReplyApi.getAutoReplyPage({
...queryParams,
type: msgType.value
})
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const onTabChange = (tabName: TabPaneName) => {
msgType.value = tabName as MsgType
handleQuery()
}
/** 新增按钮操作 */
const onCreate = () => {
reset()
// 打开表单,并设置初始化
reply.value = {
type: ReplyType.Text,
accountId: queryParams.accountId
}
isCreating.value = true
showDialog.value = true
}
/** 修改按钮操作 */
const onUpdate = async (id: number) => {
reset()
const data = await MpAutoReplyApi.getAutoReply(id)
// 设置属性
replyForm.value = { ...data }
delete replyForm.value['responseMessageType']
delete replyForm.value['responseContent']
delete replyForm.value['responseMediaId']
delete replyForm.value['responseMediaUrl']
delete replyForm.value['responseDescription']
delete replyForm.value['responseArticles']
reply.value = {
type: data.responseMessageType,
accountId: queryParams.accountId,
content: data.responseContent,
mediaId: data.responseMediaId,
url: data.responseMediaUrl,
title: data.responseTitle,
description: data.responseDescription,
thumbMediaId: data.responseThumbMediaId,
thumbMediaUrl: data.responseThumbMediaUrl,
articles: data.responseArticles,
musicUrl: data.responseMusicUrl,
hqMusicUrl: data.responseHqMusicUrl
}
// 打开表单
isCreating.value = false
showDialog.value = true
}
/** 删除按钮操作 */
const onDelete = async (id: number) => {
await message.confirm('是否确认删除此数据?')
await MpAutoReplyApi.deleteAutoReply(id)
await getList()
message.success('删除成功')
}
const onSubmit = async () => {
await formRef.value?.validate()
// 处理回复消息
const submitForm: any = { ...replyForm.value }
submitForm.responseMessageType = reply.value.type
submitForm.responseContent = reply.value.content
submitForm.responseMediaId = reply.value.mediaId
submitForm.responseMediaUrl = reply.value.url
submitForm.responseTitle = reply.value.title
submitForm.responseDescription = reply.value.description
submitForm.responseThumbMediaId = reply.value.thumbMediaId
submitForm.responseThumbMediaUrl = reply.value.thumbMediaUrl
submitForm.responseArticles = reply.value.articles
submitForm.responseMusicUrl = reply.value.musicUrl
submitForm.responseHqMusicUrl = reply.value.hqMusicUrl
if (replyForm.value.id !== undefined) {
await MpAutoReplyApi.updateAutoReply(submitForm)
message.success('修改成功')
} else {
await MpAutoReplyApi.createAutoReply(submitForm)
message.success('新增成功')
}
showDialog.value = false
await getList()
}
// 表单重置
const reset = () => {
replyForm.value = {
id: undefined,
accountId: queryParams.accountId,
type: msgType.value,
requestKeyword: undefined,
requestMatch: msgType.value === MsgType.Keyword ? 1 : undefined,
requestMessageType: undefined
}
formRef.value?.resetFields()
}
// 取消按钮
const cancel = () => {
showDialog.value = false
reset()
}
</script>
import WxAccountSelect from './main.vue'
export default WxAccountSelect
<template>
<el-select v-model="account.id" placeholder="请选择公众号" class="!w-240px" @change="onChanged">
<el-option v-for="item in accountList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</template>
<script lang="ts" setup>
import * as MpAccountApi from '@/api/mp/account'
import { useTagsViewStore } from '@/store/modules/tagsView'
const message = useMessage() // 消息弹窗
const { delView } = useTagsViewStore() // 视图操作
const { push, currentRoute } = useRouter() // 路由
defineOptions({ name: 'WxAccountSelect' })
const account: MpAccountApi.AccountVO = reactive({
id: -1,
name: ''
})
const accountList = ref<MpAccountApi.AccountVO[]>([])
const emit = defineEmits<{
(e: 'change', id: number, name: string)
}>()
const handleQuery = async () => {
accountList.value = await MpAccountApi.getSimpleAccountList()
if (accountList.value.length == 0) {
message.error('未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置')
delView(unref(currentRoute))
await push({ name: 'MpAccount' })
return
}
// 默认选中第一个
if (accountList.value.length > 0) {
account.id = accountList.value[0].id
if (account.id) {
account.name = accountList.value[0].name
emit('change', account.id, account.name)
}
}
}
const onChanged = (id?: number) => {
const found = accountList.value.find((v) => v.id === id)
if (account.id) {
account.name = found ? found.name : ''
emit('change', account.id, account.name)
}
}
/** 初始化 */
onMounted(() => {
handleQuery()
})
</script>
import WxLocation from './main.vue'
export default WxLocation
<!--
【微信消息 - 定位】TODO @Dhb52 目前未启用
-->
<template>
<div>
<el-link
type="primary"
target="_blank"
:href="
'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
locationY +
'&pointy=' +
locationX +
'&name=' +
label +
'&ref=yudao'
"
>
<el-col>
<el-row>
<img
:src="
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
locationX +
',' +
locationY +
'&key=' +
qqMapKey +
'&size=250*180'
"
/>
</el-row>
<el-row>
<Icon icon="ep:location" />
{{ label }}
</el-row>
</el-col>
</el-link>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'WxLocation' })
const props = defineProps({
locationX: {
required: true,
type: Number
},
locationY: {
required: true,
type: Number
},
label: {
// 地名
required: true,
type: String
},
qqMapKey: {
// QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
required: false,
type: String,
default: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E' // 需要自定义
}
})
defineExpose({
locationX: props.locationX,
locationY: props.locationY,
label: props.label,
qqMapKey: props.qqMapKey
})
</script>
import WxMaterialSelect from './main.vue'
import { NewsType, MaterialType } from './types'
export { NewsType, MaterialType }
export default WxMaterialSelect
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
芋道源码:
① 移除 avue 组件,使用 ElementUI 原生组件
-->
<template>
<div class="pb-30px">
<!-- 类型:image -->
<div v-if="props.type === 'image'">
<div class="waterfall" v-loading="loading">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<img class="material-img" :src="item.url" />
<p class="item-name">{{ item.name }}</p>
<el-row class="ope-row">
<el-button type="success" @click="selectMaterialFun(item)">
选择
<Icon icon="ep:circle-check" />
</el-button>
</el-row>
</div>
</div>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getMaterialPageFun"
/>
</div>
<!-- 类型:voice -->
<div v-else-if="props.type === 'voice'">
<!-- 列表 -->
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="mediaId" />
<el-table-column label="文件名" align="center" prop="name" />
<el-table-column label="语音" align="center">
<template #default="scope">
<WxVoicePlayer :url="scope.row.url" />
</template>
</el-table-column>
<el-table-column
label="上传时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" fixed="right">
<template #default="scope">
<el-button type="primary" link @click="selectMaterialFun(scope.row)"
>选择
<Icon icon="ep:plus" />
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getPage"
/>
</div>
<!-- 类型:video -->
<div v-else-if="props.type === 'video'">
<!-- 列表 -->
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="mediaId" />
<el-table-column label="文件名" align="center" prop="name" />
<el-table-column label="标题" align="center" prop="title" />
<el-table-column label="介绍" align="center" prop="introduction" />
<el-table-column label="视频" align="center">
<template #default="scope">
<WxVideoPlayer :url="scope.row.url" />
</template>
</el-table-column>
<el-table-column
label="上传时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column
label="操作"
align="center"
fixed="right"
class-name="small-padding fixed-width"
>
<template #default="scope">
<el-button type="primary" link @click="selectMaterialFun(scope.row)"
>选择
<Icon icon="akar-icons:circle-plus" />
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getMaterialPageFun"
/>
</div>
<!-- 类型:news -->
<div v-else-if="props.type === 'news'">
<div class="waterfall" v-loading="loading">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<div v-if="item.content && item.content.newsItem">
<WxNews :articles="item.content.newsItem" />
<el-row class="ope-row">
<el-button type="success" @click="selectMaterialFun(item)">
选择
<Icon icon="ep:circle-check" />
</el-button>
</el-row>
</div>
</div>
</div>
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getMaterialPageFun"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import WxNews from '@/views/mp/components/wx-news'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import { NewsType } from './types'
import * as MpMaterialApi from '@/api/mp/material'
import * as MpFreePublishApi from '@/api/mp/freePublish'
import * as MpDraftApi from '@/api/mp/draft'
import { dateFormatter } from '@/utils/formatTime'
defineOptions({ name: 'WxMaterialSelect' })
const props = withDefaults(
defineProps<{
type: string
accountId: number
newsType?: NewsType
}>(),
{
newsType: NewsType.Published
}
)
const emit = defineEmits(['select-material'])
// 遮罩层
const loading = ref(false)
// 总条数
const total = ref(0)
// 数据列表
const list = ref<any[]>([])
// 查询参数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: props.accountId
})
const selectMaterialFun = (item) => {
emit('select-material', item)
}
const getPage = async () => {
loading.value = true
try {
if (props.type === 'news' && props.newsType === NewsType.Published) {
// 【图文】+ 【已发布】
await getFreePublishPageFun()
} else if (props.type === 'news' && props.newsType === NewsType.Draft) {
// 【图文】+ 【草稿】
await getDraftPageFun()
} else {
// 【素材】
await getMaterialPageFun()
}
} finally {
loading.value = false
}
}
const getMaterialPageFun = async () => {
const data = await MpMaterialApi.getMaterialPage({
...queryParams,
type: props.type
})
list.value = data.list
total.value = data.total
}
const getFreePublishPageFun = async () => {
const data = await MpFreePublishApi.getFreePublishPage(queryParams)
data.list.forEach((item: any) => {
const articles = item.content.newsItem
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl
})
})
list.value = data.list
total.value = data.total
}
const getDraftPageFun = async () => {
const data = await MpDraftApi.getDraftPage(queryParams)
data.list.forEach((draft: any) => {
const articles = draft.content.newsItem
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl
})
})
list.value = data.list
total.value = data.total
}
onMounted(async () => {
getPage()
})
</script>
<style lang="scss" scoped>
@media (width >= 992px) and (width <= 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (width >= 768px) and (width <= 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (width <= 767px) {
.waterfall {
column-count: 1;
}
}
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin: 0 auto;
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
</style>
export enum NewsType {
Draft = '2',
Published = '1'
}
export enum MaterialType {
Image = 'image',
Voice = 'voice',
Video = 'video',
News = 'news'
}
.avue-card {
&__item {
margin-bottom: 16px;
border: 1px solid #e8e8e8;
background-color: #fff;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5;
list-style: none;
font-feature-settings: 'tnum';
cursor: pointer;
height: 200px;
&:hover {
border-color: rgba(0, 0, 0, 0.09);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
}
&--add {
border: 1px dashed #000;
width: 100%;
color: rgba(0, 0, 0, 0.45);
background-color: #fff;
border-color: #d9d9d9;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
i {
margin-right: 10px;
}
&:hover {
color: #40a9ff;
background-color: #fff;
border-color: #40a9ff;
}
}
}
&__body {
display: flex;
padding: 24px;
}
&__detail {
flex: 1;
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 48px;
overflow: hidden;
margin-right: 12px;
img {
width: 100%;
height: 100%;
}
}
&__title {
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
font-size: 16px;
&:hover {
color: #1890ff;
}
}
&__info {
color: rgba(0, 0, 0, 0.45);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
height: 64px;
}
&__menu {
display: flex;
justify-content: space-around;
height: 50px;
background: #f7f9fa;
color: rgba(0, 0, 0, 0.45);
text-align: center;
line-height: 50px;
&:hover {
color: #1890ff;
}
}
}
/** joolun 额外加的 */
.avue-comment__main {
flex: unset !important;
border-radius: 5px !important;
margin: 0 8px !important;
}
.avue-comment__header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.avue-comment__body {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
.avue-comment {
margin-bottom: 30px;
display: flex;
align-items: flex-start;
&--reverse {
flex-direction: row-reverse;
.avue-comment__main {
&:before,
&:after {
left: auto;
right: -8px;
border-width: 8px 0 8px 8px;
}
&:before {
border-left-color: #dedede;
}
&:after {
border-left-color: #f8f8f8;
margin-right: 1px;
margin-left: auto;
}
}
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid transparent;
box-sizing: border-box;
vertical-align: middle;
}
&__header {
padding: 5px 15px;
background: #f8f8f8;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
&__author {
font-weight: 700;
font-size: 14px;
color: #999;
}
&__main {
flex: 1;
margin: 0 20px;
position: relative;
border: 1px solid #dedede;
border-radius: 2px;
&:before,
&:after {
position: absolute;
top: 10px;
left: -8px;
right: 100%;
width: 0;
height: 0;
display: block;
content: ' ';
border-color: transparent;
border-style: solid solid outset;
border-width: 8px 8px 8px 0;
pointer-events: none;
}
&:before {
border-right-color: #dedede;
z-index: 1;
}
&:after {
border-right-color: #f8f8f8;
margin-left: 1px;
z-index: 2;
}
}
&__body {
padding: 15px;
overflow: hidden;
background: #fff;
font-family:
Segoe UI,
Lucida Grande,
Helvetica,
Arial,
Microsoft YaHei,
FreeSans,
Arimo,
Droid Sans,
wenquanyi micro hei,
Hiragino Sans GB,
Hiragino Sans GB W3,
FontAwesome,
sans-serif;
color: #333;
font-size: 14px;
}
blockquote {
margin: 0;
font-family:
Georgia,
Times New Roman,
Times,
Kai,
Kaiti SC,
KaiTi,
BiauKai,
FontAwesome,
serif;
padding: 1px 0 1px 15px;
border-left: 4px solid #ddd;
}
}
<template>
<div>
<MsgEvent v-if="item.type === MsgType.Event" :item="item" />
<div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
<div v-else-if="item.type === MsgType.Voice">
<WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" />
</div>
<div v-else-if="item.type === MsgType.Image">
<a target="_blank" :href="item.mediaUrl">
<img :src="item.mediaUrl" style="width: 100px" />
</a>
</div>
<div
v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
style="text-align: center"
>
<WxVideoPlayer :url="item.mediaUrl" />
</div>
<div v-else-if="item.type === MsgType.Link" class="avue-card__detail">
<el-link type="success" :underline="false" target="_blank" :href="item.url">
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div>
</el-link>
<div class="avue-card__info" style="height: unset">{{ item.description }}</div>
</div>
<div v-else-if="item.type === MsgType.Location">
<WxLocation :label="item.label" :location-y="item.locationY" :location-x="item.locationX" />
</div>
<div v-else-if="item.type === MsgType.News" style="width: 300px">
<WxNews :articles="item.articles" />
</div>
<div v-else-if="item.type === MsgType.Music">
<WxMusic
:title="item.title"
:description="item.description"
:thumb-media-url="item.thumbMediaUrl"
:music-url="item.musicUrl"
:hq-music-url="item.hqMusicUrl"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import WxNews from '@/views/mp/components/wx-news'
import WxLocation from '@/views/mp/components/wx-location'
import WxMusic from '@/views/mp/components/wx-music'
import MsgEvent from './MsgEvent.vue'
import { MsgType } from '../types'
defineOptions({ name: 'Msg' })
const props = defineProps<{
item: any
}>()
const item = ref<any>(props.item)
</script>
<style scoped></style>
<template>
<div>
<div v-if="item.event === 'subscribe'">
<el-tag type="success">关注</el-tag>
</div>
<div v-else-if="item.event === 'unsubscribe'">
<el-tag type="danger">取消关注</el-tag>
</div>
<div v-else-if="item.event === 'CLICK'">
<el-tag>点击菜单</el-tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'VIEW'">
<el-tag>点击菜单链接</el-tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_waitmsg'">
<el-tag>扫码结果</el-tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_push'">
<el-tag>扫码结果</el-tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'pic_sysphoto'">
<el-tag>系统拍照发图</el-tag>
</div>
<div v-else-if="item.event === 'pic_photo_or_album'">
<el-tag>拍照或者相册</el-tag>
</div>
<div v-else-if="item.event === 'pic_weixin'">
<el-tag>微信相册</el-tag>
</div>
<div v-else-if="item.event === 'location_select'">
<el-tag>选择地理位置</el-tag>
</div>
<div v-else>
<el-tag type="danger">未知事件类型</el-tag>
</div>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
item: any
}>()
const item = ref(props.item)
</script>
<template>
<div class="execution" v-for="item in props.list" :key="item.id">
<div
class="avue-comment"
:class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }"
>
<div class="avatar-div">
<img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" />
<div class="avue-comment__author">
{{ getNickname(item.sendFrom) }}
</div>
</div>
<div class="avue-comment__main">
<div class="avue-comment__header">
<div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div>
</div>
<div
class="avue-comment__body"
:style="item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''"
>
<Msg :item="item" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import Msg from './Msg.vue'
import { formatDate } from '@/utils/formatTime'
import { User } from '../types'
import avatarWechat from '@/assets/imgs/wechat.png'
defineOptions({ name: 'MsgList' })
const props = defineProps<{
list: any[]
accountId: number
user: User
}>()
enum SendFrom {
User = 1,
MpBot = 2
}
const getAvatar = (sendFrom: SendFrom) =>
sendFrom === SendFrom.User ? props.user.avatar : avatarWechat
const getNickname = (sendFrom: SendFrom) =>
sendFrom === SendFrom.User ? props.user.nickname : '公众号'
</script>
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
@import url('../comment.scss');
@import url('../card.scss');
.avatar-div {
width: 80px;
text-align: center;
}
</style>
import WxMsg from './main.vue'
import { MsgType } from './types'
export { MsgType }
export default WxMsg
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
芋道源码:
① 移除暂时用不到的 websocket
② 代码优化,补充注释,提升阅读性
-->
<template>
<ContentWrap>
<div class="msg-div" ref="msgDivRef">
<!-- 加载更多 -->
<div v-loading="loading"></div>
<div v-if="!loading">
<div class="el-table__empty-block" v-if="hasMore" @click="loadMore"
><span class="el-table__empty-text">点击加载更多</span></div
>
<div class="el-table__empty-block" v-if="!hasMore"
><span class="el-table__empty-text">没有更多了</span></div
>
</div>
<!-- 消息列表 -->
<MsgList :list="list" :account-id="accountId" :user="user" />
</div>
<div class="msg-send" v-loading="sendLoading">
<WxReplySelect ref="replySelectRef" v-model="reply" />
<el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
</div>
</ContentWrap>
</template>
<script lang="ts" setup>
import WxReplySelect, { Reply, ReplyType } from '@/views/mp/components/wx-reply'
import MsgList from './components/MsgList.vue'
import { getMessagePage, sendMessage } from '@/api/mp/message'
import { getUser } from '@/api/mp/user'
import profile from '@/assets/imgs/profile.jpg'
import { User } from './types'
defineOptions({ name: 'WxMsg' })
const message = useMessage() // 消息弹窗
const props = defineProps({
userId: {
type: Number,
required: true
}
})
const accountId = ref(-1) // 公众号ID,需要通过userId初始化
const loading = ref(false) // 消息列表是否正在加载中
const hasMore = ref(true) // 是否可以加载更多
const list = ref<any[]>([]) // 消息列表
const queryParams = reactive({
pageNo: 1, // 当前页数
pageSize: 14, // 每页显示多少条
accountId: accountId
})
// 由于微信不再提供昵称,直接使用“用户”展示
const user: User = reactive({
nickname: '用户',
avatar: profile,
accountId: accountId // 公众号账号编号
})
// ========= 消息发送 =========
const sendLoading = ref(false) // 发送消息是否加载中
// 微信发送消息
const reply = ref<Reply>({
type: ReplyType.Text,
accountId: -1,
articles: []
})
const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null) // WxReplySelect组件ref,用于消息发送成功后清除内容
const msgDivRef = ref<HTMLDivElement | null>(null) // 消息显示窗口ref,用于滚动到底部
/** 完成加载 */
onMounted(async () => {
const data = await getUser(props.userId)
user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname
user.avatar = user.avatar?.length > 0 ? data.avatar : user.avatar
accountId.value = data.accountId
reply.value.accountId = data.accountId
refreshChange()
})
// 执行发送
const sendMsg = async () => {
if (!unref(reply)) {
return
}
// 公众号限制:客服消息,公众号只允许发送一条
if (
reply.value.type === ReplyType.News &&
reply.value.articles &&
reply.value.articles.length > 1
) {
reply.value.articles = [reply.value.articles[0]]
message.success('图文消息条数限制在 1 条以内,已默认发送第一条')
}
const data = await sendMessage({ userId: props.userId, ...reply.value })
sendLoading.value = false
list.value = [...list.value, ...[data]]
await scrollToBottom()
// 发送后清空数据
replySelectRef.value?.clear()
}
const loadMore = () => {
queryParams.pageNo++
getPage(queryParams, null)
}
const getPage = async (page: any, params: any = null) => {
loading.value = true
let dataTemp = await getMessagePage(
Object.assign(
{
pageNo: page.pageNo,
pageSize: page.pageSize,
userId: props.userId,
accountId: page.accountId
},
params
)
)
const scrollHeight = msgDivRef.value?.scrollHeight ?? 0
// 处理数据
const data = dataTemp.list.reverse()
list.value = [...data, ...list.value]
loading.value = false
if (data.length < queryParams.pageSize || data.length === 0) {
hasMore.value = false
}
queryParams.pageNo = page.pageNo
queryParams.pageSize = page.pageSize
// 滚动到原来的位置
if (queryParams.pageNo === 1) {
// 定位到消息底部
await scrollToBottom()
} else if (data.length !== 0) {
// 定位滚动条
await nextTick()
if (scrollHeight !== 0) {
if (msgDivRef.value) {
msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight - scrollHeight - 100
}
}
}
}
const refreshChange = () => {
getPage(queryParams)
}
/** 定位到消息底部 */
const scrollToBottom = async () => {
await nextTick()
if (msgDivRef.value) {
msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight
}
}
</script>
<style lang="scss" scoped>
.msg-div {
height: 50vh;
margin-right: 10px;
margin-left: 10px;
overflow: auto;
background-color: #eaeaea;
}
.msg-send {
padding: 10px;
}
.send-but {
float: right;
margin-top: 8px;
margin-bottom: 8px;
}
</style>
export enum MsgType {
Event = 'event',
Text = 'text',
Voice = 'voice',
Image = 'image',
Video = 'video',
Link = 'link',
Location = 'location',
Music = 'music',
News = 'news'
}
export interface User {
nickname: string
avatar: string
accountId: number
}
import WxMusic from './main.vue'
export default WxMusic
<!--
【微信消息 - 音乐】
-->
<template>
<div>
<el-link
type="success"
:underline="false"
target="_blank"
:href="hqMusicUrl ? hqMusicUrl : musicUrl"
>
<div
class="avue-card__body"
style="padding: 10px; background-color: #fff; border-radius: 5px"
>
<div class="avue-card__avatar">
<img :src="thumbMediaUrl" alt="" />
</div>
<div class="avue-card__detail">
<div class="avue-card__title" style="margin-bottom: unset">{{ title }}</div>
<div class="avue-card__info" style="height: unset">{{ description }}</div>
</div>
</div>
</el-link>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'WxMusic' })
const props = defineProps({
title: {
required: false,
type: String
},
description: {
required: false,
type: String
},
musicUrl: {
required: false,
type: String
},
hqMusicUrl: {
required: false,
type: String
},
thumbMediaUrl: {
required: true,
type: String
}
})
defineExpose({
musicUrl: props.musicUrl
})
</script>
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss */
@import url('../wx-msg/card.scss');
</style>
import WxNews from './main.vue'
export default WxNews
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
【微信消息 - 图文】
芋道源码:
① 代码优化,补充注释,提升阅读性
-->
<template>
<div class="news-home">
<div v-for="(article, index) in articles" :key="index" class="news-div">
<!-- 头条 -->
<a v-if="index === 0" :href="article.url" target="_blank">
<div class="news-main">
<div class="news-content">
<el-image
:src="article.picUrl"
class="material-img"
style="width: 100%; height: 120px"
/>
<div class="news-content-title">
<span>{{ article.title }}</span>
</div>
</div>
</div>
</a>
<!-- 二条/三条等等 -->
<a v-else :href="article.url" target="_blank">
<div class="news-main-item">
<div class="news-content-item">
<div class="news-content-item-title">{{ article.title }}</div>
<div class="news-content-item-img">
<img :src="article.picUrl" class="material-img" height="100%" />
</div>
</div>
</div>
</a>
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'WxNews' })
const props = withDefaults(
defineProps<{
articles: any[] | null
}>(),
{
articles: null
}
)
defineExpose({
articles: props.articles
})
</script>
<style lang="scss" scoped>
.news-home {
width: 100%;
margin: auto;
background-color: #fff;
}
.news-main {
width: 100%;
margin: auto;
}
.news-content {
position: relative;
width: 100%;
background-color: #acadae;
}
.news-content-title {
position: absolute;
bottom: 0;
left: 0;
display: inline-block;
width: 98%;
padding: 1%;
font-size: 12px;
color: #fff;
white-space: normal;
background-color: black;
opacity: 0.65;
box-sizing: unset !important;
}
.news-main-item {
padding: 5px 0;
background-color: #fff;
border-top: 1px solid #eaeaea;
}
.news-content-item {
position: relative;
}
.news-content-item-title {
display: inline-block;
width: 70%;
margin-left: 1%;
font-size: 10px;
white-space: normal;
}
.news-content-item-img {
display: inline-block;
width: 25%;
margin-right: 1%;
background-color: #acadae;
}
.material-img {
width: 100%;
}
</style>
<template>
<div>
<!-- 情况一:已经选择好素材、或者上传好图片 -->
<div class="select-item" v-if="reply.url">
<img class="material-img" :src="reply.url" />
<p class="item-name" v-if="reply.name">{{ reply.name }}</p>
<el-row class="ope-row" justify="center">
<el-button type="danger" circle @click="onDelete">
<Icon icon="ep:delete" />
</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
type="image"
:account-id="reply.accountId"
@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>
</div>
</template>
<script lang="ts" setup>
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import type { UploadRawFile } from 'element-plus'
import { getAccessToken } from '@/utils/auth'
import { Reply } from './types'
const message = useMessage()
const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary'
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
const props = defineProps<{
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply)
}>()
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'image',
title: '',
introduction: ''
})
const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.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 = () => {
reply.value.mediaId = null
reply.value.url = null
reply.value.name = null
}
const selectMaterial = (item) => {
showDialog.value = false
// reply.value.type = 'image'
reply.value.mediaId = item.mediaId
reply.value.url = item.url
reply.value.name = item.name
}
</script>
<style lang="scss" scoped>
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
.material-img {
width: 100%;
}
.item-name {
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
.item-infos {
width: 30%;
margin: auto;
}
.ope-row {
padding-top: 10px;
text-align: center;
}
}
.col-select {
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-add {
float: right;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
.el-upload__tip {
line-height: 18px;
text-align: center;
}
}
}
</style>
<template>
<div>
<el-row align="middle" justify="center">
<el-col :span="6">
<el-row align="middle" justify="center" class="thumb-div">
<el-col :span="24">
<el-row align="middle" justify="center">
<img style="width: 100px" v-if="reply.thumbMediaUrl" :src="reply.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
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</el-dialog>
</el-col>
<el-col :span="18">
<el-input v-model="reply.title" placeholder="请输入标题" />
<div style="margin: 20px 0"></div>
<el-input v-model="reply.description" placeholder="请输入描述" />
</el-col>
</el-row>
<div style="margin: 20px 0"></div>
<el-input v-model="reply.musicUrl" placeholder="请输入音乐链接" />
<div style="margin: 20px 0"></div>
<el-input v-model="reply.hqMusicUrl" placeholder="请输入高质量音乐链接" />
</div>
</template>
<script lang="ts" setup>
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import type { UploadRawFile } from 'element-plus'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { getAccessToken } from '@/utils/auth'
import { Reply } from './types'
const message = useMessage()
const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary'
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
const props = defineProps<{
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply)
}>()
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'thumb', // 音乐类型为thumb
title: '',
introduction: ''
})
const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.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
reply.value.thumbMediaId = item.mediaId
reply.value.thumbMediaUrl = item.url
}
</script>
<template>
<div>
<el-row>
<div class="select-item" v-if="reply.articles && reply.articles.length > 0">
<WxNews :articles="reply.articles" />
<el-col class="ope-row">
<el-button type="danger" circle @click="onDelete">
<Icon icon="ep:delete" />
</el-button>
</el-col>
</div>
<!-- 选择素材 -->
<el-col :span="24" v-if="!reply.content">
<el-row style="text-align: center" align="middle">
<el-col :span="24">
<el-button type="success" @click="showDialog = true">
{{ 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
type="news"
:account-id="reply.accountId"
:newsType="newsType"
@select-material="selectMaterial"
/>
</el-dialog>
</el-row>
</div>
</template>
<script lang="ts" setup>
import WxNews from '@/views/mp/components/wx-news'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import { Reply, NewsType } from './types'
const props = defineProps<{
modelValue: Reply
newsType: NewsType
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply)
}>()
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const showDialog = ref(false)
const selectMaterial = (item: any) => {
showDialog.value = false
reply.value.articles = item.content.newsItem
}
const onDelete = () => {
reply.value.articles = []
}
</script>
<style lang="scss" scoped>
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
.ope-row {
padding-top: 10px;
text-align: center;
}
}
</style>
<template>
<el-input type="textarea" :rows="5" placeholder="请输入内容" v-model="content" />
</template>
<script lang="ts" setup>
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 | undefined>({
get: () => props.modelValue,
set: (val: string | null) => {
emit('update:modelValue', val)
emit('input', val)
}
})
</script>
<template>
<div>
<el-row>
<el-input v-model="reply.title" class="input-margin-bottom" placeholder="请输入标题" />
<el-input class="input-margin-bottom" v-model="reply.description" placeholder="请输入描述" />
<el-row class="ope-row" justify="center">
<WxVideoPlayer v-if="reply.url" :url="reply.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
type="video"
:account-id="reply.accountId"
@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>
</div>
</template>
<script lang="ts" setup>
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import type { UploadRawFile } from 'element-plus'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { getAccessToken } from '@/utils/auth'
import { Reply } from './types'
const message = useMessage()
const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary'
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() }
const props = defineProps<{
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply)
}>()
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'video',
title: '',
introduction: ''
})
const beforeVideoUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.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
reply.value.mediaId = item.mediaId
reply.value.url = item.url
reply.value.name = item.name
// title、introduction:从 item 到 tempObjItem,因为素材里有 title、introduction
if (item.title) {
reply.value.title = item.title || ''
}
if (item.introduction) {
reply.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>
<template>
<div>
<div class="select-item2" v-if="reply.url">
<p class="item-name">{{ reply.name }}</p>
<el-row class="ope-row" justify="center">
<WxVoicePlayer :url="reply.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
type="voice"
:account-id="reply.accountId"
@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>
</div>
</template>
<script lang="ts" setup>
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import type { UploadRawFile } from 'element-plus'
import { getAccessToken } from '@/utils/auth'
import { Reply } from './types'
const message = useMessage()
const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary'
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
const props = defineProps<{
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply)
}>()
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'voice',
title: '',
introduction: ''
})
const beforeVoiceUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.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 = () => {
reply.value.mediaId = null
reply.value.url = null
reply.value.name = null
}
const selectMaterial = (item: Reply) => {
showDialog.value = false
// reply.value.type = ReplyType.Voice
reply.value.mediaId = item.mediaId
reply.value.url = item.url
reply.value.name = item.name
}
</script>
<style lang="scss" scoped>
.select-item2 {
padding: 10px;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
.item-name {
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
.ope-row {
width: 100%;
padding-top: 10px;
text-align: center;
}
}
.col-select {
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-add {
float: right;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
.el-upload__tip {
line-height: 18px;
text-align: center;
}
}
}
</style>
enum ReplyType {
News = 'news',
Image = 'image',
Voice = 'voice',
Video = 'video',
Music = 'music',
Text = 'text'
}
interface _Reply {
accountId: number
type: ReplyType
name?: string | null
content?: string | null
mediaId?: string | null
url?: string | null
title?: string | null
description?: string | null
thumbMediaId?: string | null
thumbMediaUrl?: string | null
musicUrl?: string | null
hqMusicUrl?: string | null
introduction?: string | null
articles?: any[]
}
type Reply = _Reply //Partial<_Reply>
enum NewsType {
Published = '1',
Draft = '2'
}
/** 利用旧的reply[accountId, type]初始化新的Reply */
const createEmptyReply = (old: Reply | Ref<Reply>): Reply => {
return {
accountId: unref(old).accountId,
type: unref(old).type,
name: null,
content: null,
mediaId: null,
url: null,
title: null,
description: null,
thumbMediaId: null,
thumbMediaUrl: null,
musicUrl: null,
hqMusicUrl: null,
introduction: null,
articles: []
}
}
export { Reply, NewsType, ReplyType, createEmptyReply }
import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types'
import WxReplySelect from './main.vue'
export type { Reply }
export { createEmptyReply, NewsType, ReplyType }
export default WxReplySelect
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
芋道源码:
① 移除多余的 rep 为前缀的变量,让 message 消息更简单
② 代码优化,补充注释,提升阅读性
③ 优化消息的临时缓存策略,发送消息时,只清理被发送消息的 tab,不会强制切回到 text 输入
④ 支持发送【视频】消息时,支持新建视频
-->
<template>
<el-tabs type="border-card" v-model="currentTab">
<!-- 类型 1:文本 -->
<el-tab-pane :name="ReplyType.Text">
<template #label>
<el-row align="middle"><Icon icon="ep:document" /> 文本</el-row>
</template>
<TabText v-model="reply.content" />
</el-tab-pane>
<!-- 类型 2:图片 -->
<el-tab-pane :name="ReplyType.Image">
<template #label>
<el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
</template>
<TabImage v-model="reply" />
</el-tab-pane>
<!-- 类型 3:语音 -->
<el-tab-pane :name="ReplyType.Voice">
<template #label>
<el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row>
</template>
<TabVoice v-model="reply" />
</el-tab-pane>
<!-- 类型 4:视频 -->
<el-tab-pane :name="ReplyType.Video">
<template #label>
<el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
</template>
<TabVideo v-model="reply" />
</el-tab-pane>
<!-- 类型 5:图文 -->
<el-tab-pane :name="ReplyType.News">
<template #label>
<el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
</template>
<TabNews v-model="reply" :news-type="newsType" />
</el-tab-pane>
<!-- 类型 6:音乐 -->
<el-tab-pane :name="ReplyType.Music">
<template #label>
<el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
</template>
<TabMusic v-model="reply" />
</el-tab-pane>
</el-tabs>
</template>
<script lang="ts" setup>
import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types'
import TabText from './components/TabText.vue'
import TabImage from './components/TabImage.vue'
import TabVoice from './components/TabVoice.vue'
import TabVideo from './components/TabVideo.vue'
import TabNews from './components/TabNews.vue'
import TabMusic from './components/TabMusic.vue'
defineOptions({ name: 'WxReplySelect' })
interface Props {
modelValue: Reply
newsType?: NewsType
}
const props = withDefaults(defineProps<Props>(), {
newsType: () => NewsType.Published
})
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply)
}>()
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 作为多个标签保存各自Reply的缓存
const tabCache = new Map<ReplyType, Reply>()
// 采用独立的ref来保存当前tab,避免在watch标签变化,对reply进行赋值会产生了循环调用
const currentTab = ref<ReplyType>(props.modelValue.type || ReplyType.Text)
watch(
currentTab,
(newTab, oldTab) => {
// 第一次进入:oldTab 为 undefined
// 判断 newTab 是因为 Reply 为 Partial
if (oldTab === undefined || newTab === undefined) {
return
}
tabCache.set(oldTab, unref(reply))
// 从缓存里面取出新tab内容,有则覆盖Reply,没有则创建空Reply
const temp = tabCache.get(newTab)
if (temp) {
reply.value = temp
} else {
let newData = createEmptyReply(reply)
newData.type = newTab
reply.value = newData
}
},
{
immediate: true
}
)
/** 清除除了`type`, `accountId`的字段 */
const clear = () => {
reply.value = createEmptyReply(reply)
}
defineExpose({
clear
})
</script>
<style lang="scss" scoped>
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
}
.select-item2 {
padding: 10px;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
}
.ope-row {
padding-top: 10px;
text-align: center;
}
.input-margin-bottom {
margin-bottom: 2%;
}
.item-name {
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-form-item__content {
line-height: unset !important;
}
.col-select {
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-select2 {
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-add {
float: right;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.avatar-uploader-icon {
width: 100px !important;
height: 100px !important;
font-size: 28px;
line-height: 100px !important;
color: #8c939d;
text-align: center;
border: 1px solid #d9d9d9;
}
.material-img {
width: 100%;
}
.thumb-div {
display: inline-block;
text-align: center;
}
.item-infos {
width: 30%;
margin: auto;
}
</style>
import WxVideoPlayer from './main.vue'
export default WxVideoPlayer
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
【微信消息 - 视频】
芋道源码:
① bug 修复:
1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容;
存在的问题:mediaId 有效期是 3 天,超过时间后无法播放
2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。
② 体验优化:弹窗关闭后,自动暂停视频的播放
-->
<template>
<div @click="playVideo()">
<!-- 提示 -->
<div>
<Icon icon="ep:video-play" :size="32" class="mr-5px" />
<p class="text-sm">点击播放视频</p>
</div>
<!-- 弹窗播放 -->
<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"
controls
playsinline
:volume="0.6"
:width="800"
:playback-rates="[0.7, 1.0, 1.5, 2.0]"
/>
<!-- 事件,暫時沒用
@mounted="handleMounted"-->
<!-- @ready="handleEvent($event)"-->
<!-- @play="handleEvent($event)"-->
<!-- @pause="handleEvent($event)"-->
<!-- @ended="handleEvent($event)"-->
<!-- @loadeddata="handleEvent($event)"-->
<!-- @waiting="handleEvent($event)"-->
<!-- @playing="handleEvent($event)"-->
<!-- @canplay="handleEvent($event)"-->
<!-- @canplaythrough="handleEvent($event)"-->
<!-- @timeupdate="handleEvent(player?.currentTime())"-->
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import 'video.js/dist/video-js.css'
import { VideoPlayer } from '@videojs-player/vue'
defineOptions({ name: 'WxVideoPlayer' })
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>
import WxVoicePlayer from './main.vue'
export default WxVoicePlayer
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
【微信消息 - 语音】
芋道源码:
① bug 修复:
1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容;
存在的问题:mediaId 有效期是 3 天,超过时间后无法播放
2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。
② 代码优化:将 props 中的 reply 调成为 data 中对应的属性,并补充相关注释
-->
<template>
<div class="wx-voice-div" @click="playVoice">
<el-icon>
<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">
<el-tag type="success" size="small">语音识别</el-tag>
{{ content }}
</div>
</div>
</template>
<script lang="ts" setup>
// 因为微信语音是 amr 格式,所以需要用到 amr 解码器:https://www.npmjs.com/package/benz-amr-recorder
import BenzAMRRecorder from 'benz-amr-recorder'
defineOptions({ name: 'WxVoicePlayer' })
const props = defineProps({
url: {
type: String, // 语音地址,例如说:https://www.iocoder.cn/xxx.amr
required: true
},
content: {
type: String, // 语音文本
required: false
}
})
const amr = ref()
const playing = ref(false)
const duration = ref()
/** 处理点击,播放或暂停 */
const playVoice = () => {
// 情况一:未初始化,则创建 BenzAMRRecorder
if (amr.value === undefined) {
amrInit()
return
}
// 情况二:已经初始化,则根据情况播放或暂时
if (amr.value.isPlaying()) {
amrStop()
} else {
amrPlay()
}
}
/** 音频初始化 */
const amrInit = () => {
amr.value = new BenzAMRRecorder()
// 设置播放
amr.value.initWithUrl(props.url).then(function () {
amrPlay()
duration.value = amr.value.getDuration()
})
// 监听暂停
amr.value.onEnded(function () {
playing.value = false
})
}
/** 音频播放 */
const amrPlay = () => {
playing.value = true
amr.value.play()
}
/** 音频暂停 */
const amrStop = () => {
playing.value = false
amr.value.stop()
}
// TODO 芋艿:下面样式有点问题
</script>
<style lang="scss" scoped>
.wx-voice-div {
display: flex;
width: 120px;
height: 50px;
padding: 5px;
background-color: #eaeaea;
border-radius: 10px;
justify-content: center;
align-items: center;
}
.amr-duration {
margin-left: 5px;
font-size: 11px;
}
</style>
<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">本地上传</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
type="image"
:account-id="accountId!"
@select-material="onMaterialSelected"
/>
</el-dialog>
</div>
</div>
</template>
<script lang="ts" setup>
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import { getAccessToken } from '@/utils/auth'
import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { NewsItem } from './types'
const message = useMessage()
const UPLOAD_URL = 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: UploadType
accountId: number
}
const uploadData: UploadData = reactive({
type: UploadType.Image,
accountId: accountId!
})
/** 素材选择完成事件*/
const onMaterialSelected = (item: any) => {
showImageDialog.value = false
newsItem.value.thumbMediaId = item.mediaId
newsItem.value.thumbUrl = item.url
}
const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(UploadType.Image, 2)(rawFile)
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>
<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 lang="ts" setup>
import WxNews from '@/views/mp/components/wx-news'
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 (width >= 992px) and (width <= 1300px) {
.waterfall {
column-count: 3;
}
}
@media (width >= 768px) and (width <= 991px) {
.waterfall {
column-count: 2;
}
}
@media (width <= 767px) {
.waterfall {
column-count: 1;
}
}
</style>
<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 lang="ts" setup>
import { Editor } from '@/components/Editor'
import { createEditorConfig } from '../editor-config'
import CoverSelect from './CoverSelect.vue'
import { type NewsItem, createEmptyNewsItem } from './types'
defineOptions({ name: 'NewsForm' })
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, unref(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>
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 }
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 }
import { IEditorConfig } from '@wangeditor/editor'
import { getAccessToken, getTenantId } from '@/utils/auth'
const message = useMessage()
type InsertFnType = (url: string, alt: string, href: string) => void
export const createEditorConfig = (
server: string,
accountId: number | undefined
): Partial<IEditorConfig> => {
return {
MENU_CONF: {
['uploadImage']: {
server,
// 单个文件的最大体积限制,默认为 2M
maxFileSize: 5 * 1024 * 1024,
// 最多可上传几个文件,默认为 100
maxNumberOfFiles: 10,
// 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
allowedFileTypes: ['image/*'],
// 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
meta: {
accountId: accountId,
type: 'image'
},
// 将 meta 拼接到 url 参数中,默认 false
metaWithUrl: true,
// 自定义增加 http header
headers: {
Accept: '*',
Authorization: 'Bearer ' + getAccessToken(),
'tenant-id': getTenantId()
},
// 跨域是否传递 cookie ,默认为 false
withCredentials: true,
// 超时时间,默认为 10 秒
timeout: 5 * 1000, // 5 秒
// form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image
fieldName: 'file',
// 上传之前触发
onBeforeUpload(file: File) {
console.log(file)
return file
},
// 上传进度的回调函数
onProgress(progress: number) {
// progress 是 0-100 的数字
console.log('progress', progress)
},
onSuccess(file: File, res: any) {
console.log('onSuccess', file, res)
},
onFailed(file: File, res: any) {
message.alertError(res.message)
console.log('onFailed', file, res)
},
onError(file: File, err: any, res: any) {
message.alertError(err.message)
console.error('onError', file, err, res)
},
// 自定义插入图片
customInsert(res: any, insertFn: InsertFnType) {
insertFn(res.data.url, 'image', res.data.url)
}
}
}
}
}
<template>
<doc-alert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
plain
@click="handleAdd"
v-hasPermi="['mp:draft:create']"
:disabled="accountId === 0"
>
<Icon icon="ep:plus" />新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<DraftTable
:loading="loading"
:list="list"
@update="onUpdate"
@delete="onDelete"
@publish="onPublish"
/>
<!-- 分页记录 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 添加或修改草稿对话框 -->
<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 lang="ts" setup>
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import * as MpDraftApi from '@/api/mp/draft'
import * as MpFreePublishApi from '@/api/mp/freePublish'
import {
type Article,
type NewsItem,
NewsForm,
DraftTable,
createEmptyNewsItem
} from './components/'
// import drafts from './mock' // 可以用改本地数据模拟,避免API调用超限
defineOptions({ name: 'MpDraft' })
const message = useMessage() // 消息
const accountId = ref(-1)
provide('accountId', accountId)
const loading = ref(true) // 列表的加载中
const list = ref<any[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: accountId
})
// ========== 草稿新建 or 修改 ==========
const showDialog = ref(false)
const newsList = ref<NewsItem[]>([])
const mediaId = ref('')
const isCreating = ref(true)
const isSubmitting = ref(false)
/** 侦听公众号变化 **/
const onAccountChanged = (id: number) => {
accountId.value = id
queryParams.pageNo = 1
getList()
}
// 关闭弹窗
const onBeforeDialogClose = async (onDone: () => {}) => {
try {
await message.confirm('修改内容可能还未保存,确定关闭吗?')
onDone()
} catch {}
}
// ======================== 列表查询 ========================
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const drafts = await MpDraftApi.getDraftPage(queryParams)
drafts.list.forEach((draft) => {
const newsList = draft.content.newsItem
// 将 thumbUrl 转成 picUrl,保证 wx-news 组件可以预览封面
newsList.forEach((item) => {
item.picUrl = item.thumbUrl
})
})
list.value = drafts.list
total.value = drafts.total
} finally {
loading.value = false
}
}
// ======================== 新增/修改草稿 ========================
/** 新增按钮操作 */
const handleAdd = () => {
isCreating.value = true
newsList.value = [createEmptyNewsItem()]
showDialog.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 onSubmitNewsItem = async () => {
isSubmitting.value = true
try {
if (isCreating.value) {
await MpDraftApi.createDraft(accountId.value, newsList.value)
message.notifySuccess('新增成功')
} else {
await MpDraftApi.updateDraft(accountId.value, mediaId.value, newsList.value)
message.notifySuccess('更新成功')
}
} finally {
showDialog.value = false
isSubmitting.value = false
await getList()
}
}
// ======================== 草稿箱发布 ========================
const onPublish = async (item: Article) => {
const mediaId = item.mediaId
const content =
'你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' +
'已发布内容不会推送给用户,也不会展示在公众号主页中。 ' +
'发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。'
try {
await message.confirm(content)
await MpFreePublishApi.submitFreePublish(accountId.value, mediaId)
message.notifySuccess('发布成功')
await getList()
} catch {}
}
/** 删除按钮操作 */
const onDelete = async (item: Article) => {
const mediaId = item.mediaId
try {
await message.confirm('此操作将永久删除该草稿, 是否继续?')
await MpDraftApi.deleteDraft(accountId.value, mediaId)
message.notifySuccess('删除成功')
await getList()
} catch {}
}
</script>
<style lang="scss" scoped>
.pagination {
float: right;
margin-right: 25px;
}
</style>
export default {
list: [
{
mediaId: 'r6ryvl6LrxBU0miaST4Y-q-G9pdsmZw0OYG4FzHQkKfpLfEwIH51wy2bxisx8PvW',
content: {
newsItem: [
{
title: '我是标题(OOO)',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9XaFphcmtJVFh3VEc4Q1MxQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN2QxTE56SFBCYXc2RE9NcUxIeS1CQjJuUHhTWjBlN2VOeGRpRi1fZUhwN1FNQjdrQV9yRU9EU0hibHREZmZoVW5acnZrN3ZjaWsxejR3RGpKczBzTHFIM0dFNFZWVkpBc0dWWlAzUEhlVmpnfn4%3D&chksm=1f6354802814dd969ef83c0f3babe555c614270b30bc383beaf7ffd13b0257f0fe5ced9af694#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
},
{
title: '我是标题(XXX)',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9yTlYwOEs1clpwcE5OUEhCQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN0NSMjFqN3N1aUZMbFNVLTZHN2ZDME9qOGp2THk2RFNlSTlKZ3Y1czFVZDdQQm5IeUg3dEppSUtpQUh5SExOOTRkT3dHNUdBdHdWSWlOendlREV3dS1jUEVQbFpiVTZmVW5iRWhZcGdkNTFRfn4%3D&chksm=1f6354802814dd96a403151cd44c7da4eecf0e475d25423e46ecd795b513bafd829a75daef9b#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
}
]
},
updateTime: 1673655730
},
{
mediaId: 'r6ryvl6LrxBU0miaST4Y-jGpXnO73ihN0lsNXknCRQHapp2xgHMRxHKG50LituFe',
content: {
newsItem: [
{
title: '我是标题(修改)',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl95WVFXYndIZnZJd0t5cjgvQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN1dlNURPbWswbEF4RDd5dVJTdjQ4cm9Cc0Q1TWhpMUh6SE1hVEE3ZHljaHhlZjZYSGF5N2JNSHpDTlh6ajNZbkpGTGpTcUQ4M3NMdW41ZUpXNFZZQ1VKbVlaMVp5ekxEV1czREdsY1dOYTZnfn4%3D&chksm=1f6354be2814dda8e6238037c2ebd52b1c8e80e93249a861ad80e4d40e5ca7207233475ca689#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
}
]
},
updateTime: 1673655584
},
{
mediaId: 'r6ryvl6LrxBU0miaST4Y-v5SrbNCPpD6M_p3TmSrYwTjKogs-0DMJgmjMyNZPeMO',
content: {
newsItem: [
{
title: '1321',
author: '3232',
digest: '1333',
content: '<p>444</p>',
contentSourceUrl: 'http://www.iocoder.cn',
thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-tlQmcl3RdC-Jcgns6IQtf7zenGy3b86WLT7GzUcrb1T',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9jelJiaDAzbmdpSkJOZ2M2QWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNDNXVVc2ZDRYeTY0Zm1weXR6dE9vQWh1TzEwbEpUVnRfVzJyaGFDNXBkZ0ZXM2JFOTNaRHNhOHRUeFdEanhMeS01X01kMUNWQ1BpRER3cjYwTl9pMnpFLUJhZXFucVVfM1pDUXlTUEl1S25nfn4%3D&chksm=1f6354bc2814ddaa56a90ad5bc3d078601c8d1589ba01827a8170587bc830ff9747b5f59c3a0#rd',
thumbUrl:
'http://mmbiz.qpic.cn/mmbiz_png/btUmCVHwbJUoicwBiacjVeQbu6QxgBVrukfSJXz509boa21SpH8OVHAqXCJiaiaAaHQJNxwwsa0gHRXVr0G5EZYamw/0?wx_fmt=png'
}
]
},
updateTime: 1673628969
},
{
mediaId: 'r6ryvl6LrxBU0miaST4Y-vdWrisK5EZbk4Y3tzh8P0PG0eEUbnQrh0BcsEb3WNP0',
content: {
newsItem: [
{
title: 'tudou',
author: 'haha',
digest: '312',
content: '<p>132312</p>',
contentSourceUrl: 'http://www.iocoder.cn',
thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qdkJ1ZjBoUmg2Uk9TS3RlQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNVg2aTJsaC1fMkU2eXNacUplN3VDTTZFZkhtMjhuTUZvWkxsNDBRSXExY2tiVXRHb09TaHgtREhzY3doZ0JYeC1TSTZ5eWZldXJsOWtfbV8yMi1aYkcyZ2pOY0haM0Ntb3VSWEtxUGVFRlNBfn4%3D&chksm=1f6354ba2814ddacf0184b24d310483641ef190b1faac098c285eb416c70017e2f54decfa1af#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG.png'
}
]
},
updateTime: 1673628760
},
{
mediaId: 'r6ryvl6LrxBU0miaST4Y-u9kTIm1DhWZDdXyxsxUVv2Z5DAB99IPxkIRTUUD206k',
content: {
newsItem: [
{
title: '12',
author: '333',
digest: '123',
content: '123',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qVVhpSDZUaFJWTzBBWWRVQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNWRnTDJWYmF2NER0clV1bThmQ0xUR3hqQnJkZ3BJSUNmNDJmc0lCZ1dadkVnZ3Z5bkN4YWtVUjhoaWZWYzZURUR4NnpMd0Y4Z3U5aUdib0lkMzI4Rjg3SG9JX2FycTMxbUctOHplaTlQVVhnfn4%3D&chksm=1f6354b62814dda076c778af33f06580165d8aa81f7798d55cfabb1886b5c74d9b2124a3535c#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8.jpg'
}
]
},
updateTime: 1673626494
},
{
mediaId: 'r6ryvl6LrxBU0miaST4Y-sO24upobaENDmeByfBTfaozB3aOqSMAV0lGy-UkHXE7',
content: {
newsItem: [
{
title: '我是标题',
author: '我是作者',
digest: '我是摘要',
content: '我是内容',
contentSourceUrl: 'https://www.iocoder.cn',
thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn',
showCoverPic: 0,
needOpenComment: 0,
onlyFansCanComment: 0,
url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9LT2dqRnpMNUpsR0hjYWtBQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNGNmazZTdlE5WkxvU0tfX2V5cjV2WjJiR0xjQUhyREFSZWo2eWNrUW9EYVh6ZkpWRXBLR3FmTEV6YldBMno3Q2ZvVXBSdzlaVDc3aFhndEpQWUwzWmFMUWt0YVVURE1VZ1FsQTdPMlRtc3JBfn4%3D&chksm=1f6354aa2814ddbcc2637382f963a8742993ac38ebcebe6e3411df5ac82ac7bbdb391be6494a#rd',
thumbUrl:
'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png'
}
]
},
updateTime: 1673534279
}
],
total: 6
}
<template>
<doc-alert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<div class="waterfall" v-loading="loading">
<div
class="waterfall-item"
v-show="item.content && item.content.newsItem"
v-for="item in list"
:key="item.articleId"
>
<wx-news :articles="item.content.newsItem" />
<el-row justify="center" class="ope-row">
<el-button
type="danger"
circle
@click="handleDelete(item)"
v-hasPermi="['mp:free-publish:delete']"
>
<Icon icon="ep:delete" />
</el-button>
</el-row>
</div>
</div>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>
<script lang="ts" setup>
import * as FreePublishApi from '@/api/mp/freePublish'
import WxNews from '@/views/mp/components/wx-news'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
defineOptions({ name: 'MpFreePublish' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref<any[]>([]) // 列表的数据
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: -1
})
/** 侦听公众号变化 **/
const onAccountChanged = (id: number) => {
queryParams.accountId = id
queryParams.pageNo = 1
getList()
}
/** 查询列表 */
const getList = async () => {
try {
loading.value = true
const data = await FreePublishApi.getFreePublishPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 删除按钮操作 */
const handleDelete = async (item: any) => {
try {
// 删除的二次确认
await message.delConfirm('删除后用户将无法访问此页面,确定删除?')
// 发起删除
await FreePublishApi.deleteFreePublish(queryParams.accountId, item.articleId)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
</script>
<style lang="scss" scoped>
@media (width >= 992px) and (width <= 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (width >= 768px) and (width <= 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (width <= 767px) {
.waterfall {
column-count: 1;
}
}
.ope-row {
padding-top: 5px;
margin-top: 5px;
text-align: center;
border-top: 1px solid #eaeaea;
}
.item-name {
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-upload__tip {
margin-left: 5px;
}
/* 新增图文 */
.left {
display: inline-block;
width: 35%;
margin-top: 200px;
vertical-align: top;
}
.right {
display: inline-block;
width: 60%;
margin-top: -40px;
}
.avatar-uploader {
display: inline-block;
width: 20%;
}
.avatar-uploader .el-upload {
position: relative;
overflow: hidden;
text-align: unset !important;
cursor: pointer;
border-radius: 6px;
}
.avatar-uploader .el-upload:hover {
border-color: #165dff;
}
.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;
}
.digest {
display: inline-block;
width: 60%;
vertical-align: top;
}
/* 新增图文 */
/* 瀑布流样式 */
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin: 0 auto;
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
p {
line-height: 30px;
}
/* 瀑布流样式 */
.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;
}
.input-tt {
padding: 5px;
}
.activeAddNews {
border: 5px solid #2bb673;
}
.news-main-plus {
width: 280px;
height: 50px;
margin: auto;
text-align: center;
}
.icon-plus {
margin: 10px;
font-size: 25px;
}
.select-item {
width: 60%;
padding: 10px;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
}
.father .child {
position: relative;
bottom: 25px;
display: none;
text-align: center;
}
.father:hover .child {
display: block;
}
.thumb-div {
display: inline-block;
width: 30%;
text-align: center;
}
.thumb-but {
margin: 5px;
}
.material-img {
width: 100%;
height: 100%;
}
</style>
import type { UploadRawFile } from 'element-plus'
const message = useMessage() // 消息
enum UploadType {
Image = 'image',
Voice = 'voice',
Video = 'video'
}
const useBeforeUpload = (type: UploadType, maxSizeMB: number) => {
const fn = (rawFile: UploadRawFile): boolean => {
let allowTypes: string[] = []
let name = ''
switch (type) {
case UploadType.Image:
allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg']
maxSizeMB = 2
name = '图片'
break
case UploadType.Voice:
allowTypes = ['audio/mp3', 'audio/mpeg', 'audio/wma', 'audio/wav', 'audio/amr']
maxSizeMB = 2
name = '语音'
break
case UploadType.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 { UploadType, useBeforeUpload }
<template>
<div class="waterfall" v-loading="props.loading">
<div class="waterfall-item" v-for="item in props.list" :key="item.id">
<a target="_blank" :href="item.url">
<img class="material-img" :src="item.url" />
<div class="item-name">{{ item.name }}</div>
</a>
<el-row justify="center">
<el-button
type="danger"
circle
@click="emit('delete', item.id)"
v-hasPermi="['mp:material:delete']"
>
<Icon icon="ep:delete" />
</el-button>
</el-row>
</div>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
list: any[]
loading: boolean
}>()
const emit = defineEmits<{
(e: 'delete', v: number)
}>()
</script>
<style lang="scss" scoped>
@media (width >= 992px) and (width <= 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (width >= 768px) and (width <= 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (width <= 767px) {
.waterfall {
column-count: 1;
}
}
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin-top: 10px;
/* 芋道源码:增加 10px,避免顶着上面 */
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
</style>
<template>
<el-upload
:action="UPLOAD_URL"
:headers="HEADERS"
multiple
:limit="1"
:file-list="fileList"
:data="uploadData"
:on-error="onUploadError"
:before-upload="onBeforeUpload"
:on-success="onUploadSuccess"
>
<el-button type="primary" plain> 点击上传 </el-button>
<template #tip>
<span class="el-upload__tip" style="margin-left: 5px">
<slot></slot>
</span>
</template>
</el-upload>
</template>
<script lang="ts" setup>
import type { UploadProps, UploadUserFile } from 'element-plus'
import {
HEADERS,
UPLOAD_URL,
UploadData,
UploadType,
beforeImageUpload,
beforeVoiceUpload
} from './upload'
const message = useMessage()
const props = defineProps<{ type: UploadType }>()
const accountId = inject<number>('accountId')
const fileList = ref<UploadUserFile[]>([])
const emit = defineEmits<{
(e: 'uploaded', v: void)
}>()
const uploadData: UploadData = reactive({
type: UploadType.Image,
title: '',
introduction: '',
accountId: accountId!
})
/** 上传前检查 */
const onBeforeUpload = props.type === UploadType.Image ? beforeImageUpload : beforeVoiceUpload
/** 上传成功处理 */
const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
if (res.code !== 0) {
message.alertError('上传出错:' + res.msg)
return false
}
// 清空上传时的各种数据
fileList.value = []
uploadData.title = ''
uploadData.introduction = ''
message.notifySuccess('上传成功')
emit('uploaded')
}
/** 上传失败处理 */
const onUploadError = (err: Error) => message.error('上传失败: ' + err.message)
</script>
<style lang="scss" scoped>
.el-upload__tip {
margin-left: 5px;
}
</style>
<template>
<el-dialog title="新建视频" v-model="showDialog" width="600px">
<el-upload
:action="UPLOAD_URL"
:headers="HEADERS"
multiple
:limit="1"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeVideoUpload"
:on-error="onUploadError"
:on-success="onUploadSuccess"
ref="uploadVideoRef"
:auto-upload="false"
class="mb-5"
>
<template #trigger>
<el-button type="primary" plain>选择视频</el-button>
</template>
<template #tip>
<span class="el-upload__tip" style="margin-left: 10px"
>格式支持 MP4,文件大小不超过 10MB</span
>
</template>
</el-upload>
<el-divider />
<el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef">
<el-form-item label="标题" prop="title">
<el-input
v-model="uploadData.title"
placeholder="标题将展示在相关播放页面,建议填写清晰、准确、生动的标题"
/>
</el-form-item>
<el-form-item label="描述" prop="introduction">
<el-input
:rows="3"
type="textarea"
v-model="uploadData.introduction"
placeholder="介绍语将展示在相关播放页面,建议填写简洁明确、有信息量的内容"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取 消</el-button>
<el-button type="primary" @click="submitVideo">提 交</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import type {
FormInstance,
FormRules,
UploadInstance,
UploadProps,
UploadUserFile
} from 'element-plus'
import { HEADERS, UploadData, UPLOAD_URL, UploadType, beforeVideoUpload } from './upload'
const message = useMessage()
const accountId = inject<number>('accountId')
const uploadRules: FormRules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
introduction: [{ required: true, message: '请输入描述', trigger: 'blur' }]
}
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean)
(e: 'uploaded', v: void)
}>()
const showDialog = computed<boolean>({
get() {
return props.modelValue
},
set(val) {
emit('update:modelValue', val)
}
})
const fileList = ref<UploadUserFile[]>([])
const uploadData: UploadData = reactive({
type: UploadType.Video,
title: '',
introduction: '',
accountId: accountId!
})
const uploadFormRef = ref<FormInstance | null>(null)
const uploadVideoRef = ref<UploadInstance | null>(null)
const submitVideo = () => {
uploadFormRef.value?.validate((valid) => {
if (!valid) {
return false
}
uploadVideoRef.value?.submit()
})
}
/** 上传成功处理 */
const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
if (res.code !== 0) {
message.error('上传出错:' + res.msg)
return false
}
// 清空上传时的各种数据
fileList.value = []
uploadData.title = ''
uploadData.introduction = ''
showDialog.value = false
message.notifySuccess('上传成功')
emit('uploaded')
}
/** 上传失败处理 */
const onUploadError = (err: Error) => message.error(`上传失败: ${err.message}`)
</script>
<template>
<el-table :data="props.list" stripe border v-loading="props.loading" style="margin-top: 10px">
<el-table-column label="编号" align="center" prop="mediaId" />
<el-table-column label="文件名" align="center" prop="name" />
<el-table-column label="标题" align="center" prop="title" />
<el-table-column label="介绍" align="center" prop="introduction" />
<el-table-column label="视频" align="center">
<template #default="scope">
<WxVideoPlayer v-if="scope.row.url" :url="scope.row.url" />
</template>
</el-table-column>
<el-table-column
label="上传时间"
align="center"
:formatter="dateFormatter"
prop="createTime"
width="180"
>
<template #default="scope">
<span>{{ scope.row.createTime }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right">
<template #default="scope">
<el-button type="primary" link @click="handleDownload(scope.row.url)">
<Icon icon="ep:download" />下载
</el-button>
<el-button
type="primary"
link
@click="emit('delete', scope.row.id)"
v-hasPermi="['mp:material:delete']"
>
<Icon icon="ep:delete" />删除
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts" setup>
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import { dateFormatter } from '@/utils/formatTime'
const props = defineProps<{
list: any[]
loading: boolean
}>()
const emit = defineEmits<{
(e: 'delete', v: number)
(e: 'download', v: string)
}>()
// 下载文件
const handleDownload = (url: string) => {
window.open(url, '_blank')
}
</script>
<template>
<el-table :data="props.list" stripe border v-loading="props.loading" style="margin-top: 10px">
<el-table-column label="编号" align="center" prop="mediaId" />
<el-table-column label="文件名" align="center" prop="name" />
<el-table-column label="语音" align="center">
<template #default="scope">
<WxVoicePlayer v-if="scope.row.url" :url="scope.row.url" />
</template>
</el-table-column>
<el-table-column
label="上传时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180"
>
<template #default="scope">
<span>{{ scope.row.createTime }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-button type="primary" link @click="emit('delete', scope.row.id)">
<Icon icon="ep:download" />下载
</el-button>
<el-button
type="primary"
link
@click="emit('delete', scope.row.id)"
v-hasPermi="['mp:material:delete']"
>
<Icon icon="ep:delete" />删除
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts" setup>
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import { dateFormatter } from '@/utils/formatTime'
const props = defineProps<{
list: any[]
loading: boolean
}>()
const emit = defineEmits<{
(e: 'delete', v: number)
}>()
</script>
import type { UploadProps, UploadRawFile } from 'element-plus'
import { getRefreshToken } from '@/utils/auth'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
const HEADERS = { Authorization: 'Bearer ' + getRefreshToken() } // 请求头(解决 el-upload 上传过程中,无法刷新令牌的问题)
const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传地址
interface UploadData {
type: UploadType
title: string
introduction: string
accountId: number
}
const beforeImageUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(UploadType.Image, 2)(rawFile)
const beforeVoiceUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(UploadType.Voice, 2)(rawFile)
const beforeVideoUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(UploadType.Video, 10)(rawFile)
export {
HEADERS,
UPLOAD_URL,
UploadType,
UploadData,
beforeImageUpload,
beforeVoiceUpload,
beforeVideoUpload
}
<template>
<doc-alert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form class="-mb-15px" :inline="true" label-width="68px">
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-tabs v-model="type" @tab-change="onTabChange">
<!-- tab 1:图片 -->
<el-tab-pane :name="UploadType.Image">
<template #label>
<el-row align="middle"> <Icon icon="ep:picture" />图片 </el-row>
</template>
<UploadFile
v-hasPermi="['mp:material:upload-permanent']"
:type="UploadType.Image"
@uploaded="getList"
>
支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M
</UploadFile>
<!-- 列表 -->
<ImageTable :loading="loading" :list="list" @delete="handleDelete" />
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</el-tab-pane>
<!-- tab 2:语音 -->
<el-tab-pane :name="UploadType.Voice">
<template #label>
<el-row align="middle"> <Icon icon="ep:microphone" />语音 </el-row>
</template>
<UploadFile
v-hasPermi="['mp:material:upload-permanent']"
:type="UploadType.Voice"
@uploaded="getList"
>
格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s
</UploadFile>
<!-- 列表 -->
<VoiceTable :list="list" :loading="loading" @delete="handleDelete" />
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</el-tab-pane>
<!-- tab 3:视频 -->
<el-tab-pane :name="UploadType.Video">
<template #label>
<el-row align="middle"> <Icon icon="ep:video-play" /> 视频 </el-row>
</template>
<el-button
v-hasPermi="['mp:material:upload-permanent']"
type="primary"
plain
@click="showCreateVideo = true"
>新建视频</el-button
>
<!-- 新建视频的弹窗 -->
<UploadVideo v-model="showCreateVideo" />
<!-- 列表 -->
<VideoTable :list="list" :loading="loading" @delete="handleDelete" />
<!-- 分页组件 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</el-tab-pane>
</el-tabs>
</ContentWrap>
</template>
<script lang="ts" setup name="MpMaterial">
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import ImageTable from './components/ImageTable.vue'
import VoiceTable from './components/VoiceTable.vue'
import VideoTable from './components/VideoTable.vue'
import UploadFile from './components/UploadFile.vue'
import UploadVideo from './components/UploadVideo.vue'
import { UploadType } from './components/upload'
import * as MpMaterialApi from '@/api/mp/material'
const message = useMessage() // 消息
const type = ref<UploadType>(UploadType.Image) // 素材类型
const loading = ref(false) // 遮罩层
const list = ref<any[]>([]) // 总条数
const total = ref(0) // 数据列表
const accountId = ref(-1)
provide('accountId', accountId)
// 查询参数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: accountId,
permanent: true
})
const showCreateVideo = ref(false) // 是否新建视频的弹窗
/** 侦听公众号变化 **/
const onAccountChanged = (id: number) => {
accountId.value = id
queryParams.accountId = id
queryParams.pageNo = 1
getList()
}
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await MpMaterialApi.getMaterialPage({
...queryParams,
type: type.value
})
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 处理 table 切换 */
const onTabChange = () => {
// 提前情况数据,避免 tab 切换后显示垃圾数据
list.value = []
total.value = 0
// 从第一页开始查询
handleQuery()
}
/** 处理删除操作 */
const handleDelete = async (id: number) => {
await message.confirm('此操作将永久删除该文件, 是否继续?')
await MpMaterialApi.deletePermanentMaterial(id)
message.alertSuccess('删除成功')
}
</script>
<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
type="news"
:account-id="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" v-model="menu.reply" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import WxReplySelect from '@/views/mp/components/wx-reply'
import WxNews from '@/views/mp/components/wx-news'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import menuOptions from './menuOptions'
const message = useMessage()
const props = defineProps<{
accountId: number
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>
<template>
<draggable
v-model="menuList"
item-key="id"
ghost-class="draggable-ghost"
:animation="400"
@end="onParentDragEnd"
>
<template #item="{ element: parent, index: x }">
<div class="menu_bottom">
<!-- 一级菜单 -->
<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="props.parentIndex === x && parent.children">
<draggable
v-model="parent.children"
item-key="id"
ghost-class="draggable-ghost"
:animation="400"
@end="onChildDragEnd"
>
<template #item="{ element: child, index: y }">
<div class="menu_bottom subtitle">
<div
class="menu_subItem"
v-if="parent.children"
:class="{ active: props.activeIndex === `${x}-${y}` }"
@click="subMenuClicked(child, x, y)"
>
{{ child.name }}
</div>
</div>
</template>
</draggable>
<!-- 二级菜单加号, 当长度 小于 5 才显示二级菜单的加号 -->
<div
class="menu_bottom menu_addicon"
v-if="!parent.children || parent.children.length < 5"
@click="addSubMenu(x, parent)"
>
<Icon icon="ep:plus" class="plus" />
</div>
</div>
</div>
</template>
</draggable>
<!-- 一级菜单加号 -->
<div class="menu_bottom menu_addicon" v-if="menuList.length < 3" @click="addMenu">
<Icon icon="ep:plus" class="plus" />
</div>
</template>
<script lang="ts" setup>
import { Menu } from './types'
import draggable from 'vuedraggable'
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)
}
/**
* 处理一级菜单展开后被拖动,激活(展开)原来活动的一级菜单
*
* @param oldIndex: 一级菜单拖动前的位置
* @param newIndex: 一级菜单拖动后的位置
*/
const onParentDragEnd = ({ oldIndex, newIndex }) => {
// 二级菜单没有展开,直接返回
if (props.activeIndex === '__MENU_NOT_SELECTED__') {
return
}
// 使用一个辅助数组来模拟菜单移动,然后找到展开的二级菜单的新下标`newParent`
let positions = new Array<boolean>(menuList.value.length).fill(false)
positions[props.parentIndex] = true
const [out] = positions.splice(oldIndex, 1) // 移出菜单,保存到变量out
positions.splice(newIndex, 0, out) // 把out变量插入被移出的菜单
const newParentIndex = positions.indexOf(true)
// 找到菜单元素,触发一级菜单点击
const parent = menuList.value[newParentIndex]
emit('menu-clicked', parent, newParentIndex)
}
/**
* 处理二级菜单展开后被拖动,激活被拖动的菜单
*
* @param newIndex 二级菜单拖动后的位置
*/
const onChildDragEnd = ({ newIndex }) => {
const x = props.parentIndex
const y = newIndex
const children = menuList.value[x]?.children
if (children && children?.length > 0) {
const child = children[y]
emit('submenu-clicked', child, x, y)
}
}
</script>
<style lang="scss" scoped>
.menu_bottom {
position: relative;
display: 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;
}
}
.draggable-ghost {
background: #f7fafc;
border: 1px solid #4299e1;
opacity: 0.5;
}
</style>
export default [
{
value: 'view',
label: '跳转网页'
},
{
value: 'miniprogram',
label: '跳转小程序'
},
{
value: 'click',
label: '点击回复'
},
{
value: 'article_view_limited',
label: '跳转图文消息'
},
{
value: 'scancode_push',
label: '扫码直接返回结果'
},
{
value: 'scancode_waitmsg',
label: '扫码回复'
},
{
value: 'pic_sysphoto',
label: '系统拍照发图'
},
{
value: 'pic_photo_or_album',
label: '拍照或者相册'
},
{
value: 'pic_weixin',
label: '微信相册'
},
{
value: 'location_select',
label: '选择地理位置'
}
]
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>
<template>
<doc-alert title="公众号菜单" url="https://doc.iocoder.cn/mp/menu/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form class="-mb-15px" ref="queryFormRef" :inline="true" label-width="68px">
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<div class="clearfix public-account-management" v-loading="loading">
<!--左边配置菜单-->
<div class="left">
<div class="weixin-hd">
<div class="weixin-title">{{ accountName }}</div>
</div>
<div class="clearfix weixin-menu">
<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="onSave" v-hasPermi="['mp:menu:save']"
>保存并发布菜单</el-button
>
<el-button class="save_btn" type="danger" @click="onClear" v-hasPermi="['mp:menu:delete']"
>清空菜单</el-button
>
</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">
<p>请选择菜单配置</p>
</div>
</div>
</ContentWrap>
</template>
<script lang="ts" setup>
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import MenuEditor from './components/MenuEditor.vue'
import MenuPreviewer from './components/MenuPreviewer.vue'
import * as MpMenuApi from '@/api/mp/menu'
import * as UtilsTree from '@/utils/tree'
import { RawMenu, Menu } from './components/types'
defineOptions({ name: 'MpMenu' })
const message = useMessage() // 消息
const MENU_NOT_SELECTED = '__MENU_NOT_SELECTED__'
// ======================== 列表查询 ========================
const loading = ref(false) // 遮罩层
const accountId = ref(-1)
const accountName = ref<string>('')
const menuList = ref<Menu[]>([])
// ======================== 菜单操作 ========================
// 当前选中菜单编码:
// * 一级('x')
// * 二级('x-y')
// * 未选中(MENU_NOT_SELECTED)
const activeIndex = ref<string>(MENU_NOT_SELECTED)
// 二级菜单显示标志: 归属的一级菜单index
// * 未初始化:-1
// * 初始化:x
const parentIndex = ref(-1)
// ======================== 菜单编辑 ========================
const showRightPanel = ref(false) // 右边配置显示默认详情还是配置详情
const isParent = ref<boolean>(true) // 是否一级菜单,控制MenuEditor中name字段长度
const activeMenu = ref<Menu>({}) // 选中菜单,MenuEditor的modelValue
// 一些临时值放在这里进行判断,如果放在 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) // 跳转图文时的素材选择弹窗
/** 侦听公众号变化 **/
const onAccountChanged = (id: number, name: string) => {
accountId.value = id
accountName.value = name
getList()
}
/** 查询并转换菜单 **/
const getList = async () => {
loading.value = false
try {
const data = await MpMenuApi.getMenuList(accountId.value)
const menuData = menuListToFrontend(data)
menuList.value = UtilsTree.handleTree(menuData, 'id')
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
resetForm()
getList()
}
// 将后端返回的 menuList,转换成前端的 menuList
const menuListToFrontend = (list: any[]) => {
if (!list) return []
const result: RawMenu[] = []
list.forEach((item: RawMenu) => {
const menu: any = {
...item
}
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 as RawMenu)
})
return result
}
// 重置表单,清空表单数据
const resetForm = () => {
// 菜单操作
activeIndex.value = MENU_NOT_SELECTED
parentIndex.value = -1
// 菜单编辑
showRightPanel.value = false
activeMenu.value = {}
tempSelfObj.value = { grand: Level.Undefined, x: 0, y: 0 }
dialogNewsVisible.value = false
}
// ======================== 菜单操作 ========================
// 一级菜单点击事件
const menuClicked = (parent: Menu, x: number) => {
// 右侧的表单相关
showRightPanel.value = true // 右边菜单
activeMenu.value = parent // 这个如果放在顶部,flag 会没有。因为重新赋值了。
tempSelfObj.value.grand = Level.Parent // 表示一级菜单
tempSelfObj.value.x = x // 表示一级菜单索引
isParent.value = true
// 左侧的选中
activeIndex.value = `${x}` // 菜单选中样式
parentIndex.value = x // 二级菜单显示标志
}
// 二级菜单点击事件
const subMenuClicked = (child: Menu, x: number, y: number) => {
// 右侧的表单相关
showRightPanel.value = true // 右边菜单
activeMenu.value = child // 将点击的数据放到临时变量,对象有引用作用
tempSelfObj.value.grand = Level.Child // 表示二级菜单
tempSelfObj.value.x = x // 表示一级菜单索引
tempSelfObj.value.y = y // 表示二级菜单索引
isParent.value = false
// 左侧的选中
activeIndex.value = `${x}-${y}`
}
// 删除当前菜单
const onDeleteMenu = async () => {
try {
await message.confirm('确定要删除吗?')
if (tempSelfObj.value.grand === Level.Parent) {
// 一级菜单的删除方法
menuList.value.splice(tempSelfObj.value.x, 1)
} else if (tempSelfObj.value.grand === Level.Child) {
// 二级菜单的删除方法
menuList.value[tempSelfObj.value.x].children?.splice(tempSelfObj.value.y, 1)
}
// 提示
message.notifySuccess('删除成功')
// 处理菜单的选中
activeMenu.value = {}
showRightPanel.value = false
activeIndex.value = MENU_NOT_SELECTED
} catch {}
}
// ======================== 菜单编辑 ========================
const onSave = async () => {
try {
await message.confirm('确定要保存吗?')
loading.value = true
await MpMenuApi.saveMenu(accountId.value, menuListToBackend())
getList()
message.notifySuccess('发布成功')
} finally {
loading.value = false
}
}
const onClear = async () => {
try {
await message.confirm('确定要删除吗?')
loading.value = true
await MpMenuApi.deleteMenu(accountId.value)
handleQuery()
message.notifySuccess('清空成功')
} finally {
loading.value = false
}
}
// 将前端的 menuList,转换成后端接收的 menuList
const menuListToBackend = () => {
const result: any[] = []
menuList.value.forEach((item) => {
const menu = menuToBackend(item)
result.push(menu)
// 处理子菜单
if (!item.children || item.children.length <= 0) {
return
}
menu.children = []
item.children.forEach((subItem) => {
menu.children.push(menuToBackend(subItem))
})
})
return result
}
// 将前端的 menu,转换成后端接收的 menu
// TODO: @芋艿,需要根据后台API删除不需要的字段
const menuToBackend = (menu: any) => {
let result = {
...menu,
children: undefined, // 不处理子节点
reply: undefined // 稍后复制
}
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
}
</script>
<!--本组件样式-->
<style lang="scss" scoped="scoped">
/* 公共颜色变量 */
.clearfix {
*zoom: 1;
}
.clearfix::after {
display: table;
clear: both;
content: '';
}
div {
text-align: left;
}
.weixin-hd {
position: relative;
bottom: 426px;
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 {
position: absolute;
top: 33px;
left: 0;
width: 100%;
font-size: 14px;
color: #fff;
text-align: center;
}
.weixin-menu {
padding-left: 43px;
font-size: 12px;
background: transparent url('./assets/menu_foot.png') no-repeat 0 0;
}
.public-account-management {
width: 1200px;
// min-width: 1200px;
margin: 0 auto;
.left {
position: relative;
display: block;
float: left;
width: 350px;
height: 715px;
padding: 518px 25px 88px;
background: url('./assets/iphone_backImg.png') no-repeat;
background-size: 100% auto;
box-sizing: border-box;
.save_div {
margin-top: 15px;
text-align: center;
.save_btn {
bottom: 20px;
left: 100px;
}
}
}
/* 右边菜单内容 */
.right {
float: left;
width: 63%;
padding: 20px;
margin-left: 20px;
background-color: #e8e7e7;
box-sizing: border-box;
}
}
</style>
<!--素材样式-->
<style lang="scss" scoped>
.pagination {
margin-right: 25px;
text-align: right;
}
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
}
.ope-row {
padding-top: 10px;
text-align: center;
}
.item-name {
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<template>
<div>
<el-table v-loading="props.loading" :data="props.list">
<el-table-column
label="发送时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="消息类型" align="center" prop="type" width="80" />
<el-table-column label="发送方" align="center" prop="sendFrom" width="80">
<template #default="scope">
<el-tag v-if="scope.row.sendFrom === 1" type="success">粉丝</el-tag>
<el-tag v-else type="info">公众号</el-tag>
</template>
</el-table-column>
<el-table-column label="用户标识" align="center" prop="openid" width="300" />
<el-table-column label="内容" prop="content">
<template #default="scope">
<!-- 【事件】区域 -->
<div v-if="scope.row.type === MsgType.Event && scope.row.event === 'subscribe'">
<el-tag type="success">关注</el-tag>
</div>
<div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'unsubscribe'">
<el-tag type="danger">取消关注</el-tag>
</div>
<div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'CLICK'">
<el-tag>点击菜单</el-tag>
{{ scope.row.eventKey }}
</div>
<div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'VIEW'">
<el-tag>点击菜单链接</el-tag>
{{ scope.row.eventKey }}
</div>
<div
v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'scancode_waitmsg'"
>
<el-tag>扫码结果</el-tag>
{{ scope.row.eventKey }}
</div>
<div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'scancode_push'">
<el-tag>扫码结果</el-tag>
{{ scope.row.eventKey }}
</div>
<div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'pic_sysphoto'">
<el-tag>系统拍照发图</el-tag>
</div>
<div
v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'pic_photo_or_album'"
>
<el-tag>拍照或者相册</el-tag>
</div>
<div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'pic_weixin'">
<el-tag>微信相册</el-tag>
</div>
<div
v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'location_select'"
>
<el-tag>选择地理位置</el-tag>
</div>
<div v-else-if="scope.row.type === MsgType.Event">
<el-tag type="danger">未知事件类型</el-tag>
</div>
<!-- 【消息】区域 -->
<div v-else-if="scope.row.type === MsgType.Text">{{ scope.row.content }}</div>
<div v-else-if="scope.row.type === MsgType.Voice">
<wx-voice-player :url="scope.row.mediaUrl" :content="scope.row.recognition" />
</div>
<div v-else-if="scope.row.type === MsgType.Image">
<a target="_blank" :href="scope.row.mediaUrl">
<img :src="scope.row.mediaUrl" style="width: 100px" />
</a>
</div>
<div v-else-if="scope.row.type === MsgType.Video || scope.row.type === 'shortvideo'">
<wx-video-player :url="scope.row.mediaUrl" style="margin-top: 10px" />
</div>
<div v-else-if="scope.row.type === MsgType.Link">
<el-tag>链接</el-tag>
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
</div>
<div v-else-if="scope.row.type === MsgType.Location">
<WxLocation
:label="scope.row.label"
:location-y="scope.row.locationY"
:location-x="scope.row.locationX"
/>
</div>
<div v-else-if="scope.row.type === MsgType.Music">
<WxMusic
:title="scope.row.title"
:description="scope.row.description"
:thumb-media-url="scope.row.thumbMediaUrl"
:music-url="scope.row.musicUrl"
:hq-music-url="scope.row.hqMusicUrl"
/>
</div>
<div v-else-if="scope.row.type === MsgType.News">
<WxNews :articles="scope.row.articles" />
</div>
<div v-else>
<el-tag type="danger">未知消息类型</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-button
link
type="primary"
@click="emit('send', scope.row.userId)"
v-hasPermi="['mp:message:send']"
>
消息
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
</div>
</template>
<script lang="ts" setup>
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import WxLocation from '@/views/mp/components/wx-location'
import WxMusic from '@/views/mp/components/wx-music'
import WxNews from '@/views/mp/components/wx-news'
import { dateFormatter } from '@/utils/formatTime'
import { MsgType } from '@/views/mp/components/wx-msg/types'
const props = defineProps({
list: {
type: Array,
required: true
},
loading: {
type: Boolean,
required: true
}
})
const emit = defineEmits<{ (e: 'send', v: number) }>()
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
<el-form-item label="消息类型" prop="type">
<el-select v-model="queryParams.type" placeholder="请选择消息类型" class="!w-240px">
<el-option
v-for="dict in getStrDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="用户标识" prop="openid">
<el-input
v-model="queryParams.openid"
placeholder="请输入用户标识"
clearable
:v-on="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
style="width: 240px"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="['00:00:00', '23:59:59']"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<MessageTable :list="list" :loading="loading" @send="handleSend" />
<Pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 发送消息的弹窗 -->
<el-dialog
title="粉丝消息列表"
v-model="messageBox.show"
@click="messageBox.show = true"
width="50%"
destroy-on-close
>
<WxMsg :user-id="messageBox.userId" />
</el-dialog>
</template>
<script lang="ts" setup>
import * as MpMessageApi from '@/api/mp/message'
import WxMsg from '@/views/mp/components/wx-msg'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import MessageTable from './MessageTable.vue'
import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
import { MsgType } from '@/views/mp/components/wx-msg/types'
import type { FormInstance } from 'element-plus'
defineOptions({ name: 'MpMessage' })
const loading = ref(false)
const total = ref(0) // 数据的总页数
const list = ref<any[]>([]) // 当前页的列表数据
// 搜索参数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
openid: '',
accountId: -1,
type: MsgType.Text,
createTime: []
})
const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单
// 消息对话框
const messageBox = reactive({
show: false,
userId: 0
})
/** 侦听accountId */
const onAccountChanged = (id: number) => {
queryParams.accountId = id
queryParams.pageNo = 1
handleQuery()
}
/** 查询列表 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const getList = async () => {
try {
loading.value = true
const data = await MpMessageApi.getMessagePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 重置按钮操作 */
const resetQuery = async () => {
// 暂存 accountId,并在 reset 后恢复
const accountId = queryParams.accountId
queryFormRef.value?.resetFields()
queryParams.accountId = accountId
handleQuery()
}
/** 打开消息发送窗口 */
const handleSend = async (userId: number) => {
messageBox.userId = userId
messageBox.show = true
}
</script>
<template>
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form class="-mb-15px" ref="queryForm" :inline="true" label-width="68px">
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
<el-form-item label="时间范围" prop="dateRange">
<el-date-picker
v-model="dateRange"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
@change="getSummary"
class="!w-240px"
/>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 图表 -->
<ContentWrap>
<el-row>
<el-col :span="12" class="card-box">
<el-card>
<template #header>
<div>
<span>用户增减数据</span>
</div>
</template>
<Echart :options="userSummaryOption" :height="420" />
</el-card>
</el-col>
<el-col :span="12" class="card-box">
<el-card>
<template #header>
<div>
<span>累计用户数据</span>
</div>
</template>
<Echart :options="userCumulateOption" :height="420" />
</el-card>
</el-col>
<el-col :span="12" class="card-box">
<el-card>
<template #header>
<div>
<span>消息概况数据</span>
</div>
</template>
<Echart :options="upstreamMessageOption" :height="420" />
</el-card>
</el-col>
<el-col :span="12" class="card-box">
<el-card>
<template #header>
<div>
<span>接口分析数据</span>
</div>
</template>
<Echart :options="interfaceSummaryOption" :height="420" />
</el-card>
</el-col>
</el-row>
</ContentWrap>
</template>
<script lang="ts" setup>
import { formatDate, addTime, betweenDay, beginOfDay, endOfDay } from '@/utils/formatTime'
import * as StatisticsApi from '@/api/mp/statistics'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
defineOptions({ name: 'MpStatistics' })
const message = useMessage() // 消息弹窗
// 默认开始时间是当前日期-7,结束时间是当前日期-1
const dateRange = ref([
beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7)),
endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))
])
const accountId = ref(-1) // 选中的公众号编号
const xAxisDate = ref([] as any[]) // X 轴的日期范围
// 用户增减数据图表配置项
const userSummaryOption = reactive({
color: ['#67C23A', '#E5323E'],
legend: {
data: ['新增用户', '取消关注的用户']
},
tooltip: {},
xAxis: {
data: [] as any[] // X 轴的日期范围
},
yAxis: {
minInterval: 1
},
series: [
{
name: '新增用户',
type: 'bar',
label: {
show: true
},
barGap: 0,
data: [] as any[] // 新增用户的数据
},
{
name: '取消关注的用户',
type: 'bar',
label: {
show: true
},
data: [] as any[] // 取消关注的用户的数据
}
]
})
// 累计用户数据图表配置项
const userCumulateOption = reactive({
legend: {
data: ['累计用户量']
},
xAxis: {
type: 'category',
data: [] as any[]
},
yAxis: {
minInterval: 1
},
series: [
{
name: '累计用户量',
data: [] as any[], // 累计用户量的数据
type: 'line',
smooth: true,
label: {
show: true
}
}
]
})
// 消息发送概况数据图表配置项
const upstreamMessageOption = reactive({
color: ['#67C23A', '#E5323E'],
legend: {
data: ['用户发送人数', '用户发送条数']
},
tooltip: {},
xAxis: {
data: [] as any[] // X 轴的日期范围
},
yAxis: {
minInterval: 1
},
series: [
{
name: '用户发送人数',
type: 'line',
smooth: true,
label: {
show: true
},
data: [] as any[] // 用户发送人数的数据
},
{
name: '用户发送条数',
type: 'line',
smooth: true,
label: {
show: true
},
data: [] as any[] // 用户发送条数的数据
}
]
})
// 接口分析况数据图表配置项
const interfaceSummaryOption = reactive({
color: ['#67C23A', '#E5323E', '#E6A23C', '#409EFF'],
legend: {
data: ['被动回复用户消息的次数', '失败次数', '最大耗时', '总耗时']
},
tooltip: {},
xAxis: {
data: [] as any[] // X 轴的日期范围
},
yAxis: {},
series: [
{
name: '被动回复用户消息的次数',
type: 'bar',
label: {
show: true
},
barGap: 0,
data: [] as any[] // 被动回复用户消息的次数的数据
},
{
name: '失败次数',
type: 'bar',
label: {
show: true
},
data: [] as any[] // 失败次数的数据
},
{
name: '最大耗时',
type: 'bar',
label: {
show: true
},
data: [] as any[] // 最大耗时的数据
},
{
name: '总耗时',
type: 'bar',
label: {
show: true
},
data: [] as any[] // 总耗时的数据
}
]
})
/** 侦听公众号变化 **/
const onAccountChanged = (id: number) => {
accountId.value = id
getSummary()
}
/** 加载数据 */
const getSummary = () => {
// 如果没有选中公众号账号,则进行提示。
if (!accountId) {
message.error('未选中公众号,无法统计数据')
return false
}
// 必须选择 7 天内,因为公众号有时间跨度限制为 7
if (betweenDay(dateRange.value[0], dateRange.value[1]) >= 7) {
message.error('时间间隔 7 天以内,请重新选择')
return false
}
// 清空横坐标日期
xAxisDate.value = []
// 横坐标加载日期数据
const days = betweenDay(dateRange.value[0], dateRange.value[1]) // 相差天数
for (let i = 0; i <= days; i++) {
xAxisDate.value.push(
formatDate(addTime(dateRange.value[0], 3600 * 1000 * 24 * i), 'YYYY-MM-DD')
)
}
// 初始化图表
initUserSummaryChart()
initUserCumulateChart()
initUpstreamMessageChart()
interfaceSummaryChart()
}
/** 用户增减数据 */
const initUserSummaryChart = async () => {
userSummaryOption.xAxis.data = []
userSummaryOption.series[0].data = []
userSummaryOption.series[1].data = []
try {
// 用户增减数据
const data = await StatisticsApi.getUserSummary({
accountId: accountId.value,
date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])]
})
// 横坐标
userSummaryOption.xAxis.data = xAxisDate.value
// 处理数据
xAxisDate.value.forEach((date, index) => {
data.forEach((item) => {
// 匹配日期
const refDate = formatDate(new Date(item.refDate), 'YYYY-MM-DD')
if (refDate.indexOf(date) === -1) {
return
}
// 设置数据到对应的位置
userSummaryOption.series[0].data[index] = item.newUser
userSummaryOption.series[1].data[index] = item.cancelUser
})
})
} catch {}
}
/** 累计用户数据 */
const initUserCumulateChart = async () => {
userCumulateOption.xAxis.data = []
userCumulateOption.series[0].data = []
// 发起请求
try {
const data = await StatisticsApi.getUserCumulate({
accountId: accountId.value,
date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])]
})
userCumulateOption.xAxis.data = xAxisDate.value
// 处理数据
data.forEach((item, index) => {
userCumulateOption.series[0].data[index] = item.cumulateUser
})
} catch {}
}
/** 消息概况数据 */
const initUpstreamMessageChart = async () => {
upstreamMessageOption.xAxis.data = []
upstreamMessageOption.series[0].data = []
upstreamMessageOption.series[1].data = []
// 发起请求
try {
const data = await StatisticsApi.getUpstreamMessage({
accountId: accountId.value,
date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])]
})
upstreamMessageOption.xAxis.data = xAxisDate.value
// 处理数据
data.forEach((item, index) => {
upstreamMessageOption.series[0].data[index] = item.messageUser
upstreamMessageOption.series[1].data[index] = item.messageCount
})
} catch {}
}
/** 接口分析数据 */
const interfaceSummaryChart = async () => {
interfaceSummaryOption.xAxis.data = []
interfaceSummaryOption.series[0].data = []
interfaceSummaryOption.series[1].data = []
interfaceSummaryOption.series[2].data = []
interfaceSummaryOption.series[3].data = []
// 发起请求
try {
const data = await StatisticsApi.getInterfaceSummary({
accountId: accountId.value,
date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])]
})
interfaceSummaryOption.xAxis.data = xAxisDate.value
// 处理数据
data.forEach((item, index) => {
interfaceSummaryOption.series[0].data[index] = item.callbackCount
interfaceSummaryOption.series[1].data[index] = item.failCount
interfaceSummaryOption.series[2].data[index] = item.maxTimeCost
interfaceSummaryOption.series[3].data[index] = item.totalTimeCost
})
} catch {}
}
</script>
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="80px"
>
<el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import * as MpTagApi from '@/api/mp/tag'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'MpTagForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formType = ref<'create' | 'update' | ''>('') // 表单的类型:create - 新增;update - 修改
const formData = ref({
accountId: -1,
name: ''
})
const formRules: FormRules = {
name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }]
}
const formRef = ref<FormInstance | null>(null) // 表单 Ref
const emit = defineEmits<{
(e: 'success'): void
}>()
/** 打开弹窗 */
const open = async (type: 'create' | 'update', accountId: number, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
formData.value.accountId = accountId
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await MpTagApi.getTag(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const submitForm = async () => {
// 校验表单
if (!formRef) return
const valid = await formRef.value?.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
const data = formData.value as MpTagApi.TagVO
if (formType.value === 'create') {
await MpTagApi.createTag(data)
message.success(t('common.createSuccess'))
} else {
await MpTagApi.updateTag(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
accountId: -1,
name: ''
}
formRef.value?.resetFields()
}
</script>
<template>
<doc-alert title="公众号标签" url="https://doc.iocoder.cn/mp/tag/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['mp:tag:create']"
:disabled="queryParams.accountId === 0"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleSync"
v-hasPermi="['mp:tag:sync']"
:disabled="queryParams.accountId === 0"
>
<Icon icon="ep:refresh" class="mr-5px" /> 同步
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" />
<el-table-column label="标签名称" align="center" prop="name" />
<el-table-column label="粉丝数" align="center" prop="count" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['mp:tag:update']"
>
修改
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['mp:tag:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗:添加/修改 -->
<TagForm ref="formRef" @success="getList" />
</template>
<script lang="ts" setup>
import { dateFormatter } from '@/utils/formatTime'
import * as MpTagApi from '@/api/mp/tag'
import TagForm from './TagForm.vue'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
defineOptions({ name: 'MpTag' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref<any[]>([]) // 列表的数据
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: -1
})
const formRef = ref<InstanceType<typeof TagForm> | null>(null)
/** 侦听公众号变化 **/
const onAccountChanged = (id: number) => {
queryParams.accountId = id
queryParams.pageNo = 1
getList()
}
/** 查询列表 */
const getList = async () => {
try {
loading.value = true
const data = await MpTagApi.getTagPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 添加/修改操作 */
const openForm = (type: 'create' | 'update', id?: number) => {
formRef.value?.open(type, queryParams.accountId, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await MpTagApi.deleteTag(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 同步操作 */
const handleSync = async () => {
try {
await message.confirm('是否确认同步标签?')
await MpTagApi.syncTag(queryParams.accountId as number)
message.success('同步标签成功')
await getList()
} catch {}
}
</script>
<template>
<Dialog v-model="dialogVisible" title="修改">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="80px"
>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="formData.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="标签" prop="tagIds">
<el-select v-model="formData.tagIds" clearable multiple placeholder="请选择标签">
<el-option
v-for="item in tagList"
:key="item.tagId"
:label="item.name"
:value="item.tagId"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import * as MpTagApi from '@/api/mp/tag'
import * as MpUserApi from '@/api/mp/user'
defineOptions({ name: 'MpUserForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单的加载中
const formData = ref({
id: undefined,
nickname: undefined,
remark: undefined,
tagIds: []
})
const formRules = reactive({}) // 表单的校验
const formRef = ref() // 表单 Ref
const tagList = ref([]) // 公众号标签列表
/** 打开弹窗 */
const open = async (id: number) => {
dialogVisible.value = true
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await MpUserApi.getUser(id)
} finally {
formLoading.value = false
}
}
// 加载标签
tagList.value = await MpTagApi.getSimpleTagList()
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
if (!formRef) return
const valid = await formRef.value.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
await MpUserApi.updateUser(formData.value)
message.success(t('common.updateSuccess'))
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
nickname: undefined,
remark: undefined,
tagIds: []
}
formRef.value?.resetFields()
}
</script>
<template>
<doc-alert title="公众号粉丝" url="https://doc.iocoder.cn/mp/user/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</el-form-item>
<el-form-item label="用户标识" prop="openid">
<el-input
v-model="queryParams.openid"
placeholder="请输入用户标识"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input
v-model="queryParams.nickname"
placeholder="请输入昵称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"> <Icon icon="ep:search" />搜索 </el-button>
<el-button @click="resetQuery"> <Icon icon="ep:refresh" />重置 </el-button>
<el-button
type="success"
plain
@click="handleSync"
v-hasPermi="['mp:user:sync']"
:disabled="queryParams.accountId === 0"
>
<Icon icon="ep:refresh" class="mr-5px" /> 同步
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" />
<el-table-column label="用户标识" align="center" prop="openid" width="260" />
<el-table-column label="昵称" align="center" prop="nickname" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="标签" align="center" prop="tagIds" width="200">
<template #default="scope">
<span v-for="(tagId, index) in scope.row.tagIds" :key="index">
<el-tag>{{ tagList.find((tag) => tag.tagId === tagId)?.name }} </el-tag>&nbsp;
</span>
</template>
</el-table-column>
<el-table-column label="订阅状态" align="center" prop="subscribeStatus">
<template #default="scope">
<el-tag v-if="scope.row.subscribeStatus === 0" type="success">已订阅</el-tag>
<el-tag v-else type="danger">未订阅</el-tag>
</template>
</el-table-column>
<el-table-column
label="订阅时间"
align="center"
prop="subscribeTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
type="primary"
link
@click="openForm(scope.row.id)"
v-hasPermi="['mp:user:update']"
>
修改
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗:修改 -->
<UserForm ref="formRef" @success="getList" />
</template>
<script lang="ts" setup>
import { dateFormatter } from '@/utils/formatTime'
import * as MpUserApi from '@/api/mp/user'
import * as MpTagApi from '@/api/mp/tag'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import type { FormInstance } from 'element-plus'
import UserForm from './UserForm.vue'
defineOptions({ name: 'MpUser' })
const message = useMessage() // 消息
const loading = ref(true) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref<any[]>([]) // 列表的数据
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: -1,
openid: '',
nickname: ''
})
const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单
const tagList = ref<any[]>([]) // 公众号标签列表
/** 侦听公众号变化 **/
const onAccountChanged = (id: number) => {
queryParams.accountId = id
queryParams.pageNo = 1
getList()
}
/** 查询列表 */
const getList = async () => {
try {
loading.value = true
const data = await MpUserApi.getUserPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
const accountId = queryParams.accountId
queryFormRef.value?.resetFields()
queryParams.accountId = accountId
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref<InstanceType<typeof UserForm> | null>(null)
const openForm = (id: number) => {
formRef.value?.open(id)
}
/** 同步标签 */
const handleSync = async () => {
try {
await message.confirm('是否确认同步粉丝?')
await MpUserApi.syncUser(queryParams.accountId)
message.success('开始从微信公众号同步粉丝信息,同步需要一段时间,建议稍后再查询')
await getList()
} catch {}
}
/** 初始化 */
onMounted(async () => {
tagList.value = await MpTagApi.getSimpleTagList()
})
</script>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment