fix: ele统一风格

This commit is contained in:
hw
2025-11-13 18:35:10 +08:00
parent 7733d0a7f4
commit cdae277868
82 changed files with 823 additions and 1159 deletions

View File

@@ -0,0 +1,20 @@
// 统一导出所有模块组件
export { default as Location } from './location/location.vue';
export { default as MaterialSelect } from './material-select/material-select.vue';
export * from './material-select/types';
export * from './msg/types';
export { default as Music } from './music/music.vue';
export { default as News } from './news/news.vue';
export { default as ReplySelect } from './reply/reply.vue';
export * from './reply/types';
export { default as VideoPlayer } from './video-play/video-play.vue';
export { default as VoicePlayer } from './voice-play/voice-play.vue';

View File

@@ -0,0 +1,65 @@
<!--
微信消息 - 定位TODO @Dhb52 目前未启用TODO @芋艿需要测试下
-->
<script lang="ts" setup>
import { IconifyIcon } from '@vben/icons';
import { Col, Row } from 'ant-design-vue';
defineOptions({ name: 'Location' });
const props = defineProps({
locationX: {
required: true,
type: Number,
},
locationY: {
required: true,
type: Number,
},
label: {
// 地名
required: true,
type: String,
},
qqMapKey: {
// TODO @芋艿:是不是要换成全局的读取?
// 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>
<template>
<div>
<a
target="_blank"
:href="`https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=${
locationY
}&pointy=${locationX}&name=${label}&ref=yudao`"
>
<Col>
<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`"
/>
</Row>
<Row>
<IconifyIcon icon="lucide:map-pin" />
{{ label }}
</Row>
</Col>
</a>
</div>
</template>

View File

@@ -0,0 +1,324 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { computed, onMounted, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatTime } from '@vben/utils';
import { Button, Pagination, Spin } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDraftPage } from '#/api/mp/draft';
import { getFreePublishPage } from '#/api/mp/freePublish';
import { getMaterialPage } from '#/api/mp/material';
import { News, VideoPlayer, VoicePlayer } from '#/views/mp/components/index';
import { NewsType } from './types';
defineOptions({ name: 'MaterialSelect' });
const props = withDefaults(
defineProps<{
accountId: number;
newsType?: NewsType;
type: string;
}>(),
{
newsType: NewsType.Published,
},
);
const emit = defineEmits(['selectMaterial']);
const loading = ref(false); // 遮罩层
const total = ref(0); // 总条数
const list = ref<any[]>([]); // 数据列表
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: props.accountId,
}); // 查询参数
/** 选择素材 */
function selectMaterialFun(item: any) {
emit('selectMaterial', item);
}
/** 获取分页数据 */
async function getPage() {
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;
}
}
/** 获取素材分页 */
async function getMaterialPageFun() {
const data = await getMaterialPage({
...queryParams,
type: props.type,
});
list.value = data.list;
total.value = data.total;
}
/** 获取已发布图文分页 */
async function getFreePublishPageFun() {
const data = await 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;
}
/** 获取草稿图文分页 */
async function getDraftPageFun() {
const data = await 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;
}
// 音频素材表格列
const voiceColumns = computed(() => [
{ field: 'mediaId', title: '编号', align: 'center' },
{ field: 'name', title: '文件名', align: 'center' },
{
field: 'url',
title: '语音',
align: 'center',
slots: { default: 'voice' },
},
{
field: 'createTime',
title: '上传时间',
align: 'center',
width: 180,
formatter: ({ cellValue }: any) =>
formatTime(cellValue, 'YYYY-MM-DD HH:mm:ss'),
},
{
field: 'actions',
title: '操作',
align: 'center',
fixed: 'right',
slots: { default: 'actions' },
},
]);
// 视频素材表格列
const videoColumns = computed(() => [
{ field: 'mediaId', title: '编号', align: 'center' },
{ field: 'name', title: '文件名', align: 'center' },
{ field: 'title', title: '标题', align: 'center' },
{ field: 'introduction', title: '介绍', align: 'center' },
{
field: 'url',
title: '视频',
align: 'center',
slots: { default: 'video' },
},
{
field: 'createTime',
title: '上传时间',
align: 'center',
width: 180,
formatter: ({ cellValue }: any) =>
formatTime(cellValue, 'YYYY-MM-DD HH:mm:ss'),
},
{
field: 'actions',
title: '操作',
align: 'center',
fixed: 'right',
slots: { default: 'actions' },
},
]);
// 语音表格
const [VoiceGrid] = useVbenVxeGrid({
gridOptions: {
columns: voiceColumns.value,
border: true,
pagerConfig: {
enabled: true,
currentPage: 1,
pageSize: 10,
},
proxyConfig: {
ajax: {
query: async ({ page }) => {
const data = await getMaterialPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
accountId: props.accountId,
type: 'voice',
});
return data;
},
},
},
toolbarConfig: {
refresh: true,
},
} as VxeTableGridOptions<any>,
});
// 视频表格
const [VideoGrid] = useVbenVxeGrid({
gridOptions: {
columns: videoColumns.value,
border: true,
pagerConfig: {
enabled: true,
currentPage: 1,
pageSize: 10,
},
proxyConfig: {
ajax: {
query: async ({ page }) => {
const data = await getMaterialPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
accountId: props.accountId,
type: 'video',
});
return data;
},
},
},
toolbarConfig: {
refresh: true,
},
} as VxeTableGridOptions<any>,
});
// 对于 image 和 news 类型,需要手动加载数据
onMounted(() => {
if (props.type === 'image' || props.type === 'news') {
getPage();
}
});
</script>
<template>
<div class="pb-8">
<!-- 类型image -->
<div v-if="props.type === 'image'">
<Spin :spinning="loading">
<div
class="columns-1 gap-2.5 md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
>
<div
v-for="item in list"
:key="item.mediaId"
class="mb-2.5 break-inside-avoid rounded border border-gray-200 p-2.5 transition-shadow hover:shadow-md"
>
<img :src="item.url" :alt="item.name" class="w-full rounded" />
<p class="my-2 truncate text-sm">{{ item.name }}</p>
<div class="flex justify-center">
<Button type="primary" @click="selectMaterialFun(item)">
选择
<IconifyIcon icon="lucide:circle-check" />
</Button>
</div>
</div>
</div>
</Spin>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="getMaterialPageFun"
/>
</div>
</div>
<!-- 类型voice -->
<div v-else-if="props.type === 'voice'">
<VoiceGrid>
<template #voice="{ row }">
<VoicePlayer :url="row.url" />
</template>
<template #actions="{ row }">
<Button type="link" @click="selectMaterialFun(row)">
选择
<IconifyIcon icon="lucide:circle-check" />
</Button>
</template>
</VoiceGrid>
</div>
<!-- 类型video -->
<div v-else-if="props.type === 'video'">
<VideoGrid>
<template #video="{ row }">
<VideoPlayer :url="row.url" />
</template>
<template #actions="{ row }">
<Button type="link" @click="selectMaterialFun(row)">
选择
<IconifyIcon icon="lucide:circle-check" />
</Button>
</template>
</VideoGrid>
</div>
<!-- 类型news -->
<div v-else-if="props.type === 'news'">
<Spin :spinning="loading">
<div
class="columns-1 gap-2.5 md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
>
<div
v-for="item in list"
:key="item.mediaId"
class="mb-2.5 break-inside-avoid"
>
<div v-if="item.content && item.content.newsItem">
<News :articles="item.content.newsItem" />
<div class="mt-2 flex justify-center">
<Button type="primary" @click="selectMaterialFun(item)">
选择
<IconifyIcon icon="lucide:circle-check" />
</Button>
</div>
</div>
</div>
</div>
</Spin>
<!-- 分页组件 -->
<div class="mt-4 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="getMaterialPageFun"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,4 @@
export enum NewsType {
Draft = '2',
Published = '1',
}

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Tag } from 'ant-design-vue';
const props = defineProps<{
item: any;
}>();
const item = ref(props.item);
</script>
<template>
<div>
<div v-if="item.event === 'subscribe'">
<Tag color="success">关注</Tag>
</div>
<div v-else-if="item.event === 'unsubscribe'">
<Tag color="error">取消关注</Tag>
</div>
<!-- @hw看看能不能处理下 linter 报错哈 -->
<div v-else-if="item.event === 'CLICK'">
<Tag>点击菜单</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'VIEW'">
<Tag>点击菜单链接</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_waitmsg'">
<Tag>扫码结果</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_push'">
<Tag>扫码结果</Tag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'pic_sysphoto'">
<Tag>系统拍照发图</Tag>
</div>
<div v-else-if="item.event === 'pic_photo_or_album'">
<Tag>拍照或者相册</Tag>
</div>
<div v-else-if="item.event === 'pic_weixin'">
<Tag>微信相册</Tag>
</div>
<div v-else-if="item.event === 'location_select'">
<Tag>选择地理位置</Tag>
</div>
<div v-else-if="item.event === 'SCAN'">
<Tag>扫码</Tag>
</div>
<div v-else>
<Tag color="error">未知事件类型</Tag>
</div>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { User } from './types';
import { formatDateTime } from '@vben/utils';
import avatarWechat from '#/assets/imgs/wechat.png';
import Msg from './msg.vue';
// 确保 User 类型被识别为已使用
type PropsUser = User;
defineOptions({ name: 'MsgList' });
const props = defineProps<{
accountId: number;
list: any[];
user: PropsUser;
}>();
const SendFrom = {
MpBot: 2,
User: 1,
} as const; // 使用常量对象替代枚举,避免 linter 误报
type SendFromType = (typeof SendFrom)[keyof typeof SendFrom];
// 显式引用枚举成员供模板使用
const MpBotValue = SendFrom.MpBot;
const UserValue = SendFrom.User;
const getAvatar = (sendFrom: SendFromType) =>
sendFrom === UserValue ? props.user.avatar : avatarWechat;
const getNickname = (sendFrom: SendFromType) =>
sendFrom === UserValue ? props.user.nickname : '公众号';
</script>
<template>
<div v-for="item in props.list" :key="item.id">
<div
class="mb-[30px] flex items-start"
:class="{ 'flex-row-reverse': item.sendFrom === MpBotValue }"
>
<div class="w-20 text-center">
<img
:src="getAvatar(item.sendFrom)"
class="box-border h-12 w-12 rounded-full border border-transparent align-middle"
/>
<div class="text-sm font-bold text-[#999]">
{{ getNickname(item.sendFrom) }}
</div>
</div>
<div class="relative mx-5 flex-1 rounded-[5px] border border-[#dedede]">
<div
class="flex items-center justify-between rounded-t-[5px] border-b border-[#eee] bg-[#f8f8f8] px-[15px] py-[5px]"
>
<div class="text-sm text-[#999]">
{{ formatDateTime(item.createTime) }}
</div>
</div>
<div
class="overflow-hidden rounded-b-[5px] bg-white px-[15px] py-[15px] text-sm text-[#333]"
:style="item.sendFrom === MpBotValue ? 'background: #6BED72;' : ''"
>
<Msg :item="item" />
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
Location,
Music,
News,
VideoPlayer,
VoicePlayer,
} from '#/views/mp/components';
import MsgEvent from './msg-event.vue';
import { MsgType } from './types';
defineOptions({ name: 'Msg' });
const props = defineProps<{
item: any;
}>();
const item = ref<any>(props.item);
</script>
<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">
<VoicePlayer :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" class="w-[100px]" />
</a>
</div>
<div
v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
class="text-center"
>
<VideoPlayer :url="item.mediaUrl" />
</div>
<div v-else-if="item.type === MsgType.Link" class="flex-1">
<a target="_blank" :href="item.url">
<div
class="mb-3 text-base text-[rgba(0,0,0,0.85)] hover:text-[#1890ff]"
>
<IconifyIcon icon="lucide:link" />{{ item.title }}
</div>
</a>
<div
class="h-auto overflow-hidden text-[rgba(0,0,0,0.45)]"
style="height: unset"
>
{{ item.description }}
</div>
</div>
<div v-else-if="item.type === MsgType.Location">
<Location
:label="item.label"
:location-y="item.locationY"
:location-x="item.locationX"
/>
</div>
<div v-else-if="item.type === MsgType.News" class="w-[300px]">
<News :articles="item.articles" />
</div>
<div v-else-if="item.type === MsgType.Music">
<Music
:title="item.title"
:description="item.description"
:thumb-media-url="item.thumbMediaUrl"
:music-url="item.musicUrl"
:hq-music-url="item.hqMusicUrl"
/>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,17 @@
export enum MsgType {
Event = 'event',
Image = 'image',
Link = 'link',
Location = 'location',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
export interface User {
nickname: string;
avatar: string;
accountId: number;
}

View File

@@ -0,0 +1,63 @@
<!--
微信消息 - 音乐
-->
<script lang="ts" setup>
defineOptions({ name: 'Music' });
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,
},
});
defineExpose({
musicUrl: props.musicUrl,
});
</script>
<template>
<div>
<a
target="_blank"
:href="hqMusicUrl ? hqMusicUrl : musicUrl"
style="text-decoration: none"
>
<div class="flex rounded-[5px] bg-white p-[10px]">
<div class="mr-3 h-12 w-12 overflow-hidden rounded-full">
<img :src="thumbMediaUrl" alt="" class="h-full w-full object-cover" />
</div>
<div class="flex-1">
<div class="text-base text-[rgba(0,0,0,0.85)] hover:text-[#1890ff]">
{{ title }}
</div>
<div class="line-clamp-3 text-[rgba(0,0,0,0.45)]">
{{ description }}
</div>
</div>
</div>
</a>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,68 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
微信消息 - 图文
芋道源码
代码优化补充注释提升阅读性
-->
<script lang="ts" setup>
import { Image } from 'ant-design-vue';
defineOptions({ name: 'News' });
const props = withDefaults(
defineProps<{
articles?: any[] | null;
}>(),
{
articles: null,
},
);
defineExpose({
articles: props.articles,
});
</script>
<template>
<!-- DONE @hwtindwind 替代 -->
<div class="mx-auto w-full bg-white">
<div v-for="(article, index) in articles" :key="index">
<!-- 头条 -->
<a v-if="index === 0" :href="article.url" target="_blank">
<div class="mx-auto w-full">
<div class="relative w-full bg-[#acadae]">
<Image
:src="article.picUrl || article.thumbUrl"
class="h-[120px] w-full"
:preview="false"
/>
<div
class="absolute bottom-0 left-0 inline-block w-[100%] whitespace-normal bg-black p-[1%] text-xs text-white opacity-65"
>
<span>{{ article.title }}</span>
</div>
</div>
</div>
</a>
<!-- 二条/三条等等 -->
<a v-else :href="article.url" target="_blank">
<div class="border-t border-[#eaeaea] bg-white py-[5px]">
<div class="relative">
<div
class="ml-[1%] inline-block w-[70%] whitespace-normal text-[10px]"
>
{{ article.title }}
</div>
<div class="mr-[1%] inline-block w-[25%] bg-[#acadae]">
<img
:src="article.picUrl || article.thumbUrl"
class="h-full w-full"
/>
</div>
</div>
</div>
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
芋道源码
移除多余的 rep 为前缀的变量 message 消息更简单
代码优化补充注释提升阅读性
优化消息的临时缓存策略发送消息时只清理被发送消息的 tab不会强制切回到 text 输入
支持发送视频消息时支持新建视频
-->
<script lang="ts" setup>
import type { Reply } from './types';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Row, Tabs } from 'ant-design-vue';
import { NewsType } from '../material-select/types';
import TabImage from './tab-image.vue';
import TabMusic from './tab-music.vue';
import TabNews from './tab-news.vue';
import TabText from './tab-text.vue';
import TabVideo from './tab-video.vue';
import TabVoice from './tab-voice.vue';
import { createEmptyReply, ReplyType } from './types';
defineOptions({ name: 'ReplySelect' });
const props = withDefaults(
defineProps<{
modelValue: Reply | undefined;
newsType?: NewsType;
}>(),
{
newsType: () => NewsType.Published,
},
);
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
// 提供一个默认的 Reply 对象,避免 undefined 导致的错误
const defaultReply: Reply = {
accountId: -1,
type: ReplyType.Text,
};
const reply = computed<Reply>({
get: () => props.modelValue || defaultReply,
set: (val) => emit('update:modelValue', val),
});
/** 清除除了`type`, `accountId`的字段 */
function clear() {
reply.value = createEmptyReply(reply);
}
defineExpose({
clear,
});
</script>
<template>
<!-- 之前使用的currentTab会导致组件tab不切换直接改为使用reply.type,不做tab缓存缓存会多很多垃圾字段 -->
<Tabs v-model:active-key="reply.type" type="card" @change="clear">
<!-- 类型 1文本 -->
<Tabs.TabPane :key="ReplyType.Text">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:document" /> 文本</Row>
</template>
<TabText v-model="reply.content" />
</Tabs.TabPane>
<!-- 类型 2图片 -->
<Tabs.TabPane :key="ReplyType.Image">
<template #tab>
<Row align="middle">
<IconifyIcon icon="ep:picture" class="mr-5px" /> 图片
</Row>
</template>
<TabImage v-model="reply" />
</Tabs.TabPane>
<!-- 类型 3语音 -->
<Tabs.TabPane :key="ReplyType.Voice">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:phone" /> 语音</Row>
</template>
<TabVoice v-model="reply" />
</Tabs.TabPane>
<!-- 类型 4视频 -->
<Tabs.TabPane :key="ReplyType.Video">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:share" /> 视频</Row>
</template>
<TabVideo v-model="reply" />
</Tabs.TabPane>
<!-- 类型 5图文 -->
<Tabs.TabPane :key="ReplyType.News">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:reading" /> 图文</Row>
</template>
<TabNews v-model="reply" :news-type="newsType" />
</Tabs.TabPane>
<!-- 类型 6音乐 -->
<Tabs.TabPane :key="ReplyType.Music">
<template #tab>
<Row align="middle"><IconifyIcon icon="ep:service" />音乐</Row>
</template>
<TabMusic v-model="reply" />
</Tabs.TabPane>
</Tabs>
</template>

