feat: 自动回复迁移

This commit is contained in:
hw
2025-11-04 16:53:08 +08:00
parent 7a5f4b01e2
commit 84795d10cd
28 changed files with 2060 additions and 103 deletions

View File

@@ -5,8 +5,10 @@ VITE_BASE=/
# 请求路径
VITE_BASE_URL=http://47.103.66.220:48080
# VITE_BASE_URL=http://192.168.1.49:48080
# 接口地址
VITE_GLOB_API_URL=http://47.103.66.220:48080/admin-api
# VITE_GLOB_API_URL=http://192.168.1.49:48080/admin-api
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
# 是否打开 devtoolstrue 为打开false 为关闭

View File

@@ -43,6 +43,7 @@
"@vben/styles": "workspace:*",
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@videojs-player/vue": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"benz-amr-recorder": "^1.1.5",
@@ -52,6 +53,7 @@
"highlight.js": "catalog:",
"pinia": "catalog:",
"tinymce": "catalog:",
"video.js": "catalog:",
"vue": "catalog:",
"vue-dompurify-html": "catalog:",
"vue-router": "catalog:",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,90 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import WxAccountSelect from '#/views/mp/modules/wx-account-select/main.vue';
import { MsgType } from './modules/types';
/** 获取表格列配置 */
export function useGridColumns(
msgType: MsgType,
): VxeTableGridOptions['columns'] {
const columns: VxeTableGridOptions['columns'] = [];
// 请求消息类型列(仅消息回复显示)
if (msgType === MsgType.Message) {
columns.push({
field: 'requestMessageType',
title: '请求消息类型',
minWidth: 120,
});
}
// 关键词列(仅关键词回复显示)
if (msgType === MsgType.Keyword) {
columns.push({
field: 'requestKeyword',
title: '关键词',
minWidth: 150,
});
}
// 匹配类型列(仅关键词回复显示)
if (msgType === MsgType.Keyword) {
columns.push({
field: 'requestMatch',
title: '匹配类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH },
},
});
}
// 回复消息类型列
columns.push(
{
field: 'responseMessageType',
title: '回复消息类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.MP_MESSAGE_TYPE },
},
},
{
field: 'responseContent',
title: '回复内容',
minWidth: 200,
slots: { default: 'replyContent' },
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 140,
fixed: 'right',
slots: { default: 'actions' },
},
);
return columns;
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'accountId',
label: '公众号',
component: markRaw(WxAccountSelect),
},
];
}

View File

