feat(frontend): Task 8 - 聊天设置页 + 消息搜索页 + 联系人改造

pages/chat/settings.vue(聊天设置页):
- 对方信息展示(头像+昵称)
- 置顶/取消置顶切换
- 清空聊天记录(确认弹窗)
- 删除会话(确认弹窗 + 双层返回)

pages/chat/search.vue(消息搜索页):
- 全局消息搜索(关键词匹配)
- 搜索结果列表(发送者头像+昵称+内容+时间)
- 点击跳转到对应会话

联系人模块改造:
- contact/detail.vue "发消息"按钮改为跳转聊天页

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-03 11:02:30 +08:00
parent 565afb3149
commit 0ea8c0d331
3 changed files with 408 additions and 1 deletions

View File

@@ -0,0 +1,217 @@
<!--
消息搜索页
设计系统design-system/echochat/MASTER.md
功能全局消息搜索结果按会话分组点击跳转到聊天页
-->
<template>
<view class="page-wrapper">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input-wrap">
<text class="search-icon">&#128269;</text>
<input
class="search-input"
v-model="keyword"
placeholder="搜索聊天记录"
placeholder-style="color: #94A3B8"
confirm-type="search"
focus
@confirm="onSearch"
/>
</view>
<text class="search-cancel" @tap="goBack">取消</text>
</view>
<!-- 搜索结果 -->
<scroll-view scroll-y class="result-list">
<!-- 空状态 -->
<view v-if="searched && results.length === 0" class="empty-state">
<text class="empty-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>
</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>
</view>
</scroll-view>
</view>
</template>
<script>
import { ref } from 'vue'
import imApi from '@/api/im'
export default {
name: 'ChatSearch',
setup() {
const keyword = ref('')
const results = ref([])
const searched = ref(false)
const onSearch = async () => {
const kw = keyword.value.trim()
if (!kw) return
searched.value = true
try {
const res = await imApi.searchMessages(kw)
results.value = (res.data && res.data.list) || []
} catch {
results.value = []
}
}
const openChat = (item) => {
uni.navigateTo({
url: `/pages/chat/conversation?conversationId=${item.conversation_id}&peerId=${item.sender_id}&peerName=${encodeURIComponent(item.sender_nickname || '')}&peerAvatar=${encodeURIComponent(item.sender_avatar || '')}`
})
}
const goBack = () => {
uni.navigateBack()
}
return {
keyword,
results,
searched,
onSearch,
openChat,
goBack
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #E2E8F0;
}
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
background-color: #F1F5F9;
border-radius: 36rpx;
padding: 0 24rpx;
height: 68rpx;
}
.search-icon {
font-size: 28rpx;
margin-right: 12rpx;
color: #94A3B8;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
}
.search-cancel {
margin-left: 20rpx;
font-size: 28rpx;
color: #2563EB;
}
.result-list {
height: calc(100vh - 100rpx);
}
.result-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #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;
}
</style>

View File

@@ -0,0 +1,186 @@
<!--
聊天设置页
设计系统design-system/echochat/MASTER.md
功能对方信息展示置顶清空聊天记录删除会话
-->
<template>
<view class="page-wrapper">
<!-- 对方信息 -->
<view class="user-card">
<view class="user-avatar-wrap">
<image v-if="peerAvatar" class="user-avatar" :src="peerAvatar" mode="aspectFill" />
<view v-else class="user-avatar user-avatar-placeholder">
<text class="avatar-text">{{ (peerName || '?')[0] }}</text>
</view>
</view>
<text class="user-name">{{ peerName || '未知用户' }}</text>
</view>
<!-- 操作项 -->
<view class="settings-group">
<view class="settings-item" @tap="togglePin">
<text class="settings-label">{{ isPinned ? '取消置顶' : '置顶聊天' }}</text>
<text class="settings-value">{{ isPinned ? '已置顶' : '' }}</text>
</view>
</view>
<view class="settings-group">
<view class="settings-item settings-danger" @tap="onClearHistory">
<text class="settings-label-danger">清空聊天记录</text>
</view>
<view class="settings-item settings-danger" @tap="onDeleteConversation">
<text class="settings-label-danger">删除会话</text>
</view>
</view>
</view>
</template>
<script>
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { useChatStore } from '@/store/chat'
export default {
name: 'ChatSettings',
setup() {
const chatStore = useChatStore()
const conversationId = ref(0)
const peerId = ref(0)
const peerName = ref('')
const peerAvatar = ref('')
const isPinned = ref(false)
onLoad((query) => {
conversationId.value = parseInt(query.conversationId) || 0
peerId.value = parseInt(query.peerId) || 0
peerName.value = decodeURIComponent(query.peerName || '')
peerAvatar.value = decodeURIComponent(query.peerAvatar || '')
const conv = chatStore.conversationList.find(c => c.id === conversationId.value)
if (conv) {
isPinned.value = conv.is_pinned
}
})
const togglePin = async () => {
const newVal = !isPinned.value
await chatStore.pinConversation(conversationId.value, newVal)
isPinned.value = newVal
uni.showToast({ title: newVal ? '已置顶' : '已取消置顶', icon: 'none' })
}
const onClearHistory = () => {
uni.showModal({
title: '提示',
content: '确定清空聊天记录?此操作不可恢复',
success: async (res) => {
if (res.confirm) {
await chatStore.clearHistory(conversationId.value)
uni.showToast({ title: '已清空', icon: 'none' })
}
}
})
}
const onDeleteConversation = () => {
uni.showModal({
title: '提示',
content: '确定删除该会话?',
success: async (res) => {
if (res.confirm) {
await chatStore.deleteConversation(conversationId.value)
uni.navigateBack({ delta: 2 })
}
}
})
}
return {
peerName,
peerAvatar,
isPinned,
togglePin,
onClearHistory,
onDeleteConversation
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F1F5F9;
}
.user-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 32rpx;
background-color: #FFFFFF;
margin-bottom: 16rpx;
}
.user-avatar-wrap {
margin-bottom: 20rpx;
}
.user-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 30rpx;
}
.user-avatar-placeholder {
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
color: #FFFFFF;
font-size: 48rpx;
font-weight: 600;
}
.user-name {
font-size: 34rpx;
font-weight: 600;
color: #1E293B;
}
.settings-group {
background-color: #FFFFFF;
margin-bottom: 16rpx;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.settings-item:last-child {
border-bottom: none;
}
.settings-label {
font-size: 30rpx;
color: #1E293B;
}
.settings-value {
font-size: 26rpx;
color: #94A3B8;
}
.settings-label-danger {
font-size: 30rpx;
color: #EF4444;
}
</style>

View File

@@ -136,7 +136,11 @@ const selectGroup = () => {
}
const sendMessage = () => {
uni.showToast({ title: '聊天功能开发中', icon: 'none' })
const f = friend.value
if (!f) return
uni.navigateTo({
url: `/pages/chat/conversation?conversationId=0&peerId=${f.user_id}&peerName=${encodeURIComponent(f.remark || f.nickname || f.username)}&peerAvatar=${encodeURIComponent(f.avatar || '')}`
})
}
const handleBlock = () => {