alipay-emulator/components/message/chat/chat-list.vue

874 lines
25 KiB
Vue
Raw Permalink 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.

<template>
<view>
<view v-for="(message, index) in displayList" :key="message.id || index" class="message-item" :class="{
isMe: message.isMe,
'm-t-16': shouldApplyMt16(index) && !shouldShowTime(index),
'is-sort-mode': sortMode,
'sort-dragging': sortMode && dragIndex === index,
'sort-drop-before': sortMode && dragOverIndex === index && dragIndex !== index && dropPosition === 'before',
'sort-drop-after': sortMode && dragOverIndex === index && dragIndex !== index && dropPosition === 'after'
}">
<!-- 排序模式下的拖拽手柄 -->
<view v-if="sortMode" class="sort-handle-wrap" @longpress="onSortLongPress(index, $event)"
@touchmove.stop.prevent="onSortTouchMove(index, $event)" @touchend.stop="onSortTouchEnd(index, $event)">
<view class="sort-handle-bar"></view>
<view class="sort-handle-bar"></view>
<view class="sort-handle-bar"></view>
</view>
<view style="flex: 1; overflow: hidden;">
<view class="time m-t-44" v-if="shouldShowTime(index)">
<view class="top-text" v-if="phone == 'iphone' && index == 0">信息 · 短信</view>
<view class="top-text" v-if="phone == 'huawei' && index == 0">短信/彩信</view>
<text v-if="phone == 'huawei'">{{ formatHuaweiTopTime(message.time) }}</text>
<text v-else>{{ formatChatTime(message.time) }} <text
v-if="(phone == 'oppo' || (phone == 'vivo' && message.isMe)) && message.simIndex">
{{ simInfo[`sim${message.simIndex}`] }}
</text></text>
<image style="width: 20rpx;height: 24rpx;margin-left: 8rpx; "
v-if="phone == 'oppo' || (phone == 'vivo' && message.isMe)"
:src="`/static/image/phone-message/huawei/chat-ka${message.simIndex}.png`">
</image>
</view>
<view class="chat-box" :id="'msg-' + index" :class="{
'tail-right': shouldApplyTailRight(index),
'tail-left': shouldApplyTailLeft(index),
'delivered': isLastMeMessage(index)
}" @longpress="!sortMode && onMessageLongPress(index, message)">
<text v-if="message.isMe && phone == 'mi'" class="send-text">送达</text>
<view class="chat-bubble">
<view v-html="formatMessageContent(message.content, message.isMe)"></view>
</view>
</view>
<view v-if="phone == 'huawei'" class="second-info">
<text>{{ formatHuaweiBottomTime(message.time) }}</text>
<image :src="`/static/image/phone-message/huawei/chat-ka${message.simIndex}.png`"></image>
</view>
<view v-if="(phone == 'oppo' || phone == 'vivo') && message.isMe" class="second-info">
<text v-if="message.isMe" class="delivered">已送达</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch, nextTick } from 'vue'
import { onLoad, onPageScroll } from "@dcloudio/uni-app";
import { stringUtil, util } from '@/utils/common.js';
const props = defineProps({
// 手机品牌
phone: {
type: String,
default: 'iphone'
},
messageList: {
type: Array,
default: []
},
// 是否处于拖拽排序模式
sortMode: {
type: Boolean,
default: false
}
})
const SIM_STORAGE_KEY = 'sim_info'
const emit = defineEmits(['onMessageLongPress', 'sort'])
const simInfo = ref({
sim1: '中国电信',
sim2: '中国移动'
})
onMounted(() => {
try {
const cached = uni.getStorageSync(SIM_STORAGE_KEY)
simInfo.value = cached ? JSON.parse(cached) : { sim1: '中国电信', sim2: '中国移动' }
} catch (e) {
simInfo.value = { sim1: '中国电信', sim2: '中国移动' }
}
})
// 排序模式下的本地唦本列表
// 不直接修改 props.messageList排序完成后 emit 给父组件
const localSortList = ref([])
const dragIndex = ref(-1)
const dragOverIndex = ref(-1)
const dropPosition = ref('after') // 'before'=插入目标上方, 'after'=插入目标下方
let isDragging = false
let sortItemRects = []
// 当 sortMode 切换时同步本地副本
watch(() => props.sortMode, (val) => {
if (val) {
localSortList.value = props.messageList.map(item => ({ ...item }))
} else {
dragIndex.value = -1
dragOverIndex.value = -1
isDragging = false
sortItemRects = []
localSortList.value = []
}
})
// 实际渲染用的列表:排序模式下用内部副本,否则用原始列表
const displayList = computed(() => {
if (props.sortMode) return localSortList.value
return props.messageList
})
// 判断消息isMe==true时上一条消息是否也是isMe==true如果不是则应用m-t-16样式
const shouldApplyMt16 = (index) => {
if (index === 0) return false;
const currentMsg = displayList.value[index];
const prevMsg = displayList.value[index - 1];
if (currentMsg.isMe && !prevMsg.isMe) {
return true;
}
return false;
}
// 判断消息isMe==true时下一条消息是否也是isMe==true如果不是则应用m-t-16样式
const shouldApplyNextIsMe = (index) => {
const currentMsg = displayList.value[index];
// 拦截:如果已经是数组的最后一条消息,必然不存在下一条消息
if (index >= displayList.value.length - 1) return false;
const nextMsg = displayList.value[index + 1];
if (currentMsg.isMe && nextMsg.isMe) {
return true;
}
return false;
}
// 判断是否为最后一条自己发出的消息
const isLastMeMessage = (currentIndex) => {
const currentMsg = displayList.value[currentIndex];
if (!currentMsg.isMe) return false;
// 往后遍历如果还能找到isMe=true的消息说明当前这条不是最后一条
for (let i = currentIndex + 1; i < displayList.value.length; i++) {
if (displayList.value[i].isMe) {
return false;
}
}
return true;
}
// 判断 tail-right右侧我方气泡尾巴样式是否生效
const shouldApplyTailRight = (index) => {
const currentMsg = displayList.value[index];
if (!currentMsg.isMe) return false; // 不是我发的直接没右侧尾巴
// 如果这是整体列表最后一条消息,必然有尾巴
if (index === displayList.value.length - 1) return true;
const nextMsg = displayList.value[index + 1];
// 条件 c: 下一条消息 isMe == false (也就是被打断了)
if (!nextMsg.isMe) return true;
// 条件 a: 下一条消息间隔三分钟以上 (180000 毫秒) - 时间分割线导致当前段落结束
const currentMsgTime = new Date(currentMsg.time.replace(/-/g, '/')).getTime();
const nextMsgTime = new Date(nextMsg.time.replace(/-/g, '/')).getTime();
if (!isNaN(currentMsgTime) && !isNaN(nextMsgTime) && (nextMsgTime - currentMsgTime > 180000)) {
return true;
}
// 其他情况(连绵不断的连发,中间没超时间也没被对方打断),则隐藏中间环节气泡尾巴
return false;
}
// 判断 tail-left 样式是否生效
const shouldApplyTailLeft = (index) => {
const currentMsg = displayList.value[index];
if (currentMsg.isMe) return false;
// 如果这是整体列表最后一条消息也就符合最后一条isMe==false的特征
if (index === displayList.value.length - 1) return true;
const nextMsg = displayList.value[index + 1];
// 条件 c: 下一条消息 isMe == true
if (nextMsg.isMe) return true;
// 条件 a: 下一条消息间隔三分钟以上 (180000 毫秒)
const currentMsgTime = new Date(currentMsg.time.replace(/-/g, '/')).getTime();
const nextMsgTime = new Date(nextMsg.time.replace(/-/g, '/')).getTime();
if (!isNaN(currentMsgTime) && !isNaN(nextMsgTime) && (nextMsgTime - currentMsgTime > 180000)) {
return true;
}
// 条件 b: 这是最后一条 isMe == false 的消息
for (let i = index + 1; i < displayList.value.length; i++) {
if (!displayList.value[i].isMe) {
return false; // 往后还有不是自己发的消息,所以当前并非最后一条 isMe==false
}
}
return true;
}
// 获取格式化后的聊天时间显示
const formatChatTime = (timeStr) => {
if (!timeStr) return '';
const date = new Date(timeStr.replace(/-/g, '/'));
if (isNaN(date.getTime())) return timeStr;
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.floor((today.getTime() - target.getTime()) / (1000 * 60 * 60 * 24));
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const timeNum = `${hours}:${minutes}`;
if (diffDays === 0) {
if (props.phone == 'iphone' || props.phone == 'oppo') return `今天 ${timeNum}`;
return `${timeNum}`;
} else if (diffDays === 1) {
return `昨天 ${timeNum}`;
} else if (diffDays > 1 && diffDays < 7) {
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return `${weekDays[date.getDay()]} ${timeNum}`;
} else {
// 超过一周,同一年显示 月日 时分,跨年显示 年月日 时分
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}月${date.getDate()}日 ${timeNum}`;
} else {
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${timeNum}`;
}
}
}
// 华为顶部时间栏:只显示日期维度的信息,无具体时分秒
const formatHuaweiTopTime = (timeStr) => {
if (!timeStr) return '';
const date = new Date(timeStr.replace(/-/g, '/'));
if (isNaN(date.getTime())) return timeStr;
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.floor((today.getTime() - target.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return `今天`;
} else if (diffDays === 1) {
return `昨天`;
} else {
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const weekdayStr = weekDays[date.getDay()];
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日${weekdayStr}`;
}
}
// 华为底部小尾巴附加时间:上下午时分、或者“刚刚”
const formatHuaweiBottomTime = (timeStr) => {
if (!timeStr) return '';
const date = new Date(timeStr.replace(/-/g, '/'));
if (isNaN(date.getTime())) return timeStr;
const now = new Date();
// 判断是否在一分钟内(差值在 60000 毫秒以内)
const diffMs = now.getTime() - date.getTime();
if (diffMs >= 0 && diffMs <= 60000) {
return '刚刚';
}
const ampm = date.getHours() >= 12 ? '下午' : '上午';
// 转换为 12 小时制
let displayHour = date.getHours() % 12;
displayHour = displayHour ? displayHour : 12; // 如果是 0(午夜) 则是12
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${ampm}${displayHour}:${minutes}`;
}
// 判断是否显示时间
// timeMode: 'show'=强制显示, 'hide'=强制隐藏, 'auto'或无=按间隔逻辑判断
const shouldShowTime = (index) => {
const currentMsg = displayList.value[index];
const mode = currentMsg.timeMode;
// 强制隐藏(兼容旧 hideTime 字段)
if (mode === 'hide' || currentMsg.hideTime) return false;
// 强制显示
if (mode === 'show') return true;
// 以下为 auto / 默认 逻辑
if (index === 0) return true; // 第一条必定显示
// 排序模式下每条均显示时间
if (props.sortMode) return true;
const currentMsgTime = new Date(currentMsg.time.replace(/-/g, '/')).getTime();
const prevMsgTime = new Date(displayList.value[index - 1].time.replace(/-/g, '/')).getTime();
if (isNaN(currentMsgTime) || isNaN(prevMsgTime)) return true;
// 绝对值比较,兼容排序后时间倒序
return Math.abs(currentMsgTime - prevMsgTime) > 180000;
}
// 格式化文本:给网址和 5 位以上连续数字加上下划线样式,非我方消息同时赋予文字蓝色
const formatMessageContent = (content, isMe) => {
if (!content) return '';
// 匹配 url 或 5位以上连续数字
// URL: http(s)://... 或 xxx.xxx/...
const combinedRegex = /((?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:\/[a-zA-Z0-9_/%-]*)*)|(\b\d{5,}(?:\.\d+)?\b)/g;
return content.replace(combinedRegex, (match, p1, p2) => {
// 如果是数字且包含小数点(通常是金额),则不添加下划线样式
if (p2 && p2.includes('.')) {
return match;
}
let color = '#3A85F8'
if (props.phone == 'iphone') color = '#0B7AE3'
if (props.phone == 'mi') color = '#3A85F8'
const colorStyle = !isMe ? `color: ${color};` : '';
return `<span style="text-decoration: underline; ${colorStyle}">${match}</span>`;
});
}
/**
* 长按信息
* @param index
* @param message
*/
const onMessageLongPress = (index, message) => {
emit('onLongPress', index, message)
}
// ===================== 拖拽排序逻辑 =====================
/**
* 刷新所有排序条目的 rect 坐标缓存
*/
const refreshSortRects = (callback) => {
const query = uni.createSelectorQuery()
const len = localSortList.value.length
let collected = []
let done = 0
for (let i = 0; i < len; i++) {
query.select('#msg-' + i).boundingClientRect(rect => {
collected[i] = rect
done++
if (done === len && callback) callback(collected)
})
}
query.exec()
}
/**
* 长按手柄开始拖拽
*/
const onSortLongPress = (idx, e) => {
dragIndex.value = idx
dragOverIndex.value = idx
isDragging = true
refreshSortRects(rects => {
sortItemRects = rects
})
uni.vibrateShort({ type: 'medium' })
}
/**
* 拖拽移动 - 根据触摸点 Y 坐标確定悬停位置和插入方向
*/
const onSortTouchMove = (idx, e) => {
if (!isDragging || dragIndex.value === -1) return
if (!e.touches || !e.touches[0]) return
const touchY = e.touches[0].clientY
if (!sortItemRects || sortItemRects.length === 0) return
let overIdx = dragOverIndex.value
for (let i = 0; i < sortItemRects.length; i++) {
const rect = sortItemRects[i]
if (rect && touchY >= rect.top && touchY <= rect.bottom) {
overIdx = i
// 判断手指在该元素的上半区还是下半区
const mid = rect.top + rect.height / 2
dropPosition.value = touchY < mid ? 'before' : 'after'
break
}
}
dragOverIndex.value = overIdx
}
/**
* 拖拽结束 - 根据 dropPosition 決定插入到目标的上方还是下方
*/
const onSortTouchEnd = (idx, e) => {
if (!isDragging) return
const from = dragIndex.value
const to = dragOverIndex.value
if (from !== -1 && to !== -1 && from !== to) {
const list = [...localSortList.value]
const [removed] = list.splice(from, 1)
// from 被移除后,如果 to 在 from 后面to 需要 -1
let insertAt = to > from ? to - 1 : to
if (dropPosition.value === 'after') insertAt += 1
list.splice(insertAt, 0, removed)
localSortList.value = list
emit('sort', list.map(item => ({ ...item })))
nextTick(() => {
refreshSortRects(rects => {
sortItemRects = rects
})
})
}
dragIndex.value = -1
dragOverIndex.value = -1
dropPosition.value = 'after'
isDragging = false
}
</script>
<style lang="less" scoped>
/* ===== 排序模式公共样式(跨所有手机品牌通用)===== */
/* 排序模式下每条消息左右横排(手柄 + 气泡取剩余宽度)*/
.is-sort-mode {
display: flex;
flex-direction: row;
align-items: stretch;
}
/* 拖拽手柄区域 */
.sort-handle-wrap {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 60rpx;
flex-shrink: 0;
padding: 0 8rpx;
.sort-handle-bar {
margin: 3rpx 0;
}
}
.sort-handle-bar {
width: 30rpx;
height: 4rpx;
border-radius: 2rpx;
background-color: #CCCCCC;
}
/* 被拖拽中的条目:高亮蓝边 + 轻微缩进 */
.sort-dragging {
opacity: 0.7;
background-color: rgba(0, 122, 255, 0.06) !important;
border-left: 4rpx solid #007AFF;
}
/* 插入线:将拖拽项放在目标上方 */
.sort-drop-before {
border-top: 3rpx dashed #007AFF !important;
padding-top: 4rpx;
}
/* 插入线:将拖拽项放在目标下方 */
.sort-drop-after {
border-bottom: 3rpx dashed #007AFF !important;
padding-bottom: 4rpx;
}
// 苹果样式
.iphone-style {
.m-t-16 {
margin-top: 16rpx !important;
}
.top-text {
text-align: center;
font-size: 20rpx;
color: #838383;
margin-top: 26rpx;
}
.time {
color: #838383;
font-size: 20rpx;
text-align: center;
margin-bottom: 20rpx;
margin-top: 36rpx;
}
.chat-box {
display: flex;
justify-content: flex-start;
width: 100%;
.chat-bubble {
padding: 18rpx 22rpx;
background-color: #E9E9EB;
border-radius: 34rpx;
max-width: 600rpx;
font-size: 32rpx;
line-height: 38rpx;
color: #1A1A1A;
margin: 4rpx 30rpx 0;
word-break: break-all;
}
}
.isMe {
.chat-box {
justify-content: flex-end;
}
.chat-bubble {
background-color: #34C85A;
color: #fff;
}
}
.tail-left {
position: relative;
}
.tail-left::after {
position: absolute;
left: 18rpx;
bottom: 0;
content: '';
background: url('/static/image/phone-message/iphone/left-msg-box.png') no-repeat center bottom;
background-size: 100% 100%;
width: 28rpx;
height: 36rpx;
}
.tail-right {
position: relative;
}
.delivered::before {
position: absolute;
right: 18rpx;
bottom: -12rpx;
content: '已送达';
font-size: 20rpx;
line-height: 20rpx;
color: #8B8B8B;
transform: translateY(100%);
}
.tail-right::after {
position: absolute;
right: 18rpx;
bottom: 0;
content: '';
background: url('/static/image/phone-message/iphone/right-msg-box.png') no-repeat center bottom;
background-size: 100% 100%;
width: 28rpx;
height: 36rpx;
}
}
// 小米样式
.mi-style {
.top-text {
text-align: center;
font-size: 20rpx;
color: #838383;
margin-top: 26rpx;
}
.time {
padding: 0 54rpx;
color: #646464;
font-size: 24rpx;
line-height: 28rpx;
margin-bottom: 16rpx;
}
.chat-box {
display: flex;
justify-content: flex-start;
width: 100%;
.chat-bubble {
padding: 22rpx 42rpx;
background-color: #FFFFFF;
border-radius: 32rpx;
font-size: 32rpx;
line-height: 50rpx;
color: #1A1A1A;
max-width: 550rpx;
margin: 0 24rpx;
word-break: break-all;
}
}
.message-item {
margin-top: 16rpx;
}
.m-t-44 {
margin-top: 44rpx;
}
.isMe {
.time {
text-align: right;
}
.chat-box {
justify-content: flex-end;
align-items: center;
.send-text {
color: #646464;
font-size: 24rpx;
}
.chat-bubble {
margin-left: 12rpx;
background-color: #3681FF;
color: #FFFFFF;
}
}
}
}
// oppo样式
.oppo-style {
.m-t-16 {
margin-top: 24rpx !important;
}
.time {
color: #727377;
font-size: 24rpx;
line-height: 24rpx;
text-align: center;
margin-bottom: 20rpx;
margin-top: 48rpx;
display: flex;
justify-content: center;
align-items: center;
}
.message-item {
margin-top: 12rpx;
}
.chat-box {
display: flex;
justify-content: flex-start;
width: 100%;
.chat-bubble {
padding: 28rpx 48rpx;
background-color: #FFFFFF;
border-radius: 32rpx;
max-width: 600rpx;
font-size: 32rpx;
line-height: 44rpx;
color: #1A1A1A;
margin: 0 32rpx;
word-break: break-all;
}
}
.isMe {
.chat-box {
justify-content: flex-end;
}
.chat-bubble {
background-color: #00A1FF;
color: #fff;
}
}
.second-info {
font-size: 24rpx;
text-align: right;
padding: 0 34rpx;
margin-top: 14rpx;
padding-bottom: 6rpx;
.delivered {
font-size: 24rpx;
line-height: 24rpx;
color: #727377;
}
}
}
// 华为样式
.huawei-style {
.top-text {
text-align: center;
font-size: 22rpx;
line-height: 22rpx;
color: #545454;
margin-bottom: 12rpx;
}
.time {
text-align: center;
font-size: 22rpx;
line-height: 22rpx;
color: #545454;
margin-top: 40rpx;
}
.chat-box {
display: flex;
justify-content: flex-start;
width: 100%;
.chat-bubble {
padding: 12rpx 24rpx;
background-color: #FFFFFF;
border-radius: 4rpx 28rpx 28rpx 28rpx;
max-width: 550rpx;
margin: 0 30rpx;
margin-top: 34rpx;
font-size: 30rpx;
line-height: 38rpx;
color: #1a1a1a;
font-weight: 500;
word-break: break-all;
}
}
.second-info {
display: flex;
align-items: center;
color: #545454;
font-size: 20rpx;
line-height: 24rpx;
margin-top: 16rpx;
padding-left: 52rpx;
padding-right: 56rpx;
image {
width: 14rpx;
height: 18rpx;
margin-left: 8rpx;
}
}
.m-t-44 {
margin-top: 44rpx;
}
.isMe {
.second-info {
justify-content: flex-end;
}
.message-item {
margin-top: 34rpx;
}
.chat-box {
justify-content: flex-end;
align-items: center;
.send-text {
color: #646464;
font-size: 24rpx;
}
.chat-bubble {
margin-left: 12rpx;
background-color: #3681FF;
color: #FFFFFF;
border-radius: 28rpx 4rpx 28rpx 28rpx;
}
}
}
}
// vivo样式
.vivo-style {
.m-t-16 {
margin-top: 24rpx !important;
}
.time {
color: #ACACAC;
font-size: 24rpx;
line-height: 24rpx;
text-align: center;
margin: 40rpx 0;
display: flex;
justify-content: center;
align-items: center;
}
.message-item {
margin-top: 12rpx;
}
.chat-box {
display: flex;
justify-content: flex-start;
width: 100%;
.chat-bubble {
padding: 38rpx 38rpx 32rpx 40rpx;
background-color: #FFFFFF;
border-radius: 32rpx;
max-width: 100%;
font-size: 36rpx;
line-height: 48rpx;
color: #1A1A1A;
margin: 0 32rpx;
word-break: break-all;
}
}
.isMe {
.chat-box {
justify-content: flex-end;
}
.chat-bubble {
background-color: #0078FE;
color: #fff;
}
}
.second-info {
font-size: 24rpx;
text-align: right;
padding: 0 34rpx;
margin-top: 12rpx;
padding-bottom: 6rpx;
.delivered {
font-size: 24rpx;
line-height: 24rpx;
color: #ACACAC;
}
}
}
</style>