View File

@@ -0,0 +1,152 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import { Button, Col, message, Modal, Row, Upload } from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { MaterialSelect } from '#/views/mp/components';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
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: '',
});
/** 图片上传前校验 */
function beforeImageUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Image, 2)(file as any);
}
/** 上传成功 */
function onUploadSuccess(info: any) {
const res = info.response || info;
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(res.data);
}
/** 删除图片 */
function onDelete() {
reply.value.mediaId = null;
reply.value.url = null;
reply.value.name = null;
}
/** 选择素材 */
function selectMaterial(item: any) {
showDialog.value = false;
// reply.value.type = 'image'
reply.value.mediaId = item.mediaId;
reply.value.url = item.url;
reply.value.name = item.name;
}
</script>
<template>
<div>
<!-- 情况一已经选择好素材或者上传好图片 -->
<div
class="mx-auto mb-[10px] w-[280px] border border-[#eaeaea] p-[10px]"
v-if="reply.url"
>
<img class="w-full" :src="reply.url" />
<p
class="overflow-hidden text-ellipsis whitespace-nowrap text-center text-xs"
v-if="reply.name"
>
{{ reply.name }}
</p>
<Row class="pt-[10px] text-center" justify="center">
<Button type="primary" danger shape="circle" @click="onDelete">
<IconifyIcon icon="ep:delete" />
</Button>
</Row>
</div>
<!-- 情况二未做完上述操作 -->
<Row v-else class="text-center" align="middle">
<!-- 选择素材 -->
<Col
:span="12"
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Button type="primary" @click="showDialog = true">
素材库选择 <IconifyIcon icon="ep:circle-check" />
</Button>
<Modal
title="选择图片"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<MaterialSelect
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<!-- 文件上传 -->
<Col
:span="12"
class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Upload
:action="UPLOAD_URL"
:headers="HEADERS"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeImageUpload"
@success="
(response: any) => {
onUploadSuccess(response);
}
"
>
<Button type="primary">上传图片</Button>
<template #tip>
<span>
<div class="text-center leading-[18px]">
支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M
</div>
</span>
</template>
</Upload>
</Col>
</Row>
</div>
</template>

