初始化

This commit is contained in:
2025-12-09 13:09:59 +08:00
parent 5031d694ac
commit f8d7a0c4eb
6 changed files with 1041 additions and 27 deletions

View File

@@ -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 })
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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,