This commit is contained in:
dylanmay
2025-11-27 12:17:53 +08:00
55 changed files with 5319 additions and 503 deletions

View File

@@ -1,24 +1,26 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { MpMsgType } from '@vben/constants';
import Location from '#/views/mp/components/wx-location/wx-location.vue';
import Music from '#/views/mp/components/wx-music/wx-music.vue';
import News from '#/views/mp/components/wx-news/wx-news.vue';
import VideoPlayer from '#/views/mp/components/wx-video-play/wx-video-play.vue';
import VoicePlayer from '#/views/mp/components/wx-voice-play/wx-voice-play.vue';
import {
WxLocation,
WxMusic,
WxNews,
WxVideoPlayer,
WxVoicePlayer,
} from '#/views/mp/components';
import MsgEvent from './msg-event.vue';
// TODO @hwantd 和 ele 保持一致例如说1props2WxVoicePlayer 这种;
defineOptions({ name: 'Msg' });
defineOptions({ name: 'WxMsg' });
const props = defineProps<{
item: any;
}>();
const item = ref<any>(props.item);
withDefaults(
defineProps<{
item?: any;
}>(),
{
item: {},
},
);
</script>
<template>
@@ -28,7 +30,7 @@ const item = ref<any>(props.item);
<div v-else-if="item.type === MpMsgType.Text">{{ item.content }}</div>
<div v-else-if="item.type === MpMsgType.Voice">
<VoicePlayer :url="item.mediaUrl" :content="item.recognition" />
<WxVideoPlayer :url="item.mediaUrl" :content="item.recognition" />
</div>
<div v-else-if="item.type === MpMsgType.Image">
@@ -41,7 +43,7 @@ const item = ref<any>(props.item);
v-else-if="item.type === MpMsgType.Video || item.type === 'shortvideo'"
class="text-center"
>
<VideoPlayer :url="item.mediaUrl" />
<WxVoicePlayer :url="item.mediaUrl" />
</div>
<div v-else-if="item.type === MpMsgType.Link" class="flex-1">
@@ -66,7 +68,7 @@ const item = ref<any>(props.item);
</div>
<div v-else-if="item.type === MpMsgType.Location">
<Location
<WxLocation
:label="item.label"
:location-y="item.locationY"
:location-x="item.locationX"
@@ -74,11 +76,11 @@ const item = ref<any>(props.item);
</div>
<div v-else-if="item.type === MpMsgType.News" class="w-[300px]">
<News :articles="item.articles" />
<WxNews :articles="item.articles" />
</div>
<div v-else-if="item.type === MpMsgType.Music">
<Music
<WxMusic
:title="item.title"
:description="item.description"
:thumb-media-url="item.thumbMediaUrl"

View File

@@ -0,0 +1,7 @@
export interface WxMusicProps {
title?: string;
description?: string;
musicUrl?: string;
hqMusicUrl?: string;
thumbMediaUrl: string;
}

View File

