feat:单聊页面bug修复+后台管理端好友页面bug修复+项目进度、记忆文件更新

This commit is contained in:
bujinyuan
2026-03-04 10:03:16 +08:00
parent 7b1d061955
commit 9399f86a19
30 changed files with 838 additions and 530 deletions

View File

@@ -90,6 +90,9 @@
* 登录成功后 reLaunch 到首页
*/
import { useUserStore } from '@/store/user'
import { useWebSocketStore } from '@/store/websocket'
import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
export default {
name: 'LoginPage',
@@ -152,6 +155,10 @@ export default {
account: this.form.account.trim(),
password: this.form.password
})
const wsStore = useWebSocketStore()
wsStore.connect()
useChatStore().initWsListeners()
useContactStore().initWsListeners()
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
} catch (e) {

View File

@@ -222,8 +222,13 @@ export default {
finally { loadingMore.value = false }
}
const canRecall = (msg) => {
if (!msg.created_at) return false
return (Date.now() - new Date(msg.created_at).getTime()) < 2 * 60 * 1000
}
const onMsgLongPress = (msg) => {
if (!isSelf(msg) || msg.status === 2) return
if (!isSelf(msg) || msg.status === 2 || !canRecall(msg)) return
uni.showActionSheet({
itemList: ['撤回'],
success: (res) => {
@@ -304,9 +309,12 @@ export default {
/* ===== 消息列表 ===== */
.msg-list {
flex: 1;
height: 0;
min-height: 0;
padding: 16rpx 24rpx;
box-sizing: border-box;
width: 100%;
overflow: hidden;
}
.load-more { text-align: center; padding: 16rpx 0; }
.load-more-text { font-size: 24rpx; color: #94A3B8; }

View File

@@ -21,6 +21,9 @@
focus
@confirm="onSearch"
/>
<view v-if="keyword" class="search-clear" @tap="keyword = ''; groups = []; searched = false">
<uni-icons type="clear" size="18" color="#94A3B8" />
</view>
</view>
<view class="search-cancel" @tap="goBack">
<text class="search-cancel-text">取消</text>
@@ -29,28 +32,35 @@
<!-- 搜索结果 -->
<scroll-view scroll-y class="result-list">
<!-- 空状态 -->
<view v-if="searched && results.length === 0" class="empty-state">
<text class="empty-text">未找到相关消息</text>
<view v-if="searching" class="status-state">
<text class="status-text">搜索中...</text>
</view>
<!-- 结果列表 -->
<view
v-for="item in results"
:key="item.id"
class="result-item"
@tap="openChat(item)"
>
<view class="result-avatar-wrap">
<image v-if="item.sender_avatar" class="result-avatar" :src="item.sender_avatar" mode="aspectFill" />
<view v-else class="result-avatar result-avatar-placeholder">
<text class="avatar-text">{{ (item.sender_nickname || '?')[0] }}</text>
<view v-else-if="searched && groups.length === 0" class="status-state">
<text class="status-text">未找到相关消息</text>
</view>
<!-- 按会话分组展示 -->
<view v-for="group in groups" :key="group.conversation_id" class="result-group">
<view class="group-header" @tap="openConversation(group)">
<view class="group-avatar-wrap">
<image v-if="group.peer_avatar" class="group-avatar" :src="group.peer_avatar" mode="aspectFill" />
<view v-else class="group-avatar group-avatar-placeholder">
<text class="avatar-text">{{ (group.peer_name || '?')[0] }}</text>
</view>
</view>
<text class="group-name">{{ group.peer_name || '未知用户' }}</text>
<text class="group-count">{{ group.messages.length }} 条相关</text>
</view>
<view class="result-info">
<text class="result-name">{{ item.sender_nickname || '未知用户' }}</text>
<text class="result-content">{{ item.content }}</text>
<text class="result-time">{{ item.created_at }}</text>
<view
v-for="msg in group.messages"
:key="msg.id"
class="result-item"
@tap="openConversation(group)"
>
<text class="result-sender">{{ msg.sender_nickname || '未知' }}</text>
<text class="result-content">{{ msg.content }}</text>
<text class="result-time">{{ msg.created_at }}</text>
</view>
</view>
</scroll-view>
@@ -58,32 +68,66 @@
</template>
<script>
import { ref } from 'vue'
import { ref, watch } from 'vue'
import imApi from '@/api/im'
import { useChatStore } from '@/store/chat'
export default {
name: 'ChatSearch',
setup() {
const chatStore = useChatStore()
const keyword = ref('')
const results = ref([])
const groups = ref([])
const searched = ref(false)
const searching = ref(false)
let debounceTimer = null
watch(keyword, (val) => {
clearTimeout(debounceTimer)
if (!val.trim()) { groups.value = []; searched.value = false; return }
debounceTimer = setTimeout(() => onSearch(), 300)
})
const onSearch = async () => {
const kw = keyword.value.trim()
if (!kw) return
searching.value = true
searched.value = true
try {
const res = await imApi.searchMessages(kw)
results.value = (res.data && res.data.list) || []
const list = (res.data && res.data.list) || []
groups.value = _groupByConversation(list)
} catch {
results.value = []
groups.value = []
} finally {
searching.value = false
}
}
const openChat = (item) => {
/** 将扁平消息列表按 conversation_id 分组,附加会话的 peer 信息 */
const _groupByConversation = (messages) => {
const map = {}
for (const msg of messages) {
const convId = msg.conversation_id
if (!map[convId]) {
const conv = chatStore.conversationList.find(c => c.id === convId)
map[convId] = {
conversation_id: convId,
peer_name: conv?.peer_nickname || msg.sender_nickname || '未知',
peer_avatar: conv?.peer_avatar || msg.sender_avatar || '',
peer_id: conv?.peer_user_id || 0,
messages: []
}
}
map[convId].messages.push(msg)
}
return Object.values(map)
}
const openConversation = (group) => {
uni.navigateTo({
url: `/pages/chat/conversation?conversationId=${item.conversation_id}&peerId=${item.sender_id}&peerName=${encodeURIComponent(item.sender_nickname || '')}&peerAvatar=${encodeURIComponent(item.sender_avatar || '')}`
url: `/pages/chat/conversation?conversationId=${group.conversation_id}&peerId=${group.peer_id}&peerName=${encodeURIComponent(group.peer_name)}&peerAvatar=${encodeURIComponent(group.peer_avatar)}`
})
}
@@ -91,14 +135,7 @@ export default {
uni.navigateBack()
}
return {
keyword,
results,
searched,
onSearch,
openChat,
goBack
}
return { keyword, groups, searched, searching, onSearch, openConversation, goBack }
}
}
</script>
@@ -127,12 +164,8 @@ export default {
height: 68rpx;
gap: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
}
.search-input { flex: 1; font-size: 28rpx; color: #1E293B; }
.search-clear { min-width: 44rpx; min-height: 44rpx; display: flex; align-items: center; justify-content: center; }
.search-cancel {
margin-left: 20rpx;
@@ -142,92 +175,42 @@ export default {
align-items: center;
justify-content: center;
}
.search-cancel-text { font-size: 28rpx; color: #2563EB; }
.search-cancel:active { opacity: 0.6; }
.search-cancel-text {
font-size: 28rpx;
color: #2563EB;
}
.search-cancel:active {
opacity: 0.6;
}
.result-list {
height: calc(100vh - 100rpx);
.result-list { height: calc(100vh - 100rpx); }
/* 分组 */
.result-group { margin-bottom: 16rpx; }
.group-header {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #E2E8F0;
}
.group-header:active { background-color: #F1F5F9; }
.group-avatar-wrap { flex-shrink: 0; margin-right: 16rpx; }
.group-avatar { width: 64rpx; height: 64rpx; border-radius: 16rpx; }
.group-avatar-placeholder { background-color: #2563EB; display: flex; align-items: center; justify-content: center; }
.avatar-text { color: #FFFFFF; font-size: 26rpx; font-weight: 600; }
.group-name { flex: 1; font-size: 30rpx; font-weight: 500; color: #1E293B; }
.group-count { font-size: 24rpx; color: #94A3B8; }
/* 消息条目 */
.result-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
padding: 16rpx 32rpx 16rpx 112rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #F1F5F9;
transition: background-color 150ms ease;
border-bottom: 1rpx solid #F8FAFC;
}
.result-item:active { background-color: #F1F5F9; }
.result-sender { font-size: 26rpx; color: #64748B; flex-shrink: 0; }
.result-content { flex: 1; font-size: 26rpx; color: #1E293B; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-time { font-size: 22rpx; color: #94A3B8; margin-left: 12rpx; flex-shrink: 0; }
.result-item:active {
background-color: #F1F5F9;
}
.result-avatar-wrap {
flex-shrink: 0;
margin-right: 20rpx;
}
.result-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
}
.result-avatar-placeholder {
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
color: #FFFFFF;
font-size: 28rpx;
font-weight: 600;
}
.result-info {
flex: 1;
overflow: hidden;
}
.result-name {
font-size: 28rpx;
font-weight: 500;
color: #1E293B;
margin-bottom: 4rpx;
}
.result-content {
font-size: 26rpx;
color: #64748B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4rpx;
}
.result-time {
font-size: 22rpx;
color: #94A3B8;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding-top: 200rpx;
}
.empty-text {
font-size: 28rpx;
color: #94A3B8;
}
/* 状态 */
.status-state { display: flex; align-items: center; justify-content: center; padding-top: 200rpx; }
.status-text { font-size: 28rpx; color: #94A3B8; }
</style>

View File

@@ -67,9 +67,13 @@ export default {
const togglePin = async () => {
const newVal = !isPinned.value
await chatStore.pinConversation(conversationId.value, newVal)
isPinned.value = newVal
uni.showToast({ title: newVal ? '已置顶' : '已取消置顶', icon: 'none' })
try {
await chatStore.pinConversation(conversationId.value, newVal)
isPinned.value = newVal
uni.showToast({ title: newVal ? '已置顶' : '已取消置顶', icon: 'none' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const onClearHistory = () => {
@@ -78,8 +82,12 @@ export default {
content: '确定清空聊天记录?此操作不可恢复',
success: async (res) => {
if (res.confirm) {
await chatStore.clearHistory(conversationId.value)
uni.showToast({ title: '已清空', icon: 'none' })
try {
await chatStore.clearHistory(conversationId.value)
uni.showToast({ title: '已清空', icon: 'none' })
} catch {
uni.showToast({ title: '清空失败', icon: 'none' })
}
}
}
})
@@ -91,8 +99,12 @@ export default {
content: '确定删除该会话?',
success: async (res) => {
if (res.confirm) {
await chatStore.deleteConversation(conversationId.value)
uni.navigateBack({ delta: 2 })
try {
await chatStore.deleteConversation(conversationId.value)
uni.navigateBack({ delta: 2 })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})

View File

@@ -97,6 +97,7 @@ const handleAccept = async (requestId) => {
uni.showToast({ title: '已接受', icon: 'success' })
} catch (e) {
console.error('接受好友申请失败', e)
uni.showToast({ title: e?.data?.message || '接受申请失败', icon: 'none' })
} finally {
processingMap[requestId] = false
}
@@ -110,6 +111,7 @@ const handleReject = async (requestId) => {
uni.showToast({ title: '已拒绝', icon: 'none' })
} catch (e) {
console.error('拒绝好友申请失败', e)
uni.showToast({ title: e?.data?.message || '拒绝申请失败', icon: 'none' })
} finally {
processingMap[requestId] = false
}

View File

@@ -37,7 +37,7 @@
<view v-else-if="searchResults.length > 0" class="result-list">
<view
v-for="user in searchResults"
:key="user.user_id"
:key="user.id"
class="user-item"
>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
@@ -53,10 +53,10 @@
<view
v-else
class="add-btn"
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
:class="{ 'add-btn--disabled': addingMap[user.id] }"
@tap="showAddDialog(user)"
>
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
<text class="add-btn-text">{{ addingMap[user.id] ? '已发送' : '添加' }}</text>
</view>
</view>
</view>
@@ -79,7 +79,7 @@
<view v-else-if="recommendations.length > 0" class="result-list">
<view
v-for="user in recommendations"
:key="user.user_id"
:key="user.id"
class="user-item"
>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
@@ -91,10 +91,10 @@
</view>
<view
class="add-btn"
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
:class="{ 'add-btn--disabled': addingMap[user.id] }"
@tap="showAddDialog(user)"
>
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
<text class="add-btn-text">{{ addingMap[user.id] ? '已发送' : '添加' }}</text>
</view>
</view>
</view>
@@ -141,7 +141,7 @@ const doSearch = async () => {
}
const showAddDialog = (user) => {
if (addingMap[user.user_id]) return
if (addingMap[user.id]) return
uni.showModal({
title: '添加好友',
editable: true,
@@ -149,13 +149,17 @@ const showAddDialog = (user) => {
content: '',
success: async (res) => {
if (res.confirm) {
addingMap[user.user_id] = true
addingMap[user.id] = true
try {
await contactStore.sendRequest(user.user_id, res.content || '')
await contactStore.sendRequest(user.id, res.content || '')
uni.showToast({ title: '申请已发送', icon: 'success' })
} catch (e) {
console.error('发送好友申请失败', e)
addingMap[user.user_id] = false
addingMap[user.id] = false
uni.showToast({
title: e?.data?.message || '发送申请失败',
icon: 'none'
})
}
}
}