View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
Button,
Col,
Input,
message,
Modal,
Row,
Upload,
} from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { MaterialSelect } from '#/views/mp/components';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
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: '',
});
/** 图片上传前校验 */
function beforeImageUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Image, 2)(file as any);
}
/** 上传成功 */
function onUploadSuccess(info: any) {
const res = info.response || info;
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(res.data);
}
/** 选择素材 */
function selectMaterial(item: any) {
showDialog.value = false;
reply.value.thumbMediaId = item.mediaId;
reply.value.thumbMediaUrl = item.url;
}
</script>
<template>
<div>
<Row align="middle" justify="center">
<Col :span="6">
<Row align="middle" justify="center" class="inline-block text-center">
<Col :span="24">
<Row align="middle" justify="center">
<img
class="w-[100px]"
v-if="reply.thumbMediaUrl"
:src="reply.thumbMediaUrl"
/>
<IconifyIcon v-else icon="ep:plus" />
</Row>
<Row align="middle" justify="center" class="mt-[2%]">
<div>
<Upload
:action="UPLOAD_URL"
:headers="HEADERS"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeImageUpload"
@success="
(response: any) => {
onUploadSuccess(response);
}
"
>
<template #default>
<Button type="link">本地上传</Button>
</template>
</Upload>
<Button type="link" @click="showDialog = true" class="ml-[5px]">
素材库选择
</Button>
</div>
</Row>
</Col>
</Row>
<Modal
title="选择图片"
v-model:open="showDialog"
width="80%"
destroy-on-close
>
<MaterialSelect
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<Col :span="18">
<Input v-model:value="reply.title as string" placeholder="请输入标题" />
<div class="my-5"></div>
<Input
v-model:value="reply.description as string"
placeholder="请输入描述"
/>
</Col>
</Row>
<div class="my-5"></div>
<Input
v-model:value="reply.musicUrl as string"
placeholder="请输入音乐链接"
/>
<div class="my-5"></div>
<Input
v-model:value="reply.hqMusicUrl as string"
placeholder="请输入高质量音乐链接"
/>
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { Reply } from './types';
import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Col, Modal, Row } from 'ant-design-vue';
import { MaterialSelect, News } from '#/views/mp/components';
import { NewsType } from '../material-select/types';
const props = defineProps<{
modelValue: Reply;
newsType: NewsType;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const showDialog = ref(false);
/** 选择素材 */
function selectMaterial(item: any) {
showDialog.value = false;
reply.value.articles = item.content.newsItem;
}
/** 删除图文 */
function onDelete() {
reply.value.articles = [];
}
</script>
<template>
<div>
<Row>
<div
class="mx-auto mb-[10px] w-[280px] border border-[#eaeaea] p-[10px]"
v-if="reply.articles && reply.articles.length > 0"
>
<News :articles="reply.articles" />
<Col class="pt-[10px] text-center">
<Button type="primary" danger shape="circle" @click="onDelete">
<IconifyIcon icon="ep:delete" />
</Button>
</Col>
</div>
<!-- 选择素材 -->
<Col :span="24" v-if="!reply.content">
<Row class="text-center" align="middle">
<Col :span="24">
<Button type="primary" @click="showDialog = true">
{{
newsType === NewsType.Published
? '选择已发布图文'
: '选择草稿箱图文'
}}
<IconifyIcon icon="ep:circle-check" />
</Button>
</Col>
</Row>
</Col>
<Modal
title="选择图文"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<MaterialSelect
type="news"
:account-id="reply.accountId"
:news-type="newsType"
@select-material="selectMaterial"
/>
</Modal>
</Row>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Input } from 'ant-design-vue';
const props = defineProps<{
modelValue?: null | string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: null | string): void;
(e: 'input', v: null | string): void;
}>();
const content = computed({
get: () => props.modelValue,
set: (val: null | string) => {
emit('update:modelValue', val);
emit('input', val);
},
});
</script>
<template>
<Input.TextArea
:rows="5"
placeholder="请输入内容"
v-model:value="content as string"
class="w-full"
/>
</template>