@@ -1,37 +1,23 @@
<script lang="ts" setup>
import type { WxMusicProps } from './types';
import { computed } from 'vue';
import { ElLink } from 'element-plus';
/** 微信消息 - 音乐 */
defineOptions({ name: 'Music' });
// TODO @hwantd 和 ele 的代码风格不一致例如说props
defineOptions({ name: 'WxMusic' });
const props = defineProps({
title: {
required: false,
type: String,
default: '',
},
description: {
required: false,
type: String,
default: '',
},
musicUrl: {
required: false,
type: String,
default: '',
},
hqMusicUrl: {
required: false,
type: String,
default: '',
},
thumbMediaUrl: {
required: true,
type: String,
},
const props = withDefaults(defineProps<WxMusicProps>(), {
title: '',
description: '',
musicUrl: '',
hqMusicUrl: '',
thumbMediaUrl: '',
});
const href = computed(() => props.hqMusicUrl || props.musicUrl);
defineExpose({
musicUrl: props.musicUrl,
});
@@ -43,7 +29,7 @@ defineExpose({
type="success"
:underline="false"
target="_blank"
:href="hqMusicUrl ? hqMusicUrl : musicUrl"
:href="href"
class="block"
>
<div

View File

@@ -5,6 +5,7 @@ import { IconifyIcon } from '@vben/icons';
// 因为微信语音是 amr 格式,所以需要用到 amr 解码器https://www.npmjs.com/package/benz-amr-recorder
import BenzAMRRecorder from 'benz-amr-recorder';
import { ElTag } from 'element-plus';
/** 微信消息 - 语音 */
defineOptions({ name: 'WxVoicePlayer' });
@@ -82,7 +83,7 @@ function amrStop() {
</span>
</el-icon>
<div v-if="content">
<el-tag type="success" size="small">语音识别</el-tag>
<ElTag type="success" size="small">语音识别</ElTag>
{{ content }}
</div>
</div>

View File

@@ -106,15 +106,18 @@ function plusNews() {
@click="activeNewsIndex = index"
>
<div class="relative w-full bg-[#acadae]">
<img class="h-full w-full" :src="news.thumbUrl" />
<img
class="max-h-[200px] min-h-[100px] w-full object-cover"
:src="news.thumbUrl"
/>
<div
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
class="absolute bottom-0 left-0 mb-[5px] ml-[5px] inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap p-[1%] text-[18px] text-white"
>
{{ news.title }}
</div>
</div>
<div
class="relative flex justify-center gap-[10px] py-[5px] text-center"
class="absolute bottom-0 right-[-45px] top-0 flex flex-col justify-center gap-[10px] py-[5px] text-center"
v-if="newsList.length > 1"
>
<ElButton
@@ -130,6 +133,7 @@ function plusNews() {
type="danger"
circle
size="small"
class="!ml-0"
@click="() => removeNews(index)"
>
<IconifyIcon icon="lucide:trash-2" />
@@ -146,19 +150,19 @@ function plusNews() {
"
@click="activeNewsIndex = index"
>
<div class="relative">
<div class="bg-[#acadae]">
<img class="block h-full w-full" :src="news.thumbUrl" />
<div
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
>
{{ news.title }}
</div>
<div class="relative flex items-center justify-between">
<div
class="mb-[5px] ml-[5px] h-[25px] flex-1 overflow-hidden text-ellipsis whitespace-nowrap p-[1%] text-[16px]"
>
{{ news.title }}
</div>
<img
class="block h-[90px] w-[90px] object-cover"
:src="news.thumbUrl"
/>
</div>
<div
class="relative flex justify-center gap-[10px] py-[5px] text-center"
class="absolute bottom-0 right-[-45px] top-0 flex flex-col justify-center gap-[10px] py-[5px] text-center"
>
<ElButton
v-if="newsList.length > index + 1"
@@ -174,6 +178,7 @@ function plusNews() {
type="info"
circle
size="small"
class="!ml-0"
@click="() => moveUpNews(index)"
>
<IconifyIcon icon="lucide:arrow-up" />
@@ -183,6 +188,7 @@ function plusNews() {
type="danger"
size="small"
circle
class="!ml-0"
@click="() => removeNews(index)"
>
<IconifyIcon icon="lucide:trash-2" />

View File

@@ -76,9 +76,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
});
}
});
// TODO @jaweArticle 类型,报错;
return {
list: res.list as unknown as Article[],
list: res.list as unknown as MpFreePublishApi.FreePublish[],
total: res.total,
};
},
@@ -92,7 +91,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
refresh: true,
search: true,
},
} as VxeTableGridOptions<Article>,
} as VxeTableGridOptions<MpFreePublishApi.FreePublish>,
});
</script>

View File

