初始化
This commit is contained in:
@@ -26,3 +26,8 @@ export function syncMessages(roomId: string, page = 1, pageSize = 50) {
|
||||
page_size: pageSize
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回消息
|
||||
export function recallMessage(messageId: number) {
|
||||
return request.post<void>('/messages/recall', { message_id: messageId })
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<view class="message-bubble" :class="getBubbleClass(msg)">
|
||||
<view class="message-bubble" :class="getBubbleClass(msg)" @longpress="handleMessageLongPress(msg)">
|
||||
<!-- 文本消息 -->
|
||||
<text v-if="msg.message_type === 0" class="text-content">{{ msg.content }}</text>
|
||||
|
||||
@@ -142,11 +142,21 @@
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<view class="input-area">
|
||||
<!-- @ 提及选择器 -->
|
||||
<mention-picker
|
||||
v-if="isGroupChat"
|
||||
:show="showMentionPicker"
|
||||
:users="mentionUsers"
|
||||
@select="handleMentionSelect"
|
||||
@close="showMentionPicker = false"
|
||||
/>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<view class="toolbar">
|
||||
<wd-icon name="emotion" size="48rpx" class="tool-icon" @click="showEmoji = true" />
|
||||
<wd-icon name="picture" size="48rpx" class="tool-icon" @click="chooseImage" />
|
||||
<wd-icon name="folder" size="48rpx" class="tool-icon" @click="chooseFile" />
|
||||
<wd-icon v-if="isGroupChat" name="at" size="48rpx" class="tool-icon" @click="triggerMention" />
|
||||
</view>
|
||||
|
||||
<!-- 输入框 -->
|
||||
@@ -159,6 +169,7 @@
|
||||
placeholder="输入消息..."
|
||||
no-border
|
||||
@confirm="sendTextMessage"
|
||||
@input="onInputChange"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -204,6 +215,65 @@
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 消息操作菜单 -->
|
||||
<wd-action-sheet
|
||||
v-model="showMsgActions"
|
||||
:actions="msgActionItems"
|
||||
@select="onMsgActionSelect"
|
||||
cancel-text="取消"
|
||||
/>
|
||||
|
||||
<!-- 文件确认弹窗 -->
|
||||
<wd-popup v-model="showFileConfirm" position="center" custom-style="border-radius: 24rpx; width: 85%;">
|
||||
<view class="file-confirm-modal">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">发送{{ pendingFile.type === 'image' ? '图片' : pendingFile.type === 'video' ? '视频' : '文件' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<view v-if="pendingFile.type === 'image'" class="preview-area">
|
||||
<image
|
||||
:src="pendingFile.path"
|
||||
mode="aspectFit"
|
||||
class="preview-image"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<view v-else-if="pendingFile.type === 'video'" class="preview-area">
|
||||
<video
|
||||
:src="pendingFile.path"
|
||||
class="preview-video"
|
||||
object-fit="contain"
|
||||
:autoplay="false"
|
||||
:show-center-play-btn="true"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 文件预览 -->
|
||||
<view v-else class="preview-area file-preview">
|
||||
<view class="file-icon">
|
||||
<wd-icon name="file" size="80rpx" color="var(--color-primary)" />
|
||||
</view>
|
||||
<view class="file-details">
|
||||
<text class="file-name-preview">{{ pendingFile.name }}</text>
|
||||
<text class="file-size-preview">{{ formatSize(pendingFile.size) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<view v-if="pendingFile.type !== 'file'" class="file-info-bar">
|
||||
<text class="info-name">{{ pendingFile.name }}</text>
|
||||
<text v-if="pendingFile.size" class="info-size">{{ formatSize(pendingFile.size) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="modal-footer">
|
||||
<wd-button plain @click="cancelFileSend">取消</wd-button>
|
||||
<wd-button type="primary" :loading="uploading" @click="confirmFileSend">发送</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<wd-toast />
|
||||
</view>
|
||||
</template>
|
||||
@@ -223,7 +293,10 @@ import { useTheme } from '@/composables/useTheme'
|
||||
import { useWebRTC } from '@/composables/useWebRTC'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import CallWindow from '@/components/call/CallWindow.vue'
|
||||
import type { ChatMessage, Attachment } from '@/types/api'
|
||||
import MentionPicker from '@/components/chat/MentionPicker.vue'
|
||||
import type { MentionUser } from '@/components/chat/MentionPicker.vue'
|
||||
import * as roomApi from '@/api/modules/room'
|
||||
import type { ChatMessage, Attachment, GroupMember } from '@/types/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const chatStore = useChatStore()
|
||||
@@ -265,8 +338,69 @@ const showEmoji = ref(false)
|
||||
const playingAudioId = ref<number | null>(null)
|
||||
const page = ref(1)
|
||||
|
||||
// 文件确认弹窗
|
||||
const showFileConfirm = ref(false)
|
||||
const uploading = ref(false)
|
||||
const pendingFile = ref<{
|
||||
path: string
|
||||
name: string
|
||||
size: number
|
||||
type: 'image' | 'video' | 'file'
|
||||
messageType: number
|
||||
}>({
|
||||
path: '',
|
||||
name: '',
|
||||
size: 0,
|
||||
type: 'file',
|
||||
messageType: 8
|
||||
})
|
||||
|
||||
// 消息操作
|
||||
const showMsgActions = ref(false)
|
||||
const selectedMessage = ref<ChatMessage | null>(null)
|
||||
|
||||
// @ 提及
|
||||
const showMentionPicker = ref(false)
|
||||
const groupMembers = ref<GroupMember[]>([])
|
||||
const isGroupChat = ref(false)
|
||||
const mentionUsers = computed<MentionUser[]>(() => {
|
||||
return groupMembers.value
|
||||
.filter(m => m.user_id !== currentUser.value?.id)
|
||||
.map(m => ({
|
||||
id: m.user_id,
|
||||
name: m.nickname || m.user?.name || '未知',
|
||||
avatar: m.user?.avatar
|
||||
}))
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const currentUser = computed(() => authStore.user)
|
||||
|
||||
// 消息操作菜单项
|
||||
const msgActionItems = computed(() => {
|
||||
if (!selectedMessage.value) return []
|
||||
const msg = selectedMessage.value
|
||||
const items: any[] = []
|
||||
|
||||
// 文本消息可以复制
|
||||
if (msg.message_type === 0) {
|
||||
items.push({ name: '复制', value: 'copy' })
|
||||
}
|
||||
|
||||
// 自己发送的消息在2分钟内可撤回
|
||||
if (msg.isSelf) {
|
||||
const sendTime = new Date(msg.created_at).getTime()
|
||||
const now = Date.now()
|
||||
if (now - sendTime < 2 * 60 * 1000) {
|
||||
items.push({ name: '撤回', value: 'recall' })
|
||||
}
|
||||
}
|
||||
|
||||
// 删除消息(本地删除)
|
||||
items.push({ name: '删除', value: 'delete', color: '#fa5151' })
|
||||
|
||||
return items
|
||||
})
|
||||
const displayName = computed(() => targetUser.value?.name || chatName.value || '聊天')
|
||||
const targetUser = computed(() => {
|
||||
// 优先从联系人中获取目标用户信息
|
||||
@@ -300,9 +434,17 @@ onLoad((options: any) => {
|
||||
chatName.value = decodeURIComponent(options?.name || '聊天')
|
||||
targetAvatar.value = decodeURIComponent(options?.avatar || '')
|
||||
|
||||
// 检查是否是群聊
|
||||
isGroupChat.value = !targetId.value && !!roomId.value
|
||||
|
||||
loadMessages()
|
||||
setupWebSocket()
|
||||
|
||||
// 群聊加载成员列表
|
||||
if (isGroupChat.value && roomId.value) {
|
||||
loadGroupMembers()
|
||||
}
|
||||
|
||||
// 如果带有 callType 参数,自动发起通话
|
||||
const callType = options?.callType
|
||||
if (callType && targetId.value) {
|
||||
@@ -338,6 +480,16 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 加载群成员(用于 @ 功能)
|
||||
async function loadGroupMembers() {
|
||||
if (!roomId.value) return
|
||||
try {
|
||||
groupMembers.value = await roomApi.getGroupMembers(roomId.value)
|
||||
} catch (e) {
|
||||
console.error('加载群成员失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 方法
|
||||
async function loadMessages() {
|
||||
if (!roomId.value) return
|
||||
@@ -541,13 +693,20 @@ async function sendTextMessage() {
|
||||
function chooseImage() {
|
||||
showMore.value = false
|
||||
uni.chooseImage({
|
||||
count: 9,
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album'],
|
||||
success: async (res) => {
|
||||
for (const path of res.tempFilePaths) {
|
||||
await uploadAndSend(path, 1)
|
||||
const filePath = res.tempFilePaths[0]
|
||||
const fileInfo = await getFileInfo(filePath)
|
||||
pendingFile.value = {
|
||||
path: filePath,
|
||||
name: fileInfo.name || '图片',
|
||||
size: fileInfo.size,
|
||||
type: 'image',
|
||||
messageType: 1
|
||||
}
|
||||
showFileConfirm.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -558,7 +717,16 @@ function shootCamera() {
|
||||
count: 1,
|
||||
sourceType: ['camera'],
|
||||
success: async (res) => {
|
||||
await uploadAndSend(res.tempFilePaths[0], 1)
|
||||
const filePath = res.tempFilePaths[0]
|
||||
const fileInfo = await getFileInfo(filePath)
|
||||
pendingFile.value = {
|
||||
path: filePath,
|
||||
name: '拍摄图片',
|
||||
size: fileInfo.size,
|
||||
type: 'image',
|
||||
messageType: 1
|
||||
}
|
||||
showFileConfirm.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -571,7 +739,14 @@ function chooseFile() {
|
||||
type: 'file',
|
||||
success: async (res: any) => {
|
||||
const file = res.tempFiles[0]
|
||||
await uploadAndSend(file.path, 8, file.name)
|
||||
pendingFile.value = {
|
||||
path: file.path,
|
||||
name: file.name || '文件',
|
||||
size: file.size || 0,
|
||||
type: 'file',
|
||||
messageType: 8
|
||||
}
|
||||
showFileConfirm.value = true
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
@@ -580,7 +755,16 @@ function chooseFile() {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
success: async (res: any) => {
|
||||
await uploadAndSend(res.tempFilePaths[0], 8)
|
||||
const filePath = res.tempFilePaths[0]
|
||||
const fileInfo = await getFileInfo(filePath)
|
||||
pendingFile.value = {
|
||||
path: filePath,
|
||||
name: fileInfo.name,
|
||||
size: fileInfo.size,
|
||||
type: 'file',
|
||||
messageType: 8
|
||||
}
|
||||
showFileConfirm.value = true
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
@@ -593,14 +777,16 @@ function chooseFile() {
|
||||
input.onchange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
// 读取文件为临时路径
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
// 创建一个临时 blob URL
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
await uploadAndSend(blobUrl, 8, file.name)
|
||||
// 创建一个临时 blob URL
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
pendingFile.value = {
|
||||
path: blobUrl,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: 'file',
|
||||
messageType: 8
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
showFileConfirm.value = true
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
@@ -796,11 +982,37 @@ function chooseVideo() {
|
||||
sourceType: ['album', 'camera'],
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
await uploadAndSend(res.tempFilePath, 3, res.name)
|
||||
const fileInfo = await getFileInfo(res.tempFilePath)
|
||||
pendingFile.value = {
|
||||
path: res.tempFilePath,
|
||||
name: res.name || fileInfo.name || '视频',
|
||||
size: res.size || fileInfo.size,
|
||||
type: 'video',
|
||||
messageType: 3
|
||||
}
|
||||
showFileConfirm.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 取消文件发送
|
||||
function cancelFileSend() {
|
||||
showFileConfirm.value = false
|
||||
pendingFile.value = { path: '', name: '', size: 0, type: 'file', messageType: 8 }
|
||||
}
|
||||
|
||||
// 确认文件发送
|
||||
async function confirmFileSend() {
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadAndSend(pendingFile.value.path, pendingFile.value.messageType, pendingFile.value.name)
|
||||
showFileConfirm.value = false
|
||||
pendingFile.value = { path: '', name: '', size: 0, type: 'file', messageType: 8 }
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
@@ -892,6 +1104,102 @@ function onAvatarClick(msg: ChatMessage) {
|
||||
url: `/pages/contact/detail?userId=${userId}`
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 消息操作 ==========
|
||||
function handleMessageLongPress(msg: ChatMessage) {
|
||||
selectedMessage.value = msg
|
||||
showMsgActions.value = true
|
||||
}
|
||||
|
||||
async function onMsgActionSelect(action: { value: string }) {
|
||||
if (!selectedMessage.value) return
|
||||
const msg = selectedMessage.value
|
||||
showMsgActions.value = false
|
||||
|
||||
switch (action.value) {
|
||||
case 'copy':
|
||||
copyMessage(msg)
|
||||
break
|
||||
case 'recall':
|
||||
await recallMessage(msg)
|
||||
break
|
||||
case 'delete':
|
||||
deleteMessage(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 复制消息
|
||||
function copyMessage(msg: ChatMessage) {
|
||||
if (msg.message_type !== 0) return
|
||||
|
||||
uni.setClipboardData({
|
||||
data: msg.content || '',
|
||||
success: () => {
|
||||
toast.success('已复制')
|
||||
},
|
||||
fail: () => {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回消息
|
||||
async function recallMessage(msg: ChatMessage) {
|
||||
try {
|
||||
await messageApi.recallMessage(msg.id)
|
||||
// 将消息转为撤回消息
|
||||
const index = messages.value.findIndex(m => m.id === msg.id)
|
||||
if (index > -1) {
|
||||
messages.value[index] = {
|
||||
...messages.value[index],
|
||||
message_type: 5, // 系统消息类型
|
||||
content: '你撤回了一条消息'
|
||||
}
|
||||
}
|
||||
toast.success('已撤回')
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '撤回失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除消息(本地删除)
|
||||
function deleteMessage(msg: ChatMessage) {
|
||||
const index = messages.value.findIndex(m => m.id === msg.id)
|
||||
if (index > -1) {
|
||||
messages.value.splice(index, 1)
|
||||
chatStore.removeMessage(roomId.value, msg.id)
|
||||
toast.success('已删除')
|
||||
}
|
||||
}
|
||||
|
||||
// ========== @ 提及功能 ==========
|
||||
// 监听输入变化
|
||||
function onInputChange(e: any) {
|
||||
const value = e.detail?.value || inputText.value
|
||||
// 检查最后输入的字符是否是 @
|
||||
if (value.endsWith('@') && isGroupChat.value) {
|
||||
showMentionPicker.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 手动触发 @
|
||||
function triggerMention() {
|
||||
inputText.value += '@'
|
||||
showMentionPicker.value = true
|
||||
}
|
||||
|
||||
// 选择 @ 的用户
|
||||
function handleMentionSelect(user: MentionUser) {
|
||||
// 将 @ 后面加上用户名和空格
|
||||
// 如果最后一个字符是 @,则不重复添加
|
||||
if (inputText.value.endsWith('@')) {
|
||||
inputText.value = inputText.value.slice(0, -1) + `@${user.name} `
|
||||
} else {
|
||||
inputText.value += `@${user.name} `
|
||||
}
|
||||
showMentionPicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -1119,4 +1427,106 @@ function onAvatarClick(msg: ChatMessage) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// 文件确认弹窗样式
|
||||
.file-confirm-modal {
|
||||
padding: 32rpx;
|
||||
background: var(--bg-content);
|
||||
|
||||
.modal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
background: var(--bg-page);
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 400rpx;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
width: 100%;
|
||||
max-height: 400rpx;
|
||||
}
|
||||
|
||||
&.file-preview {
|
||||
flex-direction: column;
|
||||
padding: 40rpx;
|
||||
gap: 20rpx;
|
||||
|
||||
.file-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--color-primary-rgb, 99, 102, 241), 0.1);
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-name-preview {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.file-size-preview {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-info-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 20rpx;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.info-name {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-size {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
<template v-else-if="activeTab === 'groups'">
|
||||
<wd-status-tip v-if="contactGroups.length === 0 && ungroupedContacts.length === 0" tip="暂无联系人" />
|
||||
<template v-else>
|
||||
<!-- 分组操作提示 -->
|
||||
<view class="group-tip">
|
||||
<text>长按分组名可编辑,长按联系人可移动分组</text>
|
||||
<wd-button size="small" type="primary" plain @click="showCreateGroupModal = true">新建分组</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 未分组 -->
|
||||
<view v-if="ungroupedContacts.length > 0" class="contact-group">
|
||||
<view class="group-header" @click="toggleCollapse(0)">
|
||||
@@ -85,6 +91,7 @@
|
||||
:key="contact.id"
|
||||
class="contact-item"
|
||||
@click="goContactDetail(contact)"
|
||||
@longpress="handleContactLongPress(contact)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
@@ -106,7 +113,7 @@
|
||||
:key="group.id"
|
||||
class="contact-group"
|
||||
>
|
||||
<view class="group-header" @click="toggleCollapse(group.id)">
|
||||
<view class="group-header" @click="toggleCollapse(group.id)" @longpress="handleGroupLongPress(group)">
|
||||
<wd-icon
|
||||
:name="collapsedIds.includes(group.id) ? 'arrow-right' : 'arrow-down'"
|
||||
size="28rpx"
|
||||
@@ -120,6 +127,7 @@
|
||||
:key="contact.id"
|
||||
class="contact-item"
|
||||
@click="goContactDetail(contact)"
|
||||
@longpress="handleContactLongPress(contact)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
@@ -284,7 +292,68 @@
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 联系人操作菜单 -->
|
||||
<wd-action-sheet
|
||||
v-model="showContactActions"
|
||||
:actions="contactActionItems"
|
||||
@select="onContactActionSelect"
|
||||
cancel-text="取消"
|
||||
/>
|
||||
|
||||
<!-- 分组操作菜单 -->
|
||||
<wd-action-sheet
|
||||
v-model="showGroupActions"
|
||||
:actions="groupActionItems"
|
||||
@select="onGroupActionSelect"
|
||||
cancel-text="取消"
|
||||
/>
|
||||
|
||||
<!-- 移动到分组选择 -->
|
||||
<wd-action-sheet
|
||||
v-model="showMoveGroupModal"
|
||||
:actions="moveGroupActions"
|
||||
@select="onMoveGroupSelect"
|
||||
cancel-text="取消"
|
||||
/>
|
||||
|
||||
<!-- 创建分组弹窗 -->
|
||||
<wd-popup v-model="showCreateGroupModal" position="center" custom-style="border-radius: 24rpx; width: 80%;">
|
||||
<view class="create-group-modal">
|
||||
<view class="modal-title">新建分组</view>
|
||||
<wd-input v-model="newGroupName" placeholder="请输入分组名称" clearable />
|
||||
<view class="modal-footer">
|
||||
<wd-button plain @click="showCreateGroupModal = false">取消</wd-button>
|
||||
<wd-button type="primary" :loading="creating" @click="handleCreateGroup">确定</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 重命名分组弹窗 -->
|
||||
<wd-popup v-model="showRenameGroupModal" position="center" custom-style="border-radius: 24rpx; width: 80%;">
|
||||
<view class="create-group-modal">
|
||||
<view class="modal-title">重命名分组</view>
|
||||
<wd-input v-model="renameGroupName" placeholder="请输入新名称" clearable />
|
||||
<view class="modal-footer">
|
||||
<wd-button plain @click="showRenameGroupModal = false">取消</wd-button>
|
||||
<wd-button type="primary" :loading="renaming" @click="handleRenameGroup">确定</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 修改备注弹窗 -->
|
||||
<wd-popup v-model="showRemarkModal" position="center" custom-style="border-radius: 24rpx; width: 80%;">
|
||||
<view class="create-group-modal">
|
||||
<view class="modal-title">修改备注</view>
|
||||
<wd-input v-model="remarkName" placeholder="请输入备注名" clearable />
|
||||
<view class="modal-footer">
|
||||
<wd-button plain @click="showRemarkModal = false">取消</wd-button>
|
||||
<wd-button type="primary" :loading="savingRemark" @click="handleSaveRemark">确定</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<wd-toast />
|
||||
<wd-message-box />
|
||||
|
||||
<!-- 自定义 TabBar -->
|
||||
<app-tab-bar current="contacts" />
|
||||
@@ -298,6 +367,7 @@ import { useContactStore, useChatStore } from '@/stores'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import * as roomApi from '@/api/modules/room'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
@@ -322,6 +392,8 @@ interface GroupChat {
|
||||
const contactStore = useContactStore()
|
||||
const chatStore = useChatStore()
|
||||
const { isDark } = useTheme()
|
||||
const toast = useToast()
|
||||
const messageBox = useMessage()
|
||||
|
||||
// Tab 配置
|
||||
const tabs = [
|
||||
@@ -339,6 +411,24 @@ const collapsedIds = ref<(number | string)[]>([])
|
||||
const contactGroups = ref<ContactGroup[]>([])
|
||||
const groupChats = ref<GroupChat[]>([])
|
||||
|
||||
// 联系人操作相关
|
||||
const showContactActions = ref(false)
|
||||
const selectedContact = ref<Contact | null>(null)
|
||||
const showMoveGroupModal = ref(false)
|
||||
const showRemarkModal = ref(false)
|
||||
const remarkName = ref('')
|
||||
const savingRemark = ref(false)
|
||||
|
||||
// 分组操作相关
|
||||
const showGroupActions = ref(false)
|
||||
const selectedGroup = ref<ContactGroup | null>(null)
|
||||
const showCreateGroupModal = ref(false)
|
||||
const showRenameGroupModal = ref(false)
|
||||
const newGroupName = ref('')
|
||||
const renameGroupName = ref('')
|
||||
const creating = ref(false)
|
||||
const renaming = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const contacts = computed(() => chatStore.contacts)
|
||||
const friendRequestCount = computed(() => contactStore.friendRequests.filter(r => r.status === 'pending').length)
|
||||
@@ -388,6 +478,31 @@ const createdGroups = computed(() => groupChats.value.filter(g => g.category ===
|
||||
const managedGroups = computed(() => groupChats.value.filter(g => g.category === 'managed'))
|
||||
const joinedGroups = computed(() => groupChats.value.filter(g => g.category === 'joined'))
|
||||
|
||||
// 联系人操作菜单项
|
||||
const contactActionItems = [
|
||||
{ name: '修改备注', value: 'remark' },
|
||||
{ name: '移动到分组', value: 'move' },
|
||||
{ name: '删除好友', value: 'delete', color: '#fa5151' }
|
||||
]
|
||||
|
||||
// 分组操作菜单项
|
||||
const groupActionItems = [
|
||||
{ name: '重命名', value: 'rename' },
|
||||
{ name: '删除分组', value: 'delete', color: '#fa5151' }
|
||||
]
|
||||
|
||||
// 移动到分组选项
|
||||
const moveGroupActions = computed(() => {
|
||||
const actions: any[] = [{ name: '未分组', value: 0 }]
|
||||
contactGroups.value.forEach(g => {
|
||||
// 排除当前所在分组
|
||||
if (selectedContact.value?.group_id !== g.id) {
|
||||
actions.push({ name: g.group_name, value: g.id })
|
||||
}
|
||||
})
|
||||
return actions
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
@@ -473,6 +588,178 @@ function scrollToLetter(letter: string) {
|
||||
// TODO: 实现滚动到指定字母
|
||||
console.log('Scroll to:', letter)
|
||||
}
|
||||
|
||||
// ========== 联系人操作 ==========
|
||||
function handleContactLongPress(contact: Contact) {
|
||||
selectedContact.value = contact
|
||||
remarkName.value = contact.remark_name || ''
|
||||
showContactActions.value = true
|
||||
}
|
||||
|
||||
function onContactActionSelect(action: { value: string }) {
|
||||
showContactActions.value = false
|
||||
switch (action.value) {
|
||||
case 'remark':
|
||||
showRemarkModal.value = true
|
||||
break
|
||||
case 'move':
|
||||
showMoveGroupModal.value = true
|
||||
break
|
||||
case 'delete':
|
||||
handleDeleteContact()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRemark() {
|
||||
if (!selectedContact.value) return
|
||||
savingRemark.value = true
|
||||
try {
|
||||
await contactApi.updateContact(selectedContact.value.id.toString(), {
|
||||
remark_name: remarkName.value
|
||||
})
|
||||
// 更新本地数据
|
||||
const idx = chatStore.contacts.findIndex(c => c.id === selectedContact.value?.id)
|
||||
if (idx > -1) {
|
||||
chatStore.contacts[idx].remark_name = remarkName.value
|
||||
}
|
||||
showRemarkModal.value = false
|
||||
toast.success('备注已修改')
|
||||
} catch (e) {
|
||||
toast.error('修改失败')
|
||||
} finally {
|
||||
savingRemark.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onMoveGroupSelect(action: { value: number }) {
|
||||
if (!selectedContact.value) return
|
||||
showMoveGroupModal.value = false
|
||||
try {
|
||||
await contactApi.updateContact(selectedContact.value.id.toString(), {
|
||||
group_id: action.value || 0
|
||||
})
|
||||
// 更新本地数据
|
||||
const idx = chatStore.contacts.findIndex(c => c.id === selectedContact.value?.id)
|
||||
if (idx > -1) {
|
||||
chatStore.contacts[idx].group_id = action.value || 0
|
||||
}
|
||||
toast.success('已移动到分组')
|
||||
} catch (e) {
|
||||
toast.error('移动失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteContact() {
|
||||
if (!selectedContact.value) return
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
title: '删除好友',
|
||||
msg: `确定要删除好友"${selectedContact.value.remark_name || selectedContact.value.user?.name || '未知'}"吗?`
|
||||
})
|
||||
await contactApi.deleteContact(selectedContact.value.id.toString())
|
||||
// 从本地列表移除
|
||||
const idx = chatStore.contacts.findIndex(c => c.id === selectedContact.value?.id)
|
||||
if (idx > -1) {
|
||||
chatStore.contacts.splice(idx, 1)
|
||||
}
|
||||
toast.success('已删除')
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 分组操作 ==========
|
||||
function handleGroupLongPress(group: ContactGroup) {
|
||||
selectedGroup.value = group
|
||||
renameGroupName.value = group.group_name
|
||||
showGroupActions.value = true
|
||||
}
|
||||
|
||||
function onGroupActionSelect(action: { value: string }) {
|
||||
showGroupActions.value = false
|
||||
switch (action.value) {
|
||||
case 'rename':
|
||||
showRenameGroupModal.value = true
|
||||
break
|
||||
case 'delete':
|
||||
handleDeleteGroup()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
if (!newGroupName.value.trim()) {
|
||||
toast.warning('请输入分组名称')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const newGroup = await contactApi.createGroup({ group_name: newGroupName.value.trim() })
|
||||
contactGroups.value.push(newGroup)
|
||||
showCreateGroupModal.value = false
|
||||
newGroupName.value = ''
|
||||
toast.success('分组已创建')
|
||||
} catch (e) {
|
||||
toast.error('创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameGroup() {
|
||||
if (!selectedGroup.value || !renameGroupName.value.trim()) {
|
||||
toast.warning('请输入分组名称')
|
||||
return
|
||||
}
|
||||
renaming.value = true
|
||||
try {
|
||||
await contactApi.updateGroup(selectedGroup.value.id, { group_name: renameGroupName.value.trim() })
|
||||
// 更新本地数据
|
||||
const idx = contactGroups.value.findIndex(g => g.id === selectedGroup.value?.id)
|
||||
if (idx > -1) {
|
||||
contactGroups.value[idx].group_name = renameGroupName.value.trim()
|
||||
}
|
||||
showRenameGroupModal.value = false
|
||||
toast.success('分组已重命名')
|
||||
} catch (e) {
|
||||
toast.error('重命名失败')
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGroup() {
|
||||
if (!selectedGroup.value) return
|
||||
const contactsInGroup = getGroupContacts(selectedGroup.value.id)
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
title: '删除分组',
|
||||
msg: contactsInGroup.length > 0
|
||||
? `该分组下有 ${contactsInGroup.length} 位好友,删除后好友将移至未分组。确定要删除吗?`
|
||||
: '确定要删除该分组吗?'
|
||||
})
|
||||
await contactApi.deleteGroup(selectedGroup.value.id)
|
||||
// 从本地列表移除
|
||||
const idx = contactGroups.value.findIndex(g => g.id === selectedGroup.value?.id)
|
||||
if (idx > -1) {
|
||||
contactGroups.value.splice(idx, 1)
|
||||
}
|
||||
// 将该分组下的好友移至未分组
|
||||
chatStore.contacts.forEach(c => {
|
||||
if (c.group_id === selectedGroup.value?.id) {
|
||||
c.group_id = 0
|
||||
}
|
||||
})
|
||||
toast.success('分组已删除')
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -743,4 +1030,39 @@ function scrollToLetter(letter: string) {
|
||||
.bottom-safe-area {
|
||||
height: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
// 分组提示
|
||||
.group-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 30rpx;
|
||||
background: rgba(var(--color-primary-rgb, 99, 102, 241), 0.05);
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
text {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗样式
|
||||
.create-group-modal {
|
||||
padding: 40rpx;
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -247,6 +247,39 @@
|
||||
cancel-text="取消"
|
||||
/>
|
||||
|
||||
<!-- 禁言时长选择弹窗 -->
|
||||
<wd-popup v-model="showMuteModal" position="bottom" safe-area-inset-bottom custom-style="border-radius: 24rpx 24rpx 0 0;">
|
||||
<view class="mute-modal">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">禁言成员</text>
|
||||
<text class="modal-subtitle">选择禁言 {{ mutingMember?.nickname || mutingMember?.user?.name || '该成员' }} 的时长</text>
|
||||
</view>
|
||||
<view class="mute-options">
|
||||
<view class="mute-option" @click="confirmMute(600)">
|
||||
<text>10分钟</text>
|
||||
</view>
|
||||
<view class="mute-option" @click="confirmMute(3600)">
|
||||
<text>1小时</text>
|
||||
</view>
|
||||
<view class="mute-option" @click="confirmMute(43200)">
|
||||
<text>12小时</text>
|
||||
</view>
|
||||
<view class="mute-option" @click="confirmMute(86400)">
|
||||
<text>1天</text>
|
||||
</view>
|
||||
<view class="mute-option" @click="confirmMute(604800)">
|
||||
<text>7天</text>
|
||||
</view>
|
||||
<view class="mute-option danger" @click="confirmMute(0)">
|
||||
<text>永久禁言</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<wd-button block plain @click="showMuteModal = false">取消</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<wd-toast />
|
||||
<wd-message-box />
|
||||
</view>
|
||||
@@ -288,6 +321,10 @@ const showCallModal = ref(false)
|
||||
const showAdminModal = ref(false)
|
||||
const showTransferModal = ref(false)
|
||||
const showMemberActions = ref(false)
|
||||
const showMuteModal = ref(false)
|
||||
|
||||
// 禁言操作
|
||||
const mutingMember = ref<GroupMember | null>(null)
|
||||
|
||||
// 管理员设置
|
||||
const selectedAdminIds = ref<Set<string>>(new Set())
|
||||
@@ -557,11 +594,22 @@ async function setMemberRole(member: GroupMember, role: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function muteMember(member: GroupMember) {
|
||||
function muteMember(member: GroupMember) {
|
||||
mutingMember.value = member
|
||||
showMuteModal.value = true
|
||||
}
|
||||
|
||||
async function confirmMute(duration: number) {
|
||||
if (!mutingMember.value) return
|
||||
try {
|
||||
// 默认禁言1小时
|
||||
await roomApi.muteGroupMember(roomId.value, member.user_id, 3600)
|
||||
toast.success('已禁言1小时')
|
||||
await roomApi.muteGroupMember(roomId.value, mutingMember.value.user_id, duration)
|
||||
const durationText = duration === 0 ? '永久' :
|
||||
duration < 3600 ? `${duration / 60}分钟` :
|
||||
duration < 86400 ? `${duration / 3600}小时` :
|
||||
`${duration / 86400}天`
|
||||
toast.success(`已禁言${durationText}`)
|
||||
showMuteModal.value = false
|
||||
mutingMember.value = null
|
||||
loadGroupInfo()
|
||||
} catch (e) {
|
||||
toast.error('操作失败')
|
||||
@@ -1003,4 +1051,38 @@ function initAdminSelection() {
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
// 禁言弹窗样式
|
||||
.mute-modal {
|
||||
background: var(--bg-content);
|
||||
|
||||
.mute-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.mute-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24rpx 16rpx;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,7 +54,12 @@
|
||||
v-for="item in conversations"
|
||||
:key="item.id"
|
||||
>
|
||||
<view class="conversation-item" @click="goChat(item)">
|
||||
<view
|
||||
class="conversation-item"
|
||||
:class="{ 'is-top': item.is_top, 'is-special': item.is_special_care }"
|
||||
@click="goChat(item)"
|
||||
@longpress="handleLongPress(item)"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-wrap">
|
||||
<app-avatar
|
||||
@@ -65,12 +70,19 @@
|
||||
:badge="item.is_muted ? 0 : item.unread_count"
|
||||
:dot="item.is_muted && item.unread_count > 0"
|
||||
/>
|
||||
<!-- 特别关心标识 -->
|
||||
<view v-if="item.is_special_care" class="special-badge">
|
||||
<wd-icon name="heart-fill" size="20rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容 -->
|
||||
<view class="conversation-content">
|
||||
<view class="conversation-header">
|
||||
<text class="conversation-name text-ellipsis">{{ item.name || '未知' }}</text>
|
||||
<view class="name-wrap">
|
||||
<wd-icon v-if="item.is_top" name="pin" size="24rpx" class="pin-icon" />
|
||||
<text class="conversation-name text-ellipsis" :class="{ 'special-name': item.is_special_care }">{{ item.name || '未知' }}</text>
|
||||
</view>
|
||||
<text class="conversation-time">{{ formatMessageTime(item.last_time) }}</text>
|
||||
</view>
|
||||
<view class="conversation-footer">
|
||||
@@ -102,6 +114,14 @@
|
||||
@select="onPlusMenuSelect"
|
||||
/>
|
||||
|
||||
<!-- 会话操作菜单 -->
|
||||
<wd-action-sheet
|
||||
v-model="showConvActions"
|
||||
:actions="convActionItems"
|
||||
@select="onConvActionSelect"
|
||||
cancel-text="取消"
|
||||
/>
|
||||
|
||||
<!-- 侧边抽屉 -->
|
||||
<wd-popup
|
||||
v-model="showDrawer"
|
||||
@@ -153,6 +173,7 @@ import { useTheme } from '@/composables/useTheme'
|
||||
import { formatMessageTime, generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import * as conversationApi from '@/api/modules/conversation'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import AppTabBar from '@/components/common/AppTabBar.vue'
|
||||
import type { Conversation } from '@/types/conversation'
|
||||
@@ -179,6 +200,8 @@ const searchKeyword = ref('')
|
||||
const showPlusMenu = ref(false)
|
||||
const showDrawer = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const showConvActions = ref(false)
|
||||
const selectedConv = ref<Conversation | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const user = computed(() => authStore.user)
|
||||
@@ -192,6 +215,32 @@ const plusMenuActions = [
|
||||
{ name: '扫一扫', value: 'scan' }
|
||||
]
|
||||
|
||||
// 会话操作菜单项
|
||||
const convActionItems = computed(() => {
|
||||
if (!selectedConv.value) return []
|
||||
const conv = selectedConv.value
|
||||
const isGroup = conv.is_group || conv.type === 2
|
||||
|
||||
const items: any[] = [
|
||||
{ name: conv.is_top ? '取消置顶' : '置顶会话', value: 'toggleTop' },
|
||||
{ name: conv.is_muted ? '开启提醒' : '消息免打扰', value: 'toggleMute' }
|
||||
]
|
||||
|
||||
// 私聊才显示特别关心
|
||||
if (!isGroup) {
|
||||
items.push({ name: conv.is_special_care ? '取消特别关心' : '特别关心', value: 'toggleSpecial' })
|
||||
}
|
||||
|
||||
// 有未读消息时显示标记已读
|
||||
if (conv.unread_count && conv.unread_count > 0) {
|
||||
items.push({ name: '标记已读', value: 'markRead' })
|
||||
}
|
||||
|
||||
items.push({ name: '删除会话', value: 'delete', color: '#fa5151' })
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
@@ -227,19 +276,111 @@ function goChat(item: Conversation) {
|
||||
})
|
||||
}
|
||||
|
||||
function toggleTop(item: Conversation) {
|
||||
toast.show(item.is_top ? '已取消置顶' : '已置顶')
|
||||
// 长按会话
|
||||
function handleLongPress(item: Conversation) {
|
||||
selectedConv.value = item
|
||||
showConvActions.value = true
|
||||
}
|
||||
|
||||
// 会话操作菜单选择
|
||||
async function onConvActionSelect(action: { value: string }) {
|
||||
if (!selectedConv.value) return
|
||||
const conv = selectedConv.value
|
||||
|
||||
switch (action.value) {
|
||||
case 'toggleTop':
|
||||
await toggleTop(conv)
|
||||
break
|
||||
case 'toggleMute':
|
||||
await toggleMute(conv)
|
||||
break
|
||||
case 'toggleSpecial':
|
||||
await toggleSpecialCare(conv)
|
||||
break
|
||||
case 'markRead':
|
||||
await markAsRead(conv)
|
||||
break
|
||||
case 'delete':
|
||||
await deleteConversation(conv)
|
||||
break
|
||||
}
|
||||
showConvActions.value = false
|
||||
}
|
||||
|
||||
// 置顶/取消置顶
|
||||
async function toggleTop(item: Conversation) {
|
||||
try {
|
||||
const newValue = !item.is_top
|
||||
await conversationApi.updateConversation({
|
||||
target_id: item.target_id,
|
||||
is_top: newValue
|
||||
})
|
||||
item.is_top = newValue
|
||||
conversationStore.sortConversations()
|
||||
toast.success(newValue ? '已置顶' : '已取消置顶')
|
||||
} catch (e) {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 免打扰
|
||||
async function toggleMute(item: Conversation) {
|
||||
try {
|
||||
const newValue = !item.is_muted
|
||||
await conversationApi.updateConversation({
|
||||
target_id: item.target_id,
|
||||
is_muted: newValue
|
||||
})
|
||||
item.is_muted = newValue
|
||||
toast.success(newValue ? '已开启免打扰' : '已开启提醒')
|
||||
} catch (e) {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 特别关心
|
||||
async function toggleSpecialCare(item: Conversation) {
|
||||
try {
|
||||
const newValue = !item.is_special_care
|
||||
await conversationApi.updateConversation({
|
||||
target_id: item.target_id,
|
||||
is_special_care: newValue
|
||||
})
|
||||
item.is_special_care = newValue
|
||||
toast.success(newValue ? '已设为特别关心' : '已取消特别关心')
|
||||
} catch (e) {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 标记已读
|
||||
async function markAsRead(item: Conversation) {
|
||||
try {
|
||||
await conversationStore.clearUnread(item.target_id)
|
||||
toast.success('已标记为已读')
|
||||
} catch (e) {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除会话
|
||||
async function deleteConversation(item: Conversation) {
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
title: '提示',
|
||||
msg: '确定删除该会话吗?'
|
||||
})
|
||||
await conversationApi.deleteConversation(item.target_id)
|
||||
// 从本地列表移除
|
||||
const index = conversationStore.conversations.findIndex(c => c.id === item.id)
|
||||
if (index > -1) {
|
||||
conversationStore.conversations.splice(index, 1)
|
||||
}
|
||||
toast.success('已删除')
|
||||
} catch {
|
||||
// 取消
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,11 +510,35 @@ async function logout() {
|
||||
&:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.is-top {
|
||||
background: rgba(var(--color-primary-rgb, 99, 102, 241), 0.05);
|
||||
}
|
||||
|
||||
&.is-special {
|
||||
.conversation-name {
|
||||
color: #ec4899;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
margin-right: 24rpx;
|
||||
|
||||
.special-badge {
|
||||
position: absolute;
|
||||
bottom: -4rpx;
|
||||
right: -4rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
background: #ec4899;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid var(--bg-content);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
@@ -434,11 +599,28 @@ async function logout() {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.name-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.pin-icon {
|
||||
color: var(--color-primary);
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
|
||||
&.special-name {
|
||||
color: #ec4899;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-time {
|
||||
|
||||
@@ -100,6 +100,18 @@ export const useChatStore = defineStore('chat', () => {
|
||||
messages.value[roomId] = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除单条消息
|
||||
*/
|
||||
function removeMessage(roomId: string, messageId: number) {
|
||||
if (messages.value[roomId]) {
|
||||
const index = messages.value[roomId].findIndex(m => m.id === messageId)
|
||||
if (index > -1) {
|
||||
messages.value[roomId].splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 群通知相关状态
|
||||
const lastGroupNotification = ref<{ room_id: string; type: string; data: any } | null>(null)
|
||||
const myMuteStatus = ref<Record<string, string | null>>({}) // room_id -> muted_until
|
||||
@@ -149,6 +161,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
updateContactLastMsg,
|
||||
incrementUnread,
|
||||
clearRoomMessages,
|
||||
removeMessage,
|
||||
setLastGroupNotification,
|
||||
setMyMuteStatus,
|
||||
isMyMuted,
|
||||
|
||||
Reference in New Issue
Block a user