View File

@@ -0,0 +1,196 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
Button,
Col,
Input,
message,
Modal,
Row,
Upload,
} from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { MaterialSelect, VideoPlayer } from '#/views/mp/components';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val: Reply) => emit('update:modelValue', val),
});
const showDialog = ref(false);
const fileList = ref([]);
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'video',
title: '',
introduction: '',
});
/** 视频上传前校验 */
function beforeVideoUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Video, 10)(file as any);
}
/** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
const formData = new FormData();
formData.append('file', info.file as File);
formData.append('accountId', String(uploadData.accountId));
formData.append('type', uploadData.type);
if (uploadData.title) {
formData.append('title', uploadData.title);
}
if (uploadData.introduction) {
formData.append('introduction', uploadData.introduction);
}
try {
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
info.onProgress?.({ percent });
}
});
// 监听上传完成
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const res = JSON.parse(xhr.responseText);
onUploadSuccess(res);
info.onSuccess?.(res);
} catch {
info.onError?.(new Error('解析响应失败'));
message.error('上传失败:解析响应失败');
}
} else {
info.onError?.(new Error(`上传失败HTTP ${xhr.status}`));
message.error('上传失败,请重试');
}
});
// 监听上传错误
xhr.addEventListener('error', () => {
info.onError?.(new Error('上传请求失败'));
message.error('上传失败,请重试');
});
// 发送请求
xhr.open('POST', UPLOAD_URL);
xhr.setRequestHeader('Authorization', HEADERS.Authorization);
xhr.send(formData);
} catch (error: any) {
info.onError?.(error);
message.error('上传失败,请重试');
}
}
/** 上传成功 */
function onUploadSuccess(res: any) {
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
selectMaterial(res.data);
}
/** 选择素材后设置 */
function 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>
<template>
<div>
<Row>
<Input
v-model:value="reply.title as string"
class="mb-[2%]"
placeholder="请输入标题"
/>
<Input
class="mb-[2%]"
v-model:value="reply.description as string"
placeholder="请输入描述"
/>
<Row class="w-full pt-[10px] text-center" justify="center">
<VideoPlayer v-if="reply.url" :url="reply.url" />
</Row>
<Col class="w-full">
<Row class="text-center" align="middle">
<!-- 选择素材 -->
<Col :span="12">
<Button type="primary" @click="showDialog = true">
素材库选择 <IconifyIcon icon="ep:circle-check" />
</Button>
<Modal
title="选择视频"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<MaterialSelect
type="video"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<!-- 文件上传 -->
<Col :span="12">
<Upload
:file-list="fileList"
:before-upload="beforeVideoUpload"
:custom-request="customRequest"
>
<Button type="primary">
新建视频 <IconifyIcon icon="ep:upload" />
</Button>
</Upload>
</Col>
</Row>
</Col>
</Row>
</div>
</template>

