Merge pull request !284 from dylanmay/dev
This commit is contained in:
芋道源码
2025-11-29 03:34:46 +00:00
committed by Gitee
23 changed files with 1623 additions and 65 deletions

View File

@@ -43,7 +43,6 @@ const queryParams = reactive({
pageSize: 10,
}); // 查询参数
// TODO @dylan可以把【点击上传】3 个 tab 的按钮,放到右侧的 toolbar 一起,和刷新按钮放在一行;
const voiceGridColumns: VxeTableGridOptions<MpMaterialApi.Material>['columns'] =
[
{

View File

@@ -10,12 +10,12 @@ import { Button, Card, Form, message, Pagination, Tabs } from 'ant-design-vue';
import { deletePermanentMaterial, getMaterialPage } from '#/api/mp/material';
import { WxAccountSelect } from '#/views/mp/components';
import ImageTable from './components/image-table.vue';
import { UploadType } from './components/upload';
import UploadFile from './components/UploadFile.vue';
import UploadVideo from './components/UploadVideo.vue';
import VideoTable from './components/video-table.vue';
import VoiceTable from './components/voice-table.vue';
import ImageTable from './modules/image-table.vue';
import { UploadType } from './modules/upload';
import UploadFile from './modules/UploadFile.vue';
import UploadVideo from './modules/UploadVideo.vue';
import VideoTable from './modules/video-table.vue';
import VoiceTable from './modules/voice-table.vue';
defineOptions({ name: 'MpMaterial' });
@@ -117,19 +117,16 @@ async function handleDelete(id: number) {
图片
</span>
</template>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Image"
@uploaded="getList"
>
支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M
</UploadFile>
<!-- 列表 -->
<ImageTable
:list="list"
:loading="loading"
@delete="handleDelete"
/>
<ImageTable :list="list" :loading="loading" @delete="handleDelete">
<template #toolbar-tools>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Image"
@uploaded="getList"
/>
</template>
</ImageTable>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
@@ -151,19 +148,16 @@ async function handleDelete(id: number) {
语音
</span>
</template>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Voice"
@uploaded="getList"
>
格式支持 mp3/wma/wav/amr文件大小不超过 2M播放长度不超过 60s
</UploadFile>
<!-- 列表 -->
<VoiceTable
:list="list"
:loading="loading"
@delete="handleDelete"
/>
<VoiceTable :list="list" :loading="loading" @delete="handleDelete">
<template #toolbar-tools>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Voice"
@uploaded="getList"
/>
</template>
</VoiceTable>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
@@ -185,21 +179,20 @@ async function handleDelete(id: number) {
视频
</span>
</template>
<Button
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
type="primary"
@click="showCreateVideo = true"
>
新建视频
</Button>
<!-- 列表 -->
<VideoTable :list="list" :loading="loading" @delete="handleDelete">
<template #toolbar-tools>
<Button
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
type="primary"
@click="showCreateVideo = true"
>
新建视频
</Button>
</template>
</VideoTable>
<!-- 新建视频的弹窗 -->
<UploadVideo v-model:open="showCreateVideo" @uploaded="getList" />
<!-- 列表 -->
<VideoTable
:list="list"
:loading="loading"
@delete="handleDelete"
/>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination

View File

@@ -86,7 +86,6 @@ const customRequest: UploadProps['customRequest'] = async function (options) {
:file-list="fileList"
:headers="HEADERS"
:multiple="true"
class="mb-4"
>
<Button type="primary">
<IconifyIcon icon="lucide:upload" class="mr-1" />

View File

@@ -1,7 +1,6 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpMaterialApi } from '#/api/mp/material';
// TODO @dylan看看 ele 要迁移一个么?
/** 视频表格列配置 */
export function useVideoGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
return [

View File

@@ -0,0 +1,106 @@
<script lang="ts" setup>
import type { MpMaterialApi } from '#/api/mp/material';
import { nextTick, onMounted, watch } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { useImageGridColumns } from './data';
const props = defineProps<{
list: MpMaterialApi.Material[];
loading: boolean;
}>();
const emit = defineEmits<{
delete: [v: number];
}>();
const columns = useImageGridColumns();
const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
gridOptions: {
border: true,
columns,
keepSource: true,
pagerConfig: {
enabled: false,
},
rowConfig: {
keyField: 'id',
isHover: true,
height: 220,
},
toolbarConfig: {
refresh: true,
},
showOverflow: 'tooltip',
},
});
function updateGridData(data: MpMaterialApi.Material[]) {
if (gridApi.grid?.loadData) {
gridApi.grid.loadData(data);
} else {
gridApi.setGridOptions({ data });
}
}
watch(
() => props.list,
async (list: MpMaterialApi.Material[]) => {
const data = Array.isArray(list) ? list : [];
await nextTick();
updateGridData(data);
},
{ flush: 'post' },
);
watch(
() => props.loading,
(loading: boolean) => {
gridApi.setLoading(loading);
},
);
/** 初始化 */
onMounted(async () => {
await nextTick();
updateGridData(Array.isArray(props.list) ? props.list : []);
gridApi.setLoading(props.loading);
});
</script>
<template>
<Grid class="image-table-grid mt-4 pb-0">
<template #toolbar-tools>
<slot name="toolbar-tools"></slot>
</template>
<template #image="{ row }">
<div class="flex items-center justify-center" style="height: 192px">
<img
:src="row.url"
class="object-contain"
style="display: block; max-width: 100%; max-height: 192px"
/>
</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['mp:material:delete'],
popConfirm: {
title: '确定要删除该图片吗?',
confirm: () => emit('delete', row.id!),
},
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpMaterialApi } from '#/api/mp/material';
import { watch } from 'vue';
import { openWindow } from '@vben/utils';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { WxVideoPlayer } from '#/views/mp/components';
import { useVideoGridColumns } from './data';
const props = defineProps<{
list: MpMaterialApi.Material[];
loading: boolean;
}>();
const emit = defineEmits<{
delete: [v: number];
}>();
const columns = useVideoGridColumns();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
border: true,
columns,
keepSource: true,
pagerConfig: {
enabled: false,
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
showOverflow: 'tooltip',
} as VxeTableGridOptions<MpMaterialApi.Material>,
});
watch(
() => props.list,
(list: MpMaterialApi.Material[]) => {
const data = Array.isArray(list) ? list : [];
if (gridApi.grid?.loadData) {
gridApi.grid.loadData(data);
} else {
gridApi.setGridOptions({ data });
}
},
{ immediate: true },
);
watch(
() => props.loading,
(loading: boolean) => {
gridApi.setLoading(loading);
},
{ immediate: true },
);
</script>
<template>
<Grid class="mt-4">
<template #toolbar-tools>
<slot name="toolbar-tools"></slot>
</template>
<template #video="{ row }">
<WxVideoPlayer v-if="row.url" :url="row.url" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '下载',
type: 'link',
icon: ACTION_ICON.DOWNLOAD,
onClick: () => openWindow(row.url),
},
{
label: '删除',
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['mp:material:delete'],
popConfirm: {
title: '确定要删除该视频吗?',
confirm: () => emit('delete', row.id!),
},
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -10,7 +10,6 @@ import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { WxVoicePlayer } from '#/views/mp/components';
import { useVoiceGridColumns } from './data';
// TODO @dylan modules components
const props = defineProps<{
list: MpMaterialApi.Material[];
@@ -35,6 +34,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
showOverflow: 'tooltip',
} as VxeTableGridOptions<MpMaterialApi.Material>,
});
@@ -63,6 +65,9 @@ watch(
<template>
<Grid class="mt-4">
<template #toolbar-tools>
<slot name="toolbar-tools"></slot>
</template>
<template #voice="{ row }">
<WxVoicePlayer v-if="row.url" :url="row.url" />
</template>

View File

@@ -9,7 +9,6 @@ import { formatDate2 } from '@vben/utils';
import { Button, Image, Tag } from 'ant-design-vue';
// TODO @dylan看看 ele 要迁移一个么?
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
WxLocation,

View File

@@ -1,36 +1,41 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
import { MessageType } from '@vben/constants';
// TODO @dylan这个类的代码应该和对应的 antd 是一致的。调整下~看看相关的 vue 是不是也要调整掉。
/** 消息类型枚举 */
export enum MessageType {
IMAGE = 'image', // 图片消息
MPNEWS = 'mpnews', // 公众号图文消息
MUSIC = 'music', // 音乐消息
NEWS = 'news', // 图文消息
TEXT = 'text', // 文本消息
VIDEO = 'video', // 视频消息
VOICE = 'voice', // 语音消息
WXCARD = 'wxcard', // 卡券消息
}
import { requestClient } from '#/api/request';
export namespace MpMessageApi {
/** 消息信息 */
export interface Message {
id?: number;
accountId: number;
type: MessageType;
type: MessageType | string;
openid: string;
content: string;
mediaId?: string;
status: number;
remark?: string;
createTime?: Date;
sendFrom?: number;
userId?: number;
event?: string;
eventKey?: string;
mediaUrl?: string;
recognition?: string;
url?: string;
title?: string;
label?: string;
locationX?: number;
locationY?: number;
thumbMediaUrl?: string;
musicUrl?: string;
hqMusicUrl?: string;
description?: string;
articles?: any[];
}
/** 发送消息请求 */
export interface SendMessageRequest {
export interface MessageSendRequestVO {
accountId: number;
openid: string;
type: MessageType;
@@ -50,6 +55,6 @@ export function getMessagePage(params: PageParam) {
}
/** 发送消息 */
export function sendMessage(data: MpMessageApi.SendMessageRequest) {
export function sendMessage(data: MpMessageApi.MessageSendRequestVO) {
return requestClient.post('/mp/message/send', data);
}

View File

@@ -1,7 +1,7 @@
export { default as WxAccountSelect } from './wx-account-select/wx-account-select.vue';
export { default as WxLocation } from './wx-location/wx-location.vue';
export { default as WxMaterialSelect } from './wx-material-select/wx-material-select.vue';
export { default as WxMsg } from './wx-msg/msg.vue'; // TODO @hw、@dylan貌似和 antd 不同。antd 这里是 export { default as WxMsg } from './wx-msg/wx-msg.vue'; 看看哪个是对的
export { default as WxMsg } from './wx-msg/wx-msg.vue';
export { default as WxMusic } from './wx-music/wx-music.vue';
export { default as WxNews } from './wx-news/wx-news.vue';
export { default as WxReply } from './wx-reply/wx-reply.vue';

View File

@@ -0,0 +1,77 @@
import { ElMessage } from 'element-plus';
import { $t } from '#/locales';
export enum UploadType {
Image = 'image',
Video = 'video',
Voice = 'voice',
}
interface UploadTypeConfig {
allowTypes: string[];
maxSizeMB: number;
i18nKey: string;
}
export interface UploadRawFile {
name: string;
size: number;
type: string;
}
const UPLOAD_CONFIGS: Record<UploadType, UploadTypeConfig> = {
[UploadType.Image]: {
allowTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/bmp',
'image/jpg',
],
maxSizeMB: 2,
i18nKey: 'mp.upload.image',
},
[UploadType.Video]: {
allowTypes: ['video/mp4'],
maxSizeMB: 10,
i18nKey: 'mp.upload.video',
},
[UploadType.Voice]: {
allowTypes: [
'audio/mp3',
'audio/mpeg',
'audio/wma',
'audio/wav',
'audio/amr',
],
maxSizeMB: 2,
i18nKey: 'mp.upload.voice',
},
};
export const useBeforeUpload = (type: UploadType, maxSizeMB?: number) => {
const fn = (rawFile: UploadRawFile): boolean => {
const config = UPLOAD_CONFIGS[type];
const finalMaxSize = maxSizeMB ?? config.maxSizeMB;
// 格式不正确
if (!config.allowTypes.includes(rawFile.type)) {
const typeName = $t(config.i18nKey);
ElMessage.error($t('mp.upload.invalidFormat', [typeName]));
return false;
}
// 大小不正确
if (rawFile.size / 1024 / 1024 > finalMaxSize) {
const typeName = $t(config.i18nKey);
ElMessage.error($t('mp.upload.maxSize', [typeName, finalMaxSize]));
return false;
}
return true;
};
return fn;
};

View File

@@ -0,0 +1,238 @@
<script lang="ts" setup>
import { provide, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { confirm, DocAlert, Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
ElButton,
ElCard,
ElForm,
ElFormItem,
ElLoading,
ElMessage,
ElPagination,
ElTabPane,
ElTabs,
} from 'element-plus';
import { deletePermanentMaterial, getMaterialPage } from '#/api/mp/material';
import { WxAccountSelect } from '#/views/mp/components';
import ImageTable from './modules/image-table.vue';
import { UploadType } from './modules/upload';
import UploadFile from './modules/UploadFile.vue';
import UploadVideo from './modules/UploadVideo.vue';
import VideoTable from './modules/video-table.vue';
import VoiceTable from './modules/voice-table.vue';
defineOptions({ name: 'MpMaterial' });
const { hasAccessByCodes } = useAccess();
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({
accountId,
pageNo: 1,
pageSize: 10,
permanent: true,
}); // 查询参数
const showCreateVideo = ref(false); // 是否新建视频的弹窗
/** 侦听公众号变化 */
function onAccountChanged(id: number) {
accountId.value = id;
queryParams.accountId = id;
queryParams.pageNo = 1;
getList();
}
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const data = await getMaterialPage({
...queryParams,
type: type.value,
});
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 处理 tab 切换 */
function onTabChange() {
// 提前清空数据,避免 tab 切换后显示垃圾数据
list.value = [];
total.value = 0;
// 从第一页开始查询
handleQuery();
}
/** 处理删除操作 */
async function handleDelete(id: number) {
await confirm('此操作将永久删除该文件, 是否继续?');
const loadingInstance = ElLoading.service({
text: '正在删除...',
lock: true,
});
try {
await deletePermanentMaterial(id);
ElMessage.success('删除成功');
await getList();
} finally {
loadingInstance.close();
}
}
/** 分页改变事件 */
function handlePageChange(page: number) {
queryParams.pageNo = page;
getList();
}
/** 每页条数改变事件 */
function handleSizeChange(pageSize: number) {
queryParams.pageSize = pageSize;
queryParams.pageNo = 1;
getList();
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" />
</template>
<div class="h-full">
<!-- 搜索工作栏 -->
<ElCard class="h-[10%]" shadow="never">
<ElForm :model="queryParams" :inline="true">
<ElFormItem label="公众号" class="w-52">
<WxAccountSelect @change="onAccountChanged" />
</ElFormItem>
</ElForm>
</ElCard>
<ElCard shadow="never" class="mt-4 h-auto">
<ElTabs v-model="type" @tab-change="onTabChange">
<!-- tab 1图片 -->
<ElTabPane :name="UploadType.Image">
<template #label>
<span class="flex items-center">
<IconifyIcon icon="lucide:image" class="mr-1" />
图片
</span>
</template>
<!-- 列表 -->
<ImageTable :list="list" :loading="loading" @delete="handleDelete">
<template #toolbar-tools>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Image"
@uploaded="getList"
/>
</template>
</ImageTable>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<ElPagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElTabPane>
<!-- tab 2语音 -->
<ElTabPane :name="UploadType.Voice">
<template #label>
<span class="flex items-center">
<IconifyIcon icon="lucide:mic" class="mr-1" />
语音
</span>
</template>
<!-- 列表 -->
<VoiceTable :list="list" :loading="loading" @delete="handleDelete">
<template #toolbar-tools>
<UploadFile
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
:type="UploadType.Voice"
@uploaded="getList"
/>
</template>
</VoiceTable>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<ElPagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElTabPane>
<!-- tab 3视频 -->
<ElTabPane :name="UploadType.Video">
<template #label>
<span class="flex items-center">
<IconifyIcon icon="lucide:video" class="mr-1" />
视频
</span>
</template>
<!-- 列表 -->
<VideoTable :list="list" :loading="loading" @delete="handleDelete">
<template #toolbar-tools>
<ElButton
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
type="primary"
@click="showCreateVideo = true"
>
新建视频
</ElButton>
</template>
</VideoTable>
<!-- 新建视频的弹窗 -->
<UploadVideo v-model="showCreateVideo" @uploaded="getList" />
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<ElPagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElTabPane>
</ElTabs>
</ElCard>
</div>
</Page>
</template>

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
import type { UploadProps, UploadRawFile } from 'element-plus';
import type { UploadData } from './upload';
import { inject, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { ElButton, ElMessage, ElUpload } from 'element-plus';
import {
beforeImageUpload,
beforeVoiceUpload,
HEADERS,
UPLOAD_URL,
UploadType,
} from './upload';
const props = defineProps<{ type: UploadType }>();
const emit = defineEmits<{
uploaded: [v: void];
}>();
const accountId = inject<number>('accountId');
const fileList = ref<any[]>([]);
const uploadData: UploadData = reactive({
accountId: accountId!,
introduction: '',
title: '',
type: props.type,
});
/** 上传前检查 */
const onBeforeUpload =
props.type === UploadType.Image ? beforeImageUpload : beforeVoiceUpload;
/** 自定义上传 */
const customRequest: UploadProps['httpRequest'] = async function (options) {
const { file, onError, onSuccess } = options;
const formData = new FormData();
formData.append('file', file as File);
formData.append('type', uploadData.type);
formData.append('title', uploadData.title);
formData.append('introduction', uploadData.introduction);
formData.append('accountId', String(uploadData.accountId));
try {
const response = await fetch(UPLOAD_URL, {
body: formData,
headers: HEADERS,
method: 'POST',
});
const res = await response.json();
if (res.code !== 0) {
ElMessage.error(`上传出错:${res.msg}`);
onError?.(new Error(res.msg));
return;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
ElMessage.success('上传成功');
onSuccess?.(res);
emit('uploaded');
} catch (error: any) {
ElMessage.error(`上传失败: ${error.message}`);
onError?.(error);
}
};
</script>
<template>
<ElUpload
:action="UPLOAD_URL"
:before-upload="onBeforeUpload"
:http-request="customRequest"
:file-list="fileList"
:headers="HEADERS"
:multiple="true"
>
<ElButton type="primary">
<IconifyIcon icon="lucide:upload" class="mr-1" />
点击上传
</ElButton>
<template #file="{ file }">
<div class="flex items-center">
<span>{{ file.name }}</span>
<ElButton type="primary" link size="small" @click="fileList = []">
删除
</ElButton>
</div>
</template>
</ElUpload>
<div v-if="$slots.default" class="ml-1 text-sm text-gray-500">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,166 @@
<script lang="ts" setup>
import type { FormInstance, UploadProps } from 'element-plus';
import type { UploadData } from './upload';
import { inject, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
ElButton,
ElDialog,
ElDivider,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElUpload,
} from 'element-plus';
import { beforeVideoUpload, HEADERS, UPLOAD_URL, UploadType } from './upload';
withDefaults(
defineProps<{
modelValue?: boolean;
}>(),
{
modelValue: false,
},
);
const emit = defineEmits<{
'update:modelValue': [v: boolean];
uploaded: [v: void];
}>();
const accountId = inject<number>('accountId');
const uploadRules = {
introduction: [
{ message: '请输入描述', required: true, trigger: 'blur' } as const,
],
title: [{ message: '请输入标题', required: true, trigger: 'blur' } as const],
};
function handleCancel() {
emit('update:modelValue', false);
}
const fileList = ref<any[]>([]);
const uploadData: UploadData = reactive({
accountId: accountId!,
introduction: '',
title: '',
type: UploadType.Video,
});
const uploadFormRef = ref<FormInstance | null>(null);
const uploadVideoRef = ref<any>(null);
async function submitVideo() {
await uploadFormRef.value?.validate();
uploadVideoRef.value?.submit();
}
/** 自定义上传 */
const customRequest: UploadProps['httpRequest'] = async function (options) {
const { file, onError, onSuccess } = options;
const formData = new FormData();
formData.append('file', file as File);
formData.append('type', uploadData.type);
formData.append('title', uploadData.title);
formData.append('introduction', uploadData.introduction);
formData.append('accountId', String(uploadData.accountId));
try {
const response = await fetch(UPLOAD_URL, {
body: formData,
headers: HEADERS,
method: 'POST',
});
const res = await response.json();
if (res.code !== 0) {
ElMessage.error(`上传出错:${res.msg}`);
onError?.(new Error(res.msg));
return;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
emit('update:modelValue', false);
ElMessage.success('上传成功');
onSuccess?.(res);
emit('uploaded');
} catch (error: any) {
ElMessage.error(`上传失败: ${error.message}`);
onError?.(error);
}
};
</script>
<template>
<ElDialog
:model-value="modelValue"
title="新建视频"
width="600px"
@close="handleCancel"
>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="submitVideo">确定</ElButton>
</template>
<ElUpload
ref="uploadVideoRef"
:action="UPLOAD_URL"
:auto-upload="false"
:before-upload="beforeVideoUpload"
:http-request="customRequest"
:file-list="fileList"
:headers="HEADERS"
:limit="1"
:multiple="true"
class="mb-4"
>
<ElButton type="primary">
<IconifyIcon icon="lucide:video" class="mr-1" />
选择视频
</ElButton>
</ElUpload>
<div class="mb-4 ml-1 text-sm text-gray-500">
格式支持 MP4文件大小不超过 10MB
</div>
<ElDivider />
<ElForm
ref="uploadFormRef"
:model="uploadData"
:rules="uploadRules"
label-position="top"
>
<ElFormItem label="标题" prop="title">
<ElInput
v-model="uploadData.title"
placeholder="标题将展示在相关播放页面,建议填写清晰、准确、生动的标题"
/>
</ElFormItem>
<ElFormItem label="描述" prop="introduction">
<ElInput
v-model="uploadData.introduction"
type="textarea"
:rows="3"
placeholder="介绍语将展示在相关播放页面,建议填写简洁明确、有信息量的内容"
/>
</ElFormItem>
</ElForm>
</ElDialog>
</template>

View File

@@ -0,0 +1,133 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpMaterialApi } from '#/api/mp/material';
/** 视频表格列配置 */
export function useVideoGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
return [
{
field: 'mediaId',
title: '编号',
align: 'center',
width: 160,
},
{
field: 'name',
title: '文件名',
align: 'center',
minWidth: 100,
},
{
field: 'title',
title: '标题',
align: 'center',
minWidth: 200,
},
{
field: 'introduction',
title: '介绍',
align: 'center',
minWidth: 220,
},
{
field: 'video',
title: '视频',
align: 'center',
width: 220,
slots: { default: 'video' },
},
{
field: 'createTime',
title: '上传时间',
align: 'center',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'actions',
title: '操作',
align: 'center',
fixed: 'right',
width: 180,
slots: { default: 'actions' },
},
];
}
/** 语音表格列配置 */
export function useVoiceGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
return [
{
field: 'mediaId',
title: '编号',
align: 'center',
width: 160,
},
{
field: 'name',
title: '文件名',
align: 'center',
minWidth: 100,
},
{
field: 'voice',
title: '语音',
align: 'center',
width: 220,
slots: { default: 'voice' },
},
{
field: 'createTime',
title: '上传时间',
align: 'center',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'actions',
title: '操作',
align: 'center',
fixed: 'right',
width: 160,
slots: { default: 'actions' },
},
];
}
/** 图片表格列配置 */
export function useImageGridColumns(): VxeTableGridOptions<MpMaterialApi.Material>['columns'] {
return [
{
field: 'mediaId',
title: '编号',
align: 'center',
width: 400,
},
{
field: 'name',
title: '文件名',
align: 'center',
width: 200,
},
{
field: 'url',
title: '图片',
align: 'center',
width: 200,
slots: { default: 'image' },
},
{
field: 'createTime',
title: '上传时间',
align: 'center',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'actions',
title: '操作',
align: 'center',
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -31,6 +31,9 @@ const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
isHover: true,
height: 220,
},
toolbarConfig: {
refresh: true,
},
showOverflow: 'tooltip',
},
});
@@ -70,6 +73,9 @@ onMounted(async () => {
<template>
<Grid class="image-table-grid mt-4 pb-0">
<template #toolbar-tools>
<slot name="toolbar-tools" />
</template>
<template #image="{ row }">
<div class="flex items-center justify-center" style="height: 192px">
<img
@@ -98,3 +104,4 @@ onMounted(async () => {
</template>
</Grid>
</template>

View File

@@ -0,0 +1,47 @@
import type { UploadProps } from 'element-plus';
import type { UploadRawFile } from '#/views/mp/hooks/useUpload';
import { useAccessStore } from '@vben/stores';
import { UploadType, useBeforeUpload } from '#/views/mp/hooks/useUpload';
const accessStore = useAccessStore();
const HEADERS = { Authorization: `Bearer ${accessStore.accessToken}` }; // 请求头
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-permanent`; // 上传地址
interface UploadData {
accountId: number;
introduction: string;
title: string;
type: UploadType;
}
const beforeImageUpload: UploadProps['beforeUpload'] = function (
rawFile: UploadRawFile,
) {
return useBeforeUpload(UploadType.Image, 2)(rawFile);
};
const beforeVoiceUpload: UploadProps['beforeUpload'] = function (
rawFile: UploadRawFile,
) {
return useBeforeUpload(UploadType.Voice, 2)(rawFile);
};
const beforeVideoUpload: UploadProps['beforeUpload'] = function (
rawFile: UploadRawFile,
) {
return useBeforeUpload(UploadType.Video, 10)(rawFile);
};
export {
beforeImageUpload,
beforeVideoUpload,
beforeVoiceUpload,
HEADERS,
UPLOAD_URL,
type UploadData,
UploadType,
};

View File

@@ -34,6 +34,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
showOverflow: 'tooltip',
} as VxeTableGridOptions<MpMaterialApi.Material>,
});
@@ -62,6 +65,9 @@ watch(
<template>
<Grid class="mt-4">
<template #toolbar-tools>
<slot name="toolbar-tools" />
</template>
<template #video="{ row }">
<WxVideoPlayer v-if="row.url" :url="row.url" />
</template>
@@ -90,3 +96,4 @@ watch(
</template>
</Grid>
</template>

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpMaterialApi } from '#/api/mp/material';
import { watch } from 'vue';
import { openWindow } from '@vben/utils';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { WxVoicePlayer } from '#/views/mp/components';
import { useVoiceGridColumns } from './data';
const props = defineProps<{
list: MpMaterialApi.Material[];
loading: boolean;
}>();
const emit = defineEmits<{
delete: [v: number];
}>();
const columns = useVoiceGridColumns();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
border: true,
columns,
keepSource: true,
pagerConfig: {
enabled: false,
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
showOverflow: 'tooltip',
} as VxeTableGridOptions<MpMaterialApi.Material>,
});
watch(
() => props.list,
(list: MpMaterialApi.Material[]) => {
const data = Array.isArray(list) ? list : [];
if (gridApi.grid?.loadData) {
gridApi.grid.loadData(data);
} else {
gridApi.setGridOptions({ data });
}
},
{ immediate: true },
);
watch(
() => props.loading,
(loading: boolean) => {
gridApi.setLoading(loading);
},
{ immediate: true },
);
</script>
<template>
<Grid class="mt-4">
<template #toolbar-tools>
<slot name="toolbar-tools"></slot>
</template>
<template #voice="{ row }">
<WxVoicePlayer v-if="row.url" :url="row.url" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '下载',
type: 'link',
icon: ACTION_ICON.DOWNLOAD,
onClick: () => openWindow(row.url),
},
{
label: '删除',
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['mp:material:delete'],
popConfirm: {
title: '确定要删除该语音吗?',
confirm: () => emit('delete', row.id!),
},
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,211 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { DICT_TYPE, MpMsgType } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
ElButton,
ElDatePicker,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElPagination,
ElSelect,
} from 'element-plus';
import { getMessagePage } from '#/api/mp/message';
import { WxAccountSelect, WxMsg } from '#/views/mp/components';
import MessageTable from './message-table.vue';
defineOptions({ name: 'MpMessage' });
const loading = ref(false);
const total = ref(0); // 数据的总页数
const list = ref<any[]>([]); // 当前页的列表数据
const queryParams = reactive<{
accountId: number;
createTime: [Dayjs, Dayjs] | undefined;
openid: string;
pageNo: number;
pageSize: number;
type: string;
}>({
accountId: -1,
createTime: undefined,
openid: '',
pageNo: 1,
pageSize: 10,
type: MpMsgType.Text,
}); // 搜索参数
const queryFormRef = ref(); // 搜索的表单
// 消息对话框
const messageBoxVisible = ref(false);
const messageBoxUserId = ref(0);
/** 侦听 accountId */
function onAccountChanged(id: number) {
queryParams.accountId = id;
queryParams.pageNo = 1;
handleQuery();
}
/** 查询列表 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
async function getList() {
try {
loading.value = true;
const data = await getMessagePage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 重置按钮操作 */
async function resetQuery() {
// 暂存 accountId并在 reset 后恢复
const accountId = queryParams.accountId;
queryFormRef.value?.resetFields();
queryParams.accountId = accountId;
handleQuery();
}
/** 打开消息发送窗口 */
async function handleSend(userId: number) {
messageBoxUserId.value = userId;
messageBoxVisible.value = true;
}
/** 分页改变事件 */
function handlePageChange(page: number) {
queryParams.pageNo = page;
getList();
}
/** 每页条数改变事件 */
function handleSizeChange(pageSize: number) {
queryParams.pageSize = pageSize;
queryParams.pageNo = 1;
getList();
}
/** 显示总条数 */
function showTotal(total: number) {
return `${total}`;
}
</script>
<template>
<Page auto-content-height class="flex flex-col">
<!-- 搜索工作栏 -->
<div class="bg-background mb-4 rounded-lg p-4">
<ElForm
ref="queryFormRef"
:model="queryParams"
:inline="true"
class="search-form"
>
<ElFormItem label="公众号" prop="accountId">
<WxAccountSelect @change="onAccountChanged" />
</ElFormItem>
<ElFormItem label="消息类型" prop="type">
<ElSelect
v-model="queryParams.type"
placeholder="请选择消息类型"
class="!w-[240px]"
>
<ElOption
v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)"
:key="dict.value"
:value="dict.value"
:label="dict.label"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="用户标识" prop="openid">
<ElInput
v-model="queryParams.openid"
placeholder="请输入用户标识"
clearable
class="!w-[240px]"
/>
</ElFormItem>
<ElFormItem label="创建时间" prop="createTime">
<ElDatePicker
v-model="queryParams.createTime"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
class="!w-[240px]"
/>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="mdi:magnify" />
</template>
搜索
</ElButton>
<ElButton class="ml-2" @click="resetQuery">
<template #icon>
<IconifyIcon icon="mdi:refresh" />
</template>
重置
</ElButton>
</ElFormItem>
</ElForm>
</div>
<!-- 列表 -->
<div class="bg-background flex-1 rounded-lg p-4">
<MessageTable :list="list" :loading="loading" @send="handleSend" />
<div v-show="total > 0" class="mt-4 flex justify-end">
<ElPagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:show-total="showTotal"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 发送消息的弹窗 -->
<ElDialog
v-model="messageBoxVisible"
title="粉丝消息列表"
width="800"
:close-on-click-modal="false"
destroy-on-close
>
<WxMsg :user-id="messageBoxUserId" />
</ElDialog>
</Page>
</template>
<style scoped>
.search-form :deep(.el-form-item) {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,263 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MpMessageApi } from '#/api/mp/message';
import { onMounted, watch } from 'vue';
import { MpMsgType as MsgType } from '@vben/constants';
import { formatDate2 } from '@vben/utils';
import { ElButton, ElImage, ElTag } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
WxLocation,
WxMusic,
WxNews,
WxVideoPlayer,
WxVoicePlayer,
} from '#/views/mp/components';
const props = withDefaults(
defineProps<{
list?: MpMessageApi.Message[];
loading?: boolean;
}>(),
{
list() {
return [];
},
loading: false,
},
);
const emit = defineEmits<{
(e: 'send', userId: number): void;
}>();
const columns: VxeTableGridOptions<MpMessageApi.Message>['columns'] = [
{
field: 'createTime',
title: '发送时间',
width: 180,
align: 'center',
slots: { default: 'createTime' },
},
{
field: 'type',
title: '消息类型',
width: 80,
align: 'center',
},
{
field: 'sendFrom',
title: '发送方',
width: 80,
align: 'center',
slots: { default: 'sendFrom' },
},
{
field: 'openid',
title: '用户标识',
width: 300,
align: 'center',
},
{
field: 'content',
title: '内容',
align: 'left',
minWidth: 320,
slots: { default: 'content' },
},
{
field: 'actions',
title: '操作',
width: 120,
align: 'center',
fixed: 'right',
slots: { default: 'actions' },
},
];
const [Grid, gridApi] = useVbenVxeGrid<MpMessageApi.Message>({
gridOptions: {
border: true,
columns,
keepSource: true,
pagerConfig: {
enabled: false,
},
rowConfig: {
keyField: 'id',
isHover: true,
},
showOverflow: 'tooltip',
},
});
function normalizeList(list?: MpMessageApi.Message[]) {
return Array.isArray(list) ? list : [];
}
function updateGridData(data: MpMessageApi.Message[]) {
if (gridApi.grid?.loadData) {
gridApi.grid.loadData(data);
} else {
gridApi.setGridOptions({ data });
}
}
watch(
() => props.list,
(list) => {
updateGridData(normalizeList(list));
},
{ flush: 'post' },
);
watch(
() => props.loading,
(loading) => {
gridApi.setLoading(loading);
},
);
/** 初始化 */
onMounted(() => {
updateGridData(normalizeList(props.list));
gridApi.setLoading(props.loading);
});
</script>
<template>
<Grid>
<template #createTime="{ row }">
{{ row.createTime ? formatDate2(row.createTime) : '' }}
</template>
<template #sendFrom="{ row }">
<ElTag v-if="row.sendFrom === 1" type="success">粉丝</ElTag>
<ElTag v-else>公众号</ElTag>
</template>
<template #content="{ row }">
<div
v-if="
(row.type as string) === (MsgType.Event as string) &&
(row.event as string) === 'subscribe'
"
>
<ElTag type="success">关注</ElTag>
</div>
<div
v-else-if="
(row.type as string) === (MsgType.Event as string) &&
(row.event as string) === 'unsubscribe'
"
>
<ElTag type="danger">取消关注</ElTag>
</div>
<div
v-else-if="
(row.type as string) === (MsgType.Event as string) &&
(row.event as string) === 'CLICK'
"
>
<ElTag>点击菜单</ElTag>
【{{ row.eventKey }}】
</div>
<div v-else-if="row.type === MsgType.Event && row.event === 'VIEW'">
<ElTag>点击菜单链接</ElTag>
【{{ row.eventKey }}】
</div>
<div
v-else-if="
row.type === MsgType.Event && row.event === 'scancode_waitmsg'
"
>
<ElTag>扫码结果</ElTag>
【{{ row.eventKey }}】
</div>
<div
v-else-if="row.type === MsgType.Event && row.event === 'scancode_push'"
>
<ElTag>扫码结果</ElTag>
【{{ row.eventKey }}】
</div>
<div
v-else-if="row.type === MsgType.Event && row.event === 'pic_sysphoto'"
>
<ElTag>系统拍照发图</ElTag>
</div>
<div
v-else-if="
row.type === MsgType.Event && row.event === 'pic_photo_or_album'
"
>
<ElTag>拍照或者相册</ElTag>
</div>
<div v-else-if="row.type === MsgType.Event && row.event === 'pic_weixin'">
<ElTag>微信相册</ElTag>
</div>
<div
v-else-if="
row.type === MsgType.Event && row.event === 'location_select'
"
>
<ElTag>选择地理位置</ElTag>
</div>
<div v-else-if="row.type === MsgType.Event">
<ElTag type="danger">未知事件类型</ElTag>
</div>
<div v-else-if="row.type === MsgType.Text">{{ row.content }}</div>
<div v-else-if="row.type === MsgType.Voice">
<WxVoicePlayer
:url="row.mediaUrl || ''"
:content="row.recognition || ''"
/>
</div>
<div v-else-if="row.type === MsgType.Image">
<a :href="row.mediaUrl" target="_blank">
<ElImage :src="row.mediaUrl" :width="100" :preview-src-list="[]" />
</a>
</div>
<div v-else-if="row.type === MsgType.Video || row.type === 'shortvideo'">
<WxVideoPlayer :url="row.mediaUrl || ''" class="mt-2" />
</div>
<div v-else-if="row.type === MsgType.Link">
<ElTag>链接</ElTag>
<a :href="row.url" target="_blank">{{ row.title }}</a>
</div>
<div v-else-if="row.type === MsgType.Location">
<WxLocation
:label="row.label || ''"
:location-y="row.locationY || 0"
:location-x="row.locationX || 0"
/>
</div>
<div v-else-if="row.type === MsgType.Music">
<WxMusic
:title="row.title"
:description="row.description"
:thumb-media-url="row.thumbMediaUrl || ''"
:music-url="row.musicUrl"
:hq-music-url="row.hqMusicUrl"
/>
</div>
<div v-else-if="row.type === MsgType.News">
<WxNews :articles="row.articles" />
</div>
<div v-else>
<ElTag type="danger">未知消息类型</ElTag>
</div>
</template>
<template #actions="{ row }">
<ElButton type="primary" link @click="emit('send', row.userId || 0)">
消息
</ElButton>
</template>
</Grid>
</template>