2025-10-21 23:18:13 +08:00

318 lines
12 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.

<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>