View File

@@ -0,0 +1,148 @@
<script lang="ts" setup>
import type { UploadFile } from 'ant-design-vue';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import { Button, Col, message, Modal, Row, Upload } from 'ant-design-vue';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import { MaterialSelect, VoicePlayer } from '#/views/mp/components';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const UPLOAD_URL = `${import.meta.env.VITE_BASE_URL}/admin-api/mp/material/upload-temporary`;
const HEADERS = { Authorization: `Bearer ${useAccessStore().accessToken}` };
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val: Reply) => emit('update:modelValue', val),
});
const showDialog = ref(false);
const fileList = ref([]);
const uploadData = reactive({
accountId: reply.value.accountId,
type: 'voice',
title: '',
introduction: '',
});
/** 语音上传前校验 */
function beforeVoiceUpload(file: UploadFile) {
return useBeforeUpload(UploadType.Voice, 10)(file as any);
}
/** 上传成功 */
function onUploadSuccess(info: any) {
const res = info.response || info;
if (res.code !== 0) {
message.error(`上传出错:${res.msg}`);
return false;
}
// 清空上传时的各种数据
fileList.value = [];
uploadData.title = '';
uploadData.introduction = '';
// 上传好的文件,本质是个素材,所以可以进行选中
selectMaterial(res.data);
}
/** 删除语音 */
function onDelete() {
reply.value.mediaId = null;
reply.value.url = null;
reply.value.name = null;
}
/** 选择素材 */
function 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>
<template>
<div>
<div
class="mx-auto mb-[10px] border border-[#eaeaea] p-[10px]"
v-if="reply.url"
>
<p
class="overflow-hidden text-ellipsis whitespace-nowrap text-center text-xs"
>
{{ reply.name }}
</p>
<Row class="w-full pt-[10px] text-center" justify="center">
<VoicePlayer :url="reply.url" />
</Row>
<Row class="w-full pt-[10px] text-center" justify="center">
<Button type="primary" danger shape="circle" @click="onDelete">
<IconifyIcon icon="ep:delete" />
</Button>
</Row>
</div>
<Row v-else class="text-center">
<!-- 选择素材 -->
<Col
:span="12"
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Button type="primary" @click="showDialog = true">
素材库选择<IconifyIcon icon="ep:circle-check" />
</Button>
<Modal
title="选择语音"
v-model:open="showDialog"
width="90%"
destroy-on-close
>
<MaterialSelect
type="voice"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</Modal>
</Col>
<!-- 文件上传 -->
<Col
:span="12"
class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<Upload
:action="UPLOAD_URL"
:headers="HEADERS"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeVoiceUpload"
@success="
(response: any) => {
onUploadSuccess(response);
}
"
>
<Button type="primary">点击上传</Button>
<template #tip>
<div class="text-center leading-[18px]">
格式支持 mp3/wma/wav/amr文件大小不超过 2M播放长度不超过 60s
</div>
</template>
</Upload>
</Col>
</Row>
</div>
</template>

