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,142 @@
<script lang="ts" setup>
import type { MpAccountApi } from '#/api/mp/account';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useTabs } from '@vben/hooks';
import { ElMessage, ElOption, ElSelect } from 'element-plus';
import { getSimpleAccountList } from '#/api/mp/account';
defineOptions({ name: 'AccountSelect' });
const props = defineProps<{
modelValue?: number;
}>();
const emit = defineEmits<{
(e: 'change', id: number, name: string): void;
(e: 'update:modelValue', id: number): void;
}>();
const message = ElMessage; // 消息弹窗
const { closeCurrentTab } = useTabs(); // 视图操作
const { push } = useRouter();
const account: MpAccountApi.AccountSimple = reactive({
id: -1,
name: '',
});
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();
if (accountList.value.length === 0) {
message.error('未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置');
await closeCurrentTab();
await push({ name: 'MpAccount' });
return;
}
// 如果外部没有传入值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', 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) {
if (id) {
currentId.value = id;
}
}
/** 初始化 */
onMounted(() => {
handleQuery();
});
</script>
<template>
<ElSelect
v-model="currentId"
placeholder="请选择公众号"
class="!w-240px"
@change="onChanged"
>
<ElOption
v-for="item in accountList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</template>
<style lang="scss" scoped>
:deep(.el-select__wrapper) {
width: 240px !important;
}
</style>

View File

@@ -0,0 +1 @@
export { default } from './account-select.vue';

View File

@@ -0,0 +1,23 @@
// 统一导出所有模块组件
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';
export { default as AccountSelect } from './account-select/account-select.vue';

View File

@@ -0,0 +1 @@
export { default } from './location.vue';

View File

