feat(frontend): Task 7 - 会话列表页 + 聊天对话页
pages/chat/index.vue(会话列表页): - 排序展示会话列表(置顶优先 → 时间降序) - 未读 badge + 时间格式化(今天/昨天/日期) - 长按操作菜单(置顶/删除) - 正在输入状态提示 - 空状态 UI pages/chat/conversation.vue(聊天页): - 消息气泡(自己蓝色/对方白色) - 游标分页加载历史消息 - 发送消息 + 三态确认(sending/sent/failed) - 长按撤回 + 重发失败消息 - 正在输入通知(发送 + 接收) - 自定义导航栏 + 设置入口 pages.json: - 新增 chat/conversation、chat/settings、chat/search 路由 Made-with: Cursor
This commit is contained in:
@@ -62,6 +62,25 @@
|
||||
"navigationBarTitleText": "黑名单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/conversation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索消息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/index",
|
||||
"style": {
|
||||
|
||||
491
frontend/src/pages/chat/conversation.vue
Normal file
491
frontend/src/pages/chat/conversation.vue
Normal file
@@ -0,0 +1,491 @@
|
||||
<!--
|
||||
聊天对话页
|
||||
|
||||
设计系统:design-system/echochat/MASTER.md
|
||||
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B
|
||||
|
||||
功能:
|
||||
- 消息气泡(左侧对方 / 右侧自己)
|
||||
- 游标分页加载历史消息
|
||||
- 消息发送(三态:sending → sent → failed)
|
||||
- 正在输入提示
|
||||
- 消息撤回(长按自己的消息)
|
||||
-->
|
||||
<template>
|
||||
<view class="page-wrapper">
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-left" @tap="goBack">
|
||||
<text class="nav-back">❮</text>
|
||||
</view>
|
||||
<view class="nav-center">
|
||||
<text class="nav-title">{{ peerName || '聊天' }}</text>
|
||||
<text v-if="isTyping" class="nav-typing">正在输入...</text>
|
||||
</view>
|
||||
<view class="nav-right" @tap="goToSettings">
|
||||
<text class="nav-more">⋯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="msg-list"
|
||||
:scroll-into-view="scrollToId"
|
||||
:scroll-with-animation="true"
|
||||
@scrolltoupper="onLoadMore"
|
||||
>
|
||||
<!-- 加载更多提示 -->
|
||||
<view v-if="hasMore" class="load-more" @tap="onLoadMore">
|
||||
<text class="load-more-text">{{ loadingMore ? '加载中...' : '上拉加载更多' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<view
|
||||
v-for="msg in messages"
|
||||
:key="msg.client_msg_id || msg.id"
|
||||
:id="'msg-' + (msg.id || msg.client_msg_id)"
|
||||
class="msg-row"
|
||||
:class="{ 'msg-row-self': isSelf(msg), 'msg-row-other': !isSelf(msg) }"
|
||||
>
|
||||
<!-- 对方头像 -->
|
||||
<view v-if="!isSelf(msg)" class="msg-avatar-wrap">
|
||||
<image v-if="peerAvatar" class="msg-avatar" :src="peerAvatar" mode="aspectFill" />
|
||||
<view v-else class="msg-avatar msg-avatar-placeholder">
|
||||
<text class="msg-avatar-text">{{ (peerName || '?')[0] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<view
|
||||
class="msg-bubble"
|
||||
:class="{
|
||||
'bubble-self': isSelf(msg),
|
||||
'bubble-other': !isSelf(msg),
|
||||
'bubble-recalled': msg.status === 2
|
||||
}"
|
||||
@longpress="onMsgLongPress(msg)"
|
||||
>
|
||||
<text v-if="msg.status === 2" class="msg-recalled">消息已撤回</text>
|
||||
<text v-else class="msg-text">{{ msg.content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 发送状态 -->
|
||||
<view v-if="isSelf(msg) && msg._sending" class="msg-status">
|
||||
<text class="status-sending">⌛</text>
|
||||
</view>
|
||||
<view v-if="isSelf(msg) && msg._failed" class="msg-status" @tap="onResend(msg)">
|
||||
<text class="status-failed">⚠</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view id="msg-bottom" style="height: 2rpx;" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入区 -->
|
||||
<view class="input-bar">
|
||||
<view class="input-wrap">
|
||||
<input
|
||||
class="msg-input"
|
||||
v-model="inputText"
|
||||
placeholder="输入消息..."
|
||||
placeholder-style="color: #94A3B8"
|
||||
confirm-type="send"
|
||||
@confirm="onSend"
|
||||
@input="onInputChange"
|
||||
:adjust-position="true"
|
||||
/>
|
||||
</view>
|
||||
<view class="send-btn" :class="{ 'send-btn-active': inputText.trim() }" @tap="onSend">
|
||||
<text class="send-icon">➤</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
export default {
|
||||
name: 'ChatConversation',
|
||||
setup() {
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const conversationId = ref(0)
|
||||
const peerId = ref(0)
|
||||
const peerName = ref('')
|
||||
const peerAvatar = ref('')
|
||||
const inputText = ref('')
|
||||
const scrollToId = ref('')
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const messages = computed(() => chatStore.currentMessages)
|
||||
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
|
||||
const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false)
|
||||
|
||||
const isSelf = (msg) => {
|
||||
return msg.sender_id === (userStore.userInfo?.id || 0) || msg._sending
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
conversationId.value = parseInt(query.conversationId) || 0
|
||||
peerId.value = parseInt(query.peerId) || 0
|
||||
peerName.value = decodeURIComponent(query.peerName || '')
|
||||
peerAvatar.value = decodeURIComponent(query.peerAvatar || '')
|
||||
|
||||
chatStore.initWsListeners()
|
||||
|
||||
if (conversationId.value) {
|
||||
chatStore.setCurrentConversation(conversationId.value)
|
||||
loadInitialMessages()
|
||||
}
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
chatStore.setCurrentConversation(null)
|
||||
})
|
||||
|
||||
const loadInitialMessages = async () => {
|
||||
if (!chatStore.messagesMap[conversationId.value]?.length) {
|
||||
await chatStore.loadHistoryMessages(conversationId.value)
|
||||
}
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
scrollToId.value = ''
|
||||
nextTick(() => {
|
||||
scrollToId.value = 'msg-bottom'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const onSend = () => {
|
||||
const content = inputText.value.trim()
|
||||
if (!content) return
|
||||
|
||||
chatStore.sendMessage({
|
||||
conversationId: conversationId.value || 0,
|
||||
targetUserId: conversationId.value ? 0 : peerId.value,
|
||||
content,
|
||||
type: 1
|
||||
})
|
||||
|
||||
inputText.value = ''
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
let typingTimer = null
|
||||
const onInputChange = () => {
|
||||
if (typingTimer) return
|
||||
if (conversationId.value) {
|
||||
chatStore.sendTyping(conversationId.value)
|
||||
}
|
||||
typingTimer = setTimeout(() => {
|
||||
typingTimer = null
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const onLoadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
await chatStore.loadHistoryMessages(conversationId.value)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMsgLongPress = (msg) => {
|
||||
if (!isSelf(msg) || msg.status === 2) return
|
||||
uni.showActionSheet({
|
||||
itemList: ['撤回'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0 && msg.id) {
|
||||
chatStore.recallMessage(msg.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onResend = (msg) => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '是否重新发送?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
chatStore.sendMessage({
|
||||
conversationId: conversationId.value,
|
||||
content: msg.content,
|
||||
type: msg.type || 1
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/settings?conversationId=${conversationId.value}&peerId=${peerId.value}&peerName=${encodeURIComponent(peerName.value)}&peerAvatar=${encodeURIComponent(peerAvatar.value)}`
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
peerName,
|
||||
peerAvatar,
|
||||
inputText,
|
||||
scrollToId,
|
||||
loadingMore,
|
||||
messages,
|
||||
hasMore,
|
||||
isTyping,
|
||||
isSelf,
|
||||
onSend,
|
||||
onInputChange,
|
||||
onLoadMore,
|
||||
onMsgLongPress,
|
||||
onResend,
|
||||
goBack,
|
||||
goToSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrapper {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #F1F5F9;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
padding-top: var(--status-bar-height, 44px);
|
||||
background-color: #FFFFFF;
|
||||
border-bottom: 1rpx solid #E2E8F0;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
font-size: 32rpx;
|
||||
color: #1E293B;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
.nav-typing {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #2563EB;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-more {
|
||||
font-size: 36rpx;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* 消息列表 */
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
padding: 16rpx 24rpx;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
/* 消息行 */
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.msg-row-self {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg-row-other {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.msg-avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
.msg-avatar-placeholder {
|
||||
background-color: #2563EB;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.msg-avatar-text {
|
||||
color: #FFFFFF;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 气泡 */
|
||||
.msg-bubble {
|
||||
max-width: 560rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bubble-self {
|
||||
background-color: #2563EB;
|
||||
border-bottom-right-radius: 8rpx;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.bubble-other {
|
||||
background-color: #FFFFFF;
|
||||
border-bottom-left-radius: 8rpx;
|
||||
}
|
||||
|
||||
.bubble-recalled {
|
||||
background-color: transparent !important;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.bubble-self .msg-text {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.bubble-other .msg-text {
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
.msg-recalled {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 发送状态 */
|
||||
.msg-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 8rpx;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.status-sending {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
font-size: 28rpx;
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
/* 输入栏 */
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom, 0));
|
||||
background-color: #FFFFFF;
|
||||
border-top: 1rpx solid #E2E8F0;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
flex: 1;
|
||||
background-color: #F1F5F9;
|
||||
border-radius: 36rpx;
|
||||
padding: 0 28rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.msg-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
margin-left: 16rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #CBD5E1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 200ms ease;
|
||||
}
|
||||
|
||||
.send-btn-active {
|
||||
background-color: #2563EB;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
font-size: 32rpx;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,172 @@
|
||||
<!--
|
||||
消息列表页面(占位)
|
||||
消息 - 会话列表页(TabBar 页面)
|
||||
|
||||
设计系统:design-system/echochat/MASTER.md
|
||||
TabBar 页面,Phase 2(IM 模块)中实现具体功能
|
||||
|
||||
当前功能:
|
||||
- 显示模块名称和开发中提示
|
||||
- 集成自定义 TabBar 组件
|
||||
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
|
||||
-->
|
||||
<template>
|
||||
<view class="page-wrapper">
|
||||
<view class="placeholder-content">
|
||||
<text class="placeholder-icon">💬</text>
|
||||
<text class="placeholder-title">消息</text>
|
||||
<text class="placeholder-desc">即时通讯功能开发中…</text>
|
||||
<!-- 顶部栏 -->
|
||||
<view class="header">
|
||||
<text class="header-title">消息</text>
|
||||
<view class="header-actions">
|
||||
<view class="action-btn" @tap="goToSearch">
|
||||
<text class="action-icon">🔍</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view scroll-y class="conv-list" @scrolltolower="onScrollToLower">
|
||||
<!-- 空状态 -->
|
||||
<view v-if="!loading && conversations.length === 0" class="empty-state">
|
||||
<text class="empty-icon">💬</text>
|
||||
<text class="empty-text">暂无消息</text>
|
||||
<text class="empty-hint">找好友聊聊天吧</text>
|
||||
</view>
|
||||
|
||||
<!-- 会话条目 -->
|
||||
<view
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
:class="{ 'conv-pinned': conv.is_pinned }"
|
||||
@tap="openChat(conv)"
|
||||
@longpress="onLongPress(conv)"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<view class="conv-avatar-wrap">
|
||||
<image
|
||||
v-if="conv.peer_avatar"
|
||||
class="conv-avatar"
|
||||
:src="conv.peer_avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="conv-avatar conv-avatar-placeholder">
|
||||
<text class="avatar-text">{{ (conv.peer_nickname || '?')[0] }}</text>
|
||||
</view>
|
||||
<view v-if="conv.unread_count > 0" class="conv-badge">
|
||||
<text class="conv-badge-text">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<view class="conv-info">
|
||||
<view class="conv-top">
|
||||
<text class="conv-name">{{ conv.peer_nickname || '未知用户' }}</text>
|
||||
<text class="conv-time">{{ formatTime(conv.last_msg_time) }}</text>
|
||||
</view>
|
||||
<view class="conv-bottom">
|
||||
<text v-if="isTyping(conv.id)" class="conv-typing">对方正在输入...</text>
|
||||
<text v-else class="conv-preview" :class="{ 'conv-preview-unread': conv.unread_count > 0 }">
|
||||
{{ conv.last_msg_content || ' ' }}
|
||||
</text>
|
||||
<view v-if="conv.is_pinned" class="conv-pin-tag">
|
||||
<text class="pin-icon">📌</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<CustomTabBar :current="0" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
export default {
|
||||
name: 'ChatIndex',
|
||||
components: { CustomTabBar }
|
||||
components: { CustomTabBar },
|
||||
setup() {
|
||||
const chatStore = useChatStore()
|
||||
const loading = ref(false)
|
||||
|
||||
const conversations = computed(() => chatStore.sortedConversations)
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await chatStore.fetchConversations()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
chatStore.initWsListeners()
|
||||
loadData()
|
||||
})
|
||||
|
||||
const openChat = (conv) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}`
|
||||
})
|
||||
}
|
||||
|
||||
const goToSearch = () => {
|
||||
uni.navigateTo({ url: '/pages/chat/search' })
|
||||
}
|
||||
|
||||
const isTyping = (convId) => {
|
||||
return chatStore.typingMap[convId] || false
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
if (isToday) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return '昨天'
|
||||
}
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
const onLongPress = (conv) => {
|
||||
const items = [conv.is_pinned ? '取消置顶' : '置顶', '删除会话']
|
||||
uni.showActionSheet({
|
||||
itemList: items,
|
||||
success: async (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
await chatStore.pinConversation(conv.id, !conv.is_pinned)
|
||||
} else if (res.tapIndex === 1) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该会话?',
|
||||
success: async (r) => {
|
||||
if (r.confirm) {
|
||||
await chatStore.deleteConversation(conv.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onScrollToLower = () => {}
|
||||
|
||||
return {
|
||||
loading,
|
||||
conversations,
|
||||
openChat,
|
||||
goToSearch,
|
||||
isTyping,
|
||||
formatTime,
|
||||
onLongPress,
|
||||
onScrollToLower
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -35,28 +177,189 @@ export default {
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx;
|
||||
height: 88rpx;
|
||||
padding-top: var(--status-bar-height, 44px);
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16rpx;
|
||||
background-color: #F1F5F9;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 32rpx;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* 会话列表 */
|
||||
.conv-list {
|
||||
height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx);
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 32rpx;
|
||||
background-color: #FFFFFF;
|
||||
border-bottom: 1rpx solid #F1F5F9;
|
||||
}
|
||||
|
||||
.conv-pinned {
|
||||
background-color: #F8FAFC;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.conv-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.conv-avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.conv-avatar-placeholder {
|
||||
background-color: #2563EB;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: #FFFFFF;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conv-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -8rpx;
|
||||
min-width: 36rpx;
|
||||
height: 36rpx;
|
||||
padding: 0 8rpx;
|
||||
background-color: #EF4444;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conv-badge-text {
|
||||
color: #FFFFFF;
|
||||
font-size: 20rpx;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
/* 信息区 */
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conv-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
color: #1E293B;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400rpx;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conv-preview {
|
||||
font-size: 26rpx;
|
||||
color: #94A3B8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 480rpx;
|
||||
}
|
||||
|
||||
.conv-preview-unread {
|
||||
color: #64748B;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conv-typing {
|
||||
font-size: 26rpx;
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
.conv-pin-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 300rpx;
|
||||
padding-top: 200rpx;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
font-size: 36rpx;
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1E293B;
|
||||
margin-bottom: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
font-size: 28rpx;
|
||||
.empty-hint {
|
||||
font-size: 26rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user