dsds
This commit is contained in:
parent
fc119d70e8
commit
304594bdc1
@ -3,4 +3,4 @@ NODE_ENV = 'development'
|
|||||||
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
|
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
|
||||||
VITE_SERVE = "https://mini-chat.1024tool.vip/api"
|
VITE_SERVE = "https://mini-chat.1024tool.vip/api"
|
||||||
|
|
||||||
|
VITE_APP_BASE_API_img = "https://mini-chat.1024tool.vip/"
|
||||||
|
|||||||
@ -2,4 +2,4 @@
|
|||||||
NODE_ENV = 'production'
|
NODE_ENV = 'production'
|
||||||
VITE_APP_BASE_API = 'https://dsjhd9s.tbmw.cn/api/'
|
VITE_APP_BASE_API = 'https://dsjhd9s.tbmw.cn/api/'
|
||||||
VITE_SERVE = "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
12
components.d.ts
vendored
@ -12,13 +12,10 @@ declare module 'vue' {
|
|||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
@ -26,30 +23,21 @@ declare module 'vue' {
|
|||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElLink: typeof import('element-plus/es')['ElLink']
|
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
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']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
|
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
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']
|
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']
|
IndexUser: typeof import('./src/components/TagClass/indexUser.vue')['default']
|
||||||
KeyWords: typeof import('./src/components/KeyWords/index.vue')['default']
|
KeyWords: typeof import('./src/components/KeyWords/index.vue')['default']
|
||||||
MaterialPublic: typeof import('./src/components/MaterialPublic/index.vue')['default']
|
MaterialPublic: typeof import('./src/components/MaterialPublic/index.vue')['default']
|
||||||
|
|||||||
@ -8,7 +8,10 @@
|
|||||||
<el-image class="user-avatar" :src="item.sender_avatar"></el-image>
|
<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>
|
<span v-if="item.unread_count && item.unread_count > 0" class="unread-badge">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</span>
|
||||||
</div>
|
</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">
|
<span class="sex">
|
||||||
<!-- <el-icon v-if="item.sex == '男'" style="color: rgb(121.3, 187.1, 255);">
|
<!-- <el-icon v-if="item.sex == '男'" style="color: rgb(121.3, 187.1, 255);">
|
||||||
<svg class="icon" aria-hidden="true">
|
<svg class="icon" aria-hidden="true">
|
||||||
@ -83,6 +86,8 @@ const handleItem = (row) => {
|
|||||||
props.cardlist.forEach(el => { el.active = false })
|
props.cardlist.forEach(el => { el.active = false })
|
||||||
// 再设当前为true
|
// 再设当前为true
|
||||||
row.active = true
|
row.active = true
|
||||||
|
// 点击时清除未读提醒(直接修改数据源,父组件会看到变化)
|
||||||
|
if (row.unread_count) row.unread_count = 0
|
||||||
// emit后外部拿到的cardlist状态是唯一active
|
// emit后外部拿到的cardlist状态是唯一active
|
||||||
emits('change', row.sender_id)
|
emits('change', row.sender_id)
|
||||||
emits("update:modelValue", row.sender_id)
|
emits("update:modelValue", row.sender_id)
|
||||||
@ -105,7 +110,7 @@ onMounted(() => {
|
|||||||
height: 46px;
|
height: 46px;
|
||||||
// margin: 10px 0;
|
// margin: 10px 0;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
line-height: 46px;
|
line-height: 22px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@ -145,10 +150,23 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nickname {
|
.nickname {
|
||||||
margin: 0 20px;
|
margin: 0 8px 0 0px;
|
||||||
font-size: 14px ;
|
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 {
|
.sex {
|
||||||
.el-icon {
|
.el-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<UserCard @load="onLoadUserList" :cardlist="userList" height="calc(100vh - 120px)" @change="onSelectUser" />
|
<UserCard @load="onLoadUserList" :cardlist="userList" height="calc(100vh - 120px)" @change="onSelectUser" />
|
||||||
</div>
|
</div>
|
||||||
<div class="right-panel">
|
<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>
|
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||||||
|
|
||||||
@ -59,6 +59,7 @@ const messages = ref([])
|
|||||||
const draft = ref('')
|
const draft = ref('')
|
||||||
const imageInput = ref(null)
|
const imageInput = ref(null)
|
||||||
const chatBody = ref(null)
|
const chatBody = ref(null)
|
||||||
|
const isLoadingMessages = ref(false)
|
||||||
|
|
||||||
// 发送者信息
|
// 发送者信息
|
||||||
const sendeInfo = reactive({
|
const sendeInfo = reactive({
|
||||||
@ -95,11 +96,20 @@ const onSelectUser = async (id) => {
|
|||||||
msgQuery.user_id = u.sender_id
|
msgQuery.user_id = u.sender_id
|
||||||
noMoreMessages.value = false
|
noMoreMessages.value = false
|
||||||
|
|
||||||
|
// 为避免切换用户时看到明显的滚动过程:
|
||||||
|
// - 在开始加载消息前将 chat 区域设置为不可见(但依然可滚动),
|
||||||
|
// - 加载完成后恢复显示并滚到底部(保持原有的交互顺序)。
|
||||||
|
isLoadingMessages.value = true
|
||||||
|
|
||||||
// 保持原有行为:先滚动到底部再加载(不 await),以免改变用户可感知的交互顺序
|
// 保持原有行为:先滚动到底部再加载(不 await),以免改变用户可感知的交互顺序
|
||||||
|
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
// await scrollToBottom()
|
// 启动加载(不 await,使界面更流畅)
|
||||||
getMessages()
|
getMessages().finally(() => {
|
||||||
|
// 在消息加载结束后恢复可见性并尝试滚到底部
|
||||||
|
isLoadingMessages.value = false
|
||||||
|
// 确保滚到底部以避免残留偏移
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
// 重启定时器
|
// 重启定时器
|
||||||
restartMessageTimer()
|
restartMessageTimer()
|
||||||
@ -117,6 +127,10 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
|
|||||||
if (!msgQuery.user_id) return
|
if (!msgQuery.user_id) return
|
||||||
|
|
||||||
loadingMore.value = true
|
loadingMore.value = true
|
||||||
|
// 在第一次加载(page=1 且 isHistory=true)期间也将 isLoadingMessages 保持为 true
|
||||||
|
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
||||||
|
isLoadingMessages.value = true
|
||||||
|
}
|
||||||
const queryPage = pageOverride ?? msgQuery.page
|
const queryPage = pageOverride ?? msgQuery.page
|
||||||
const query = { ...msgQuery, page: queryPage }
|
const query = { ...msgQuery, page: queryPage }
|
||||||
|
|
||||||
@ -175,6 +189,10 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
|
|||||||
// 可扩展错误处理
|
// 可扩展错误处理
|
||||||
} finally {
|
} finally {
|
||||||
loadingMore.value = false
|
loadingMore.value = false
|
||||||
|
// 如果这是第一页历史加载,确保 loading 标志被清理(页面外也会在调用者 finally 中清理)
|
||||||
|
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
||||||
|
isLoadingMessages.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是向上翻页(非第一页)并且 prepend 后视图仍然靠近顶部,自动继续翻页
|
// 如果是向上翻页(非第一页)并且 prepend 后视图仍然靠近顶部,自动继续翻页
|
||||||
try {
|
try {
|
||||||
@ -467,6 +485,35 @@ watch(activeUser, (newVal) => {
|
|||||||
stopMessageTimer()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@ -522,6 +569,31 @@ watch(activeUser, (newVal) => {
|
|||||||
background: #f5f7fa;
|
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 {
|
.loading-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
317
src/views/wxChat/index.vue
Normal file
317
src/views/wxChat/index.vue
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user