feat:单聊页面bug修复+后台管理端好友页面bug修复+项目进度、记忆文件更新
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user