@@ -0,0 +1,65 @@
<!--
微信消息 - 定位TODO @Dhb52 目前未启用
-->
<script lang="ts" setup>
import { IconifyIcon } from '@vben/icons';
import { ElCol, ElLink, ElRow } from 'element-plus';
defineOptions({ name: 'Location' });
const props = defineProps({
locationX: {
required: true,
type: Number,
},
locationY: {
required: true,
type: Number,
},
label: {
// 地名
required: true,
type: String,
},
qqMapKey: {
// QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
required: false,
type: String,
default: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E', // 需要自定义
},
});
defineExpose({
locationX: props.locationX,
locationY: props.locationY,
label: props.label,
qqMapKey: props.qqMapKey,
});
</script>
<template>
<div>
<ElLink
type="primary"
target="_blank"
:href="`https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=${
locationY
}&pointy=${locationX}&name=${label}&ref=yudao`"
>
<ElCol>
<ElRow>
<img
:src="`https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|${
locationX
},${locationY}&key=${qqMapKey}&size=250*180`"
/>
</ElRow>
<ElRow>
<IconifyIcon icon="ep:location" />
{{ label }}
</ElRow>
</ElCol>
</ElLink>
</div>
</template>

View File

@@ -0,0 +1,3 @@
export { default } from './material-select.vue';
export { MaterialType, NewsType } from './types';

View File

@@ -0,0 +1,313 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
芋道源码
移除 avue 组件使用 ElementUI 原生组件
-->
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatTime } from '@vben/utils';
import {
ElButton,
ElPagination,
ElRow,
ElTable,
ElTableColumn,
} from 'element-plus';
import * as MpDraftApi from '#/api/mp/draft';
import * as MpFreePublishApi from '#/api/mp/freePublish';
import * as MpMaterialApi from '#/api/mp/material';
import News from '#/views/mp/components/news/news.vue';
import VideoPlayer from '#/views/mp/components/video-play/video-play.vue';
import VoicePlayer from '#/views/mp/components/voice-play/voice-play.vue';
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 MpMaterialApi.getMaterialPage({
...queryParams,
type: props.type,
});
list.value = data.list;
total.value = data.total;
}
/** 获取已发布图文分页 */
async function getFreePublishPageFun() {
const data = await MpFreePublishApi.getFreePublishPage(queryParams);
data.list.forEach((item: any) => {
const articles = item.content.newsItem;
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl;
});
});
list.value = data.list;
total.value = data.total;
}
/** 获取草稿图文分页 */
async function getDraftPageFun() {
const data = await MpDraftApi.getDraftPage(queryParams);
data.list.forEach((draft: any) => {
const articles = draft.content.newsItem;
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl;
});
});
list.value = data.list;
total.value = data.total;
}
onMounted(async () => {
getPage();
});
</script>
<template>
<div class="pb-30px">
<!-- 类型image -->
<div v-if="props.type === 'image'">
<div class="waterfall" v-loading="loading">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<img class="material-img" :src="item.url" />
<p class="item-name">{{ item.name }}</p>
<ElRow class="ope-row">
<ElButton type="success" @click="selectMaterialFun(item)">
选择
<IconifyIcon icon="ep:circle-check" />
</ElButton>
</ElRow>
</div>
</div>
<!-- 分页组件 -->
<ElPagination
background
layout="prev, pager, next, sizes, total"
:total="total"
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@current-change="getMaterialPageFun"
@size-change="getMaterialPageFun"
/>
</div>
<!-- 类型voice -->
<div v-else-if="props.type === 'voice'">
<!-- 列表 -->
<ElTable v-loading="loading" :data="list">
<ElTableColumn label="编号" align="center" prop="mediaId" />
<ElTableColumn label="文件名" align="center" prop="name" />
<ElTableColumn label="语音" align="center">
<template #default="scope">
<VoicePlayer :url="scope.row.url" />
</template>
</ElTableColumn>
<ElTableColumn
label="上传时间"
align="center"
prop="createTime"
width="180"
:formatter="
(row: any) => formatTime(row.createTime, 'YYYY-MM-DD HH:mm:ss')
"
/>
<ElTableColumn label="操作" align="center" fixed="right">
<template #default="scope">
<ElButton type="primary" link @click="selectMaterialFun(scope.row)">
选择
<IconifyIcon icon="ep:plus" />
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<!-- 分页组件 -->
<ElPagination
background
layout="prev, pager, next, sizes, total"
:total="total"
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@current-change="getPage"
@size-change="getPage"
/>
</div>
<!-- 类型video -->
<div v-else-if="props.type === 'video'">
<!-- 列表 -->
<ElTable v-loading="loading" :data="list">
<ElTableColumn label="编号" align="center" prop="mediaId" />
<ElTableColumn label="文件名" align="center" prop="name" />
<ElTableColumn label="标题" align="center" prop="title" />
<ElTableColumn label="介绍" align="center" prop="introduction" />
<ElTableColumn label="视频" align="center">
<template #default="scope">
<VideoPlayer :url="scope.row.url" />
</template>
</ElTableColumn>
<ElTableColumn
label="上传时间"
align="center"
prop="createTime"
width="180"
:formatter="
(row: any) => formatTime(row.createTime, 'YYYY-MM-DD HH:mm:ss')
"
/>
<ElTableColumn
label="操作"
align="center"
fixed="right"
class-name="small-padding fixed-width"
>
<template #default="scope">
<ElButton type="primary" link @click="selectMaterialFun(scope.row)">
选择
<IconifyIcon icon="akar-icons:circle-plus" />
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<!-- 分页组件 -->
<ElPagination
background
layout="prev, pager, next, sizes, total"
:total="total"
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@current-change="getMaterialPageFun"
@size-change="getMaterialPageFun"
/>
</div>
<!-- 类型news -->
<div v-else-if="props.type === 'news'">
<div class="waterfall" v-loading="loading">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<div v-if="item.content && item.content.newsItem">
<News :articles="item.content.newsItem" />
<ElRow class="ope-row">
<ElButton type="success" @click="selectMaterialFun(item)">
选择
<IconifyIcon icon="ep:circle-check" />
</ElButton>
</ElRow>
</div>
</div>
</div>
<!-- 分页组件 -->
<ElPagination
background
layout="prev, pager, next, sizes, total"
:total="total"
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@current-change="getMaterialPageFun"
@size-change="getMaterialPageFun"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
@media (width >= 992px) and (width <= 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (width >= 768px) and (width <= 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (width <= 767px) {
.waterfall {
column-count: 1;
}
}
.waterfall {
column-gap: 10px;
width: 100%;
margin: 0 auto;
column-count: 5;
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
border: 1px solid #eaeaea;
break-inside: avoid;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
</style>

View File

@@ -0,0 +1,11 @@
export enum NewsType {
Draft = '2',
Published = '1',
}
export enum MaterialType {
Image = 'image',
News = 'news',
Video = 'video',
Voice = 'voice',
}

View File

@@ -0,0 +1,116 @@
.avue-card {
&__item {
box-sizing: border-box;
height: 200px;
margin-bottom: 16px;
font-size: 14px;
font-feature-settings: 'tnum';
font-variant: tabular-nums;
line-height: 1.5;
color: rgb(0 0 0 / 65%);
cursor: pointer;
list-style: none;
background-color: #fff;
border: 1px solid #e8e8e8;
&:hover {
border-color: rgb(0 0 0 / 9%);
box-shadow: 0 2px 8px rgb(0 0 0 / 9%);
}
&--add {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 16px;
color: rgb(0 0 0 / 45%);
background-color: #fff;
border: 1px dashed #000;
border-color: #d9d9d9;
border-radius: 2px;
i {
margin-right: 10px;
}
&:hover {
color: #40a9ff;
background-color: #fff;
border-color: #40a9ff;
}
}
}
&__body {
display: flex;
padding: 24px;
}
&__detail {
flex: 1;
}
&__avatar {
width: 48px;
height: 48px;
margin-right: 12px;
overflow: hidden;
border-radius: 48px;
img {
width: 100%;
height: 100%;
}
}
&__title {
margin-bottom: 12px;
font-size: 16px;
color: rgb(0 0 0 / 85%);
&:hover {
color: #1890ff;
}
}
&__info {
display: -webkit-box;
height: 64px;
overflow: hidden;
-webkit-line-clamp: 3;
color: rgb(0 0 0 / 45%);
-webkit-box-orient: vertical;
}
&__menu {
display: flex;
justify-content: space-around;
height: 50px;
line-height: 50px;
color: rgb(0 0 0 / 45%);
text-align: center;
background: #f7f9fa;
&:hover {
color: #1890ff;
}
}
}
/** joolun 额外加的 */
.avue-comment__main {
flex: unset !important;
margin: 0 8px !important;
border-radius: 5px !important;
}
.avue-comment__header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.avue-comment__body {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}

View File

@@ -0,0 +1,109 @@
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
.avue-comment {
display: flex;
align-items: flex-start;
margin-bottom: 30px;
&--reverse {
flex-direction: row-reverse;
.avue-comment__main {
&::before,
&::after {
right: -8px;
left: auto;
border-width: 8px 0 8px 8px;
}
&::before {
border-left-color: #dedede;
}
&::after {
margin-right: 1px;
margin-left: auto;
border-left-color: #f8f8f8;
}
}
}
&__avatar {
box-sizing: border-box;
width: 48px;
height: 48px;
vertical-align: middle;
border: 1px solid transparent;
border-radius: 50%;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 15px;
background: #f8f8f8;
border-bottom: 1px solid #eee;
}
&__author {
font-size: 14px;
font-weight: 700;
color: #999;
}
&__main {
position: relative;
flex: 1;
margin: 0 20px;
border: 1px solid #dedede;
border-radius: 2px;
&::before,
&::after {
position: absolute;
top: 10px;
right: 100%;
left: -8px;
display: block;
width: 0;
height: 0;
pointer-events: none;
content: ' ';
border-color: transparent;
border-style: solid solid outset;
border-width: 8px 8px 8px 0;
}
&::before {
z-index: 1;
border-right-color: #dedede;
}
&::after {
z-index: 2;
margin-left: 1px;
border-right-color: #f8f8f8;
}
}
&__body {
padding: 15px;
overflow: hidden;
font-family:
'Segoe UI', 'Lucida Grande', Helvetica, Arial, 'Microsoft YaHei',
FreeSans, Arimo, 'Droid Sans', 'wenquanyi micro hei', 'Hiragino Sans GB',
'Hiragino Sans GB W3', FontAwesome, sans-serif;
font-size: 14px;
color: #333;
background: #fff;
}
blockquote {
padding: 1px 0 1px 15px;
margin: 0;
font-family:
Georgia, 'Times New Roman', Times, Kai, 'Kaiti SC', KaiTi, BiauKai,
FontAwesome, serif;
border-left: 4px solid #ddd;
}
}

View File

@@ -0,0 +1,3 @@
export { default } from './msg.vue';
export { MsgType } from './types';

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { ElTag } from 'element-plus';
const props = defineProps<{
item: any;
}>();
const item = ref(props.item);
</script>
<template>
<div>
<div v-if="item.event === 'subscribe'">
<ElTag type="success">关注</ElTag>
</div>
<div v-else-if="item.event === 'unsubscribe'">
<ElTag type="danger">取消关注</ElTag>
</div>
<div v-else-if="item.event === 'CLICK'">
<ElTag>点击菜单</ElTag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'VIEW'">
<ElTag>点击菜单链接</ElTag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_waitmsg'">
<ElTag>扫码结果</ElTag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'scancode_push'">
<ElTag>扫码结果</ElTag>
{{ item.eventKey }}
</div>
<div v-else-if="item.event === 'pic_sysphoto'">
<ElTag>系统拍照发图</ElTag>
</div>
<div v-else-if="item.event === 'pic_photo_or_album'">
<ElTag>拍照或者相册</ElTag>
</div>
<div v-else-if="item.event === 'pic_weixin'">
<ElTag>微信相册</ElTag>
</div>
<div v-else-if="item.event === 'location_select'">
<ElTag>选择地理位置</ElTag>
</div>
<div v-else-if="item.event === 'SCAN'">
<ElTag>扫码</ElTag>
</div>
<div v-else>
<ElTag type="danger">未知事件类型</ElTag>
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<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;
}>();
// 使用常量对象替代枚举,避免 linter 误报
const SendFrom = {
MpBot: 2,
User: 1,
} as const;
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="avue-comment__create_time">
{{ 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 lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
@import url('../comment.scss');
@import url('../card.scss');
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import { ref } from 'vue';
import Location from '#/views/mp/components/location/location.vue';
import Music from '#/views/mp/components/music/music.vue';
import News from '#/views/mp/components/news/news.vue';
import VideoPlayer from '#/views/mp/components/video-play/video-play.vue';
import VoicePlayer from '#/views/mp/components/voice-play/voice-play.vue';
import { MsgType } from '../types';
import MsgEvent from './msg-event.vue';
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">
<el-link
type="success"
:underline="false"
target="_blank"
:href="item.url"
>
<div
class="mb-3 text-base text-[rgba(0,0,0,0.85)] hover:text-[#1890ff]"
>
<i class="el-icon-link"></i>{{ item.title }}
</div>
</el-link>
<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 @@
export { default } from './music.vue';

View File

@@ -0,0 +1,70 @@
<!--
微信消息 - 音乐
-->
<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>
<el-link
type="success"
:underline="false"
target="_blank"
:href="hqMusicUrl ? hqMusicUrl : musicUrl"
>
<div
class="avue-card__body"
style="padding: 10px; background-color: #fff; border-radius: 5px"
>
<div class="avue-card__avatar">
<img :src="thumbMediaUrl" alt="" />
</div>
<div class="avue-card__detail">
<div class="avue-card__title" style="margin-bottom: unset">
{{ title }}
</div>
<div class="avue-card__info" style="height: unset">
{{ description }}
</div>
</div>
</div>
</el-link>
</div>
</template>
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss */
@import url('../msg/card.scss');
</style>

View File

@@ -0,0 +1 @@
export { default } from './news.vue';

View File

@@ -0,0 +1,125 @@
<!--
- Copyright (C) 2018-2019
- All rights reserved, Designed By www.joolun.com
微信消息 - 图文
芋道源码
代码优化补充注释提升阅读性
-->
<script lang="ts" setup>
import { ElImage } from 'element-plus';
defineOptions({ name: 'News' });
const props = withDefaults(
defineProps<{
articles?: any[] | null;
}>(),
{
articles: null,
},
);
defineExpose({
articles: props.articles,
});
</script>
<template>
<div class="news-home">
<div v-for="(article, index) in articles" :key="index" class="news-div">
<!-- 头条 -->
<a v-if="index === 0" :href="article.url" target="_blank">
<div class="news-main">
<div class="news-content">
<ElImage
:src="article.picUrl || article.thumbUrl"
class="material-img"
style="width: 100%; height: 120px"
/>
<div class="news-content-title">
<span>{{ article.title }}</span>
</div>
</div>
</div>
</a>
<!-- 二条/三条等等 -->
<a v-else :href="article.url" target="_blank">
<div class="news-main-item">
<div class="news-content-item">
<div class="news-content-item-title">{{ article.title }}</div>
<div class="news-content-item-img">
<ElImage
:src="article.picUrl || article.thumbUrl"
class="material-img"
height="100%"
/>
</div>
</div>
</div>
</a>
</div>
</div>
</template>
<style lang="scss" scoped>
.news-home {
width: 100%;
margin: auto;
background-color: #fff;
}
.news-main {
width: 100%;
margin: auto;
}
.news-content {
position: relative;
width: 100%;
background-color: #acadae;
}
.news-content-title {
position: absolute;
bottom: 0;
left: 0;
box-sizing: unset !important;
display: inline-block;
width: 98%;
padding: 1%;
font-size: 12px;
color: #fff;
white-space: normal;
background-color: black;
opacity: 0.65;
}
.news-main-item {
padding: 5px 0;
background-color: #fff;
border-top: 1px solid #eaeaea;
}
.news-content-item {
position: relative;
}
.news-content-item-title {
display: inline-block;
width: 70%;
margin-left: 1%;
font-size: 10px;
white-space: normal;
}
.news-content-item-img {
display: inline-block;
width: 25%;
margin-right: 1%;
background-color: #acadae;
}
.material-img {
width: 100%;
}
</style>

View File

@@ -0,0 +1,7 @@
export { default } from './reply.vue';
export {
createEmptyReply,
type Reply,
ReplyType,
} from './types';

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 { ElRow, ElTabPane, ElTabs } from 'element-plus';
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>
<ElTabs type="border-card" v-model="reply.type" @tab-change="clear">
<!-- 类型 1文本 -->
<ElTabPane :name="ReplyType.Text">
<template #label>
<ElRow align="middle"><IconifyIcon icon="ep:document" /> 文本</ElRow>
</template>
<TabText v-model="reply.content" />
</ElTabPane>
<!-- 类型 2图片 -->
<ElTabPane :name="ReplyType.Image">
<template #label>
<ElRow align="middle">
<IconifyIcon icon="ep:picture" class="mr-5px" /> 图片
</ElRow>
</template>
<TabImage v-model="reply" />
</ElTabPane>
<!-- 类型 3语音 -->
<ElTabPane :name="ReplyType.Voice">
<template #label>
<ElRow align="middle"><IconifyIcon icon="ep:phone" /> 语音</ElRow>
</template>
<TabVoice v-model="reply" />
</ElTabPane>
<!-- 类型 4视频 -->
<ElTabPane :name="ReplyType.Video">
<template #label>
<ElRow align="middle"><IconifyIcon icon="ep:share" /> 视频</ElRow>
</template>
<TabVideo v-model="reply" />
</ElTabPane>
<!-- 类型 5图文 -->
<ElTabPane :name="ReplyType.News">
<template #label>
<ElRow align="middle"><IconifyIcon icon="ep:reading" /> 图文</ElRow>
</template>
<TabNews v-model="reply" :news-type="newsType" />
</ElTabPane>
<!-- 类型 6音乐 -->
<ElTabPane :name="ReplyType.Music">
<template #label>
<ElRow align="middle"><IconifyIcon icon="ep:service" />音乐</ElRow>
</template>
<TabMusic v-model="reply" />
</ElTabPane>
</ElTabs>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import type { UploadRawFile } from 'element-plus';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
ElButton,
ElCol,
ElDialog,
ElMessage,
ElRow,
ElUpload,
} from 'element-plus';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import MaterialSelect from '#/views/mp/components/material-select/material-select.vue';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const message = ElMessage;
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(rawFile: UploadRawFile) {
return useBeforeUpload(UploadType.Image, 2)(rawFile);
}
/** 上传成功 */
function onUploadSuccess(res: any) {
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>
<ElRow class="pt-[10px] text-center" justify="center">
<ElButton type="danger" circle @click="onDelete">
<IconifyIcon icon="ep:delete" />
</ElButton>
</ElRow>
</div>
<!-- 情况二未做完上述操作 -->
<ElRow v-else class="text-center" align="middle">
<!-- 选择素材 -->
<ElCol
:span="12"
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<ElButton type="success" @click="showDialog = true">
素材库选择 <IconifyIcon icon="ep:circle-check" />
</ElButton>
<ElDialog
title="选择图片"
v-model="showDialog"
width="90%"
append-to-body
destroy-on-close
>
<MaterialSelect
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</ElDialog>
</ElCol>
<!-- 文件上传 -->
<ElCol
:span="12"
class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<ElUpload
:action="UPLOAD_URL"
:headers="HEADERS"
multiple
:limit="1"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeImageUpload"
:on-success="onUploadSuccess"
>
<ElButton type="primary">上传图片</ElButton>
<template #tip>
<span>
<div class="text-center leading-[18px]">
支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M
</div>
</span>
</template>
</ElUpload>
</ElCol>
</ElRow>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import type { UploadRawFile } from 'element-plus';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
ElButton,
ElCol,
ElDialog,
ElInput,
ElMessage,
ElRow,
ElUpload,
} from 'element-plus';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
// import { getAccessToken } from '@/utils/auth'
import MaterialSelect from '#/views/mp/components/material-select/material-select.vue';
// 设置上传的请求头部
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const message = ElMessage;
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(rawFile: UploadRawFile) {
return useBeforeUpload(UploadType.Image, 2)(rawFile);
}
/** 上传成功 */
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.thumbMediaId = item.mediaId;
reply.value.thumbMediaUrl = item.url;
}
</script>
<template>
<div>
<ElRow align="middle" justify="center">
<ElCol :span="6">
<ElRow align="middle" justify="center" class="inline-block text-center">
<ElCol :span="24">
<ElRow align="middle" justify="center">
<img
class="w-[100px]"
v-if="reply.thumbMediaUrl"
:src="reply.thumbMediaUrl"
/>
<IconifyIcon v-else icon="ep:plus" />
</ElRow>
<ElRow align="middle" justify="center" class="mt-[2%]">
<div>
<ElUpload
:action="UPLOAD_URL"
:headers="HEADERS"
multiple
:limit="1"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeImageUpload"
:on-success="onUploadSuccess"
>
<template #trigger>
<ElButton type="primary" link>本地上传</ElButton>
</template>
<ElButton
type="primary"
link
@click="showDialog = true"
class="ml-[5px]"
>
素材库选择
</ElButton>
</ElUpload>
</div>
</ElRow>
</ElCol>
</ElRow>
<ElDialog
title="选择图片"
v-model="showDialog"
width="80%"
append-to-body
destroy-on-close
>
<MaterialSelect
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</ElDialog>
</ElCol>
<ElCol :span="18">
<ElInput v-model="reply.title" placeholder="请输入标题" />
<div class="my-5"></div>
<ElInput v-model="reply.description" placeholder="请输入描述" />
</ElCol>
</ElRow>
<div class="my-5"></div>
<ElInput v-model="reply.musicUrl" placeholder="请输入音乐链接" />
<div class="my-5"></div>
<ElInput v-model="reply.hqMusicUrl" placeholder="请输入高质量音乐链接" />
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { Reply } from './types';
import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { ElButton, ElCol, ElDialog, ElRow } from 'element-plus';
import MaterialSelect from '#/views/mp/components/material-select/material-select.vue';
import News from '#/views/mp/components/news/news.vue';
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>
<ElRow>
<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" />
<ElCol class="pt-[10px] text-center">
<ElButton type="danger" circle @click="onDelete">
<IconifyIcon icon="ep:delete" />
</ElButton>
</ElCol>
</div>
<!-- 选择素材 -->
<ElCol :span="24" v-if="!reply.content">
<ElRow class="text-center" align="middle">
<ElCol :span="24">
<ElButton type="success" @click="showDialog = true">
{{
newsType === NewsType.Published
? '选择已发布图文'
: '选择草稿箱图文'
}}
<IconifyIcon icon="ep:circle-check" />
</ElButton>
</ElCol>
</ElRow>
</ElCol>
<ElDialog
title="选择图文"
v-model="showDialog"
width="90%"
append-to-body
destroy-on-close
>
<MaterialSelect
type="news"
:account-id="reply.accountId"
:news-type="newsType"
@select-material="selectMaterial"
/>
</ElDialog>
</ElRow>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { ElInput } from 'element-plus';
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>
<ElInput
type="textarea"
:rows="5"
placeholder="请输入内容"
v-model="content"
class="w-full"
/>
</template>

View File

@@ -0,0 +1,146 @@
<script lang="ts" setup>
import type { UploadRawFile } from 'element-plus';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
ElButton,
ElCol,
ElDialog,
ElInput,
ElMessage,
ElRow,
ElUpload,
} from 'element-plus';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import MaterialSelect from '#/views/mp/components/material-select/material-select.vue';
import VideoPlayer from '#/views/mp/components/video-play/video-play.vue';
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const message = ElMessage;
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(rawFile: UploadRawFile) {
return useBeforeUpload(UploadType.Video, 10)(rawFile);
}
/** 上传成功 */
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>
<ElRow>
<ElInput v-model="reply.title" class="mb-[2%]" placeholder="请输入标题" />
<ElInput
class="mb-[2%]"
v-model="reply.description"
placeholder="请输入描述"
/>
<ElRow class="w-full pt-[10px] text-center" justify="center">
<VideoPlayer v-if="reply.url" :url="reply.url" />
</ElRow>
<ElCol>
<ElRow class="text-center" align="middle">
<!-- 选择素材 -->
<ElCol :span="12">
<ElButton type="success" @click="showDialog = true">
素材库选择 <IconifyIcon icon="ep:circle-check" />
</ElButton>
<ElDialog
title="选择视频"
v-model="showDialog"
width="90%"
append-to-body
destroy-on-close
>
<MaterialSelect
type="video"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</ElDialog>
</ElCol>
<!-- 文件上传 -->
<ElCol :span="12">
<ElUpload
:action="UPLOAD_URL"
:headers="HEADERS"
multiple
:limit="1"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeVideoUpload"
:on-success="onUploadSuccess"
>
<ElButton type="primary">
新建视频 <IconifyIcon icon="ep:upload" />
</ElButton>
</ElUpload>
</ElCol>
</ElRow>
</ElCol>
</ElRow>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { UploadRawFile } from 'element-plus';
import type { Reply } from './types';
import { computed, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import {
ElButton,
ElCol,
ElDialog,
ElMessage,
ElRow,
ElUpload,
} from 'element-plus';
import { UploadType, useBeforeUpload } from '#/utils/useUpload';
import MaterialSelect from '#/views/mp/components/material-select/material-select.vue';
import VoicePlayer from '#/views/mp/components/voice-play/voice-play.vue';
// 设置上传的请求头部
const props = defineProps<{
modelValue: Reply;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply): void;
}>();
const message = ElMessage;
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(rawFile: UploadRawFile) {
return useBeforeUpload(UploadType.Voice, 10)(rawFile);
}
/** 上传成功 */
function onUploadSuccess(res: any) {
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>
<ElRow class="w-full pt-[10px] text-center" justify="center">
<VoicePlayer :url="reply.url" />
</ElRow>
<ElRow class="w-full pt-[10px] text-center" justify="center">
<ElButton type="danger" circle @click="onDelete">
<IconifyIcon icon="ep:delete" />
</ElButton>
</ElRow>
</div>
<ElRow v-else class="text-center">
<!-- 选择素材 -->
<ElCol
:span="12"
class="h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<ElButton type="success" @click="showDialog = true">
素材库选择<IconifyIcon icon="ep:circle-check" />
</ElButton>
<ElDialog
title="选择语音"
v-model="showDialog"
width="90%"
append-to-body
destroy-on-close
>
<MaterialSelect
type="voice"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</ElDialog>
</ElCol>
<!-- 文件上传 -->
<ElCol
:span="12"
class="float-right h-[160px] w-[49.5%] border border-[rgb(234,234,234)] py-[50px]"
>
<ElUpload
:action="UPLOAD_URL"
:headers="HEADERS"
multiple
:limit="1"
:file-list="fileList"
:data="uploadData"
:before-upload="beforeVoiceUpload"
:on-success="onUploadSuccess"
>
<ElButton type="primary">点击上传</ElButton>
<template #tip>
<div class="text-center leading-[18px]">
格式支持 mp3/wma/wav/amr文件大小不超过 2M播放长度不超过 60s
</div>
</template>
</ElUpload>
</ElCol>
</ElRow>
</div>
</template>
<style lang="scss" scoped></style>

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 @@
export { default } from './video-play.vue';

View File

@@ -0,0 +1,78 @@
<!--
- 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 { ElDialog } from 'element-plus';
import 'video.js/dist/video-js.css';
defineOptions({ name: 'VideoPlayer' });
const props = defineProps({
url: {
type: String,
required: true,
},
});
const dialogVideo = ref(false);
// const handleEvent = (log) => {
// console.log('Basic player event', log)
// }
const playVideo = () => {
dialogVideo.value = true;
};
</script>
<template>
<div @click="playVideo()">
<!-- 提示 -->
<div class="flex cursor-pointer flex-col items-center">
<IconifyIcon icon="ep:video-play" :size="32" class="mr-5px" />
<p class="text-sm">点击播放视频</p>
</div>
<!-- 弹窗播放 -->
<ElDialog v-model="dialogVideo" title="视频播放" append-to-body>
<VideoPlayer
v-if="dialogVideo"
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())"-->
</ElDialog>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default } from './voice-play.vue';

View File

@@ -0,0 +1,110 @@
<!--
- 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';
// 因为微信语音是 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();
/** 处理点击,播放或暂停 */
const playVoice = () => {
// 情况一:未初始化,则创建 BenzAMRRecorder
if (amr.value === undefined) {
amrInit();
return;
}
// 情况二:已经初始化,则根据情况播放或暂时
if (amr.value.isPlaying()) {
amrStop();
} else {
amrPlay();
}
};
/** 音频初始化 */
const amrInit = () => {
amr.value = new BenzAMRRecorder();
// 设置播放
amr.value.initWithUrl(props.url).then(() => {
amrPlay();
duration.value = amr.value.getDuration();
});
// 监听暂停
amr.value.onEnded(() => {
playing.value = false;
});
};
/** 音频播放 */
const amrPlay = () => {
playing.value = true;
amr.value.play();
};
/** 音频暂停 */
const amrStop = () => {
playing.value = false;
amr.value.stop();
};
// TODO 芋艿:下面样式有点问题
</script>
<template>
<div class="wx-voice-div" @click="playVoice">
<el-icon>
<IconifyIcon v-if="playing !== true" icon="ep:video-play" :size="32" />
<IconifyIcon v-else icon="ep:video-pause" :size="32" />
<span class="amr-duration" v-if="duration">{{ duration }} </span>
</el-icon>
<div v-if="content">
<el-tag type="success" size="small">语音识别</el-tag>
{{ content }}
</div>
</div>
</template>
<style lang="scss" scoped>
.wx-voice-div {
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 50px;
padding: 5px;
background-color: #eaeaea;
border-radius: 10px;
}
.amr-duration {
margin-left: 5px;
font-size: 11px;
}
</style>