View File

@@ -0,0 +1,53 @@
import type { Ref } from 'vue';
import { unref } from 'vue';
enum ReplyType {
Image = 'image',
Music = 'music',
News = 'news',
Text = 'text',
Video = 'video',
Voice = 'voice',
}
interface _Reply {
accountId: number;
type: ReplyType;
name?: null | string;
content?: null | string;
mediaId?: null | string;
url?: null | string;
title?: null | string;
description?: null | string;
thumbMediaId?: null | string;
thumbMediaUrl?: null | string;
musicUrl?: null | string;
hqMusicUrl?: null | string;
introduction?: null | string;
articles?: any[];
}
type Reply = _Reply; // Partial<_Reply>
/** 利用旧的reply[accountId, type]初始化新的Reply */
const createEmptyReply = (old: Ref<Reply> | 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 { createEmptyReply, type Reply, ReplyType };

View File

@@ -0,0 +1,79 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
微信消息 - 视频
芋道源码
bug 修复
1joolun 的做法使用 mediaId 从微信公众号下载对应的 mp4 素材从而播放内容
存在的问题mediaId 有效期是 3 超过时间后无法播放
2重构后的做法后端接收到微信公众号的视频消息后将视频消息的 media_id 的文件内容保存到文件服务器中这样前端可以直接使用 URL 播放
体验优化弹窗关闭后自动暂停视频的播放
-->
<script lang="ts" setup>
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { VideoPlayer } from '@videojs-player/vue';
import { Modal } from 'ant-design-vue';
import 'video.js/dist/video-js.css';
defineOptions({ name: 'VideoPlayer' });
const props = defineProps({
url: {
type: String,
required: true,
},
});
const dialogVisible = ref(false);
// DONE @hw是不是使用 vben 自带的 Modal 哈;这样 ele 通用性更好点。其它模块,涉及到 Modal 也按照这个调整噢
const playVideo = () => {
dialogVisible.value = true;
};
</script>
<template>
<div @click="playVideo()">
<!-- 提示 -->
<div class="flex cursor-pointer flex-col items-center">
<IconifyIcon icon="ep:video-play" class="size-5" />
<p class="text-sm">点击播放视频</p>
</div>
<!-- 弹窗播放 -->
<Modal
v-model:open="dialogVisible"
title="视频播放"
width="45%"
:footer="null"
>
<VideoPlayer
class="video-player vjs-big-play-centered"
:src="props.url"
poster=""
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())"-->
</Modal>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
微信消息 - 语音
芋道源码
bug 修复
1joolun 的做法使用 mediaId 从微信公众号下载对应的 mp4 素材从而播放内容
存在的问题mediaId 有效期是 3 超过时间后无法播放
2重构后的做法后端接收到微信公众号的视频消息后将视频消息的 media_id 的文件内容保存到文件服务器中这样前端可以直接使用 URL 播放
代码优化 props 中的 reply 调成为 data 中对应的属性并补充相关注释
-->
<script lang="ts" setup>
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Tag } from 'ant-design-vue';
// 因为微信语音是 amr 格式,所以需要用到 amr 解码器https://www.npmjs.com/package/benz-amr-recorder
import BenzAMRRecorder from 'benz-amr-recorder';
defineOptions({ name: 'VoicePlayer' });
const props = defineProps({
url: {
type: String, // 语音地址例如说https://www.iocoder.cn/xxx.amr
required: true,
},
content: {
type: String, // 语音文本
required: false,
default: '',
},
});
const amr = ref();
const playing = ref(false);
const duration = ref();
/** 处理点击,播放或暂停 */
function playVoice() {
// 情况一:未初始化,则创建 BenzAMRRecorder
if (amr.value === undefined) {
amrInit();
return;
}
// 情况二:已经初始化,则根据情况播放或暂时
if (amr.value.isPlaying()) {
amrStop();
} else {
amrPlay();
}
}
/** 音频初始化 */
function amrInit() {
amr.value = new BenzAMRRecorder();
// 设置播放
amr.value.initWithUrl(props.url).then(() => {
amrPlay();
duration.value = amr.value.getDuration();
});
// 监听暂停
amr.value.onEnded(() => {
playing.value = false;
});
}
/** 音频播放 */
function amrPlay() {
playing.value = true;
amr.value.play();
}
/** 音频暂停 */
function amrStop() {
playing.value = false;
amr.value.stop();
}
// TODO 芋艿:下面样式有点问题
</script>
<template>
<!-- DONE @hwtindwind 替代 -->
<div
class="flex h-[50px] w-[120px] cursor-pointer items-center justify-center rounded-[10px] bg-[#eaeaea] p-[5px]"
@click="playVoice"
>
<IconifyIcon v-if="playing !== true" icon="lucide:circle-play" :size="32" />
<IconifyIcon v-else icon="lucide:circle-pause" :size="32" />
<span v-if="duration" class="ml-[5px] text-[11px]">{{ duration }} </span>
<div v-if="content">
<Tag color="success" size="small">语音识别</Tag>
{{ content }}
</div>
</div>
</template>