feat: 消息迁移

This commit is contained in:
dylanmay
2025-11-04 14:31:32 +08:00
parent cb9fc7ad3f
commit c2b0a91ffc
41 changed files with 3524 additions and 25 deletions

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,100 @@
<script lang="ts" setup>
import { IconifyIcon } from '@vben/icons';
import WxLocation from '#/views/mp/components/wx-location';
import WxMusic from '#/views/mp/components/wx-music';
import WxNews from '#/views/mp/components/wx-news';
import WxVideoPlayer from '#/views/mp/components/wx-video-play';
import WxVoicePlayer from '#/views/mp/components/wx-voice-play';
import { MsgType } from '../types';
import MsgEvent from './MsgEvent.vue';
defineOptions({ name: 'Msg' });
defineProps<{
item: any;
}>();
</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">
<WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" />
</div>
<div v-else-if="item.type === MsgType.Image">
<a :href="item.mediaUrl" target="_blank">
<img :src="item.mediaUrl" style="width: 100px" alt="图片消息" />
</a>
</div>
<div
v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
class="text-center"
>
<WxVideoPlayer :url="item.mediaUrl" />
</div>
<div v-else-if="item.type === MsgType.Link" class="link-card">
<a :href="item.url" target="_blank" class="text-success no-underline">
<div class="link-title">
<IconifyIcon icon="mdi:link" class="mr-1" />
{{ item.title }}
</div>
</a>
<div class="link-description">{{ item.description }}</div>
</div>
<div v-else-if="item.type === MsgType.Location">
<WxLocation
:label="item.label"
:location-y="item.locationY"
:location-x="item.locationX"
/>
</div>
<div v-else-if="item.type === MsgType.News" class="news-wrapper">
<WxNews :articles="item.articles" />
</div>
<div v-else-if="item.type === MsgType.Music">
<WxMusic
: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 lang="scss">
.link-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.link-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #52c41a;
}
.link-description {
font-size: 12px;
color: #666;
}
.news-wrapper {
width: 300px;
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { Tag } from 'ant-design-vue';
defineOptions({ name: 'MsgEvent' });
defineProps<{
item: any;
}>();
</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>
<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>
<Tag color="error">未知事件类型</Tag>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { User } from '../types';
import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils';
import Msg from './Msg.vue';
defineOptions({ name: 'MsgList' });
const props = defineProps<{
accountId: number;
list: any[];
user: User;
}>();
const SendFrom = {
MpBot: 2,
User: 1,
} as const;
const getAvatar = (sendFrom: number) =>
sendFrom === SendFrom.User
? props.user.avatar
: preferences.app.defaultAvatar;
const getNickname = (sendFrom: SendFrom) =>
sendFrom === SendFrom.User ? props.user.nickname : '公众号';
</script>
<template>
<div class="execution" v-for="item in props.list" :key="item.id">
<div
class="avue-comment"
:class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }"
>
<div class="avatar-div">
<img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" />
<div class="avue-comment__author">
{{ getNickname(item.sendFrom) }}
</div>
</div>
<div class="avue-comment__main">
<div class="avue-comment__header">
<div class="avue-comment__create_time">
{{ formatDateTime(item.createTime) }}
</div>
</div>
<div
class="avue-comment__body"
:style="
item.sendFrom === SendFrom.MpBot ? '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');
.avatar-div {
width: 80px;
text-align: center;
}
</style>

View File

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

View File

