chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-left" @click="goBack">
|
||||
<uni-icons type="left" size="24" color="#333"></uni-icons>
|
||||
</view>
|
||||
<view class="nav-center">
|
||||
<text class="nav-title">{{ contactName }}</text>
|
||||
<text class="nav-sub">{{ contactOnline ? '在线' : '离线' }}</text>
|
||||
</view>
|
||||
<view class="nav-right" @click="onMoreClick">
|
||||
<uni-icons type="more-filled" size="22" color="#333"></uni-icons>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 聊天消息区域 -->
|
||||
<scroll-view class="msg-scroll" direction="vertical" :scroll-into-view="scrollToId"
|
||||
:scroll-with-animation="true" id="msgScroll">
|
||||
<view v-for="(msg, idx) in messages" :key="msg.id" :id="'msg-' + msg.id"
|
||||
class="msg-row" :class="{ self: msg.senderId == myUid }">
|
||||
<image class="msg-avatar" :src="msg.senderId == myUid ? myAvatar : contactAvatar" mode="aspectFill"></image>
|
||||
<view class="msg-content">
|
||||
<view v-if="msg.type == 'image'" class="img-bubble" @click="previewImage(msg.content)">
|
||||
<image class="msg-img" :src="msg.content" mode="widthFix"></image>
|
||||
</view>
|
||||
<view v-else class="text-bubble" :class="{ self: msg.senderId == myUid }">
|
||||
<text class="text-inner">{{ msg.content }}</text>
|
||||
</view>
|
||||
<text class="msg-time">{{ fmtClock(msg.timestamp) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="messages.length == 0" class="empty">
|
||||
<text class="empty-text">还没有消息,打个招呼吧~</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入区域 -->
|
||||
<view class="input-bar">
|
||||
<view class="action-btn" @click="toggleEmoji">
|
||||
<uni-icons :type="showEmoji ? 'keyboard' : 'smile'" size="26" color="#666"></uni-icons>
|
||||
</view>
|
||||
<view class="input-box">
|
||||
<textarea v-model="inputText" class="text-input" placeholder="说点什么..." :auto-height="true"
|
||||
:adjust-position="true" @focus="closePanels" @confirm="sendText"></textarea>
|
||||
</view>
|
||||
<view class="action-btn" @click="toggleMore">
|
||||
<uni-icons type="plus" size="26" color="#666"></uni-icons>
|
||||
</view>
|
||||
<text v-if="inputText.trim().length > 0" class="send-btn" @click="sendText">发送</text>
|
||||
</view>
|
||||
|
||||
<!-- 表情面板 -->
|
||||
<view v-if="showEmoji" class="emoji-panel">
|
||||
<scroll-view direction="vertical" class="emoji-scroll">
|
||||
<view class="emoji-list">
|
||||
<text v-for="(e, i) in emojis" :key="i" class="emoji-item" @click="insertEmoji(e)">{{ e }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 更多面板 -->
|
||||
<view v-if="showMore" class="more-panel">
|
||||
<view class="more-grid">
|
||||
<view class="more-item" @click="chooseImage">
|
||||
<uni-icons type="image" size="30" color="#666"></uni-icons>
|
||||
<text class="more-txt">图片</text>
|
||||
</view>
|
||||
<view class="more-item" @click="chooseImage(true)">
|
||||
<uni-icons type="camera" size="30" color="#666"></uni-icons>
|
||||
<text class="more-txt">拍摄</text>
|
||||
</view>
|
||||
<view class="more-item" @click="toast('位置功能开发中')">
|
||||
<uni-icons type="location" size="30" color="#666"></uni-icons>
|
||||
<text class="more-txt">位置</text>
|
||||
</view>
|
||||
<view class="more-item" @click="toast('红包功能开发中')">
|
||||
<uni-icons type="redpacket" size="30" color="#FF6B6B"></uni-icons>
|
||||
<text class="more-txt">红包</text>
|
||||
</view>
|
||||
<view class="more-item" @click="openGift">
|
||||
<uni-icons type="gift" size="30" color="#FF9500"></uni-icons>
|
||||
<text class="more-txt">礼物</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 送礼弹窗 -->
|
||||
<view v-if="showGift" class="gift-mask" @click="closeGift">
|
||||
<view class="gift-sheet" @click.stop>
|
||||
<view class="gift-sheet-head">
|
||||
<text class="gift-sheet-title">赠送礼物</text>
|
||||
<uni-icons type="close" size="22" color="#999" @click="closeGift"></uni-icons>
|
||||
</view>
|
||||
<scroll-view class="gift-sheet-list" direction="vertical" :show-scrollbar="false">
|
||||
<view v-for="(g, i) in gifts" :key="g.id" class="gift-sheet-cell"
|
||||
:class="{ active: selectedGift != null && selectedGift.id == g.id }" @click="selectGift(g)">
|
||||
<text class="gift-sheet-name">{{ g.name }}</text>
|
||||
<text class="gift-sheet-price">{{ g.price }}金币</text>
|
||||
</view>
|
||||
<view v-if="gifts.length == 0" class="gift-sheet-empty">
|
||||
<text class="empty-text">暂无可赠送的礼物</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="gift-sheet-count">
|
||||
<text class="count-label">数量</text>
|
||||
<view class="count-ctrl">
|
||||
<text class="count-btn" @click="giftCount > 1 ? giftCount = giftCount - 1 : null">-</text>
|
||||
<text class="count-num">{{ giftCount }}</text>
|
||||
<text class="count-btn" @click="giftCount = giftCount + 1">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="gift-sheet-send" @click="sendGift">
|
||||
<text class="send-text">赠送({{ giftTotal }}金币)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed, onLoad, onMounted, onUnmounted } from 'vue'
|
||||
import Api from '@/common/api-service.uts'
|
||||
import { getUserInfo } from '@/stores/user.uts'
|
||||
|
||||
type ChatMessage = {
|
||||
id : number
|
||||
senderId : number
|
||||
receiverId : number
|
||||
content : string
|
||||
timestamp : number
|
||||
type : 'text' | 'image'
|
||||
read : boolean
|
||||
sending ?: boolean
|
||||
}
|
||||
|
||||
const myUid = ref<number>(0)
|
||||
const myAvatar = ref<string>('')
|
||||
const contactUid = ref<number>(0)
|
||||
const contactName = ref<string>('')
|
||||
const contactAvatar = ref<string>('')
|
||||
const contactOnline = ref<boolean>(false)
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputText = ref<string>('')
|
||||
const showEmoji = ref<boolean>(false)
|
||||
const showMore = ref<boolean>(false)
|
||||
const scrollToId = ref<string>('')
|
||||
let pollTimer : number | null = null
|
||||
|
||||
const emojis = ref<string[]>([
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
|
||||
'🙂', '😉', '😍', '🥰', '😘', '😋', '😜', '🤪', '🤩', '🥳',
|
||||
'😎', '🤔', '🤗', '😴', '😭', '😡', '👍', '👏', '💕', '🎉',
|
||||
'🌹', '❤️', '🔥', '✨', '🍻', '🎁', '🌟', '😏', '🙄', '💔'
|
||||
])
|
||||
|
||||
const findIdx = (id : number) : number => {
|
||||
for (let i = 0; i < messages.value.length; i++) {
|
||||
if (messages.value[i].id == id) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// 提交真实消息:若轮询已拉到同一条(真实 id 已存在),则丢弃临时消息避免重复
|
||||
const commitMessage = (tempId : number, realId : number, content : string, type : 'text' | 'image', createAt : number) => {
|
||||
const tempIdx = findIdx(tempId)
|
||||
const realIdx = findIdx(realId)
|
||||
if (realIdx >= 0) {
|
||||
if (tempIdx >= 0) messages.value.splice(tempIdx, 1)
|
||||
return
|
||||
}
|
||||
if (tempIdx >= 0) {
|
||||
messages.value[tempIdx] = {
|
||||
id : realId,
|
||||
senderId : myUid.value,
|
||||
receiverId : contactUid.value,
|
||||
content : content,
|
||||
timestamp : createAt,
|
||||
type : type,
|
||||
read : true
|
||||
} as ChatMessage
|
||||
}
|
||||
}
|
||||
|
||||
const loadContact = () => {
|
||||
Api.user.detail(contactUid.value)
|
||||
.then((res : UTSJSONObject) => {
|
||||
contactName.value = String(res.get('nickname') ?? '')
|
||||
contactAvatar.value = String(res.get('avatar') ?? '')
|
||||
contactOnline.value = Boolean(res.get('is_online') ?? false)
|
||||
})
|
||||
.catch(() => {
|
||||
contactName.value = '用户' + contactUid.value
|
||||
})
|
||||
}
|
||||
|
||||
const mapMsg = (item : UTSJSONObject) : ChatMessage => {
|
||||
const ct = Number(item.get('content_type') ?? 0)
|
||||
return {
|
||||
id : Number(item.get('id')),
|
||||
senderId : Number(item.get('sender_id')),
|
||||
receiverId : Number(item.get('receiver_id')),
|
||||
content : String(item.get('content') ?? ''),
|
||||
timestamp : Number(item.get('create_at') ?? 0) * 1000,
|
||||
type : ct == 1 ? 'image' : 'text',
|
||||
read : true
|
||||
} as ChatMessage
|
||||
}
|
||||
|
||||
// 初始化历史(倒序返回 -> 反转成正序)
|
||||
const loadHistory = () => {
|
||||
Api.message.history({ contact_id: contactUid.value, contact_type: 0, page: 1, limit: 50 })
|
||||
.then((res : UTSJSONObject) => {
|
||||
const list = res.get('list') as UTSJSONObject[] | null
|
||||
if (list == null) return
|
||||
const arr : ChatMessage[] = []
|
||||
for (const it of list) {
|
||||
arr.push(mapMsg(it))
|
||||
}
|
||||
arr.reverse()
|
||||
messages.value = arr
|
||||
scrollToBottom()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 轮询:仅追加缺失的新消息
|
||||
const pollNew = () => {
|
||||
Api.message.history({ contact_id: contactUid.value, contact_type: 0, page: 1, limit: 50 })
|
||||
.then((res : UTSJSONObject) => {
|
||||
const list = res.get('list') as UTSJSONObject[] | null
|
||||
if (list == null) return
|
||||
let changed = false
|
||||
for (const it of list) {
|
||||
const id = Number(it.get('id'))
|
||||
if (findIdx(id) >= 0) continue
|
||||
messages.value.push(mapMsg(it))
|
||||
changed = true
|
||||
}
|
||||
if (changed) scrollToBottom()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messages.value.length == 0) return
|
||||
const lastId = messages.value[messages.value.length - 1].id
|
||||
// 延迟一帧确保渲染完成
|
||||
setTimeout(() => {
|
||||
scrollToId.value = 'msg-' + lastId
|
||||
}, 50)
|
||||
}
|
||||
|
||||
let tempCounter = 0
|
||||
const sendText = () => {
|
||||
const text = inputText.value.trim()
|
||||
if (text.length == 0) return
|
||||
inputText.value = ''
|
||||
closePanels()
|
||||
const tempId = pushOptimistic(text, 'text')
|
||||
Api.message.send({ receiver_id: contactUid.value, receiver_type: 0, content: text, content_type: 0 })
|
||||
.then((msgObj : UTSJSONObject) => {
|
||||
commitMessage(tempId, Number(msgObj.get('id')), text, 'text', Number(msgObj.get('create_at') ?? 0) * 1000)
|
||||
})
|
||||
.catch(() => {
|
||||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
|
||||
const pushOptimistic = (content : string, type : 'text' | 'image') : number => {
|
||||
tempCounter--
|
||||
const tempId = tempCounter
|
||||
messages.value.push({
|
||||
id : tempId,
|
||||
senderId : myUid.value,
|
||||
receiverId : contactUid.value,
|
||||
content : content,
|
||||
timestamp : Date.now(),
|
||||
type : type,
|
||||
read : true,
|
||||
sending : true
|
||||
} as ChatMessage)
|
||||
scrollToBottom()
|
||||
return tempId
|
||||
}
|
||||
|
||||
const chooseImage = (camera : boolean = false) => {
|
||||
closePanels()
|
||||
uni.chooseImage({
|
||||
count : 1,
|
||||
sourceType : camera ? ['camera'] : ['album'],
|
||||
success : (res) => {
|
||||
const path = res.tempFilePaths[0]
|
||||
const tempId = pushOptimistic(path, 'image')
|
||||
Api.message.send({ receiver_id: contactUid.value, receiver_type: 0, content: path, content_type: 1 })
|
||||
.then((msgObj : UTSJSONObject) => {
|
||||
commitMessage(tempId, Number(msgObj.get('id')), path, 'image', Number(msgObj.get('create_at') ?? 0) * 1000)
|
||||
})
|
||||
.catch(() => {
|
||||
uni.showToast({ title: '图片发送失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const previewImage = (url : string) => {
|
||||
uni.previewImage({ urls: [url], current: url })
|
||||
}
|
||||
|
||||
const toggleEmoji = () => {
|
||||
showEmoji.value = !showEmoji.value
|
||||
showMore.value = false
|
||||
}
|
||||
|
||||
const toggleMore = () => {
|
||||
showMore.value = !showMore.value
|
||||
showEmoji.value = false
|
||||
}
|
||||
|
||||
const closePanels = () => {
|
||||
showEmoji.value = false
|
||||
showMore.value = false
|
||||
}
|
||||
|
||||
const insertEmoji = (e : string) => {
|
||||
inputText.value += e
|
||||
}
|
||||
|
||||
const fmtClock = (ts : number) : string => {
|
||||
if (! ts) return ''
|
||||
const d = new Date(ts)
|
||||
const h = d.getHours().toString().padStart(2, '0')
|
||||
const m = d.getMinutes().toString().padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
const toast = (t : string) => {
|
||||
uni.showToast({ title: t, icon: 'none' })
|
||||
closePanels()
|
||||
}
|
||||
|
||||
// ===== 送礼弹窗 =====
|
||||
type IGift = {
|
||||
id : number
|
||||
name : string
|
||||
icon : string
|
||||
price : number
|
||||
}
|
||||
const showGift = ref<boolean>(false)
|
||||
const gifts = reactive<IGift[]>([])
|
||||
const selectedGift = ref<IGift | null>(null)
|
||||
const giftCount = ref<number>(1)
|
||||
const giftLoading = ref<boolean>(false)
|
||||
|
||||
const giftTotal = computed<number>(() => {
|
||||
const p = selectedGift.value?.price ?? 0
|
||||
return p * giftCount.value
|
||||
})
|
||||
|
||||
const openGift = () => {
|
||||
closePanels()
|
||||
showGift.value = true
|
||||
loadGifts()
|
||||
}
|
||||
|
||||
const loadGifts = () => {
|
||||
if (giftLoading.value) return
|
||||
giftLoading.value = true
|
||||
Api.gift.list()
|
||||
.then((res : any) => {
|
||||
const list = (res as Array<IGift>) ?? []
|
||||
gifts.splice(0, gifts.length)
|
||||
list.forEach((g : IGift) => gifts.push(g))
|
||||
if (gifts.length > 0 && selectedGift.value == null) {
|
||||
selectedGift.value = gifts[0]
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
uni.showToast({ title: '礼物加载失败', icon: 'none' })
|
||||
})
|
||||
.finally(() => { giftLoading.value = false })
|
||||
}
|
||||
|
||||
const selectGift = (g : IGift) => {
|
||||
selectedGift.value = g
|
||||
}
|
||||
|
||||
const sendGift = () => {
|
||||
if (selectedGift.value == null) {
|
||||
uni.showToast({ title: '请选择礼物', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (contactUid.value <= 0) return
|
||||
uni.showLoading({ title: '赠送中...', mask: true })
|
||||
Api.gift.send({
|
||||
to_uid: contactUid.value,
|
||||
gift_id: selectedGift.value.id,
|
||||
count: giftCount.value,
|
||||
message: ''
|
||||
})
|
||||
.then(() => {
|
||||
uni.hideLoading()
|
||||
showGift.value = false
|
||||
uni.showToast({ title: '赠送成功', icon: 'success' })
|
||||
})
|
||||
.catch((e : any) => {
|
||||
uni.hideLoading()
|
||||
const msg = (e as UTSJSONObject)?.get('message') as string
|
||||
uni.showToast({ title: msg ?? '赠送失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
|
||||
const closeGift = () => {
|
||||
showGift.value = false
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
const onMoreClick = () => {
|
||||
uni.showActionSheet({
|
||||
itemList: ['清空本地聊天记录', '举报用户'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex == 0) {
|
||||
messages.value = []
|
||||
uni.showToast({ title: '已清空', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((options : any) => {
|
||||
const uid = Number(options?.uid ?? options?.userId ?? 0)
|
||||
contactUid.value = uid
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const me = getUserInfo()
|
||||
if (me != null) {
|
||||
myUid.value = me.uid
|
||||
myAvatar.value = me.avatar
|
||||
}
|
||||
loadContact()
|
||||
loadHistory()
|
||||
pollTimer = setInterval(() => {
|
||||
pollNew()
|
||||
}, 4000) as number
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer != null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #ededed;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
padding-top: var(--status-bar-height);
|
||||
height: 90rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1rpx solid #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-left, .nav-right {
|
||||
width: 90rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 32rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-sub {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.msg-scroll {
|
||||
flex: 1;
|
||||
padding: 20rpx 16rpx 10rpx;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.msg-row.self {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 12rpx;
|
||||
background-color: #ccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
margin: 0 16rpx;
|
||||
}
|
||||
|
||||
.msg-row.self .msg-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.text-bubble {
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.text-bubble.self {
|
||||
background-color: #95EC69;
|
||||
}
|
||||
|
||||
.text-inner {
|
||||
font-size: 30rpx;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.45;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.img-bubble {
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
max-width: 360rpx;
|
||||
}
|
||||
|
||||
.msg-img {
|
||||
width: 300rpx;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 20rpx;
|
||||
color: #b2b2b2;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 80rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
padding: 12rpx 16rpx;
|
||||
background-color: #f7f7f7;
|
||||
border-top: 1rpx solid #e0e0e0;
|
||||
padding-bottom: calc(12rpx + var(--uni-safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
flex: 1;
|
||||
margin: 0 12rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 12rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
max-height: 180rpx;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
font-size: 30rpx;
|
||||
min-height: 40rpx;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background-color: #07C160;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 6rpx;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.emoji-panel {
|
||||
height: 360rpx;
|
||||
background-color: #fff;
|
||||
border-top: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.emoji-scroll {
|
||||
height: 360rpx;
|
||||
}
|
||||
|
||||
.emoji-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 10rpx;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
font-size: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.more-panel {
|
||||
background-color: #fff;
|
||||
border-top: 1rpx solid #eee;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.more-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.more-item {
|
||||
width: 130rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 10rpx 10rpx;
|
||||
}
|
||||
|
||||
.more-txt {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.gift-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.gift-sheet {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-top-left-radius: 28rpx;
|
||||
border-top-right-radius: 28rpx;
|
||||
padding: 24rpx 30rpx calc(30rpx + var(--uni-safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gift-sheet-head {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.gift-sheet-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.gift-sheet-list {
|
||||
max-height: 420rpx;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
.gift-sheet-cell {
|
||||
width: 22%;
|
||||
margin: 10rpx 1.5%;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 16rpx;
|
||||
background-color: #f7f7f7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gift-sheet-cell.active {
|
||||
background-color: #fff0ec;
|
||||
border: 2rpx solid #FF6B6B;
|
||||
}
|
||||
|
||||
.gift-sheet-name {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.gift-sheet-price {
|
||||
font-size: 22rpx;
|
||||
color: #FF6B6B;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.gift-sheet-empty {
|
||||
width: 100%;
|
||||
padding: 60rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gift-sheet-count {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.count-ctrl {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
background-color: #f0f0f0;
|
||||
text-align: center;
|
||||
line-height: 60rpx;
|
||||
font-size: 36rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.count-num {
|
||||
min-width: 60rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.gift-sheet-send {
|
||||
height: 92rpx;
|
||||
border-radius: 46rpx;
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.send-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-tabs">
|
||||
<view class="nav-tab" :class="{ active: activeTab == 0 }" @click="switchTab(0)">
|
||||
<text class="nav-tab-txt" :class="{ active: activeTab == 0 }">消息</text>
|
||||
</view>
|
||||
<view class="nav-tab" :class="{ active: activeTab == 1 }" @click="switchTab(1)">
|
||||
<text class="nav-tab-txt" :class="{ active: activeTab == 1 }">缘友</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息 Tab:会话列表 -->
|
||||
<scroll-view v-if="activeTab == 0" class="list-scroll" direction="vertical"
|
||||
:refresher-enabled="true" :refresher-triggered="refreshing"
|
||||
@refresherrefresh="loadConversations">
|
||||
<view v-if="conversations.length == 0" class="empty">
|
||||
<text class="empty-icon">💬</text>
|
||||
<text class="empty-text">还没有聊天,去「缘友」找人聊聊吧</text>
|
||||
</view>
|
||||
<view v-for="(item, idx) in conversations" :key="idx" class="conv-item"
|
||||
@click="openChat(item.peer_id)">
|
||||
<view class="avatar-wrap">
|
||||
<image class="avatar" :src="item.avatar || defaultAvatar" mode="aspectFill"></image>
|
||||
<view v-if="item.online" class="online-dot"></view>
|
||||
</view>
|
||||
<view class="conv-main">
|
||||
<view class="conv-row">
|
||||
<text class="conv-name">{{ item.nickname || '用户' + item.peer_id }}</text>
|
||||
<text class="conv-time">{{ fmtTime(item.last_time) }}</text>
|
||||
</view>
|
||||
<view class="conv-row">
|
||||
<text class="conv-last">{{ item.is_self ? '我: ' : '' }}{{ previewContent(item) }}</text>
|
||||
<view v-if="item.unread_count > 0" class="badge">
|
||||
<text class="badge-txt">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 缘友 Tab:在线用户,点击直接发起聊天 -->
|
||||
<scroll-view v-else class="list-scroll" direction="vertical"
|
||||
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="loadOnline">
|
||||
<view v-if="onlineUsers.length == 0" class="empty">
|
||||
<text class="empty-icon">🌟</text>
|
||||
<text class="empty-text">暂无在线缘友,稍后再来看看</text>
|
||||
</view>
|
||||
<view v-for="(u, idx) in onlineUsers" :key="idx" class="conv-item" @click="openChat(u.uid)">
|
||||
<view class="avatar-wrap">
|
||||
<image class="avatar" :src="u.avatar || defaultAvatar" mode="aspectFill"></image>
|
||||
<view v-if="u.online" class="online-dot"></view>
|
||||
</view>
|
||||
<view class="conv-main">
|
||||
<view class="conv-row">
|
||||
<text class="conv-name">{{ u.nickname || '用户' + u.uid }}</text>
|
||||
<text class="conv-time online-tag">在线</text>
|
||||
</view>
|
||||
<view class="conv-row">
|
||||
<text class="conv-last">点击发起聊天</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, onShow } from 'vue'
|
||||
import Api from '@/common/api-service.uts'
|
||||
|
||||
type Convo = {
|
||||
peer_id : number
|
||||
nickname : string
|
||||
avatar : string
|
||||
online : boolean
|
||||
last_content : string
|
||||
last_content_type : number
|
||||
last_time : number
|
||||
unread_count : number
|
||||
is_self : boolean
|
||||
}
|
||||
|
||||
type OnlineUser = {
|
||||
uid : number
|
||||
nickname : string
|
||||
avatar : string
|
||||
online : boolean
|
||||
}
|
||||
|
||||
const defaultAvatar = 'https://randomuser.me/api/portraits/lego/1.jpg'
|
||||
const activeTab = ref<number>(0)
|
||||
const refreshing = ref<boolean>(false)
|
||||
const conversations = ref<Convo[]>([])
|
||||
const onlineUsers = ref<OnlineUser[]>([])
|
||||
|
||||
const switchTab = (i : number) => {
|
||||
activeTab.value = i
|
||||
if (i == 0) {
|
||||
loadConversations()
|
||||
} else {
|
||||
loadOnline()
|
||||
}
|
||||
}
|
||||
|
||||
const loadConversations = () => {
|
||||
refreshing.value = true
|
||||
Api.message.conversations()
|
||||
.then((res : UTSJSONObject) => {
|
||||
const list = res.get('list') as UTSJSONObject[] | null
|
||||
const arr : Convo[] = []
|
||||
if (list != null) {
|
||||
for (const it of list) {
|
||||
arr.push({
|
||||
peer_id : Number(it.get('peer_id')),
|
||||
nickname : String(it.get('nickname') ?? ''),
|
||||
avatar : String(it.get('avatar') ?? ''),
|
||||
online : Boolean(it.get('online') ?? false),
|
||||
last_content : String(it.get('last_content') ?? ''),
|
||||
last_content_type : Number(it.get('last_content_type') ?? 0),
|
||||
last_time : Number(it.get('last_time') ?? 0),
|
||||
unread_count : Number(it.get('unread_count') ?? 0),
|
||||
is_self : Boolean(it.get('is_self') ?? false)
|
||||
} as Convo)
|
||||
}
|
||||
}
|
||||
conversations.value = arr
|
||||
})
|
||||
.catch(() => {
|
||||
conversations.value = []
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const loadOnline = () => {
|
||||
refreshing.value = true
|
||||
Api.user.online()
|
||||
.then((res : UTSJSONObject) => {
|
||||
let list : UTSJSONObject[] | null = null
|
||||
if (res.containsKey('list')) {
|
||||
list = res.get('list') as UTSJSONObject[] | null
|
||||
} else {
|
||||
list = res as any as UTSJSONObject[]
|
||||
}
|
||||
const arr : OnlineUser[] = []
|
||||
if (list != null) {
|
||||
for (const it of list) {
|
||||
const uid = Number(it.get('uid') ?? it.get('id') ?? 0)
|
||||
if (uid <= 0) continue
|
||||
const online = Boolean(it.get('online') ?? it.get('is_online') ?? (Number(it.get('online_status') ?? 0) == 1))
|
||||
arr.push({
|
||||
uid : uid,
|
||||
nickname : String(it.get('nickname') ?? ''),
|
||||
avatar : String(it.get('avatar') ?? ''),
|
||||
online : online
|
||||
} as OnlineUser)
|
||||
}
|
||||
}
|
||||
onlineUsers.value = arr
|
||||
})
|
||||
.catch(() => {
|
||||
onlineUsers.value = []
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const previewContent = (item : Convo) : string => {
|
||||
if (item.last_content_type == 1) return '[图片]'
|
||||
if (item.last_content_type == 2) return '[语音]'
|
||||
return item.last_content || '[暂无消息]'
|
||||
}
|
||||
|
||||
const fmtTime = (sec : number) : string => {
|
||||
if (! sec) return ''
|
||||
const d = new Date(sec * 1000)
|
||||
const now = new Date()
|
||||
const diff = (now.getTime() - d.getTime()) / 1000
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
|
||||
if (diff < 86400 * 2) return '昨天'
|
||||
const y = d.getFullYear()
|
||||
const m = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
if (y == now.getFullYear()) return `${m}月${day}日`
|
||||
return `${y}年${m}月${day}日`
|
||||
}
|
||||
|
||||
const openChat = (uid : number) => {
|
||||
uni.navigateTo({ url: `/pages/chat/chat?uid=${uid}` })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConversations()
|
||||
})
|
||||
|
||||
// 从聊天页返回时刷新会话(更新未读/最新消息)
|
||||
onShow(() => {
|
||||
if (activeTab.value == 0) {
|
||||
loadConversations()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f5f6f8;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
padding-top: var(--status-bar-height);
|
||||
height: 90rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
margin: 0 24rpx;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.nav-tab-txt {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.nav-tab-txt.active {
|
||||
font-size: 38rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
padding: 10rpx 20rpx;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 24rpx 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
position: absolute;
|
||||
right: 2rpx;
|
||||
bottom: 2rpx;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #4CD964;
|
||||
border: 3rpx solid #fff;
|
||||
}
|
||||
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-size: 32rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
max-width: 360rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.online-tag {
|
||||
color: #4CD964;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.badge {
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 16rpx;
|
||||
background-color: #FF4D4F;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge-txt {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 160rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user