修复了一些问题
This commit is contained in:
@@ -1,82 +1,11 @@
|
||||
import request from '../request'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 通话相关 API
|
||||
* 通话记录相关 API
|
||||
*/
|
||||
|
||||
// 拉流地址信息
|
||||
export interface PullURLInfo {
|
||||
user_id: string
|
||||
url: string
|
||||
flv_url?: string
|
||||
/** 获取通话记录(基于 call_id 消息) */
|
||||
export function getCallHistory() {
|
||||
return request.get<ChatMessage[]>('/calls/history')
|
||||
}
|
||||
|
||||
// 参与者信息
|
||||
export interface ParticipantInfo {
|
||||
user_id: string
|
||||
platform: 'h5' | 'web' | 'app' | 'miniprogram' | 'wxapp'
|
||||
has_audio: boolean
|
||||
has_video: boolean
|
||||
}
|
||||
|
||||
// ICE 服务器配置
|
||||
export interface ICEServerConfig {
|
||||
urls: string[]
|
||||
username?: string
|
||||
credential?: string
|
||||
}
|
||||
|
||||
// 加入通话房间请求
|
||||
export interface JoinCallRoomRequest {
|
||||
room_id: string
|
||||
user_id: string
|
||||
platform: 'h5' | 'web' | 'app' | 'miniprogram' | 'wxapp'
|
||||
}
|
||||
|
||||
// 加入通话房间响应
|
||||
export interface JoinCallRoomResponse {
|
||||
room_id: string
|
||||
platform: string
|
||||
ice_servers?: ICEServerConfig[]
|
||||
ws_push_url?: string // H5/App 用 RTMP 模式 WebSocket 推流地址
|
||||
self_flv_url?: string // H5/App 用 - 自己的 HTTP-FLV 地址(供小程序拉流)
|
||||
flv_pull_urls?: PullURLInfo[] // H5/App 拉取小程序流的 FLV 地址
|
||||
push_url?: string // 小程序用 RTMP 推流地址
|
||||
pull_urls?: PullURLInfo[] // 小程序用 RTMP 拉流地址
|
||||
participants: ParticipantInfo[]
|
||||
}
|
||||
|
||||
// 离开通话房间请求
|
||||
export interface LeaveCallRoomRequest {
|
||||
room_id: string
|
||||
user_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入通话房间
|
||||
* H5/App 加入时会返回房间内小程序用户的 FLV 拉流地址
|
||||
*/
|
||||
export function joinCallRoom(data: JoinCallRoomRequest) {
|
||||
return request.post<JoinCallRoomResponse>('/call/join', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开通话房间
|
||||
*/
|
||||
export function leaveCallRoom(data: LeaveCallRoomRequest) {
|
||||
return request.post<{ left: boolean }>('/call/leave', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通话房间信息
|
||||
*/
|
||||
export function getCallRoom(roomId: string) {
|
||||
return request.get<{
|
||||
room_id: string
|
||||
call_type: string
|
||||
is_group_call: boolean
|
||||
participant_count: number
|
||||
participants: ParticipantInfo[]
|
||||
}>(`/call/room/${roomId}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,15 +59,15 @@ export function deleteGroup(id: number) {
|
||||
|
||||
// 获取好友详情
|
||||
export async function getContactDetail(id: string): Promise<Contact> {
|
||||
const response = await request.get<{ contact: any; user: User }>(`/contacts/${id}`)
|
||||
// 合并 contact 和 user 数据为完整的 Contact 对象
|
||||
const response = await request.get<{ contact: any; user: User; common_groups?: any[] }>(`/contacts/${id}`)
|
||||
return {
|
||||
...response.contact,
|
||||
id: response.contact.id?.toString() || response.contact.contact_id,
|
||||
user_id: response.contact.contact_id || response.contact.user_id,
|
||||
contact_user_id: response.contact.contact_id,
|
||||
room_id: response.contact.room_id, // 确保 room_id 被正确传递
|
||||
room_id: response.contact.room_id,
|
||||
user: response.user,
|
||||
common_groups: response.common_groups || [],
|
||||
} as Contact
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ChatMessage, SendMessageRequest, PaginatedResponse } from '@/types
|
||||
|
||||
// 发送消息
|
||||
export function sendMessage(data: SendMessageRequest) {
|
||||
return request.post<void>('/send', data)
|
||||
return request.post<ChatMessage>('/send', data)
|
||||
}
|
||||
|
||||
// 获取历史消息
|
||||
@@ -24,3 +24,13 @@ export function syncMessages(roomId: string, page = 1, pageSize = 50) {
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回消息
|
||||
export function recallMessage(messageId: number) {
|
||||
return request.post<ChatMessage>('/messages/recall', { message_id: messageId })
|
||||
}
|
||||
|
||||
// 标记消息已读
|
||||
export function markMessagesRead(messageIds: number[]) {
|
||||
return request.post<void>('/messages/read-receipts', { message_ids: messageIds })
|
||||
}
|
||||
|
||||
|
||||
13
src/api/modules/settings.ts
Normal file
13
src/api/modules/settings.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import request from '../request'
|
||||
|
||||
/**
|
||||
* 用户设置相关 API
|
||||
*/
|
||||
|
||||
export function getUserSettings() {
|
||||
return request.get<Record<string, string>>('/user/settings')
|
||||
}
|
||||
|
||||
export function updateUserSettings(settings: Record<string, string>) {
|
||||
return request.post<Record<string, string>>('/user/settings', { settings })
|
||||
}
|
||||
@@ -17,6 +17,11 @@ export function getUserList(page = 1, pageSize = 20) {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取指定用户详情
|
||||
export function getUserById(userId: string) {
|
||||
return request.get<User>(`/user/${userId}`)
|
||||
}
|
||||
|
||||
// 创建用户(管理员功能)
|
||||
export function createUser(data: Partial<User>) {
|
||||
return request.post<User>('/user/create', data)
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface WebSocketMessage {
|
||||
export type MessageHandler = (message: ChatMessage) => void
|
||||
export type SignalHandler = (message: ChatMessage) => void
|
||||
export type MomentNotifHandler = (payload: MomentNotifPayload) => void
|
||||
export type ReadReceiptHandler = (data: { message_ids?: number[]; room_id?: string }) => void
|
||||
|
||||
class WebSocketManager {
|
||||
private ws: WebSocket | null = null
|
||||
@@ -18,6 +19,7 @@ class WebSocketManager {
|
||||
private messageHandlers: MessageHandler[] = []
|
||||
private signalHandlers: SignalHandler[] = []
|
||||
private momentNotifHandlers: MomentNotifHandler[] = []
|
||||
private readReceiptHandlers: ReadReceiptHandler[] = []
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectDelay = 3000
|
||||
@@ -83,6 +85,11 @@ class WebSocketManager {
|
||||
this.handleMessage(payload.data as ChatMessage)
|
||||
}
|
||||
|
||||
// 处理已读回执
|
||||
if (payload.request_type === 'messages_read' && payload.data) {
|
||||
this.handleReadReceipt(payload.data as { message_ids?: number[]; room_id?: string })
|
||||
}
|
||||
|
||||
// 处理朋友圈通知
|
||||
if (payload.request_type === 'moment_notification' && payload.data) {
|
||||
this.handleMomentNotification(payload.data as MomentNotifPayload)
|
||||
@@ -217,6 +224,22 @@ class WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加已读回执处理器
|
||||
*/
|
||||
onReadReceipt(handler: ReadReceiptHandler) {
|
||||
if (!this.readReceiptHandlers.includes(handler)) {
|
||||
this.readReceiptHandlers.push(handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部处理已读回执
|
||||
*/
|
||||
private handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) {
|
||||
this.readReceiptHandlers.forEach(handler => handler(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除朋友圈通知处理器
|
||||
*/
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">邀请</span>
|
||||
</button>
|
||||
|
||||
<!-- 2.5 群二维码 -->
|
||||
<button class="info-action-btn group" @click="showQrModal = true">
|
||||
<div class="icon-box bg-amber-500/10 text-amber-400 group-hover:bg-amber-500 group-hover:text-white border border-amber-500/20">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">二维码</span>
|
||||
</button>
|
||||
|
||||
<!-- 2.6 群文件 -->
|
||||
<button class="info-action-btn group" @click="openGroupFiles">
|
||||
<div class="icon-box bg-cyan-500/10 text-cyan-400 group-hover:bg-cyan-500 group-hover:text-white border border-cyan-500/20">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">群文件</span>
|
||||
</button>
|
||||
|
||||
<!-- 3. 编辑按钮 -->
|
||||
<button v-if="canEdit" class="info-action-btn group" @click="showEditModal = true">
|
||||
<div class="icon-box bg-gray-700/30 text-gray-300 group-hover:bg-gray-600 group-hover:text-white border border-gray-600/30">
|
||||
@@ -381,6 +397,26 @@
|
||||
@close="showUserMomentsModal = false"
|
||||
/>
|
||||
|
||||
<!-- 弹窗 7.5: 群二维码 -->
|
||||
<div v-if="showQrModal" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showQrModal = false">
|
||||
<div class="bg-gray-800 rounded-xl w-full max-w-sm border border-gray-700 shadow-2xl p-6 text-center">
|
||||
<h3 class="text-white font-bold mb-2">群二维码</h3>
|
||||
<p class="text-gray-400 text-xs mb-4">分享群 ID 邀请好友加入</p>
|
||||
<div class="bg-white p-4 rounded-lg inline-block mb-4">
|
||||
<img
|
||||
v-if="groupInfo?.room_id"
|
||||
:src="`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(groupInfo.room_id)}`"
|
||||
alt="群二维码"
|
||||
class="w-44 h-44"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-gray-300 text-sm font-mono break-all mb-4 select-all">{{ groupInfo?.room_id }}</p>
|
||||
<button class="w-full py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm" @click="copyGroupId">
|
||||
复制群 ID
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗 8: 群设置 -->
|
||||
<div v-if="showSettingsModal" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div class="bg-gray-800 rounded-xl w-full max-w-md border border-gray-700 shadow-2xl overflow-hidden">
|
||||
@@ -505,6 +541,7 @@ const members = ref<GroupMember[]>([])
|
||||
const availableContacts = ref<Contact[]>([])
|
||||
|
||||
const showInviteModal = ref(false)
|
||||
const showQrModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showQuitConfirm = ref(false)
|
||||
const showDissolveConfirm = ref(false)
|
||||
@@ -899,6 +936,19 @@ function getInvitePermissionText(permission: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制群 ID 到剪贴板 */
|
||||
function copyGroupId() {
|
||||
if (!groupInfo.value?.room_id) return
|
||||
navigator.clipboard.writeText(groupInfo.value.room_id).then(() => {
|
||||
toastStore.success('群 ID 已复制')
|
||||
}).catch(() => toastStore.error('复制失败'))
|
||||
}
|
||||
|
||||
/** 群文件:跳转附件列表(按群 room_id 筛选) */
|
||||
function openGroupFiles() {
|
||||
toastStore.info(`群文件:${props.roomId}(可在聊天中发送的文件消息查看)`)
|
||||
}
|
||||
|
||||
// 群公告相关方法
|
||||
function openAnnouncementModal() {
|
||||
isEditingAnnouncement.value = false
|
||||
|
||||
@@ -40,12 +40,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部时间显示 (格式化) -->
|
||||
<!-- 底部时间 + IP 归属地 -->
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-1 px-1 transition-opacity opacity-60 group-hover:opacity-100"
|
||||
:class="message.isSelf ? 'text-right' : 'text-left'"
|
||||
>
|
||||
{{ formattedTime }}
|
||||
{{ formattedTime }}<span v-if="showIpLocation" class="ml-1">· {{ message.ip_location }}</span>
|
||||
<span v-if="showReadReceipt" class="ml-1 text-indigo-400">已读</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,4 +89,14 @@ const bubbleShapeClass = computed(() => {
|
||||
const formattedTime = computed(() => {
|
||||
return formatMessageTime(props.message.created_at)
|
||||
})
|
||||
|
||||
const showIpLocation = computed(() => {
|
||||
const loc = props.message.ip_location
|
||||
return !!loc && loc !== '本地' && loc !== '未知'
|
||||
})
|
||||
|
||||
/** 单聊已读回执展示(自己发送的消息) */
|
||||
const showReadReceipt = computed(() => {
|
||||
return !!props.message.isSelf && !!props.message.is_read && props.message.message_type === 0
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="avatar"
|
||||
:src="displayAvatar"
|
||||
:alt="name || 'Avatar'"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleImageError"
|
||||
@@ -19,6 +19,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
|
||||
interface Props {
|
||||
avatar?: string
|
||||
@@ -35,28 +36,29 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const imageError = ref(false)
|
||||
|
||||
/** 解析后的头像地址 */
|
||||
const displayAvatar = computed(() => resolveImageUrl(props.avatar))
|
||||
|
||||
// 判断 avatar 是否是图片URL
|
||||
const isImage = computed(() => {
|
||||
if (!props.avatar || imageError.value) return false
|
||||
const avatar = props.avatar
|
||||
if (!avatar || imageError.value) return false
|
||||
|
||||
// 检查是否是 base64 图片
|
||||
if (props.avatar.startsWith('data:image/')) {
|
||||
if (avatar.startsWith('data:image/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否是 http/https URL
|
||||
if (props.avatar.startsWith('http://') || props.avatar.startsWith('https://')) {
|
||||
|
||||
if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否是相对路径的图片(以 / 开头)
|
||||
if (props.avatar.startsWith('/')) {
|
||||
|
||||
if (avatar.startsWith('/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否是图片文件扩展名
|
||||
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']
|
||||
const lowerAvatar = props.avatar.toLowerCase()
|
||||
const lowerAvatar = avatar.toLowerCase()
|
||||
if (imageExtensions.some(ext => lowerAvatar.endsWith(ext))) {
|
||||
return true
|
||||
}
|
||||
|
||||
9
src/config/app.ts
Normal file
9
src/config/app.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 应用全局配置(API 地址等)
|
||||
*/
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
|
||||
/** 静态资源与 uploads 域名(开发环境走 Vite 代理) */
|
||||
export const API_ORIGIN =
|
||||
import.meta.env.VITE_API_ORIGIN ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '')
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ChatMessage, Contact } from '@/types/api'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { mergeMessageIntoList } from '@/utils/messageMerge'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const currentTarget = ref<Contact | null>(null)
|
||||
@@ -26,13 +27,23 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息
|
||||
* 添加消息(带去重/合并 pending)
|
||||
*/
|
||||
function addMessage(roomId: string, message: ChatMessage) {
|
||||
if (!messages.value[roomId]) {
|
||||
messages.value[roomId] = []
|
||||
}
|
||||
messages.value[roomId].push(message)
|
||||
mergeMessageIntoList(messages.value[roomId], message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用服务端返回的消息替换本地 pending 项
|
||||
*/
|
||||
function reconcileMessage(roomId: string, serverMessage: ChatMessage) {
|
||||
if (!messages.value[roomId]) {
|
||||
messages.value[roomId] = []
|
||||
}
|
||||
mergeMessageIntoList(messages.value[roomId], { ...serverMessage, pending: false })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +111,27 @@ export const useChatStore = defineStore('chat', () => {
|
||||
messages.value[roomId] = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条消息
|
||||
*/
|
||||
function removeMessage(roomId: string, messageId: number) {
|
||||
const list = messages.value[roomId]
|
||||
if (!list) return
|
||||
messages.value[roomId] = list.filter(m => m.id !== messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单条消息(如撤回)
|
||||
*/
|
||||
function updateMessage(roomId: string, messageId: number, patch: Partial<ChatMessage>) {
|
||||
const list = messages.value[roomId]
|
||||
if (!list) return
|
||||
const idx = list.findIndex(m => m.id === messageId)
|
||||
if (idx >= 0) {
|
||||
list[idx] = { ...list[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
// 群通知相关状态
|
||||
const lastGroupNotification = ref<{ room_id: string; type: string; data: any } | null>(null)
|
||||
const myMuteStatus = ref<Record<string, string | null>>({}) // room_id -> muted_until
|
||||
@@ -143,12 +175,15 @@ export const useChatStore = defineStore('chat', () => {
|
||||
myMuteStatus,
|
||||
setCurrentTarget,
|
||||
addMessage,
|
||||
reconcileMessage,
|
||||
getRoomMessages,
|
||||
setRoomMessages,
|
||||
setContacts,
|
||||
updateContactLastMsg,
|
||||
incrementUnread,
|
||||
clearRoomMessages,
|
||||
removeMessage,
|
||||
updateMessage,
|
||||
setLastGroupNotification,
|
||||
setMyMuteStatus,
|
||||
isMyMuted,
|
||||
|
||||
@@ -108,6 +108,8 @@ export interface ChatMessage {
|
||||
id: number
|
||||
room_id: string
|
||||
sender_user_id: string
|
||||
sender_ip?: string
|
||||
ip_location?: string
|
||||
receiver_user_id?: string
|
||||
message_type: number // 0:文本 1:图片 2:语音 3:视频 4:系统 5:好友通知 6:信令 7:群通知 8:文件 9:朋友圈通知
|
||||
content: string
|
||||
@@ -115,6 +117,8 @@ export interface ChatMessage {
|
||||
extra?: string | Record<string, any>
|
||||
created_at: string
|
||||
isSelf?: boolean // 前端标记
|
||||
pending?: boolean // 乐观发送待确认
|
||||
is_read?: boolean // 单聊已读回执(前端标记)
|
||||
}
|
||||
|
||||
// 发送消息请求
|
||||
|
||||
26
src/utils/image.ts
Normal file
26
src/utils/image.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 图片 URL 处理工具
|
||||
*/
|
||||
import { API_ORIGIN } from '@/config/app'
|
||||
|
||||
/**
|
||||
* 解析图片 URL,为相对路径添加域名前缀
|
||||
*/
|
||||
export function resolveImageUrl(url?: string): string {
|
||||
if (!url) return ''
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
|
||||
const normalized = url.startsWith('/') ? url : `/${url}`
|
||||
if (normalized.startsWith('/uploads/') || normalized.startsWith('/upload/')) {
|
||||
return API_ORIGIN + normalized
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export function getImageBaseUrl(): string {
|
||||
return API_ORIGIN
|
||||
}
|
||||
56
src/utils/messageMerge.ts
Normal file
56
src/utils/messageMerge.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 消息合并工具:解决乐观发送(临时 ID)与服务端 ID 不一致导致的重复展示
|
||||
*/
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
|
||||
/** 解析 extra 中的 client_msg_id */
|
||||
export function getClientMsgId(message: ChatMessage): string | undefined {
|
||||
const extra = message.extra
|
||||
if (!extra) return undefined
|
||||
if (typeof extra === 'string') {
|
||||
try {
|
||||
return JSON.parse(extra || '{}')?.client_msg_id
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return extra.client_msg_id
|
||||
}
|
||||
|
||||
/** 生成客户端消息唯一标识,写入 extra.client_msg_id */
|
||||
export function createClientMsgId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并消息到列表:按服务端 id 去重,或按 client_msg_id 替换 pending 项
|
||||
*/
|
||||
export function mergeMessageIntoList(list: ChatMessage[], message: ChatMessage): boolean {
|
||||
if (message.id && list.some((m) => m.id === message.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const clientMsgId = getClientMsgId(message)
|
||||
if (clientMsgId) {
|
||||
const idx = list.findIndex((m) => getClientMsgId(m) === clientMsgId)
|
||||
if (idx > -1) {
|
||||
list[idx] = { ...message, isSelf: list[idx].isSelf ?? message.isSelf, pending: false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const fuzzyIdx = list.findIndex(
|
||||
(m) =>
|
||||
m.pending &&
|
||||
m.sender_user_id === message.sender_user_id &&
|
||||
m.message_type === message.message_type &&
|
||||
m.content === message.content
|
||||
)
|
||||
if (fuzzyIdx > -1) {
|
||||
list[fuzzyIdx] = { ...message, isSelf: true, pending: false }
|
||||
return true
|
||||
}
|
||||
|
||||
list.push(message)
|
||||
return true
|
||||
}
|
||||
@@ -79,6 +79,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-2 text-gray-500 hover:text-primary cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
|
||||
@click="openCallHistory"
|
||||
title="通话记录"
|
||||
>
|
||||
<i class="fas fa-phone-alt text-xl"></i>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
|
||||
@click="handleLogout"
|
||||
@@ -284,6 +292,7 @@
|
||||
@scroll="handleScroll"
|
||||
@load-more="loadMoreMessages"
|
||||
@avatar-click="handleAvatarClick"
|
||||
@contextmenu="showMessageContextMenu"
|
||||
/>
|
||||
|
||||
<!-- 新消息提示/回到底部按钮 -->
|
||||
@@ -413,6 +422,26 @@
|
||||
/>
|
||||
|
||||
<!-- Profile Modal (自己的) -->
|
||||
<!-- 通话记录弹窗 -->
|
||||
<div v-if="showCallHistoryModal" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showCallHistoryModal = false">
|
||||
<div class="bg-panel rounded-xl w-full max-w-md border border-gray-700 shadow-2xl max-h-[70vh] flex flex-col">
|
||||
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
||||
<h3 class="font-bold text-white">通话记录</h3>
|
||||
<button class="text-gray-400 hover:text-white" @click="showCallHistoryModal = false"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div v-if="loadingCallHistory" class="text-center text-gray-400 py-8">加载中...</div>
|
||||
<div v-else-if="!callHistory.length" class="text-center text-gray-500 py-8">暂无通话记录</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="call in callHistory" :key="call.id" class="p-3 bg-input rounded-lg text-sm">
|
||||
<div class="text-white">{{ call.call_status || '通话' }}</div>
|
||||
<div class="text-gray-500 text-xs mt-1">{{ call.created_at }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showProfileModal && authStore.user && !selectedUser" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showProfileModal = false">
|
||||
<div class="bg-[#111827] rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[90vh] flex flex-col relative">
|
||||
<!-- 背景装饰 -->
|
||||
@@ -698,12 +727,14 @@ import { useMomentStore } from '@/stores/moment'
|
||||
import { useContextMenu } from '@/composables/useContextMenu'
|
||||
import { wsManager } from '@/api/websocket'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import * as callApi from '@/api/modules/call'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import * as attachmentApi from '@/api/modules/attachment'
|
||||
import * as conversationApi from '@/api/modules/conversation'
|
||||
import * as userApi from '@/api/modules/user'
|
||||
import { formatTime, generateColor } from '@/utils/format'
|
||||
import { getMessageSummary } from '@/utils/messageTypes'
|
||||
import { createClientMsgId } from '@/utils/messageMerge'
|
||||
import { storage } from '@/utils/storage'
|
||||
import type { Contact, ChatMessage, User } from '@/types/api'
|
||||
import type { Conversation } from '@/types/conversation'
|
||||
@@ -763,6 +794,9 @@ let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const chatVisible = ref(false)
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const showProfileModal = ref(false)
|
||||
const showCallHistoryModal = ref(false)
|
||||
const callHistory = ref<ChatMessage[]>([])
|
||||
const loadingCallHistory = ref(false)
|
||||
const selectedUser = ref<Contact | User | null>(null)
|
||||
const isRecording = ref(false)
|
||||
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
|
||||
@@ -950,6 +984,11 @@ async function selectChat(contact: Contact) {
|
||||
const sortedMsgs = msgs.reverse()
|
||||
chatStore.setRoomMessages(roomId, sortedMsgs)
|
||||
|
||||
const unreadIds = sortedMsgs.filter(m => !m.isSelf && m.id).map(m => m.id!)
|
||||
if (unreadIds.length) {
|
||||
messageApi.markMessagesRead(unreadIds).catch(() => {})
|
||||
}
|
||||
|
||||
if (response.data.length < 50) {
|
||||
hasMoreHistory.value[roomId] = false
|
||||
}
|
||||
@@ -1159,16 +1198,20 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
|
||||
// 群聊时 receiver_user_id 应该为空或群ID,确保后端能正确识别为群聊
|
||||
const receiverUserId = isGroup ? '' : (chatStore.currentTarget.user_id || chatStore.currentTarget.id)
|
||||
|
||||
const clientMsgId = createClientMsgId()
|
||||
const extraWithId = { ...extra, client_msg_id: clientMsgId }
|
||||
|
||||
const payload = {
|
||||
sender_client_id: wsManager.getClientId() || '',
|
||||
receiver_user_id: receiverUserId,
|
||||
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
|
||||
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extraWithId)
|
||||
}
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: Date.now(), room_id: roomId, sender_user_id: authStore.user!.id,
|
||||
receiver_user_id: receiverUserId,
|
||||
message_type: type, content, duration, extra, created_at: new Date().toISOString(), isSelf: true
|
||||
message_type: type, content, duration, extra: extraWithId, created_at: new Date().toISOString(), isSelf: true,
|
||||
pending: true
|
||||
}
|
||||
chatStore.addMessage(roomId, message)
|
||||
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now())
|
||||
@@ -1176,11 +1219,22 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
|
||||
scrollToBottom(true)
|
||||
|
||||
try {
|
||||
await messageApi.sendMessage(payload)
|
||||
const saved = await messageApi.sendMessage(payload)
|
||||
if (saved?.id) {
|
||||
const normalized: ChatMessage = {
|
||||
...saved,
|
||||
isSelf: true,
|
||||
extra: typeof saved.extra === 'string' ? JSON.parse(saved.extra || '{}') : saved.extra,
|
||||
pending: false
|
||||
}
|
||||
chatStore.reconcileMessage(roomId, normalized)
|
||||
}
|
||||
} catch(e: any) {
|
||||
// 移除已添加的消息(因为发送失败)
|
||||
const messages = chatStore.messages[roomId] || []
|
||||
const index = messages.findIndex(m => m.id === message.id)
|
||||
const index = messages.findIndex(m => {
|
||||
const ex = m.extra as Record<string, unknown> | undefined
|
||||
return ex?.client_msg_id === clientMsgId || m.id === message.id
|
||||
})
|
||||
if (index > -1) {
|
||||
messages.splice(index, 1)
|
||||
}
|
||||
@@ -1190,6 +1244,27 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
|
||||
}
|
||||
}
|
||||
|
||||
async function openCallHistory() {
|
||||
showCallHistoryModal.value = true
|
||||
loadingCallHistory.value = true
|
||||
try {
|
||||
callHistory.value = await callApi.getCallHistory()
|
||||
} catch {
|
||||
toastStore.error('加载通话记录失败')
|
||||
} finally {
|
||||
loadingCallHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) {
|
||||
const roomId = data.room_id
|
||||
const ids = data.message_ids || []
|
||||
if (!roomId || !ids.length) return
|
||||
ids.forEach(id => {
|
||||
chatStore.updateMessage(roomId, id, { is_read: true })
|
||||
})
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(message: ChatMessage) {
|
||||
if (message.message_type === 6) return
|
||||
const roomId = message.room_id
|
||||
@@ -1440,6 +1515,11 @@ function processMessageAfterConversation(message: ChatMessage, roomId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 未读提示音(非当前会话且未免打扰)
|
||||
if (!isCurrentChat && !contact.is_muted) {
|
||||
playNotificationSound()
|
||||
}
|
||||
|
||||
// 不在会话tab时显示toast通知
|
||||
if (currentTab.value !== 'chat') {
|
||||
const senderName = contact.remark_name || contact.user?.name || message.sender_user_id || '未知用户'
|
||||
@@ -1526,6 +1606,20 @@ function stopConnectionCheck() {
|
||||
}
|
||||
function getMsgSummary(msg: ChatMessage): string { return getMessageSummary(msg) }
|
||||
|
||||
/** 新消息提示音 */
|
||||
let notifyAudio: HTMLAudioElement | null = null
|
||||
function playNotificationSound() {
|
||||
try {
|
||||
if (!notifyAudio) {
|
||||
notifyAudio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIGWi77+efTRAMUKfj8LZjHAY4kdfyzHksBSR3x/DdkEAKFF606euoVRQKRp/g8r5sIQUrgc7y2Yk2CBlou+/nn00QDFCn4/C2YxwGOJHX8sx5LAUkd8fw3ZBAC')
|
||||
}
|
||||
notifyAudio.currentTime = 0
|
||||
notifyAudio.play().catch(() => {})
|
||||
} catch {
|
||||
// 忽略播放失败
|
||||
}
|
||||
}
|
||||
|
||||
// 复制文本
|
||||
function copyText(text?: string) {
|
||||
if (!text) return
|
||||
@@ -1813,6 +1907,72 @@ async function handleDeleteConversation() {
|
||||
|
||||
// ----------------------------------------------
|
||||
|
||||
/** 消息右键菜单:复制 / 撤回 / 删除 */
|
||||
function showMessageContextMenu(event: MouseEvent, message: ChatMessage) {
|
||||
if (!chatStore.currentTarget) return
|
||||
const roomId = getRoomId(chatStore.currentTarget)
|
||||
const menuItems: import('@/stores/contextMenu').MenuItem[] = []
|
||||
|
||||
if (message.message_type === 0 && message.content) {
|
||||
menuItems.push({
|
||||
label: '复制',
|
||||
icon: 'fas fa-copy',
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
toastStore.success('已复制')
|
||||
}).catch(() => toastStore.error('复制失败'))
|
||||
},
|
||||
})
|
||||
menuItems.push({
|
||||
label: '转发',
|
||||
icon: 'fas fa-share',
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
toastStore.success('内容已复制,可粘贴到其他会话转发')
|
||||
}).catch(() => toastStore.error('复制失败'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (message.isSelf && message.id && message.message_type !== 4) {
|
||||
const sendTime = new Date(message.created_at).getTime()
|
||||
if (Date.now() - sendTime < 2 * 60 * 1000) {
|
||||
menuItems.push({
|
||||
label: '撤回',
|
||||
icon: 'fas fa-undo',
|
||||
action: async () => {
|
||||
try {
|
||||
const updated = await messageApi.recallMessage(message.id!)
|
||||
chatStore.updateMessage(roomId, message.id!, {
|
||||
message_type: updated.message_type ?? 4,
|
||||
content: updated.content || '撤回了一条消息',
|
||||
})
|
||||
toastStore.success('已撤回')
|
||||
} catch (e: any) {
|
||||
toastStore.error(e?.message || '撤回失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: '删除',
|
||||
icon: 'fas fa-trash-alt',
|
||||
danger: true,
|
||||
action: () => {
|
||||
if (message.id) {
|
||||
chatStore.removeMessage(roomId, message.id)
|
||||
}
|
||||
toastStore.success('已从本地删除')
|
||||
},
|
||||
})
|
||||
|
||||
if (menuItems.length) {
|
||||
showContextMenu(event, menuItems)
|
||||
}
|
||||
}
|
||||
|
||||
function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
|
||||
const menuItems = [
|
||||
{
|
||||
@@ -2297,6 +2457,31 @@ function getUserEmail(user: Contact | User | null): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 登录后预拉取有未读会话的最新消息(bulk sync 轻量版) */
|
||||
async function prefetchUnreadMessages() {
|
||||
const targets = conversationStore.conversations
|
||||
.filter(c => (c.unread_count || 0) > 0)
|
||||
.slice(0, 5)
|
||||
|
||||
for (const conv of targets) {
|
||||
const roomId = conv.room_id || conv.target_id
|
||||
if (!roomId || chatStore.getRoomMessages(roomId).length > 0) continue
|
||||
try {
|
||||
const response = await messageApi.syncMessages(roomId, 1, 30)
|
||||
if (response?.data?.length) {
|
||||
const msgs = response.data.map((msg: ChatMessage) => ({
|
||||
...msg,
|
||||
isSelf: msg.sender_user_id === authStore.user!.id,
|
||||
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
|
||||
}))
|
||||
chatStore.setRoomMessages(roomId, msgs.reverse())
|
||||
}
|
||||
} catch {
|
||||
// 预拉取失败不影响主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(currentTab, (newTab) => {
|
||||
storage.setCurrentTab(newTab)
|
||||
if (newTab === 'contact' || newTab === 'moment') {
|
||||
@@ -2326,11 +2511,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
wsManager.onMessage(handleWebSocketMessage)
|
||||
wsManager.onReadReceipt(handleReadReceipt)
|
||||
wsManager.onSignal(webrtc.handleSignaling)
|
||||
// 朋友圈通知已在 App.vue 中统一注册,避免重复
|
||||
}
|
||||
await loadContacts()
|
||||
await conversationStore.loadConversations()
|
||||
await prefetchUnreadMessages()
|
||||
// 获取朋友圈未读数
|
||||
momentStore.fetchUnreadCount()
|
||||
// 开始检查连接状态
|
||||
|
||||
@@ -129,6 +129,12 @@
|
||||
>
|
||||
<i class="fas fa-bell-slash mr-2"></i>{{ contact.is_muted ? '取消免打扰' : '免打扰' }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
|
||||
@click="handleToggleBlock"
|
||||
>
|
||||
<i class="fas fa-ban mr-2"></i>{{ contact.is_blocked ? '移出黑名单' : '加入黑名单' }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-red-500/20 text-red-400 rounded-lg transition text-sm text-left"
|
||||
@click="showDeleteConfirm = true"
|
||||
@@ -468,6 +474,23 @@ async function handleToggleMuted() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleBlock() {
|
||||
if (!contact.value) return
|
||||
|
||||
const nextBlocked = !contact.value.is_blocked
|
||||
try {
|
||||
await contactApi.updateContact(contact.value.id, { is_blocked: nextBlocked })
|
||||
contact.value.is_blocked = nextBlocked
|
||||
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
|
||||
if (listContact) {
|
||||
listContact.is_blocked = nextBlocked
|
||||
}
|
||||
toastStore.success(nextBlocked ? '已加入黑名单' : '已移出黑名单')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteContact() {
|
||||
if (!contact.value) return
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<i class="far fa-star w-5 text-center opacity-70" :class="contact?.is_special_care ? 'text-yellow-400 fas' : ''"></i>
|
||||
<span>{{ contact?.is_special_care ? '取消特别关心' : '特别关心' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleBlock">
|
||||
<i class="fas fa-ban w-5 text-center opacity-70" :class="contact?.is_blocked ? 'text-red-400' : ''"></i>
|
||||
<span>{{ contact?.is_blocked ? '移出黑名单' : '加入黑名单' }}</span>
|
||||
</button>
|
||||
<div class="h-px bg-gray-700 my-1 mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-red-400 hover:bg-red-500/10" @click="showDeleteConfirm = true">
|
||||
<i class="fas fa-trash-alt w-5 text-center opacity-70"></i><span>删除好友</span>
|
||||
@@ -631,6 +635,19 @@ async function handleToggleSpecialCare() {
|
||||
} catch(e) { toastStore.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleToggleBlock() {
|
||||
if (!contact.value) return
|
||||
try {
|
||||
const newVal = !contact.value.is_blocked
|
||||
await contactApi.updateContact(contact.value.id, { is_blocked: newVal })
|
||||
contact.value.is_blocked = newVal
|
||||
showMoreOptions.value = false
|
||||
toastStore.success(newVal ? '已加入黑名单' : '已移出黑名单')
|
||||
} catch (e) {
|
||||
toastStore.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteContact() {
|
||||
if(!contact.value) return
|
||||
try {
|
||||
|
||||
@@ -14,19 +14,19 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'http://localhost:12080',
|
||||
target: 'https://g-ws.nailaoyun.cn',
|
||||
target: 'http://localhost:12080',
|
||||
// target: 'https://g-ws.nailaoyun.cn',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
// target: 'ws://localhost:12080',
|
||||
target: 'wss://g-ws.nailaoyun.cn',
|
||||
target: 'ws://localhost:12080',
|
||||
// target: 'wss://g-ws.nailaoyun.cn',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/upload': {
|
||||
// target: 'http://localhost:12080',
|
||||
target: 'https://g-ws.nailaoyun.cn',
|
||||
target: 'http://localhost:12080',
|
||||
// target: 'https://g-ws.nailaoyun.cn',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user