318 lines
12 KiB
Vue
318 lines
12 KiB
Vue
<template>
|
||
<div class="wx-chat">
|
||
<aside class="friend-list" ref="friendListRef" @scroll="onFriendListScroll">
|
||
<div class="search">PC 微信 聊天</div>
|
||
<ul>
|
||
<li v-for="item in users" :key="item.sender_id" :class="{selected: selectedUser && selectedUser.sender_id === item.sender_id}" @click="selectUser(item)">
|
||
<img class="avatar" :src="item.sender_avatar || defaultAvatar" alt="avatar" />
|
||
<div class="meta">
|
||
<div class="name-row">
|
||
<span class="name">{{ item.sender_name }}</span>
|
||
<span class="time">{{ formatTime(item.send_time) }}</span>
|
||
</div>
|
||
<div class="last">
|
||
<span class="content">{{ item.content }}</span>
|
||
<span v-if="item.unread_count > 0" class="badge">{{ item.unread_count }}</span>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
<div class="list-loading" v-if="userListLoading">加载中...</div>
|
||
<div class="list-end" v-if="userListEnd">已加载全部</div>
|
||
<div class="list-error" v-if="userListError">
|
||
加载失败,请
|
||
<button @click.stop="retryLoadUsers">重试</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="chat-area">
|
||
<header class="chat-header" v-if="selectedUser">
|
||
<img class="header-avatar" :src="selectedUser.sender_avatar || defaultAvatar" />
|
||
<div class="header-title">{{ selectedUser.sender_name }}</div>
|
||
</header>
|
||
|
||
<main class="chat-messages" ref="messagesRef" @scroll.passive="onMessagesScroll">
|
||
<div class="loading-top" v-if="messagesLoading && messagesPage>0">加载中...</div>
|
||
<div class="messages-wrap">
|
||
<div v-for="msg in messages" :key="msg.id || msg._id || msg.send_time + msg.content" :class="['message', {me: isMyMessage(msg)}]">
|
||
<img class="msg-avatar" :src="msg.sender_avatar || defaultAvatar" />
|
||
<div class="bubble">
|
||
<div class="sender" v-if="!isMyMessage(msg)">{{ msg.sender_name }}</div>
|
||
<div class="content" v-if="msg.msg_type===0">{{ msg.content }}</div>
|
||
<div class="content" v-else>
|
||
<img class="img-preview" :src="msg.content" @click="previewImage(msg.content)" />
|
||
</div>
|
||
<div class="time">{{ formatTime(msg.send_time) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="messages-end" v-if="messagesEnd && messages.length>0">已加载全部历史消息</div>
|
||
</main>
|
||
|
||
<footer class="chat-input">
|
||
<textarea v-model="inputText" @keydown.enter.prevent="onEnterSend" placeholder="输入消息,回车发送"></textarea>
|
||
<div class="controls">
|
||
<input ref="fileInput" type="file" accept="image/*" multiple @change="onFilesSelected" style="display:none" />
|
||
<button @click="triggerFile">图片</button>
|
||
<button @click="sendText">发送</button>
|
||
</div>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { getUserList, send_message, get_messages } from '@/api/chat'
|
||
import { uploadFile } from '@/api/upload'
|
||
|
||
const route = useRoute()
|
||
const app_id = route.params.app_id || route.query.app_id || ''
|
||
|
||
const defaultAvatar = '/favicon.ico'
|
||
|
||
// Friend list state
|
||
const users = ref([])
|
||
const userPage = ref(0)
|
||
const userPageSize = ref(20)
|
||
const userTotal = ref(0)
|
||
const userListLoading = ref(false)
|
||
const userListEnd = ref(false)
|
||
const userListError = ref(false)
|
||
const friendListRef = ref(null)
|
||
|
||
// Chat state
|
||
const selectedUser = ref(null)
|
||
const messages = ref([]) // messages sorted newest-last (visual top=oldest)
|
||
const messagesPage = ref(0)
|
||
const messagesPageSize = ref(20)
|
||
const messagesTotal = ref(0)
|
||
const messagesLoading = ref(false)
|
||
const messagesEnd = ref(false)
|
||
const messagesRef = ref(null)
|
||
|
||
const inputText = ref('')
|
||
const fileInput = ref(null)
|
||
|
||
const currentUserId = ref('current_user_id') // TODO: replace with real current user ID source
|
||
|
||
function formatTime(ts){
|
||
if(!ts) return ''
|
||
const d = new Date(ts)
|
||
const now = new Date()
|
||
if(d.getFullYear() !== now.getFullYear()) return `${d.getFullYear()}-${d.getMonth()+1}-${d.getDate()}`
|
||
if(d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
return `${pad(d.getMonth()+1)}-${pad(d.getDate())}`
|
||
}
|
||
function pad(n){return n<10?('0'+n):n}
|
||
|
||
async function loadUsers(){
|
||
if(userListLoading.value || userListEnd.value) return
|
||
userListLoading.value = true
|
||
userListError.value = false
|
||
try{
|
||
const params = { app_id, page: userPage.value, page_size: userPageSize.value }
|
||
const res = await getUserList(params)
|
||
// backend returns { list, page, page_size, total }
|
||
const list = res.list || []
|
||
userTotal.value = res.total || 0
|
||
if(list.length>0){
|
||
users.value = users.value.concat(list)
|
||
userPage.value += 1
|
||
if(users.value.length >= userTotal.value) userListEnd.value = true
|
||
} else {
|
||
userListEnd.value = true
|
||
}
|
||
}catch(e){
|
||
console.error(e)
|
||
userListError.value = true
|
||
}finally{
|
||
userListLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onFriendListScroll(e){
|
||
const el = friendListRef.value
|
||
if(!el) return
|
||
// load next page when scrolled to bottom
|
||
if(el.scrollTop + el.clientHeight >= el.scrollHeight - 10){
|
||
loadUsers()
|
||
}
|
||
}
|
||
|
||
function retryLoadUsers(){
|
||
userListError.value = false
|
||
loadUsers()
|
||
}
|
||
|
||
function selectUser(u){
|
||
if(selectedUser.value && selectedUser.value.sender_id === u.sender_id) return
|
||
selectedUser.value = u
|
||
// reset message state and load newest messages
|
||
messages.value = []
|
||
messagesPage.value = 0
|
||
messagesEnd.value = false
|
||
loadMessages(true)
|
||
// clear unread locally
|
||
u.unread_count = 0
|
||
}
|
||
|
||
async function loadMessages(initial=false){
|
||
if(messagesLoading.value || messagesEnd.value) return
|
||
if(!selectedUser.value) return
|
||
messagesLoading.value = true
|
||
try{
|
||
const params = { app_id, user_id: selectedUser.value.sender_id, page: messagesPage.value, page_size: messagesPageSize.value }
|
||
const res = await get_messages(params)
|
||
const list = (res.list||[])
|
||
messagesTotal.value = res.total || 0
|
||
if(initial){
|
||
// initial load: we want newest messages visible at bottom
|
||
messages.value = list.reverse() // assume API returns newest-first; adjust as needed
|
||
// after render, scroll to bottom
|
||
await nextTick()
|
||
scrollMessagesToBottom()
|
||
messagesPage.value = 1
|
||
if(messages.value.length >= messagesTotal.value) messagesEnd.value = true
|
||
} else {
|
||
// loading older messages (scroll top) - prepend
|
||
const beforeTop = messagesRef.value ? messagesRef.value.scrollHeight : null
|
||
messages.value = list.reverse().concat(messages.value)
|
||
await nextTick()
|
||
// keep scroll position
|
||
if(messagesRef.value && beforeTop!=null){
|
||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight - beforeTop
|
||
}
|
||
messagesPage.value += 1
|
||
if(messages.value.length >= messagesTotal.value) messagesEnd.value = true
|
||
}
|
||
}catch(e){
|
||
console.error(e)
|
||
}finally{
|
||
messagesLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onMessagesScroll(){
|
||
const el = messagesRef.value
|
||
if(!el || messagesLoading.value || messagesEnd.value) return
|
||
if(el.scrollTop <= 10){
|
||
// load older
|
||
loadMessages(false)
|
||
}
|
||
}
|
||
|
||
function scrollMessagesToBottom(){
|
||
const el = messagesRef.value
|
||
if(!el) return
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
|
||
function isMyMessage(msg){
|
||
return String(msg.sender_id) === String(currentUserId.value)
|
||
}
|
||
|
||
async function sendText(){
|
||
if(!selectedUser.value) return
|
||
const text = inputText.value && inputText.value.trim()
|
||
if(!text) return
|
||
const payload = { app_id, content: text, msg_type: 1, to_user_id: selectedUser.value.sender_id }
|
||
// optimistic UI
|
||
const sendingMsg = { sender_id: currentUserId.value, sender_name: '我', sender_avatar: defaultAvatar, content: text, msg_type:1, send_time: new Date().toISOString(), _sending: true }
|
||
messages.value.push(sendingMsg)
|
||
inputText.value = ''
|
||
scrollMessagesToBottom()
|
||
try{
|
||
const res = await send_message(payload)
|
||
// replace last sending message state
|
||
const idx = messages.value.findIndex(m=>m._sending)
|
||
if(idx!==-1){
|
||
messages.value[idx] = Object.assign({}, messages.value[idx], res || {}, {_sending:false})
|
||
}
|
||
}catch(e){
|
||
console.error(e)
|
||
// mark last as failed
|
||
const idx = messages.value.findIndex(m=>m._sending)
|
||
if(idx!==-1) messages.value[idx]._sending = false, messages.value[idx]._failed = true
|
||
}
|
||
}
|
||
|
||
function onEnterSend(){
|
||
sendText()
|
||
}
|
||
|
||
function triggerFile(){
|
||
fileInput.value && fileInput.value.click()
|
||
}
|
||
|
||
async function onFilesSelected(e){
|
||
const files = Array.from(e.target.files || [])
|
||
if(files.length===0) return
|
||
for(const f of files){
|
||
await uploadAndSend(f)
|
||
}
|
||
// clear input
|
||
e.target.value = ''
|
||
}
|
||
|
||
async function uploadAndSend(file){
|
||
if(!selectedUser.value) return
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
try{
|
||
const up = await uploadFile(fd, '')
|
||
const url = up && up.url || up.data && up.data.url || up.path || up
|
||
// optimistic insert image message
|
||
const sendingMsg = { sender_id: currentUserId.value, sender_name: '我', sender_avatar: defaultAvatar, content: url, msg_type:1, send_time: new Date().toISOString(), _sending:true }
|
||
messages.value.push(sendingMsg)
|
||
scrollMessagesToBottom()
|
||
const payload = { app_id, content: url, msg_type: 1, to_user_id: selectedUser.value.sender_id }
|
||
const res = await send_message(payload)
|
||
const idx = messages.value.findIndex(m=>m._sending && m.msg_type===1)
|
||
if(idx!==-1) messages.value[idx] = Object.assign({}, messages.value[idx], res || {}, {_sending:false})
|
||
}catch(e){
|
||
console.error(e)
|
||
const idx = messages.value.findIndex(m=>m._sending && m.msg_type===1)
|
||
if(idx!==-1) messages.value[idx]._sending = false, messages.value[idx]._failed = true
|
||
}
|
||
}
|
||
|
||
function previewImage(url){
|
||
// simple preview in new tab
|
||
window.open(url, '_blank')
|
||
}
|
||
|
||
onMounted(()=>{
|
||
loadUsers()
|
||
})
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.wx-chat{display:flex;height:100vh}
|
||
.friend-list{width:300px;border-right:1px solid #e6e6e6;overflow:auto}
|
||
.friend-list .search{padding:12px;border-bottom:1px solid #f0f0f0}
|
||
.friend-list ul{list-style:none;padding:0;margin:0}
|
||
.friend-list li{display:flex;padding:10px;cursor:pointer;border-bottom:1px solid #f5f5f5}
|
||
.friend-list li.selected{background:#f0f8ff}
|
||
.friend-list .avatar{width:48px;height:48px;border-radius:6px;margin-right:10px}
|
||
.friend-list .meta{flex:1}
|
||
.friend-list .name-row{display:flex;justify-content:space-between}
|
||
.badge{background:#f56c6c;color:#fff;padding:2px 6px;border-radius:12px;font-size:12px;margin-left:8px}
|
||
.chat-area{flex:1;display:flex;flex-direction:column}
|
||
.chat-header{height:64px;display:flex;align-items:center;padding:10px;border-bottom:1px solid #eee}
|
||
.header-avatar{width:40px;height:40px;border-radius:6px;margin-right:10px}
|
||
.chat-messages{flex:1;overflow:auto;padding:16px;background:#fafafa}
|
||
.message{display:flex;margin-bottom:12px}
|
||
.message.me{flex-direction:row-reverse}
|
||
.msg-avatar{width:36px;height:36px;border-radius:6px;margin:0 10px}
|
||
.bubble{max-width:60%;background:#fff;padding:8px;border-radius:6px;box-shadow:0 1px 1px rgba(0,0,0,0.05)}
|
||
.message.me .bubble{background:#dcf8c6}
|
||
.time{font-size:12px;color:#999;margin-top:6px}
|
||
.chat-input{height:100px;border-top:1px solid #eee;padding:8px;display:flex;flex-direction:column}
|
||
.chat-input textarea{flex:1;resize:none;padding:8px}
|
||
.controls{display:flex;gap:8px;margin-top:8px}
|
||
.img-preview{max-width:200px;cursor:pointer}
|
||
</style>
|