Stapxs-QQ-Lite-2.0/src/components/MsgBody.vue

468 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
* @FileDescription: 消息模板
* @Author: Stapxs
* @Date:
* 2022/08/03
* 2022/12/12
* @Version:
* 1.0 - 初始版本
* 1.5 - 重构为 ts 版本代码格式优化
-->
<template>
<div :class="'message' + (type ? ' ' + type : '') + (data.revoke ? ' revoke' : '') + (isMe ? ' me': '')" :data-raw="getMsgRawTxt(data.message)"
:id="'chat-' + getSeq(data.message_id)" :data-sender="data.sender.user_id" :data-time="data.time"
@mouseleave="hiddenUserInfo">
<img name="avatar" :src="'https://q1.qlogo.cn/g?b=qq&s=0&nk=' + data.sender.user_id" v-show="!isMe || type == 'merge'">
<div class="message-space" v-if="isMe && type != 'merge'"></div>
<div :class="isMe ? (type == 'merge' ? 'message-body' : 'message-body me') : 'message-body'">
<a v-if="data.sender.card || data.sender.nickname" v-show="!isMe || type == 'merge'">
{{ data.sender.card ? data.sender.card : data.sender.nickname }}
</a>
<a v-else v-show="!isMe || type == 'merge'">
{{ isMe ? runtimeData.loginInfo.nickname : runtimeData.chatInfo.show.name }}
</a>
<div>
<!-- 回复指示框 -->
<div v-if="data.source && data.source.seq" :class="isMe ? (type == 'merge' ? 'msg-replay' : 'msg-replay me') : 'msg-replay'"
@click="scrollToMsg(data.source.seq)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
d="M8.31 189.9l176-151.1c15.41-13.3 39.69-2.509 39.69 18.16v80.05C384.6 137.9 512 170.1 512 322.3c0 61.44-39.59 122.3-83.34 154.1c-13.66 9.938-33.09-2.531-28.06-18.62c45.34-145-21.5-183.5-176.6-185.8v87.92c0 20.7-24.31 31.45-39.69 18.16l-176-151.1C-2.753 216.6-2.784 199.4 8.31 189.9z">
</path>
</svg>
<a> {{ getRepInfo((data.source ? data.source.message : ''), data) }} </a>
</div>
<!-- 消息体 -->
<div v-for="(item, index) in data.message" :class="View.isMsgInline(item.type) ? 'msg-inline' : ''" :key="data.message_id + '-m-' + index">
<div v-if="item.type === undefined" ></div>
<span v-else-if="isDebugMsg" class="msg-text">{{ item }}</span>
<span v-else-if="item.type == 'text'" @click="textClick" v-show="item.text !== ''" class="msg-text" v-html="parseText(item.text)"></span>
<img v-else-if="item.type == 'image'" :title="$t('chat_view_pic')" :alt="$t('chat_group_pic')" @load="scrollButtom" @error="imgLoadFail" @click="imgClick(data.message_id)" :class="imgStyle(data.message.length, index, item.asface)" :src="item.url">
<img v-else-if="item.type == 'face'" :alt="item.text" class="msg-face" :src="require('./../assets/img/qq-face/' + item.id + '.gif')" :title="item.text">
<span v-else-if="item.type == 'bface'" style="font-style: italic;opacity: 0.7;">[ {{ $t('chat_fun_menu_pic') }}{{ item.text }} ]</span>
<div v-else-if="item.type == 'at'" v-show="isAtShow(data.source, item.qq)" :class="getAtClass(item.qq)">
<a @mouseenter="showUserInfo" :data-id="item.qq" :data-group="data.group_id">{{ getAtName(item) }}</a>
</div>
<div v-else-if="item.type == 'file'" :class="'msg-file' + (isMe ? ' me' : '')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M0 64C0 28.65 28.65 0 64 0H224V128C224 145.7 238.3 160 256 160H384V448C384 483.3 355.3 512 320 512H64C28.65 512 0 483.3 0 448V64zM256 128V0L384 128H256z"/></svg>
<div>
<div><p>{{ item.name }}</p><a>{{ getSizeFromBytes(item.size) }}</a></div><i>{{ item.md5 }}</i></div>
<div>
<svg @click="downloadFile(item, data.message_id)" v-if="item.downloadingPercentage === undefined" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M374.6 310.6l-160 160C208.4 476.9 200.2 480 192 480s-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 370.8V64c0-17.69 14.33-31.1 31.1-31.1S224 46.31 224 64v306.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0S387.1 298.1 374.6 310.6z"/></svg>
<svg v-if="item.downloadingPercentage !== undefined" class="download-bar" xmlns="http://www.w3.org/2000/svg">
<circle cx="50%" cy="50%" r="40%" stroke-width="15%" fill="none" stroke-linecap="round" />
<circle cx="50%" cy="50%" r="40%" stroke-width="15%" fill="none" :stroke-dasharray="item.downloadingPercentage === undefined ?
'0,10000' : `${Math.floor(2 * Math.PI * 25) * item.downloadingPercentage / 100},10000`" />
</svg>
</div>
</div>
<div v-else-if="item.type == 'video'" class="msg-video">
<video v-if="item.url" controls><source :src="item.url" type="video/mp4"></video>
<div v-else-if="!getVideo" :class="getVideoUrl(item, data.message_id)"></div>
</div>
<div v-else-if="item.type == 'xml'" v-html="View.buildXML(item.data, item.id, data.message_id)" @click="View.cardClick('xml-' + data.message_id)"></div>
<div v-else-if="item.type == 'json'" v-html="View.buildJSON(item.data, data.message_id)" @click="View.cardClick('json-' + data.message_id)">
</div>
<span v-else-if="item.type == 'forward'" class="msg-unknown" @click="View.getForwardMsg(item.id)">{{ $t('chat_show_forward') }}</span>
<div :data-seq="getSeq(item.id).toString()" @click="scrollToMsg(getSeq(item.id).toString())" v-else-if="item.type == 'reply'" :class="isMe ? (type == 'merge' ? 'msg-replay' : 'msg-replay me') : 'msg-replay'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
d="M8.31 189.9l176-151.1c15.41-13.3 39.69-2.509 39.69 18.16v80.05C384.6 137.9 512 170.1 512 322.3c0 61.44-39.59 122.3-83.34 154.1c-13.66 9.938-33.09-2.531-28.06-18.62c45.34-145-21.5-183.5-176.6-185.8v87.92c0 20.7-24.31 31.45-39.69 18.16l-176-151.1C-2.753 216.6-2.784 199.4 8.31 189.9z">
</path>
</svg>
<a class="msg-unknown"> {{ $t('chat_jump_reply') }} </a>
</div>
<span v-else class="msg-unknown">{{ '( ' + $t('chat_unsupported_msg') + ': ' + item.type + ' )'
}}</span>
</div>
<!-- 链接预览框 -->
<div :class="'msg-link-view ' + linkViewStyle"
v-if="pageViewInfo !== undefined && Object.keys(pageViewInfo).length > 0">
<div :class="'bar' + (isMe ? ' me' : '')"></div>
<div>
<img :id="data.message_id + '-linkview-img'" @load="linkViewPicFin" alt="预览图片" title="查看图片"
:src="pageViewInfo.img" v-if="pageViewInfo.img !== undefined">
<div class="body">
<p>{{ pageViewInfo.site }}</p>
<span :href="pageViewInfo.url">{{ pageViewInfo.title }}</span>
<span>{{ pageViewInfo.desc }}</span>
</div>
</div>
</div>
</div>
</div>
<code style="display: none;">{{ data.raw_message }}</code>
</div>
</template>
<script lang="ts">
import Util from '@/function/util'
import Option from '@/function/option'
import { MsgBodyFuns as ViewFuns } from '@/function/model/msg-body'
import { defineComponent } from 'vue'
import { Connector } from '@/function/connect'
import { runtimeData } from '@/function/msg'
import { Logger, PopInfo, PopType } from '@/function/base'
import app from '@/main'
export default defineComponent({
name: 'MsgBody',
props: ['data', 'type'],
data () {
return {
getSizeFromBytes: Util.getSizeFromBytes,
isMe: false,
isDebugMsg: Option.get('debug_msg'),
linkViewStyle: '',
View: ViewFuns,
runtimeData: runtimeData,
pageViewInfo: undefined as { [key: string]: any } | undefined,
gotLink: false,
getVideo: false
}
},
methods: {
/**
* 获取消息的纯文本(此方法可能会被遗弃)
* @param message 消息对象
*/
getMsgRawTxt (message: any) {
return Util.getMsgRawTxt(message)
},
/**
* 判断是否需要隐藏重复的 At
* @param source 回复信息
* @param at at 信息
*/
isAtShow (source: any, at: any) {
if (source) {
return !(at === source.user_id)
}
return true
},
/**
* 根据消息状态获取 At 消息实际的 CSS class
* @param who
*/
getAtClass (who: number | string) {
let back = 'msg-at'
if (this.isMe && this.type != 'merge') {
back += ' me'
}
if (runtimeData.loginInfo.uin == who || who == 'all') {
back += ' atme'
}
return back
},
/**
* 在 At 消息返回内容没有名字的时候尝试在群成员列表内寻找
* @param item
*/
getAtName (item: { [key: string]: any }) {
if(item.text != undefined) {
return item.text
} else {
for(let i=0; i<runtimeData.chatInfo.info.group_members.length; i++) {
const user = runtimeData.chatInfo.info.group_members[i]
if(user.user_id == Number(item.qq)) {
return '@' + (user.card != '' ? user.card : user.nickname)
}
}
}
},
/**
* 滚动到指定消息
* @param id 消息 seq
*/
scrollToMsg (id: string) {
this.$emit('scrollToMsg', 'chat-' + id)
},
/**
* 处理图片显示需要的样式,顺便添加图片列表
* @param length 消息段数
* @param at 图片在消息中的位置
*/
imgStyle (length: number, at: number, isFace: boolean) {
let style = 'msg-img'
// 处理样式
// if(isFace) { style += ' face' }
if(isFace) { style += ' ' }
if(length === 1) { return style += ' alone' }
if(at === 0) { return style += ' top' }
if(at === length - 1) { return style += ' button' }
return style
},
/**
* 图片点击
* @param msgId 消息 ID
*/
imgClick (msgId: string) {
const seq = Util.parseMsgId(msgId).seqid
if(runtimeData.chatInfo.info.image_list !== undefined) {
// 寻找实际的序号
let num = -1
for(let i = 0; i < runtimeData.chatInfo.info.image_list.length; i++) {
const item = runtimeData.chatInfo.info.image_list[i]
if(item.index == seq && item.message_id == msgId) {
num = i
break
}
}
// 显示
const viewer = app.config.globalProperties.$viewer
if(num >= 0 && viewer) {
viewer.view(num)
viewer.show()
runtimeData.tags.viewer.index = num
} else {
new PopInfo().add(PopType.INFO, this.$t('pop_find_pic_fail'))
}
}
},
/**
* 图片加载完成,滚到底部
*/
scrollButtom () {
this.$emit('scrollButtom', null)
},
/**
* 图片加载失败
*/
imgLoadFail (event: Event) {
const sender = event.currentTarget as HTMLImageElement
const parent = sender.parentNode as HTMLDivElement
parent.style.display = 'flex'
parent.style.flexDirection = 'column'
parent.style.alignItems = 'center'
parent.style.padding = '20px 50px'
parent.style.border = '2px dashed var(--color-card-2)'
parent.style.borderRadius = '10px'
parent.style.margin = '10px 0'
parent.innerText = ''
// 新建 svg
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 512 512')
svg.innerHTML = '<path d="M119.4 44.1c23.3-3.9 46.8-1.9 68.6 5.3l49.8 77.5-75.4 75.4c-1.5 1.5-2.4 3.6-2.3 5.8s1 4.2 2.6 5.7l112 104c2.9 2.7 7.4 2.9 10.5 .3s3.8-7 1.7-10.4l-60.4-98.1 90.7-75.6c2.6-2.1 3.5-5.7 2.4-8.8L296.8 61.8c28.5-16.7 62.4-23.2 95.7-17.6C461.5 55.6 512 115.2 512 185.1v5.8c0 41.5-17.2 81.2-47.6 109.5L283.7 469.1c-7.5 7-17.4 10.9-27.7 10.9s-20.2-3.9-27.7-10.9L47.6 300.4C17.2 272.1 0 232.4 0 190.9v-5.8c0-69.9 50.5-129.5 119.4-141z"/>'
svg.style.width = '40px'
svg.style.opacity = '0.8'
svg.style.fill = 'var(--color-main)'
if(this.isMe) {
svg.style.fill = 'var(--color-font-r)'
}
parent.appendChild(svg)
// 新建 span
const span = document.createElement('span')
span.innerText = this.$t('chat_load_img_fail')
span.style.marginTop = '10px'
span.style.fontSize = '0.8rem'
span.style.color = 'var(--color-font-2)'
if(this.isMe) {
span.style.color = 'var(--color-font-1-r)'
}
parent.appendChild(span)
// 链接
const a = document.createElement('a')
a.innerText = this.$t('chat_view_pic')
a.target = '__blank'
a.href = sender.src
a.style.marginTop = '10px'
a.style.fontSize = '0.7rem'
a.style.color = 'var(--color-font-2)'
if(this.isMe) {
a.style.color = 'var(--color-font-1-r)'
}
parent.appendChild(a)
},
/**
* 获取消息 ID 的 seq
* @param id 消息 ID
*/
getSeq (id: string) {
const seq = Util.parseMsgId(id).seqid
return seq ? seq : id
},
/**
* 处理纯文本消息和链接预览
* @param text 纯文本消息
*/
parseText (text: string) {
const logger = new Logger()
text = ViewFuns.parseText(text)
// 链接判定
const reg = /(http|ftp|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?/gi //eslint-disable-line
text = text.replaceAll(reg, '<a href="" data-link="$&" onclick="return false">$&</a>')
let linkList = text.match(reg)
if (linkList !== null && !this.gotLink) {
this.gotLink = true
const fistLink = linkList[0]
// 获取链接预览
fetch('https://api.stapxs.cn/tool/page-info/' + encodeURIComponent(fistLink))
.then(res => res.json())
.then(res => {
if (res.status === undefined && Object.keys(res).length > 0) {
logger.debug(this.$t('chat_link_view_success') + ': ' + res['og:title'])
const pageData = {
site: res['og:site_name'] === undefined ? '' : res['og:site_name'],
title: res['og:title'] === undefined ? '' : res['og:title'],
desc: res['og:description'] === undefined ? '' : res['og:description'],
img: res['og:image'],
link: res['og:url']
}
this.pageViewInfo = pageData
}
// GA上传使用链接预览功能的事件用于分析成功
const reg1 = /\/\/(.*?)\//g
const getDom = fistLink.match(reg1)
if (getDom !== null) {
this.$gtag.event('link_view', { domain: RegExp.$1, statue: true })
} else {
this.$gtag.event('link_view')
}
})
.catch(error => {
if (error) {
logger.error(this.$t('chat_link_view_fail') + ': ' + fistLink)
// GA上传使用链接预览功能的事件用于分析失败
const reg1 = /\/\/(.*?)\//g
const getDom = fistLink.match(reg1)
if (getDom !== null) {
this.$gtag.event('link_view', { domain: RegExp.$1, statue: false })
} else {
this.$gtag.event('link_view')
}
}
})
}
// 返回
return text
},
/**
* 对链接预览的图片长宽进行判定以确定显示样式
*/
linkViewPicFin () {
const img = document.getElementById(this.data.message_id + '-linkview-img') as HTMLImageElement
if (img !== null) {
const w = img.naturalWidth
const h = img.naturalHeight
if (w > h) {
this.linkViewStyle = 'large'
}
}
},
/**
* 当鼠标悬停在 at 消息上时显示被 at 人的消息悬浮窗
* @param event 消息事件
*/
showUserInfo (event: Event) {
const sender = event.currentTarget as HTMLDivElement
const id = sender.dataset.id
const group = sender.dataset.group
// 获取鼠标位置
const pointEvent = event as MouseEvent || window.event as MouseEvent
const pointX = pointEvent.offsetX
const pointY = pointEvent.clientY
// TODO: 出界判定不做了怪麻烦的
// 请求用户信息
Connector.send('getGroupMemberInfo', { group_id: group, user_id: id },
'getGroupMemberInfo_' + pointX + '_' + pointY)
},
/**
* 隐藏 At 信息面板
*/
hiddenUserInfo () {
if(runtimeData.chatInfo.info.now_member_info !== undefined) {
runtimeData.chatInfo.info.now_member_info = undefined
}
},
/**
* 获取回复内容(拼接名字和消息内容)
* @param msg 消息对象
* @param data 回复信息
*/
getRepInfo (msg: any, data: any) {
const list = this.runtimeData.chatInfo.info.group_members.filter((item) => {
return Number(item.user_id) === Number(data.source.user_id)
})
if (list.length === 1) {
return (list[0].card !== '' ? list[0].card : list[0].nickname) + ': ' + msg
}
return msg
},
/**
* 下载消息中的文件
* @param data 消息对象
*/
downloadFile(data: any, message_id: string) {
const onProcess = function (event: ProgressEvent): undefined {
if (!event.lengthComputable) return
data.downloadingPercentage = Math.floor(event.loaded / event.total * 100)
}
if(data.url) {
// 消息中有文件链接的话就不用获取了 ……
Util.downloadFile(data.url, data.name, onProcess)
} else {
// 获取下载链接
Connector.send('get_file_url', {
id: runtimeData.chatInfo.show.id,
message_id: message_id,
fid: data.fid
}, 'downloadFile_' + message_id + '_' + data.name)
}
},
/**
*
* @param msgId 消息 ID
* @param fid 文件 ID
*/
getVideoUrl(data: any, message_id: string) {
this.getVideo = true
Connector.send('get_video_url', {
id: runtimeData.chatInfo.show.id,
message_id: message_id,
fid: data.fid,
md5: data.md5
}, 'getVideoUrl_' + message_id)
},
/**
* 文本消息被点击
* @param event 事件
*/
textClick(event: Event) {
const target = event.target as HTMLElement
if(target.dataset.link) {
// 点击了链接
const link = target.dataset.link
Util.openLink(link)
}
}
},
mounted () {
// 初始化 isMe 参数
this.isMe = Number(runtimeData.loginInfo.uin) === Number(this.data.sender.user_id)
}
})
</script>