@@ -0,0 +1,222 @@
<script lang="ts" setup>
import type { User } from './types';
import { nextTick, onMounted, reactive, ref, unref } from 'vue';
import { preferences } from '@vben/preferences';
import { Button, message, Spin } from 'ant-design-vue';
import { getMessagePage, sendMessage } from '#/api/mp/message';
import { getUser } from '#/api/mp/user';
import WxReplySelect from '#/views/mp/components/wx-reply';
import MsgList from './components/MsgList.vue';
defineOptions({ name: 'WxMsg' });
const props = defineProps<{
userId: number;
}>();
const accountId = ref(-1); // 公众号ID需要通过userId初始化
const loading = ref(false); // 消息列表是否正在加载中
const hasMore = ref(true); // 是否可以加载更多
const list = ref<any[]>([]); // 消息列表
const queryParams = reactive({
accountId,
pageNo: 1, // 当前页数
pageSize: 14, // 每页显示多少条
});
// 由于微信不再提供昵称,直接使用"用户"展示
const user: User = reactive({
accountId, // 公众号账号编号
avatar: preferences.app.defaultAvatar,
nickname: '用户',
});
// ========= 消息发送 =========
const sendLoading = ref(false); // 发送消息是否加载中
// 微信发送消息
const reply = ref<any>({
accountId: -1,
articles: [],
type: 'text',
});
const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null); // WxReplySelect组件ref用于消息发送成功后清除内容
const msgDivRef = ref<HTMLDivElement | null>(null); // 消息显示窗口ref用于滚动到底部
/** 完成加载 */
onMounted(async () => {
const data = await getUser(props.userId);
user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname;
user.avatar = data.avatar?.length > 0 ? data.avatar : user.avatar;
accountId.value = data.accountId;
reply.value.accountId = data.accountId;
refreshChange();
});
// 执行发送
const sendMsg = async () => {
if (!unref(reply)) {
return;
}
// 公众号限制:客服消息,公众号只允许发送一条
if (
reply.value.type === 'news' &&
reply.value.articles &&
reply.value.articles.length > 1
) {
reply.value.articles = [reply.value.articles[0]];
message.success('图文消息条数限制在 1 条以内,已默认发送第一条');
}
const data = await sendMessage({
...reply.value,
userId: props.userId,
} as any);
sendLoading.value = false;
list.value = [...list.value, data];
await scrollToBottom();
// 发送后清空数据
replySelectRef.value?.clear();
};
const loadMore = () => {
queryParams.pageNo++;
getPage(queryParams, null);
};
const getPage = async (page: any, params: any = null) => {
loading.value = true;
const dataTemp = await getMessagePage(
Object.assign(
{
accountId: page.accountId,
pageNo: page.pageNo,
pageSize: page.pageSize,
userId: props.userId,
},
params,
),
);
const scrollHeight = msgDivRef.value?.scrollHeight ?? 0;
// 处理数据
const data = dataTemp.list.reverse();
list.value = [...data, ...list.value];
loading.value = false;
if (data.length < queryParams.pageSize || data.length === 0) {
hasMore.value = false;
}
queryParams.pageNo = page.pageNo;
queryParams.pageSize = page.pageSize;
// 滚动到原来的位置
if (queryParams.pageNo === 1) {
// 定位到消息底部
await scrollToBottom();
} else if (data.length > 0) {
// 定位滚动条
await nextTick();
if (scrollHeight !== 0 && msgDivRef.value) {
msgDivRef.value.scrollTop =
msgDivRef.value.scrollHeight - scrollHeight - 100;
}
}
};
const refreshChange = () => {
getPage(queryParams);
};
/** 定位到消息底部 */
const scrollToBottom = async () => {
await nextTick();
if (msgDivRef.value) {
msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight;
}
};
</script>
<template>
<div class="wx-msg-container">
<div ref="msgDivRef" class="msg-div">
<!-- 加载更多 -->
<Spin :spinning="loading" />
<div v-if="!loading">
<div v-if="hasMore" class="load-more-btn" @click="loadMore">
<span>点击加载更多</span>
</div>
<div v-else class="load-more-btn disabled">
<span>没有更多了</span>
</div>
</div>
<!-- 消息列表 -->
<MsgList :list="list" :account-id="accountId" :user="user" />
</div>
<div class="msg-send">
<Spin :spinning="sendLoading">
<WxReplySelect ref="replySelectRef" v-model="reply" />
<Button type="primary" class="send-but" @click="sendMsg">
发送(S)
</Button>
</Spin>
</div>
</div>
</template>
<style lang="scss" scoped>
.wx-msg-container {
display: flex;
flex-direction: column;
height: 100%;
}
.msg-div {
flex: 1;
height: 50vh;
margin: 0 10px;
overflow: auto;
background-color: #eaeaea;
}
.load-more-btn {
padding: 12px;
font-size: 14px;
color: #409eff;
text-align: center;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: #f5f7fa;
}
&.disabled {
color: #909399;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
}
.msg-send {
padding: 10px;
}
.send-but {
float: right;
margin-top: 8px;
margin-bottom: 8px;
}
</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 {
accountId: number;
avatar: string;
nickname: string;
}