alipay-emulator/pages/message/chat-page/chat-page.vue

717 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 v-if="$isVip()">
<watermark :dark="data.dark" />
<liu-drag-button :canDocking="false" @clickBtn="$goRechargePage('watermark')">
<c-lottie ref="cLottieRef" :src='$watermark()' width="94px" height='74px' :loop="true"></c-lottie>
</liu-drag-button>
</view>
<view :class="`${data.phone}-style`">
<ChatLayout :phone="data.phone" :chatInfo="data.data" :sortMode="isSortMode" @send="handleSend"
:number="data.number">
<!-- 弹出操作层及遮罩 -->
<view v-if="showActionPopup" class="action-mask" @tap="closeActionPopup">
<view class="action-popup" :style="{ top: popupTop + 'px', left: popupLeft + 'px' }">
<view class="action-item" @tap.stop="handleEdit">
<image class="action-icon" src="/static/image/phone-message/bianji.png"></image>
<text>编辑</text>
</view>
<view class="action-item" @tap.stop="handleSwap">
<image class="action-icon" src="/static/image/phone-message/huhuan.png"></image>
<text>消息互换</text>
</view>
<view class="action-item" @tap.stop="handleSort">
<image class="action-icon" src="/static/image/phone-message/sort.png"></image>
<text>排序</text>
</view>
<view class="action-item" @tap.stop="handleChangeSpeaker">
<image class="action-icon" src="/static/image/phone-message/change.png"></image>
<text>切换发言人</text>
</view>
<view class="action-item" @tap.stop="handleDelete">
<image class="action-icon" src="/static/image/phone-message/shanchu.png"></image>
<text>删除</text>
</view>
<!-- 向上指的三角形,因为要求在长按元素下方 -->
<view class="triangle"></view>
</view>
</view>
<!-- 编辑消息弹窗 -->
<view v-if="showEditPopup" class="edit-mask" @tap="closeEditPopup">
<view class="edit-popup" @tap.stop>
<view class="edit-header">编辑消息</view>
<view class="edit-body">
<view class="edit-row">
<text class="edit-label">时间:</text>
<view class="time-picker-group">
<picker mode="date" :fields="$system == 'Android' ? 'day' : ''" :value="editingDate"
@change="onDateChange">
<view class="time-picker-item">
<text>{{ editingDate || '选择日期' }}</text>
</view>
</picker>
<picker mode="time" :fields="$system == 'Android' ? 'minute' : ''"
:value="editingTimeOfDay" @change="onTimeOfDayChange">
<view class="time-picker-item">
<text>{{ editingTimeOfDay || '选择时刻' }}</text>
</view>
</picker>
</view>
</view>
<view class="edit-row" style="align-items: flex-start;">
<text class="edit-label" style="padding-top: 6rpx;">时间显示:</text>
<view class="time-mode-group">
<view class="time-mode-btn" :class="{ active: editingTimeMode === 'auto' }"
@tap="editingTimeMode = 'auto'">自动</view>
<view class="time-mode-btn" :class="{ active: editingTimeMode === 'show' }"
@tap="editingTimeMode = 'show'">强制显示</view>
<view class="time-mode-btn" :class="{ active: editingTimeMode === 'hide' }"
@tap="editingTimeMode = 'hide'">强制隐藏</view>
</view>
</view>
<view class="edit-row"
v-if="data.phone == 'huawei' || data.phone == 'oppo' || data.phone == 'vivo'">
<text class="edit-label">SIM卡</text>
<view class="edit-input"
style="padding: 0; background: transparent; display: flex; align-items: center; border-radius: 8rpx; height: 70rpx;">
<uni-data-select v-model="editingSimKa" :localdata="simList" :clear="false"
placeholder="请选择卡号" @change="onSimKaChange"
style="flex: 1; border: none !important; width: 100%;"></uni-data-select>
</view>
</view>
<view class="edit-row">
<text class="edit-label">内容:</text>
</view>
<editor id="editor" class="edit-textarea" placeholder="请输入消息内容..." @ready="onEditorReady">
</editor>
</view>
<view class="edit-footer">
<view class="edit-btn cancel" @tap="closeEditPopup">取消</view>
<view class="edit-btn confirm" @tap="confirmEdit">确定</view>
</view>
</view>
</view>
<ChatList :messageList="messageList" :phone="data.phone" :sortMode="isSortMode"
@onLongPress="onMessageLongPress" @sort="onSortChange"></ChatList>
</ChatLayout>
<!-- 排序模式底部工具条 -->
<view v-if="isSortMode" class="sort-toolbar">
<view class="sort-toolbar-tip">长按消息并拖动调整顺序</view>
<view class="sort-toolbar-actions">
<view class="sort-toolbar-btn cancel" @tap="cancelSort">取消</view>
<view class="sort-toolbar-btn confirm" @tap="confirmSort">完成</view>
</view>
</view>
</view>
</template>
<script setup>
import ChatLayout from '@/components/message/chat/chat-layout.vue'
import ChatList from '@/components/message/chat/chat-list.vue'
import defaultData from '../defaultData.json'
import {
ref,
reactive,
computed,
nextTick
} from 'vue'
import {
onLoad,
onShow,
onPageScroll
} from "@dcloudio/uni-app";
import {
stringUtil,
util
} from '@/utils/common.js';
const defaultList = defaultData
// 与 list-index.vue 共用同一个缓存 key
const STORAGE_KEY = 'message_list'
let currentId = null // 当前会话的 id
let isMe = ref(true)
const messageList = ref([])
const showActionPopup = ref(false)
const popupTop = ref(0)
const popupLeft = ref(0)
const selectedMessage = ref(null)
const showEditPopup = ref(false)
const editingMessage = ref(null)
const editingTime = ref("") // 完整时间字符串 "YYYY-MM-DD HH:mm"
const editingDate = ref("") // 日期部分 "YYYY-MM-DD"
const editingTimeOfDay = ref("")  // 时刻部分 "HH:mm"
const editingSimKa = ref("")
const editingTimeMode = ref('auto') // 'auto' | 'show' | 'hide'
let editorCtx = null // 用于保存 editor 上下文
// ===== 排序模式相关状态 =====
const isSortMode = ref(false)
let sortingListCache = [] // 拖拽过程中的中间列表,由 ChatList emit 过来
const simList = [
{ text: "无卡号(不显)", value: "" },
{ text: "卡1", value: 1 },
{ text: "卡2", value: 2 }
]
/**
* 将当前 messageList 写回缓存中对应会话的 chatList
* 同时更新列表条目顶层的 time最新消息时间
*/
const saveChatList = () => {
if (!currentId) return
try {
const cached = uni.getStorageSync(STORAGE_KEY)
const list = cached ? JSON.parse(cached) : defaultList
const idx = list.findIndex(item => item.id == currentId)
if (idx > -1) {
list[idx].chatList = messageList.value.map(m => ({ ...m }))
const chatArr = list[idx].chatList
const last = chatArr.length ? chatArr[chatArr.length - 1] : null
if (last && last.time) list[idx].time = last.time
uni.setStorageSync(STORAGE_KEY, JSON.stringify(list))
}
} catch (e) { }
}
const onEditorReady = () => {
uni.createSelectorQuery().select('#editor').context((res) => {
editorCtx = res.context
if (editingMessage.value && editingMessage.value.content) {
editorCtx.setContents({
html: editingMessage.value.content
})
}
}).exec()
}
// 长按弹出弹出层
const onMessageLongPress = (index, message) => {
selectedMessage.value = message;
uni.createSelectorQuery().select('#msg-' + index).boundingClientRect(rect => {
if (rect) {
// 将弹窗定位在元素正下方 (bottom边界 + 一点点边距)
popupTop.value = rect.bottom + 10;
// 弹窗水平居中于该内容
let left = rect.left + rect.width / 2;
// 获取系统信息,防止弹出框超出屏幕左右侧
uni.getSystemInfo({
success: (info) => {
let popupWidth = 150; // 预估弹出层的固定宽度(可根据实际情况微调)
if (left < popupWidth / 2 + 10) left = popupWidth / 2 + 10;
if (left > info.windowWidth - popupWidth / 2 - 10) left = info.windowWidth - popupWidth / 2 - 10;
popupLeft.value = left;
showActionPopup.value = true;
}
})
}
}).exec();
}
const closeActionPopup = () => {
showActionPopup.value = false;
selectedMessage.value = null;
}
/**
* 编辑消息
*/
const handleEdit = () => {
editingMessage.value = selectedMessage.value;
editingTime.value = selectedMessage.value.time || "";
// 拆分日期和时刻部分
const parts = (selectedMessage.value.time || "").split(' ')
editingDate.value = parts[0] || ""
editingTimeOfDay.value = parts[1] || ""
editingSimKa.value = selectedMessage.value.simIndex !== undefined ? selectedMessage.value.simIndex : "";
editingTimeMode.value = selectedMessage.value.timeMode || 'auto';
showEditPopup.value = true;
closeActionPopup();
// 如果已经初始化过了直接赋值
if (editorCtx) {
setTimeout(() => {
editorCtx.setContents({
html: editingMessage.value.content
})
}, 100)
}
}
const closeEditPopup = () => {
showEditPopup.value = false;
editingMessage.value = null;
}
/**
* 日期选择器回调 - 更新日期部分并同步 editingTime
*/
const onDateChange = (e) => {
editingDate.value = e.detail.value
editingTime.value = `${editingDate.value} ${editingTimeOfDay.value}`.trim()
}
/**
* 时刻选择器回调 - 更新时刻部分并同步 editingTime
*/
const onTimeOfDayChange = (e) => {
editingTimeOfDay.value = e.detail.value
editingTime.value = `${editingDate.value} ${editingTimeOfDay.value}`.trim()
}
/**
* 切换sim卡
* @param value
*/
const onSimKaChange = (value) => {
editingSimKa.value = value
}
const confirmEdit = () => {
if (editingMessage.value && editorCtx) {
editorCtx.getContents({
success: (res) => {
const index = messageList.value.findIndex(item => item.id === editingMessage.value.id)
if (index > -1) {
messageList.value[index].content = res.html;
messageList.value[index].time = editingTime.value;
// 保存 timeMode: 'auto'|'show'|'hide'
if (editingTimeMode.value === 'auto') {
delete messageList.value[index].timeMode;
} else {
messageList.value[index].timeMode = editingTimeMode.value;
}
// 兼容旧 hideTime 字段:一并清除
delete messageList.value[index].hideTime;
if (editingSimKa.value) {
messageList.value[index].simIndex = Number(editingSimKa.value);
} else {
delete messageList.value[index].simIndex;
}
}
closeEditPopup();
saveChatList();
}
})
} else {
closeEditPopup();
}
}
/**
* 消息互换
*/
const handleSwap = () => {
const index = messageList.value.findIndex(item => item.id === selectedMessage.value.id)
messageList.value[index].isMe = !messageList.value[index].isMe
closeActionPopup();
saveChatList()
}
/**
* 进入拖动排序模式
*/
const handleSort = () => {
isSortMode.value = true
sortingListCache = messageList.value.map(item => ({ ...item }))
closeActionPopup()
}
/**
* ChatList 拖拽排序后回调 - 实时更新中间缓存
*/
const onSortChange = (newList) => {
sortingListCache = newList
}
/**
* 取消排序,还原 messageList
*/
const cancelSort = () => {
isSortMode.value = false
// 不应用排序结果无需重置messageList
}
/**
* 确认排序,将拖拽结果写回 messageList
*/
const confirmSort = () => {
if (sortingListCache.length > 0) {
messageList.value = sortingListCache.map(item => ({ ...item }))
}
isSortMode.value = false
saveChatList()
}
const handleChangeSpeaker = () => {
isMe.value = !isMe.value
if (isMe.value) {
uni.showToast({
title: "现在是自己发言",
icon: "none"
})
} else {
uni.showToast({
title: "现在是对方发言",
icon: "none"
})
}
closeActionPopup();
}
/**
* 删除消息
*/
const handleDelete = () => {
const index = messageList.value.findIndex(item => item.id === selectedMessage.value.id)
messageList.value.splice(index, 1)
closeActionPopup();
saveChatList()
}
const data = reactive({
phone: "iphone",
data: {},
number: 0
})
onLoad((options) => {
console.log(options)
if (options.phone) {
data.phone = options.phone
}
if (options.id) {
currentId = options.id
// 优先从缓存读取
try {
const cached = uni.getStorageSync(STORAGE_KEY)
let list = cached ? JSON.parse(cached) : defaultList
const found = list.find(item => item.id == options.id)
if (found) {
data.data = found
let number = 0
list.forEach(item => {
if (item.id == options.id) {
item.unRead = false
item.unReadNumber = 1
}
console.log(number + item.unRead ? item.unReadNumber : 0)
number = number + Number(item.unRead ? item.unReadNumber : 0)
})
data.number = number
console.log(data.data)
uni.setStorageSync(STORAGE_KEY, JSON.stringify(list))
messageList.value = found.chatList || []
return
}
} catch (e) { }
// 缓存未命中时降级用内建默认数据
data.data = defaultList.find(item => item.id == options.id)
messageList.value = data.data?.chatList || []
}
})
onShow(() => {
// #ifdef APP-PLUS
if (data.phone == 'oppo') {
util.setAndroidSystemBarColor('#FAFAFA')
} else {
util.setAndroidSystemBarColor('#ffffff')
}
setTimeout(() => {
plus.navigator.setStatusBarStyle("dark");
}, 500)
// #endif
})
const handleSend = (params) => {
console.log(params)
params.id = stringUtil.uuid()
params.isMe = isMe.value
messageList.value.push(params)
saveChatList()
}
</script>
<style lang="less" scoped>
.action-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
/* 拖动排序弹窗样式 */
.sort-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1100;
display: flex;
justify-content: center;
align-items: center;
}
.sort-popup {
width: 680rpx;
max-height: 80vh;
background-color: #FFFFFF;
border-radius: 24rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 排序模式底部工具条 - 覆盖在输入框上,高度与输入框一致 */
.sort-toolbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1200;
background-color: rgba(255, 255, 255, 0.97);
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 32rpx;
box-shadow: 0 -1rpx 0 rgba(0, 0, 0, 0.1);
}
.sort-toolbar-tip {
flex: 1;
font-size: 24rpx;
color: #999999;
text-align: left;
}
.sort-toolbar-actions {
display: flex;
flex-direction: row;
gap: 20rpx;
}
.sort-toolbar-btn {
height: 72rpx;
line-height: 72rpx;
padding: 0 44rpx;
text-align: center;
font-size: 30rpx;
border-radius: 36rpx;
}
.sort-toolbar-btn.cancel {
background-color: #F0F0F0;
color: #666666;
}
.sort-toolbar-btn.confirm {
background-color: #007AFF;
color: #FFFFFF;
font-weight: bold;
}
.action-popup {
position: fixed;
background-color: #4C4C4C;
border-radius: 12rpx;
padding: 20rpx 30rpx;
display: flex;
justify-content: space-between;
transform: translateX(-50%);
width: 540rpx;
z-index: 10;
}
.action-popup .triangle {
position: absolute;
top: -12rpx;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 12rpx solid transparent;
border-right: 12rpx solid transparent;
border-bottom: 14rpx solid #4C4C4C;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
color: #FFFFFF;
font-size: 20rpx;
margin: 0 20rpx;
text {
white-space: nowrap;
}
}
.action-icon {
width: 36rpx;
height: 36rpx;
margin-bottom: 8rpx;
}
/* 编辑弹窗样式 */
.edit-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99;
display: flex;
justify-content: center;
align-items: center;
}
.edit-popup {
width: 600rpx;
background-color: #FFFFFF;
border-radius: 16rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.edit-header {
font-size: 32rpx;
font-weight: bold;
color: #333;
text-align: center;
padding: 30rpx 0;
}
.edit-body {
padding: 30rpx;
}
.edit-textarea {
width: 100%;
height: 200rpx;
background-color: #F8F8F8;
padding: 20rpx;
border-radius: 8rpx;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
}
::v-deep .ql-container {
min-height: 100px;
}
.edit-footer {
display: flex;
}
.edit-btn {
flex: 1;
height: 90rpx;
line-height: 90rpx;
text-align: center;
font-size: 32rpx;
border-radius: 12rpx;
margin: 32rpx 16rpx;
margin-top: 0;
}
.edit-btn.cancel {
color: #767676;
background-color: #F1F1F1;
margin-left: 30rpx;
}
.edit-btn.confirm {
color: #FFFFFF;
font-weight: bold;
background-color: #1777FF;
margin-right: 30rpx;
}
.edit-row {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.edit-label {
font-size: 28rpx;
color: #333;
width: 150rpx;
flex-shrink: 0;
}
.edit-input {
flex: 1;
height: 70rpx;
background-color: #F8F8F8;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #333;
}
.time-mode-group {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.time-mode-btn {
padding: 4rpx 12rpx;
border-radius: 32rpx;
font-size: 26rpx;
color: #666666;
margin: 0 6rpx;
background-color: #F0F0F0;
border: 2rpx solid transparent;
}
.time-mode-btn.active {
color: #007AFF;
background-color: #E8F2FF;
border-color: #007AFF;
}
/* 时间选择器组合样式 */
.time-picker-group {
display: flex;
flex-direction: row;
gap: 16rpx;
flex: 1;
}
.time-picker-item {
flex: 1;
height: 70rpx;
line-height: 70rpx;
background-color: #F8F8F8;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #333333;
text-align: center;
}
</style>