@@ -0,0 +1,253 @@
<script lang="ts" setup>
import type { TabPaneName } from 'element-plus';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { computed, nextTick, onMounted, ref } from 'vue';
import { ContentWrap, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import {
ElLoading,
ElMessage,
ElMessageBox,
ElRow,
ElTabPane,
ElTabs,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import * as MpAutoReplyApi from '#/api/mp/autoReply';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ReplyContentCell from './modules/ReplyTable.vue';
import { MsgType } from './modules/types';
defineOptions({ name: 'MpAutoReply' });
const msgType = ref<MsgType>(MsgType.Keyword); // 消息类型
async function onTabChange(_tabName: TabPaneName) {
// 等待 msgType 更新完成
await nextTick();
const columns = useGridColumns(msgType.value);
if (columns) {
// 使用 setGridOptions 更新列配置
gridApi.setGridOptions({ columns });
// 等待列配置更新完成
await nextTick();
}
await gridApi.query();
// 查询完成后更新数据长度
updateTableDataLength();
}
/** 新增按钮操作 */
async function handleCreate() {
const formValues = await gridApi.formApi.getValues();
formModalApi
.setData({
isCreating: true,
msgType: msgType.value,
accountId: formValues.accountId,
})
.open();
}
/** 修改按钮操作 */
async function handleEdit(row: any) {
const data = (await MpAutoReplyApi.getAutoReply(row.id)) as any;
formModalApi
.setData({ isCreating: false, msgType: msgType.value, row: data })
.open();
}
/** 删除按钮操作 */
async function handleDelete(row: any) {
await ElMessageBox.confirm('是否确认删除此数据?');
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', ['自动回复']),
});
try {
await MpAutoReplyApi.deleteAutoReply(row.id);
ElMessage.success('删除成功');
await gridApi.query();
// 查询完成后更新数据长度
updateTableDataLength();
} finally {
loadingInstance.close();
}
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
// 表单值变化时自动提交,这样 accountId 会被正确传递到查询函数
submitOnChange: true,
},
gridOptions: {
columns: useGridColumns(msgType.value),
height: 'calc(100vh - 300px)',
// height: '600px',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await MpAutoReplyApi.getAutoReplyPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
type: msgType.value,
...formValues,
});
},
},
// 禁用自动加载,等表单初始化完成后再加载
autoLoad: false,
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<any>,
});
// 表格数据长度,用于判断是否显示新增按钮
const tableDataLength = ref(0);
// 更新表格数据长度(避免在模板中直接调用 getTableData 导致响应式循环)
function updateTableDataLength() {
try {
if (!gridApi.grid) {
return;
}
const tableData = gridApi.grid.getTableData();
tableDataLength.value = tableData?.tableData?.length || 0;
} catch {
tableDataLength.value = 0;
}
}
// 计算是否显示新增按钮:关注时回复类型只有在没有数据时才显示
const showCreateButton = computed(() => {
if (msgType.value !== MsgType.Follow) {
return true;
}
return tableDataLength.value <= 0;
});
// 页面挂载后,等待表单初始化完成再加载数据
onMounted(async () => {
// 等待 WxAccountSelect 组件加载并设置默认值
await nextTick();
if (gridApi.formApi) {
const formValues = await gridApi.formApi.getValues();
// 如果 accountId 有值,说明已经准备好了
if (formValues.accountId) {
// 设置为最新提交的值
gridApi.formApi.setLatestSubmissionValues(formValues);
// 触发首次查询
await gridApi.query();
updateTableDataLength();
}
}
});
</script>
<template>
<Page auto-content-height>
<DocAlert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" />
<!-- tab 切换 -->
<ContentWrap>
<ElTabs v-model="msgType" @tab-change="onTabChange">
<!-- tab -->
<ElTabPane :name="MsgType.Follow">
<template #label>
<ElRow align="middle">
<Icon icon="ep:star" class="mr-2px" /> 关注时回复
</ElRow>
</template>
</ElTabPane>
<ElTabPane :name="MsgType.Message">
<template #label>
<ElRow align="middle">
<Icon icon="ep:chat-line-round" class="mr-2px" /> 消息回复
</ElRow>
</template>
</ElTabPane>
<ElTabPane :name="MsgType.Keyword">
<template #label>
<ElRow align="middle">
<Icon icon="fa:newspaper-o" class="mr-2px" /> 关键词回复
</ElRow>
</template>
</ElTabPane>
</ElTabs>
<!-- 列表 -->
<FormModal
@success="
() => {
gridApi.query().then(() => {
updateTableDataLength();
});
}
"
/>
<Grid table-title="自动回复列表">
<template #toolbar-tools>
<TableAction
v-if="showCreateButton"
:actions="[
{
label: $t('ui.actionTitle.create', ['自动回复']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['mp:auto-reply:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #replyContent="{ row }">
<ReplyContentCell :row="row" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['mp:auto-reply:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['mp:auto-reply:delete'],
popConfirm: {
title: '是否确认删除此数据?',
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,128 @@
<script lang="ts" setup>
import type { FormInstance } from 'element-plus';
import type { Reply } from '#/views/mp/modules/wx-reply';
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ElForm, ElFormItem, ElInput, ElOption, ElSelect } from 'element-plus';
import WxReplySelect from '#/views/mp/modules/wx-reply';
import { MsgType } from './types';
defineOptions({ name: 'ReplyForm' });
const props = defineProps<{
modelValue: any;
msgType: MsgType;
reply: Reply;
}>();
const emit = defineEmits<{
(e: 'update:reply', v: Reply): void;
(e: 'update:modelValue', v: any): void;
}>();
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>
<template>
<div>
<ElForm ref="formRef" :model="replyForm" :rules="rules" label-width="80px">
<ElFormItem
label="消息类型"
prop="requestMessageType"
v-if="msgType === MsgType.Message"
>
<ElSelect v-model="replyForm.requestMessageType" placeholder="请选择">
<template
v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)"
:key="dict.value"
>
<ElOption
v-if="RequestMessageTypes.includes(dict.value as string)"
:label="dict.label"
:value="dict.value"
/>
</template>
</ElSelect>
</ElFormItem>
<ElFormItem
label="匹配类型"
prop="requestMatch"
v-if="msgType === MsgType.Keyword"
>
<ElSelect
v-model="replyForm.requestMatch"
placeholder="请选择匹配类型"
clearable
>
<ElOption
v-for="dict in getDictOptions(
DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH,
'number',
)"
:key="String(dict.value)"
:label="dict.label"
:value="dict.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
label="关键词"
prop="requestKeyword"
v-if="msgType === MsgType.Keyword"
>
<ElInput
v-model="replyForm.requestKeyword"
placeholder="请输入内容"
clearable
/>
</ElFormItem>
<ElFormItem label="回复消息">
<WxReplySelect v-model="reply" />
</ElFormItem>
</ElForm>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,55 @@
<script lang="ts" setup>
import WxMusic from '#/views/mp/modules/wx-music';
import WxNews from '#/views/mp/modules/wx-news';
import WxVideoPlayer from '#/views/mp/modules/wx-video-play';
import WxVoicePlayer from '#/views/mp/modules/wx-voice-play';
defineOptions({ name: 'ReplyContentCell' });
const props = defineProps<{
row: any;
}>();
</script>
<template>
<div>
<div v-if="props.row.responseMessageType === 'text'">
{{ props.row.responseContent }}
</div>
<div v-else-if="props.row.responseMessageType === 'voice'">
<WxVoicePlayer
v-if="props.row.responseMediaUrl"
:url="props.row.responseMediaUrl"
/>
</div>
<div v-else-if="props.row.responseMessageType === 'image'">
<a target="_blank" :href="props.row.responseMediaUrl">
<img :src="props.row.responseMediaUrl" style="width: 100px" />
</a>
</div>
<div
v-else-if="
props.row.responseMessageType === 'video' ||
props.row.responseMessageType === 'shortvideo'
"
>
<WxVideoPlayer
v-if="props.row.responseMediaUrl"
:url="props.row.responseMediaUrl"
style="margin-top: 10px"
/>
</div>
<div v-else-if="props.row.responseMessageType === 'news'">
<WxNews :articles="props.row.responseArticles" />
</div>
<div v-else-if="props.row.responseMessageType === 'music'">
<WxMusic
:title="props.row.responseTitle"
:description="props.row.responseDescription"
:thumb-media-url="props.row.responseThumbMediaUrl"
:music-url="props.row.responseMusicUrl"
:hq-music-url="props.row.responseHqMusicUrl"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,142 @@
<script lang="ts" setup>
import type { Reply } from '#/views/mp/modules/wx-reply';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import * as MpAutoReplyApi from '#/api/mp/autoReply';
import { $t } from '#/locales';
import { ReplyType } from '#/views/mp/modules/wx-reply/components/types';
import ReplyForm from './ReplyForm.vue';
import { MsgType } from './types';
const emit = defineEmits(['success']);
const formRef = ref<InstanceType<typeof ReplyForm> | null>(null);
const formData = ref<{ isCreating: boolean; msgType: MsgType; row?: any }>();
const replyForm = ref<any>({});
const reply = ref<Reply>({
type: ReplyType.Text,
accountId: -1,
});
const getTitle = computed(() => {
return formData.value?.isCreating
? $t('ui.actionTitle.create', ['自动回复'])
: $t('ui.actionTitle.edit', ['自动回复']);
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
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;
modalApi.lock();
try {
if (replyForm.value.id === undefined) {
await MpAutoReplyApi.createAutoReply(submitForm);
ElMessage.success('新增成功');
} else {
await MpAutoReplyApi.updateAutoReply(submitForm);
ElMessage.success('修改成功');
}
await modalApi.close();
emit('success');
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
replyForm.value = {};
reply.value = {
type: ReplyType.Text,
accountId: -1,
};
return;
}
// 加载数据
const data = modalApi.getData<{
accountId?: number;
isCreating: boolean;
msgType: MsgType;
row?: any;
}>();
if (!data) {
return;
}
formData.value = data;
if (data.isCreating) {
// 新建:初始化表单
replyForm.value = {
id: undefined,
accountId: data.accountId || -1,
type: data.msgType,
requestKeyword: undefined,
requestMatch: data.msgType === MsgType.Keyword ? 1 : undefined,
requestMessageType: undefined,
};
reply.value = {
type: ReplyType.Text,
accountId: data.accountId || -1,
};
} else if (data.row) {
// 编辑:加载数据
const rowData = data.row;
replyForm.value = { ...rowData };
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: rowData.responseMessageType,
accountId: data.accountId || -1,
content: rowData.responseContent,
mediaId: rowData.responseMediaId,
url: rowData.responseMediaUrl,
title: rowData.responseTitle,
description: rowData.responseDescription,
thumbMediaId: rowData.responseThumbMediaId,
thumbMediaUrl: rowData.responseThumbMediaUrl,
articles: rowData.responseArticles,
musicUrl: rowData.responseMusicUrl,
hqMusicUrl: rowData.responseHqMusicUrl,
};
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-4/5">
<ReplyForm
v-if="formData"
v-model="replyForm"
v-model:reply="reply"
:msg-type="formData.msgType"
ref="formRef"
/>
</Modal>
</template>

View File

@@ -0,0 +1,7 @@
// 消息类型Follow: 关注时回复Message: 消息回复Keyword: 关键词回复)
// 作为 tab.nameenum 的数字不能随意修改,与 api 参数相关
export enum MsgType {
Follow = 1,
Keyword = 3,
Message = 2,
}

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { MpAccountApi } from '#/api/mp/account';
import { onMounted, reactive, ref, unref } from 'vue';
import { computed, onMounted, reactive, ref, unref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
@@ -11,8 +11,13 @@ import { useTagsViewStore } from '#/store/tagsView';
defineOptions({ name: 'WxAccountSelect' });
const props = defineProps<{
modelValue?: number;
}>();
const emit = defineEmits<{
(e: 'change', id: number, name: string): void;
(e: 'update:modelValue', id: number): void;
}>();
const message = ElMessage; // 消息弹窗
@@ -26,6 +31,51 @@ const account: MpAccountApi.AccountSimple = reactive({
const accountList = ref<MpAccountApi.AccountSimple[]>([]);
// 计算当前选中的 ID优先使用 modelValue表单绑定否则使用内部 account.id
const currentId = computed({
get: () => {
// 如果外部传入了 modelValue优先使用外部的值
if (props.modelValue !== undefined && props.modelValue !== null) {
return props.modelValue;
}
return account.id;
},
set: (value: number) => {
// 更新内部状态
account.id = value;
// 同步到外部(表单系统)
emit('update:modelValue', value);
// 触发 change 事件(保持向后兼容)
const found = accountList.value.find(
(v: MpAccountApi.AccountSimple) => v.id === value,
);
if (found) {
account.name = found.name;
emit('change', value, found.name);
}
},
});
// 监听外部 modelValue 变化,同步到内部状态
watch(
() => props.modelValue,
(newValue) => {
if (
newValue !== undefined &&
newValue !== null &&
newValue !== account.id
) {
account.id = newValue;
const found = accountList.value.find(
(v: MpAccountApi.AccountSimple) => v.id === newValue,
);
if (found) {
account.name = found.name;
}
}
},
);
/** 查询公众号列表 */
async function handleQuery() {
accountList.value = await getSimpleAccountList();
@@ -35,25 +85,31 @@ async function handleQuery() {
await push({ name: 'MpAccount' });
return;
}
// 默认选中第一个
const firstAccount = accountList.value[0];
if (firstAccount) {
account.id = firstAccount.id;
if (account.id) {
// 如果外部没有传入值modelValue 为空),默认选中第一个
if (props.modelValue === undefined || props.modelValue === null) {
const firstAccount = accountList.value[0];
if (firstAccount) {
currentId.value = firstAccount.id;
account.name = firstAccount.name;
emit('change', account.id, account.name);
emit('change', firstAccount.id, firstAccount.name);
}
} else {
// 如果外部有值,同步到内部状态
const found = accountList.value.find(
(v: MpAccountApi.AccountSimple) => v.id === props.modelValue,
);
if (found) {
account.id = props.modelValue;
account.name = found.name;
}
}
}
/** 公众号变化 */
function onChanged(id?: number) {
const found = accountList.value.find(
(v: MpAccountApi.AccountSimple) => v.id === id,
);
if (account.id && found) {
account.name = found.name;
emit('change', account.id, account.name);
if (id) {
currentId.value = id;
}
}
@@ -65,7 +121,7 @@ onMounted(() => {
<template>
<el-select
v-model="account.id"
v-model="currentId"
placeholder="请选择公众号"
class="!w-240px"
@change="onChanged"

View File

@@ -13,9 +13,9 @@
<script lang="ts" setup>
import { ref } from 'vue';
// import { VideoPlayer } from '@videojs-player/vue';
import { VideoPlayer } from '@videojs-player/vue';
// import 'video.js/dist/video-js.css';
import 'video.js/dist/video-js.css';
defineOptions({ name: 'WxVideoPlayer' });
@@ -52,7 +52,6 @@ const playVideo = () => {
class="video-player vjs-big-play-centered"
:src="props.url"
poster=""
crossorigin="anonymous"
controls
playsinline
:volume="0.6"