@@ -0,0 +1,143 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import { getUserPage } from '#/api/mp/user';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'accountId',
label: '公众号',
component: 'Input',
},
];
}
/** 发送消息模板表单 */
export function useSendFormSchema(accountId?: number): VbenFormSchema[] {
return [
{
fieldName: 'id',
label: '模板编号',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'title',
label: '模板标题',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'userId',
label: '用户',
component: 'ApiSelect',
componentProps: {
api: async () => {
if (!accountId) {
return [];
}
const data = await getUserPage({
pageNo: 1,
pageSize: 100,
accountId,
});
return (data.list || []).map((user) => ({
label: user.nickname || user.openid,
value: user.id,
}));
},
filterable: true,
placeholder: '请选择用户',
},
rules: 'required',
},
{
fieldName: 'data',
label: '模板数据',
component: 'Textarea',
componentProps: {
rows: 4,
placeholder:
'请输入模板数据JSON 格式),例如:{"keyword1": {"value": "测试内容"}}',
},
},
{
fieldName: 'url',
label: '跳转链接',
component: 'Input',
componentProps: {
placeholder: '请输入跳转链接',
},
},
{
fieldName: 'miniProgramAppId',
label: '小程序 appId',
component: 'Input',
componentProps: {
placeholder: '请输入小程序 appId',
},
},
{
fieldName: 'miniProgramPagePath',
label: '小程序页面路径',
component: 'Input',
componentProps: {
placeholder: '请输入小程序页面路径',
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridPropTypes.Columns {
return [
{
title: '公众号模板 ID',
field: 'templateId',
minWidth: 400,
},
{
title: '标题',
field: 'title',
minWidth: 150,
},
{
title: '模板内容',
field: 'content',
minWidth: 400,
},
{
title: '模板示例',
field: 'example',
minWidth: 200,
},
{
title: '一级行业',
field: 'primaryIndustry',
minWidth: 120,
},
{
title: '二级行业',
field: 'deputyIndustry',
minWidth: 120,
},
{
title: '创建时间',
field: 'createTime',
formatter: 'formatDateTime',
minWidth: 180,
},
{
title: '操作',
width: 140,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,165 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpMessageTemplateApi } from '#/api/mp/messageTemplate';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteMessageTemplate,
getMessageTemplateList,
syncMessageTemplate,
} from '#/api/mp/messageTemplate';
import { $t } from '#/locales';
import { WxAccountSelect } from '#/views/mp/components';
import { useGridColumns, useGridFormSchema } from './data';
import SendForm from './modules/send-form.vue';
const [SendFormModal, sendFormModalApi] = useVbenModal({
connectedComponent: SendForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 公众号变化时查询数据 */
function handleAccountChange(accountId: number) {
gridApi.formApi.setValues({ accountId });
gridApi.formApi.submitForm();
}
/** 同步模板 */
async function handleSync() {
const formValues = await gridApi.formApi.getValues();
const accountId = formValues.accountId;
if (!accountId) {
ElMessage.warning('请先选择公众号');
return;
}
await confirm('是否确认同步消息模板?');
const loadingInstance = ElLoading.service({
text: '正在同步消息模板...',
});
try {
await syncMessageTemplate(accountId);
ElMessage.success('同步消息模板成功');
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 发送消息 */
function handleSend(row: MpMessageTemplateApi.MessageTemplate) {
sendFormModalApi.setData(row).open();
}
/** 删除模板 */
async function handleDelete(row: MpMessageTemplateApi.MessageTemplate) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.title]),
});
try {
await deleteMessageTemplate(row.id);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.title]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async (_params, formValues) => {
return await getMessageTemplateList({
accountId: formValues.accountId,
});
},
},
autoLoad: false,
},
pagerConfig: {
enabled: false,
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MpMessageTemplateApi.MessageTemplate>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="模版消息"
url="https://doc.iocoder.cn/mp/message-template/"
/>
</template>
<SendFormModal @success="handleRefresh" />
<Grid table-title="公众号消息模板列表">
<template #form-accountId>
<WxAccountSelect @change="handleAccountChange" />
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '同步',
type: 'primary',
icon: 'lucide:refresh-ccw',
auth: ['mp:message-template:sync'],
onClick: handleSync,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '发送',
type: 'primary',
link: true,
icon: 'lucide:send',
auth: ['mp:message-template:send'],
onClick: handleSend.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['mp:message-template:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.title]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
import type { MpMessageTemplateApi } from '#/api/mp/messageTemplate';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { sendMessageTemplate } from '#/api/mp/messageTemplate';
import { useSendFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MpMessageTemplateApi.MessageTemplate>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 构建发送请求
const values = await formApi.getValues();
const sendData: MpMessageTemplateApi.MessageTemplateSendVO = {
id: formData.value?.id || 0,
userId: values.userId,
data: values.data || undefined,
url: values.url || undefined,
miniProgramAppId: values.miniProgramAppId || undefined,
miniProgramPagePath: values.miniProgramPagePath || undefined,
};
// 如果填写了小程序信息,需要拼接成 miniprogram 字段
if (sendData.miniProgramAppId && sendData.miniProgramPagePath) {
sendData.miniprogram = JSON.stringify({
appid: sendData.miniProgramAppId,
pagepath: sendData.miniProgramPagePath,
});
}
// 如果填写了 data 字段
if (sendData.data && typeof sendData.data === 'string') {
try {
sendData.data = JSON.parse(sendData.data);
} catch {
ElMessage.error('模板数据格式不正确,请输入有效的 JSON 格式');
modalApi.unlock();
return;
}
}
// 提交表单
try {
await sendMessageTemplate(sendData);
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success('发送成功');
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 获取数据
const data = modalApi.getData<MpMessageTemplateApi.MessageTemplate>();
if (!data) {
return;
}
formData.value = data;
// 更新 form schema
const schema = useSendFormSchema(data.accountId);
formApi.setState({ schema });
// 设置到 values
await formApi.setValues({
id: data.id,
title: data.title,
});
},
});
</script>
<template>
<Modal class="w-[600px]" title="发送消息模板">
<Form class="mx-4" />
</Modal>
</template>