This commit is contained in:
左哥 2025-10-21 23:18:13 +08:00
parent fc119d70e8
commit 304594bdc1
6 changed files with 416 additions and 21 deletions

View File

@ -3,4 +3,4 @@ NODE_ENV = 'development'
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
VITE_SERVE = "https://mini-chat.1024tool.vip/api"
VITE_APP_BASE_API_img = "https://mini-chat.1024tool.vip/"

View File

@ -2,4 +2,4 @@
NODE_ENV = 'production'
VITE_APP_BASE_API = 'https://dsjhd9s.tbmw.cn/api/'
VITE_SERVE = "https://dsjhd9s.tbmw.cn/api/"
VITE_APP_BASE_API_img = "https://dsjhd9s.tbmw.cn/"

12
components.d.ts vendored
View File

@ -12,13 +12,10 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
@ -26,30 +23,21 @@ declare module 'vue' {
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTour: typeof import('element-plus/es')['ElTour']
ElTourStep: typeof import('element-plus/es')['ElTourStep']
IndexUser: typeof import('./src/components/TagClass/indexUser.vue')['default']
KeyWords: typeof import('./src/components/KeyWords/index.vue')['default']
MaterialPublic: typeof import('./src/components/MaterialPublic/index.vue')['default']

View File

@ -8,7 +8,10 @@
<el-image class="user-avatar" :src="item.sender_avatar"></el-image>
<span v-if="item.unread_count && item.unread_count > 0" class="unread-badge">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</span>
</div>
<span class="nickname">{{ item.sender_name }}</span>
<div class="name-block">
<span class="nickname">{{ item.sender_name }}</span>
<span v-if="item.send_time" class="send-time">{{ item.send_time }}</span>
</div>
<span class="sex">
<!-- <el-icon v-if="item.sex == '男'" style="color: rgb(121.3, 187.1, 255);">
<svg class="icon" aria-hidden="true">
@ -83,6 +86,8 @@ const handleItem = (row) => {
props.cardlist.forEach(el => { el.active = false })
// true
row.active = true
//
if (row.unread_count) row.unread_count = 0
// emitcardlistactive
emits('change', row.sender_id)
emits("update:modelValue", row.sender_id)
@ -105,7 +110,7 @@ onMounted(() => {
height: 46px;
// margin: 10px 0;
padding: 0 10px;
line-height: 46px;
line-height: 22px;
cursor: pointer;
position: relative;
@ -145,10 +150,23 @@ onMounted(() => {
}
.nickname {
margin: 0 20px;
margin: 0 8px 0 0px;
font-size: 14px ;
}
.name-block {
display: flex;
flex-direction: column;
justify-content: center;
margin-left: 8px;
}
.send-time {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.sex {
.el-icon {
font-size: 18px;

View File

@ -4,7 +4,7 @@
<UserCard @load="onLoadUserList" :cardlist="userList" height="calc(100vh - 120px)" @change="onSelectUser" />
</div>
<div class="right-panel">
<div class="chat-body" ref="chatBody" @scroll="handleScroll">
<div :class="['chat-body', { 'chat-body--loading': isLoadingMessages }]" ref="chatBody" @scroll="handleScroll">
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="loading-more">加载中...</div>
@ -59,6 +59,7 @@ const messages = ref([])
const draft = ref('')
const imageInput = ref(null)
const chatBody = ref(null)
const isLoadingMessages = ref(false)
//
const sendeInfo = reactive({
@ -95,11 +96,20 @@ const onSelectUser = async (id) => {
msgQuery.user_id = u.sender_id
noMoreMessages.value = false
//
// - chat
// -
isLoadingMessages.value = true
// await
scrollToBottom()
// await scrollToBottom()
getMessages()
// await使
getMessages().finally(() => {
//
isLoadingMessages.value = false
//
scrollToBottom()
})
//
restartMessageTimer()
@ -117,6 +127,10 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
if (!msgQuery.user_id) return
loadingMore.value = true
// page=1 isHistory=true isLoadingMessages true
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
isLoadingMessages.value = true
}
const queryPage = pageOverride ?? msgQuery.page
const query = { ...msgQuery, page: queryPage }
@ -175,6 +189,10 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
//
} finally {
loadingMore.value = false
// loading finally
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
isLoadingMessages.value = false
}
// prepend
try {
@ -467,6 +485,35 @@ watch(activeUser, (newVal) => {
stopMessageTimer()
}
})
// messages
// - DOM nextTick
// -
// - load
watch(messages, async (newVal, oldVal) => {
//
if (!isAutoScroll.value) return
await nextTick()
//
scrollToBottom()
//
setTimeout(() => scrollToBottom(), 120)
// load
const el = chatBody.value
if (el) {
const imgs = el.querySelectorAll('img')
imgs.forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => {
if (isAutoScroll.value) scrollToBottom()
}, { once: true })
}
})
}
})
</script>
<style scoped lang="scss">
@ -522,6 +569,31 @@ watch(activeUser, (newVal) => {
background: #f5f7fa;
}
/* Hide scrollbar but keep scrolling functionality */
.chat-body {
/* For WebKit-based browsers (Chrome, Safari, Edge Chromium) */
-webkit-overflow-scrolling: touch;
}
.chat-body::-webkit-scrollbar {
width: 0px;
height: 0px;
}
/* For Firefox */
.chat-body {
scrollbar-width: none; /* Firefox */
}
/* For IE and older Edge */
.chat-body {
-ms-overflow-style: none; /* IE 10+ */
}
/* When loading messages during user switch, hide visual content to avoid jump/flicker */
.chat-body--loading {
opacity: 0;
transition: opacity 180ms ease-in-out;
pointer-events: none; /* prevent interaction during transient loading */
}
//
.loading-more {
text-align: center;

317
src/views/wxChat/index.vue Normal file
View File

@ -0,0 +1,317 @@
<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>