bug修复

This commit is contained in:
2025-12-14 11:08:21 +08:00
parent 22c647b10d
commit 5eb361f44d
18 changed files with 1256 additions and 33 deletions

View File

@@ -41,6 +41,7 @@ import { useChatStore } from '@/stores/chat'
import { useMomentStore } from '@/stores/moment'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import { wsManager } from '@/api/websocket'
import * as systemApi from '@/api/modules/system'
import type { MomentNotifPayload } from '@/types/moment'
const webrtcStore = useWebRTCStore()
@@ -61,12 +62,24 @@ const handleMomentNotification = (payload: MomentNotifPayload) => {
momentStore.handleWsNotification(payload)
}
// 后端健康检查
async function checkBackendHealth() {
try {
const result = await systemApi.healthCheck()
console.log('✅ Backend health check:', result.status)
} catch (error) {
console.warn('⚠️ Backend health check failed:', error)
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
// 确保监听启动
groupWebRTC.initListener()
// 注册朋友圈通知处理器
wsManager.onMomentNotification(handleMomentNotification)
// 检查后端健康状态
checkBackendHealth()
})
onUnmounted(() => {

View File

@@ -52,9 +52,33 @@ export function getUserGroups() {
}>>('/groups')
}
/**
* 后端返回的群信息响应结构
* 注意:后端把 ChatRoom 对象嵌套在 room 字段里
*/
export interface GroupInfoResponse {
room: {
room_id: string
room_type: string
room_name: string
room_avatar: string
owner_id: string
creator_id: string
admin_ids: string
announcement: string
last_message_time?: string
last_message?: string
created_at: string
updated_at: string
}
member_count: number
admin_ids: string[]
owner_id: string
}
// 获取群信息
export function getGroup(roomId: string) {
return request.get<GroupInfo>(`/groups/${roomId}`)
return request.get<GroupInfoResponse>(`/groups/${roomId}`)
}
// 获取群成员列表

View File

@@ -1,8 +1,20 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import axios, { type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import type { ApiResponse } from './types'
/**
* 扩展 AxiosInstance 类型,修正返回值类型
* 响应拦截器直接返回 res.result所以实际返回 T 而非 AxiosResponse<T>
*/
interface CustomAxiosInstance extends Omit<AxiosInstance, 'get' | 'post' | 'put' | 'delete' | 'patch'> {
get<T = any>(url: string, config?: InternalAxiosRequestConfig): Promise<T>
post<T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig): Promise<T>
put<T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig): Promise<T>
delete<T = any>(url: string, config?: InternalAxiosRequestConfig): Promise<T>
patch<T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig): Promise<T>
}
// 创建axios实例
const request: AxiosInstance = axios.create({
const axiosInstance: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
@@ -11,7 +23,7 @@ const request: AxiosInstance = axios.create({
})
// 请求拦截器
request.interceptors.request.use(
axiosInstance.interceptors.request.use(
(config) => {
// 从localStorage获取token
const token = localStorage.getItem('token')
@@ -33,7 +45,7 @@ request.interceptors.request.use(
)
// 响应拦截器
request.interceptors.response.use(
axiosInstance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
@@ -46,7 +58,7 @@ request.interceptors.response.use(
}
// 返回result字段的数据
return res.result
return res.result as any
},
(error) => {
console.error('Request Error:', error)
@@ -54,5 +66,8 @@ request.interceptors.response.use(
}
)
// 导出类型正确的实例
const request = axiosInstance as CustomAxiosInstance
export default request

View File

@@ -25,6 +25,22 @@ class WebSocketManager {
private recentMomentNotifs: Set<string> = new Set()
private notifCacheTimeout = 5000 // 5秒内相同通知视为重复
/**
* 获取 WebSocket URL支持动态配置
*/
private getWebSocketUrl(userId: string): string {
// 优先使用环境变量配置
const envWsUrl = import.meta.env.VITE_WS_URL
if (envWsUrl) {
return `${envWsUrl}?user_id=${userId}`
}
// 自动根据当前页面协议和 host 构建 WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}/ws?user_id=${userId}`
}
/**
* 连接WebSocket
*/
@@ -36,7 +52,7 @@ class WebSocketManager {
}
this.userId = userId
const wsUrl = `ws://localhost:12080/ws?user_id=${userId}`
const wsUrl = this.getWebSocketUrl(userId)
try {
this.ws = new WebSocket(wsUrl)
@@ -118,6 +134,39 @@ class WebSocketManager {
return this.clientId
}
/**
* 检查连接状态
*/
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
}
/**
* 获取连接状态详情
*/
getConnectionState(): 'connected' | 'connecting' | 'disconnected' {
if (!this.ws) return 'disconnected'
switch (this.ws.readyState) {
case WebSocket.CONNECTING:
return 'connecting'
case WebSocket.OPEN:
return 'connected'
default:
return 'disconnected'
}
}
/**
* 手动触发重连
*/
reconnect(): Promise<void> {
if (this.userId) {
this.reconnectAttempts = 0
return this.connect(this.userId)
}
return Promise.reject('No userId available')
}
/**
* 添加普通消息处理器
*/

View File

@@ -447,8 +447,11 @@ async function loadData() {
groupApi.getGroupMembers(props.roomId),
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
])
groupName.value = info.name || '未命名群聊'
groupAvatar.value = info.avatar || ''
// 后端返回结构:{ room: { room_name, room_avatar, ... }, member_count, ... }
// 从嵌套的 room 对象中提取群信息
const roomInfo = info.room
groupName.value = roomInfo?.room_name || '未命名群聊'
groupAvatar.value = roomInfo?.room_avatar || ''
members.value = memberList
announcement.value = ann.announcement || ''
} catch (e) {

View File

@@ -49,6 +49,30 @@
</div>
</div>
<!-- 群公告区域 -->
<div class="w-full bg-black/20 rounded-2xl p-4 border border-white/5 mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i class="fas fa-bullhorn text-indigo-400 text-sm"></i>
<h4 class="text-sm font-bold text-gray-300">群公告</h4>
</div>
<button
v-if="canEditAnnouncement"
class="text-xs text-indigo-400 hover:text-indigo-300 transition"
@click="openAnnouncementModal"
>
{{ announcement ? '编辑' : '添加' }}
</button>
</div>
<div
class="text-sm text-gray-400 leading-relaxed cursor-pointer hover:text-gray-300 transition"
@click="openAnnouncementModal"
>
<p v-if="announcement" class="line-clamp-3 whitespace-pre-wrap">{{ announcement }}</p>
<p v-else class="text-gray-600 italic">暂无公告</p>
</div>
</div>
<!-- 操作按钮组 -->
<div class="grid grid-cols-5 gap-4 w-full max-w-lg mb-8">
<!-- 1. 通话按钮 -->
@@ -75,6 +99,14 @@
<span class="text-xs text-gray-400 group-hover:text-white mt-2">编辑</span>
</button>
<!-- 3.5 群设置按钮 -->
<button v-if="canEdit" class="info-action-btn group" @click="openSettingsModal">
<div class="icon-box bg-teal-500/10 text-teal-400 group-hover:bg-teal-500 group-hover:text-white border border-teal-500/20">
<i class="fas fa-cog"></i>
</div>
<span class="text-xs text-gray-400 group-hover:text-white mt-2">设置</span>
</button>
<!-- 4. 管理员设置仅群主可见 -->
<button v-if="isOwner" class="info-action-btn group" @click="openAdminModal">
<div class="icon-box bg-purple-500/10 text-purple-400 group-hover:bg-purple-500 group-hover:text-white border border-purple-500/20">
@@ -349,6 +381,90 @@
@close="showUserMomentsModal = false"
/>
<!-- 弹窗 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">
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
<h3 class="text-white font-bold text-sm">群设置</h3>
<button class="text-gray-400 hover:text-white" @click="showSettingsModal = false"><i class="fas fa-times"></i></button>
</div>
<div v-if="loadingSettings" class="p-8 flex justify-center">
<i class="fas fa-circle-notch fa-spin text-xl text-indigo-500"></i>
</div>
<div v-else class="p-4 space-y-4">
<!-- 群简介 -->
<div>
<label class="block text-gray-400 text-xs mb-2">群简介</label>
<textarea
v-model="groupSettings.description"
class="w-full bg-black/20 border border-gray-600 rounded-lg p-3 text-sm text-white focus:border-indigo-500 focus:outline-none h-24 resize-none"
placeholder="请输入群简介..."
></textarea>
</div>
<!-- 邀请权限 -->
<div>
<label class="block text-gray-400 text-xs mb-2">邀请权限</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="perm in [0, 1, 2]"
:key="perm"
class="py-2 px-3 rounded-lg text-sm transition border"
:class="groupSettings.invite_permission === perm
? 'bg-indigo-600 text-white border-indigo-500'
: 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
@click="groupSettings.invite_permission = perm"
>
{{ getInvitePermissionText(perm) }}
</button>
</div>
<p class="text-xs text-gray-500 mt-2">
<i class="fas fa-info-circle mr-1"></i>
设置谁可以邀请新成员加入群聊
</p>
</div>
</div>
<div class="px-4 py-3 bg-gray-900/50 border-t border-gray-700 flex justify-end gap-2">
<button class="px-4 py-2 text-gray-300 hover:text-white text-sm" @click="showSettingsModal = false">取消</button>
<button
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-500 disabled:opacity-50"
:disabled="savingSettings"
@click="saveGroupSettings"
>
<i v-if="savingSettings" class="fas fa-circle-notch fa-spin mr-1"></i>
保存设置
</button>
</div>
</div>
</div>
<!-- 弹窗 9: 群公告详情/编辑 -->
<div v-if="showAnnouncementModal" 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">
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
<h3 class="text-white font-bold text-sm">群公告</h3>
<button class="text-gray-400 hover:text-white" @click="showAnnouncementModal = false"><i class="fas fa-times"></i></button>
</div>
<div class="p-4">
<textarea
v-if="isEditingAnnouncement"
v-model="editAnnouncementText"
class="w-full bg-black/20 border border-gray-600 rounded-lg p-3 text-sm text-white focus:border-indigo-500 focus:outline-none h-40 resize-none"
placeholder="请输入群公告..."
></textarea>
<div v-else class="min-h-[100px] text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">
{{ announcement || '暂无公告' }}
</div>
</div>
<div class="px-4 py-3 bg-gray-900/50 border-t border-gray-700 flex justify-end gap-2" v-if="canEditAnnouncement">
<template v-if="isEditingAnnouncement">
<button class="px-3 py-1.5 text-xs text-gray-300 hover:text-white" @click="isEditingAnnouncement = false">取消</button>
<button class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-500" @click="saveAnnouncement">保存</button>
</template>
<button v-else class="px-3 py-1.5 text-xs bg-gray-700 text-white rounded hover:bg-gray-600" @click="startEditAnnouncement">编辑公告</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -403,6 +519,21 @@ const savingAdmins = ref(false)
const editForm = ref({ name: '', avatar: '' })
// 群设置相关
const showSettingsModal = ref(false)
const groupSettings = ref({
description: '',
invite_permission: 0 // 0-所有人 1-仅管理员 2-仅群主
})
const loadingSettings = ref(false)
const savingSettings = ref(false)
// 群公告相关
const announcement = ref('')
const showAnnouncementModal = ref(false)
const isEditingAnnouncement = ref(false)
const editAnnouncementText = ref('')
// Computed
const currentUserRole = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role ?? 0)
const isOwner = computed(() => currentUserRole.value === 2)
@@ -410,6 +541,7 @@ const isAdmin = computed(() => currentUserRole.value === 1)
const canInvite = computed(() => isOwner.value || isAdmin.value)
const canEdit = computed(() => isOwner.value)
const canRemove = computed(() => isOwner.value || isAdmin.value)
const canEditAnnouncement = computed(() => isOwner.value || isAdmin.value)
// 判断是否可以移除某个成员
function canRemoveMember(member: GroupMember): boolean {
@@ -445,15 +577,29 @@ async function loadGroupInfo() {
if (!props.roomId) return
loading.value = true
try {
const [info, memberList, contacts] = await Promise.all([
const [info, memberList, contacts, ann] = await Promise.all([
groupApi.getGroup(props.roomId),
groupApi.getGroupMembers(props.roomId),
contactApi.getContacts()
contactApi.getContacts(),
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
])
groupInfo.value = info
// 后端返回结构:{ room: { room_name, room_avatar, ... }, member_count, owner_id, ... }
// 从嵌套的 room 对象中提取群信息
const roomInfo = info.room
const normalizedInfo = {
room_id: roomInfo?.room_id || props.roomId,
room_type: 'group' as const,
name: roomInfo?.room_name || '未命名群聊',
avatar: roomInfo?.room_avatar || '',
owner_id: info.owner_id || roomInfo?.owner_id || '',
member_count: info.member_count || 0,
created_at: roomInfo?.created_at || ''
}
groupInfo.value = normalizedInfo as any
members.value = memberList
announcement.value = ann.announcement || ''
editForm.value = { name: info.name || '', avatar: info.avatar || '' }
editForm.value = { name: normalizedInfo.name, avatar: normalizedInfo.avatar }
// 过滤可邀请的好友
const memberIds = new Set(memberList.map(m => m.user_id))
@@ -709,6 +855,73 @@ async function handleSaveAdmins() {
}
}
// 群设置相关方法
async function openSettingsModal() {
showSettingsModal.value = true
loadingSettings.value = true
try {
const settings = await groupApi.getGroupSettings(props.roomId)
groupSettings.value = {
description: settings.description || '',
invite_permission: settings.invite_permission ?? 0
}
} catch (e) {
// 接口可能不存在,使用默认值
groupSettings.value = { description: '', invite_permission: 0 }
} finally {
loadingSettings.value = false
}
}
async function saveGroupSettings() {
savingSettings.value = true
try {
await groupApi.updateGroupSettings(props.roomId, {
description: groupSettings.value.description,
invite_permission: groupSettings.value.invite_permission
})
toastStore.success('群设置已保存')
showSettingsModal.value = false
emit('updated')
} catch (e) {
toastStore.error('保存失败')
} finally {
savingSettings.value = false
}
}
function getInvitePermissionText(permission: number): string {
switch (permission) {
case 0: return '所有人'
case 1: return '仅管理员'
case 2: return '仅群主'
default: return '所有人'
}
}
// 群公告相关方法
function openAnnouncementModal() {
isEditingAnnouncement.value = false
showAnnouncementModal.value = true
}
function startEditAnnouncement() {
editAnnouncementText.value = announcement.value
isEditingAnnouncement.value = true
}
async function saveAnnouncement() {
try {
await groupApi.updateGroupAnnouncement(props.roomId, editAnnouncementText.value)
announcement.value = editAnnouncementText.value
isEditingAnnouncement.value = false
showAnnouncementModal.value = false
toastStore.success('公告已更新')
} catch(e) {
toastStore.error('更新失败')
}
}
watch(() => props.show, (val) => {
if (val) loadGroupInfo()
})

View File

@@ -0,0 +1,374 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 bg-black/80 z-[9999] flex items-center justify-center p-4 backdrop-blur-md animate-fade-in"
@click.self="$emit('close')"
>
<div class="bg-[#1e1e24] rounded-2xl w-full max-w-4xl shadow-2xl border border-gray-700/50 overflow-hidden flex flex-col max-h-[85vh]">
<!-- 头部 -->
<div class="px-6 py-4 flex justify-between items-center border-b border-white/5 bg-gradient-to-r from-indigo-600/10 to-purple-600/10">
<div class="flex items-center gap-3">
<i class="fas fa-folder-open text-indigo-400 text-xl"></i>
<h2 class="text-lg font-bold text-white">附件管理</h2>
</div>
<button
class="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-gray-400 hover:text-white transition flex items-center justify-center"
@click="$emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 筛选栏 -->
<div class="px-6 py-4 border-b border-white/5 flex items-center gap-4">
<div class="flex gap-2">
<button
v-for="filter in filters"
:key="filter.value"
class="px-4 py-2 rounded-lg text-sm transition border"
:class="currentFilter === filter.value
? 'bg-indigo-600 text-white border-indigo-500'
: 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
@click="currentFilter = filter.value; loadAttachments()"
>
<i :class="filter.icon" class="mr-2"></i>
{{ filter.label }}
</button>
</div>
<div class="flex-1"></div>
<button
class="px-4 py-2 bg-gray-700/50 text-gray-300 rounded-lg hover:bg-gray-600 transition text-sm"
@click="loadAttachments"
:disabled="loading"
>
<i class="fas fa-sync-alt mr-2" :class="{ 'fa-spin': loading }"></i>
刷新
</button>
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
<!-- 加载中 -->
<div v-if="loading && attachments.length === 0" class="flex justify-center py-16">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-500"></div>
</div>
<!-- 空状态 -->
<div v-else-if="attachments.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="w-24 h-24 bg-gray-800 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-folder-open text-4xl text-gray-600"></i>
</div>
<p class="text-gray-400">暂无附件</p>
</div>
<!-- 附件列表 -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div
v-for="attachment in attachments"
:key="attachment.id"
class="relative group bg-gray-800/50 rounded-xl overflow-hidden border border-gray-700/50 hover:border-indigo-500/50 transition"
>
<!-- 图片预览 -->
<div
v-if="attachment.type === 'image'"
class="aspect-square overflow-hidden cursor-pointer"
@click="previewImage(attachment)"
>
<img
:src="attachment.file_url"
:alt="attachment.file_name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
/>
</div>
<!-- 视频预览 -->
<div
v-else-if="attachment.type === 'video'"
class="aspect-square bg-gray-900 flex items-center justify-center cursor-pointer"
@click="previewVideo(attachment)"
>
<i class="fas fa-play-circle text-4xl text-indigo-400"></i>
</div>
<!-- 文件预览 -->
<div
v-else
class="aspect-square bg-gray-900 flex flex-col items-center justify-center cursor-pointer"
@click="downloadFile(attachment)"
>
<i class="fas fa-file text-3xl text-gray-500 mb-2"></i>
<span class="text-xs text-gray-400 px-2 truncate w-full text-center">{{ attachment.file_name }}</span>
</div>
<!-- 文件信息 -->
<div class="p-2 border-t border-gray-700/50">
<p class="text-xs text-gray-300 truncate" :title="attachment.file_name">
{{ attachment.file_name }}
</p>
<div class="flex items-center justify-between mt-1">
<span class="text-[10px] text-gray-500">
{{ formatFileSize(attachment.file_size || 0) }}
</span>
<span class="text-[10px] text-gray-500">
{{ formatTime(attachment.created_at) }}
</span>
</div>
</div>
<!-- 删除按钮 -->
<button
class="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-500/80 text-white opacity-0 group-hover:opacity-100 transition flex items-center justify-center hover:bg-red-600"
@click.stop="confirmDelete(attachment)"
:disabled="deletingId === attachment.id"
>
<i class="fas text-xs" :class="deletingId === attachment.id ? 'fa-circle-notch fa-spin' : 'fa-trash'"></i>
</button>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="flex justify-center py-6">
<button
v-if="!loading"
class="px-6 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 transition text-sm"
@click="loadMore"
>
加载更多
</button>
<div v-else class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-500"></div>
</div>
</div>
<!-- 统计信息 -->
<div class="px-6 py-3 border-t border-white/5 bg-black/20 flex items-center justify-between text-xs text-gray-500">
<span> {{ total }} 个附件</span>
<span>已使用 {{ formatFileSize(totalSize) }}</span>
</div>
</div>
<!-- 图片预览 -->
<div
v-if="previewImageUrl"
class="fixed inset-0 bg-black/90 z-[10000] flex items-center justify-center"
@click="previewImageUrl = ''"
>
<img :src="previewImageUrl" class="max-w-[90vw] max-h-[90vh] object-contain" />
<button
class="absolute top-6 right-6 w-10 h-10 rounded-full bg-white/10 text-white hover:bg-white/20 transition flex items-center justify-center"
@click="previewImageUrl = ''"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 视频预览 -->
<div
v-if="previewVideoUrl"
class="fixed inset-0 bg-black/90 z-[10000] flex items-center justify-center"
@click.self="previewVideoUrl = ''"
>
<video
:src="previewVideoUrl"
controls
autoplay
class="max-w-[90vw] max-h-[90vh]"
></video>
<button
class="absolute top-6 right-6 w-10 h-10 rounded-full bg-white/10 text-white hover:bg-white/20 transition flex items-center justify-center"
@click="previewVideoUrl = ''"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 删除确认 -->
<div
v-if="deletingAttachment"
class="fixed inset-0 bg-black/80 z-[10000] flex items-center justify-center"
@click.self="deletingAttachment = null"
>
<div class="bg-gray-800 rounded-xl p-6 w-80 text-center border border-gray-700 shadow-2xl">
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-trash-alt text-2xl text-red-500"></i>
</div>
<h3 class="text-white font-bold mb-2">删除附件</h3>
<p class="text-gray-400 text-sm mb-6">确定要删除这个附件吗此操作不可恢复</p>
<div class="flex gap-3">
<button
class="flex-1 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 text-sm"
@click="deletingAttachment = null"
>
取消
</button>
<button
class="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
:disabled="deletingId !== null"
@click="handleDelete"
>
<i v-if="deletingId" class="fas fa-circle-notch fa-spin mr-1"></i>
确定删除
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import * as attachmentApi from '@/api/modules/attachment'
import type { Attachment } from '@/types/api'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits(['close'])
// 筛选选项
const filters = [
{ value: '', label: '全部', icon: 'fas fa-th' },
{ value: 'image', label: '图片', icon: 'fas fa-image' },
{ value: 'video', label: '视频', icon: 'fas fa-video' },
{ value: 'file', label: '文件', icon: 'fas fa-file' },
]
// 状态
const currentFilter = ref('')
const attachments = ref<Attachment[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const totalSize = ref(0)
const hasMore = ref(true)
// 预览
const previewImageUrl = ref('')
const previewVideoUrl = ref('')
// 删除
const deletingAttachment = ref<Attachment | null>(null)
const deletingId = ref<number | null>(null)
// 加载附件列表
async function loadAttachments(reset = true) {
if (reset) {
page.value = 1
attachments.value = []
totalSize.value = 0
}
loading.value = true
try {
const response = await attachmentApi.getAttachments(currentFilter.value || undefined, page.value, pageSize)
if (reset) {
attachments.value = response.data || []
} else {
attachments.value.push(...(response.data || []))
}
total.value = response.total || 0
hasMore.value = attachments.value.length < total.value
// 计算总大小
totalSize.value = attachments.value.reduce((sum, a) => sum + (a.file_size || 0), 0)
} catch (e) {
console.error('加载附件失败', e)
} finally {
loading.value = false
}
}
// 加载更多
function loadMore() {
page.value++
loadAttachments(false)
}
// 预览图片
function previewImage(attachment: Attachment) {
previewImageUrl.value = attachment.file_url
}
// 预览视频
function previewVideo(attachment: Attachment) {
previewVideoUrl.value = attachment.file_url
}
// 下载文件
function downloadFile(attachment: Attachment) {
const link = document.createElement('a')
link.href = attachment.file_url
link.download = attachment.file_name
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 确认删除
function confirmDelete(attachment: Attachment) {
deletingAttachment.value = attachment
}
// 执行删除
async function handleDelete() {
if (!deletingAttachment.value) return
deletingId.value = deletingAttachment.value.id
try {
await attachmentApi.deleteAttachment(deletingAttachment.value.id)
// 从列表中移除
const index = attachments.value.findIndex(a => a.id === deletingAttachment.value!.id)
if (index > -1) {
attachments.value.splice(index, 1)
total.value--
}
deletingAttachment.value = null
} catch (e) {
console.error('删除失败', e)
} finally {
deletingId.value = null
}
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化时间
function formatTime(time?: string): string {
if (!time) return ''
const date = new Date(time)
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 监听显示状态
watch(() => props.show, (val) => {
if (val) {
loadAttachments()
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -146,7 +146,7 @@
<div v-if="moment.likes && moment.likes.length > 0" class="mt-4 p-3 bg-white/5 rounded-xl">
<div class="flex items-center gap-2 text-sm">
<i class="fas fa-heart text-red-500 shrink-0"></i>
<div class="flex items-center gap-1 flex-wrap">
<div class="flex items-center gap-1 flex-wrap cursor-pointer" @click="openLikeListModal(moment)">
<template v-for="(like, idx) in moment.likes.slice(0, 8)" :key="like.id">
<Avatar
:name="like.user?.name || ''"
@@ -154,12 +154,13 @@
size="sm"
class="shrink-0 cursor-pointer hover:opacity-80 transition"
:title="like.user?.name"
@click="showUserInfo(like.user)"
@click.stop="showUserInfo(like.user)"
/>
</template>
<span v-if="moment.likes.length > 8" class="text-gray-500 text-xs ml-1">
<span v-if="moment.likes.length > 8" class="text-gray-500 text-xs ml-1 hover:text-primary">
+{{ moment.likes.length - 8 }}
</span>
<span class="text-gray-500 text-xs ml-2 hover:text-primary">查看全部</span>
</div>
</div>
</div>
@@ -188,6 +189,16 @@
@click="showUserInfo(comment.user)"
>{{ comment.user?.name }}</span>
<span class="text-[10px] text-gray-600">{{ formatCommentTime(comment.created_at) }}</span>
<!-- 删除按钮仅自己的评论可见 -->
<button
v-if="canDeleteComment(comment)"
@click.stop="handleDeleteComment(moment, comment)"
:disabled="deletingCommentId === comment.id"
class="text-gray-500 hover:text-red-500 opacity-0 group-hover/comment:opacity-100 transition ml-auto"
title="删除评论"
>
<i class="fas fa-trash-alt text-[10px]" :class="{ 'fa-spin': deletingCommentId === comment.id }"></i>
</button>
</div>
<p class="text-gray-400 break-words mt-1 leading-relaxed">{{ comment.content }}</p>
<button
@@ -227,6 +238,16 @@
>{{ reply.reply_to_user.name }}</span>
</span>
<span class="text-gray-600 text-[10px] ml-1">{{ formatCommentTime(reply.created_at) }}</span>
<!-- 删除回复按钮仅自己的回复可见 -->
<button
v-if="canDeleteComment(reply)"
@click.stop="handleDeleteComment(moment, reply)"
:disabled="deletingCommentId === reply.id"
class="text-gray-500 hover:text-red-500 opacity-0 group-hover/reply:opacity-100 transition ml-auto"
title="删除回复"
>
<i class="fas fa-trash-alt text-[10px]" :class="{ 'fa-spin': deletingCommentId === reply.id }"></i>
</button>
</div>
<p class="text-gray-400 break-words mt-0.5 text-[13px] leading-relaxed">{{ reply.content }}</p>
<button
@@ -335,6 +356,56 @@
:user="selectedUser"
@close="showUserMomentsModal = false"
/>
<!-- 点赞用户列表弹窗 -->
<div
v-if="showLikeListModal"
class="fixed inset-0 bg-black/70 z-[60] flex items-center justify-center backdrop-blur-sm p-4"
@click.self="closeLikeListModal"
>
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-scale-in">
<div class="px-5 py-4 border-b border-gray-700 flex justify-between items-center bg-gray-800/50">
<h3 class="text-white font-bold">
<i class="fas fa-heart text-red-500 mr-2"></i>
点赞用户 <span class="text-gray-500 text-sm font-normal">({{ likeList.length }})</span>
</h3>
<button @click="closeLikeListModal" class="text-gray-400 hover:text-white transition">
<i class="fas fa-times"></i>
</button>
</div>
<div class="max-h-[60vh] overflow-y-auto custom-scrollbar">
<!-- 加载中 -->
<div v-if="loadingLikes" class="p-8 text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
</div>
<!-- 空状态 -->
<div v-else-if="likeList.length === 0" class="p-8 text-center text-gray-500">
暂无点赞
</div>
<!-- 点赞列表 -->
<div v-else class="p-2">
<div
v-for="like in likeList"
:key="like.id"
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
@click="showUserInfo(like.user); closeLikeListModal()"
>
<Avatar
:name="like.user?.name || ''"
:avatar="like.user?.avatar"
size="md"
class="shrink-0"
/>
<div class="flex-1 min-w-0">
<p class="text-white font-medium truncate">{{ like.user?.name }}</p>
<p class="text-xs text-gray-500 truncate">{{ like.user?.desc || '暂无签名' }}</p>
</div>
<span class="text-xs text-gray-500">{{ formatCommentTime(like.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -354,6 +425,7 @@ import UserMomentsModal from './UserMomentsModal.vue'
import { parseMediaUrls } from '@/types/moment'
import { formatRelativeTime } from '@/utils/format'
import { storage } from '@/utils/storage'
import * as momentApi from '@/api/modules/moment'
import type { Moment, MomentComment } from '@/types/moment'
import type { User } from '@/types/api'
@@ -379,12 +451,19 @@ const commentText = ref('')
const commenting = ref(false)
const replyTo = ref<MomentComment | null>(null)
const commentInputRef = ref<HTMLInputElement | null>(null)
const deletingCommentId = ref<number | null>(null)
// 图片预览相关
const showImagePreview = ref(false)
const previewImages = ref<string[]>([])
const previewIndex = ref(0)
// 点赞列表相关
const showLikeListModal = ref(false)
const likeListMomentId = ref<number | null>(null)
const likeList = ref<any[]>([])
const loadingLikes = ref(false)
// 打开图片预览
function openImagePreview(images: string[], index: number) {
previewImages.value = images
@@ -392,6 +471,29 @@ function openImagePreview(images: string[], index: number) {
showImagePreview.value = true
}
// 打开点赞列表弹窗
async function openLikeListModal(moment: Moment) {
likeListMomentId.value = moment.id
showLikeListModal.value = true
loadingLikes.value = true
try {
const likes = await momentApi.getMomentLikes(moment.id)
likeList.value = likes
} catch (e) {
console.error('获取点赞列表失败', e)
likeList.value = moment.likes || []
} finally {
loadingLikes.value = false
}
}
// 关闭点赞列表弹窗
function closeLikeListModal() {
showLikeListModal.value = false
likeListMomentId.value = null
likeList.value = []
}
// 初始化
onMounted(() => {
momentStore.fetchMoments()
@@ -594,6 +696,33 @@ function handleViewMoments() {
showUserCard.value = false
showUserMomentsModal.value = true
}
// 判断是否可以删除评论(只能删除自己的评论)
function canDeleteComment(comment: MomentComment): boolean {
return authStore.user?.id === comment.user_id
}
// 删除评论
async function handleDeleteComment(moment: Moment, comment: MomentComment) {
if (!confirm('确定要删除这条评论吗?')) return
deletingCommentId.value = comment.id
try {
await momentApi.deleteComment(comment.id)
// 从本地数据中移除评论
if (moment.comments) {
const index = moment.comments.findIndex(c => c.id === comment.id)
if (index > -1) {
moment.comments.splice(index, 1)
moment.comment_count = Math.max(0, (moment.comment_count || 1) - 1)
}
}
} catch (e) {
console.error('删除评论失败', e)
} finally {
deletingCommentId.value = null
}
}
</script>
<style scoped>
@@ -613,4 +742,11 @@ function handleViewMoments() {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
</style>

View File

@@ -1,9 +1,10 @@
import { reactive, shallowRef, ref } from 'vue'
import * as messageApi from '@/api/modules/message'
import * as systemApi from '@/api/modules/system'
import { wsManager } from '@/api/websocket'
import { useToastStore } from '@/stores/toast'
import { useChatStore } from '@/stores/chat'
import type { ChatMessage, Contact } from '@/types/api'
import type { ChatMessage, Contact, ICEServerConfig } from '@/types/api'
import type { CallStatus } from '@/types/message'
export interface CallState {
@@ -167,7 +168,96 @@ export function useWebRTC(
}
async function sendSummaryMessage(reason: 'connected' | 'cancelled' | 'rejected' | 'busy') {
// 省略具体实现,保持原样
const roomId = getSafeRoomId(currentReceiverUserId)
if (!roomId) return
// 根据原因生成系统消息内容
let content = ''
switch (reason) {
case 'connected':
content = `通话时长 ${formatDuration(call.duration)}`
break
case 'cancelled':
content = '已取消'
break
case 'rejected':
content = '对方未接听'
break
case 'busy':
content = '对方忙'
break
}
// 构造通话结果的 extra 数据
const extraData = {
call_type: call.type,
call_reason: reason,
call_duration: call.duration,
call_id: call.id
}
try {
// 发送系统消息 (message_type = 4)
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 4, // 系统消息
content: content,
extra: JSON.stringify(extraData)
}
await messageApi.sendMessage(payload)
// 同时添加到本地消息列表
const systemMessage: ChatMessage = {
id: Date.now(),
room_id: roomId,
sender_user_id: userId,
message_type: 4,
content: content,
extra: extraData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
chatStore.addMessage(roomId, systemMessage)
} catch (error) {
console.error('发送通话结束消息失败:', error)
}
}
// ICE 服务器缓存
let cachedIceServers: RTCIceServer[] | null = null
/**
* 获取 ICE 服务器配置(优先从后端 API 获取,失败时使用备用配置)
*/
async function getIceServers(): Promise<RTCIceServer[]> {
// 如果已有缓存,直接使用
if (cachedIceServers) {
return cachedIceServers
}
try {
const servers = await systemApi.getIceServers(userId)
if (servers && servers.length > 0) {
// 转换为 RTCIceServer 格式
cachedIceServers = servers.map((s: ICEServerConfig) => ({
urls: s.urls,
username: s.username,
credential: s.credential
}))
return cachedIceServers
}
} catch (error) {
console.warn('获取 ICE 服务器配置失败,使用备用配置:', error)
}
// 备用配置:公共 STUN 服务器
cachedIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
return cachedIceServers
}
// --- WebRTC ---
@@ -189,9 +279,9 @@ export function useWebRTC(
}
async function createPC(): Promise<void> {
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
const iceServers = await getIceServers()
if (pc) { pc.close(); pc = null }
pc = new RTCPeerConnection({ iceServers: servers })
pc = new RTCPeerConnection({ iceServers })
pc.oniceconnectionstatechange = () => {
if (pc?.iceConnectionState === 'failed') {
@@ -312,6 +402,17 @@ export function useWebRTC(
function endCall() {
stopRingtone()
// 主动挂断时发送通话结束系统消息
if (isCaller.value) {
if (call.status === 'outgoing') {
sendSummaryMessage('cancelled') // 呼出状态主动取消
} else if (call.status === 'connected') {
sendSummaryMessage('connected') // 通话中主动挂断
}
} else if (call.status === 'connected') {
// 被叫方在通话中挂断也发送消息
sendSummaryMessage('connected')
}
sendSignal('hangup')
closeCall()
}

View File

@@ -79,6 +79,57 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// 自动检查定时器
let tokenCheckInterval: number | null = null
/**
* 开始定时检查 Token 有效性
* @param intervalMs 检查间隔(毫秒),默认 5 分钟
*/
function startTokenAutoCheck(intervalMs = 5 * 60 * 1000) {
stopTokenAutoCheck() // 先清除可能存在的定时器
tokenCheckInterval = window.setInterval(async () => {
if (token.value) {
try {
await authApi.checkToken()
} catch (error) {
// Token 失效,触发登出
console.warn('Token expired during auto check')
logout()
// 触发全局事件通知
window.dispatchEvent(new CustomEvent('auth-expired'))
}
}
}, intervalMs)
}
/**
* 停止定时检查
*/
function stopTokenAutoCheck() {
if (tokenCheckInterval) {
clearInterval(tokenCheckInterval)
tokenCheckInterval = null
}
}
/**
* 静默检查 Token不更新用户信息仅验证有效性
*/
async function silentCheckToken(): Promise<boolean> {
if (!token.value) {
return false
}
try {
await authApi.checkToken()
return true
} catch (error) {
return false
}
}
/**
* 更新用户信息
*/
@@ -96,6 +147,9 @@ export const useAuthStore = defineStore('auth', () => {
logout,
checkAuth,
updateUserInfo,
startTokenAutoCheck,
stopTokenAutoCheck,
silentCheckToken,
}
})

View File

@@ -40,6 +40,8 @@ export interface User {
avatar: string
desc: string
region: string
is_online?: boolean // 用户在线状态
last_online?: string // 最后在线时间
created_at: string
updated_at: string
}

View File

@@ -2,7 +2,7 @@
* 聊天会话类型定义
*/
import type { User } from './api'
import type { User, Room } from './api'
export interface Conversation {
id: number // 数据库主键ID

View File

@@ -36,6 +36,49 @@
<div v-if="momentUnreadCount > 0" class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
</div>
<!-- 连接状态指示器 -->
<div
class="relative group"
:title="connectionState === 'connected' ? '已连接' : connectionState === 'connecting' ? '连接中' : '已断开,点击重连'"
@click="handleReconnect"
>
<div
class="w-10 h-10 flex items-center justify-center rounded-xl cursor-pointer transition"
:class="{
'text-success hover:bg-success/10': connectionState === 'connected',
'text-yellow-500 hover:bg-yellow-500/10 animate-pulse': connectionState === 'connecting',
'text-red-500 hover:bg-red-500/10': connectionState === 'disconnected'
}"
>
<i class="fas text-lg" :class="{
'fa-wifi': connectionState === 'connected',
'fa-sync-alt fa-spin': connectionState === 'connecting',
'fa-exclamation-triangle': connectionState === 'disconnected'
}"></i>
</div>
<!-- 悬停提示 -->
<div class="absolute left-full ml-2 px-3 py-1.5 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition pointer-events-none z-50">
<template v-if="connectionState === 'connected'">
<span class="flex items-center gap-1.5">
<span class="w-2 h-2 bg-success rounded-full animate-pulse"></span>
服务已连接
</span>
</template>
<template v-else-if="connectionState === 'connecting'">
<span class="flex items-center gap-1.5">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
正在连接...
</span>
</template>
<template v-else>
<span class="flex items-center gap-1.5">
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
连接断开点击重连
</span>
</template>
</div>
</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"
@@ -267,6 +310,7 @@
@member-removed="handleGroupMemberRemoved"
@member-clicked="handleGroupMemberClicked"
@group-updated="handleGroupUpdated"
@show-info="showGroupInfoPanel = true"
/>
</div>
@@ -669,6 +713,7 @@ import GroupCallBanner from '@/components/chat/GroupCallBanner.vue'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import MomentPanel from '@/components/moment/MomentPanel.vue'
import * as groupApi from '@/api/modules/room'
import * as systemApi from '@/api/modules/system'
const router = useRouter()
const authStore = useAuthStore()
@@ -684,6 +729,10 @@ const webrtc = webrtcStore.webrtc
// 朋友圈未读数
const momentUnreadCount = computed(() => momentStore.unreadCount)
// 连接状态
const connectionState = ref<'connected' | 'connecting' | 'disconnected'>('connecting')
let connectionCheckInterval: number | null = null
// State
const currentTab = ref<'chat' | 'contact' | 'moment'>(storage.getCurrentTab() as 'chat' | 'contact' | 'moment')
const searchQuery = ref('')
@@ -1419,6 +1468,39 @@ function processMessageAfterConversation(message: ChatMessage, roomId: string) {
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
// 连接状态检查
function checkConnectionState() {
connectionState.value = wsManager.getConnectionState()
}
// 手动重连
async function handleReconnect() {
if (connectionState.value === 'disconnected') {
connectionState.value = 'connecting'
try {
await wsManager.reconnect()
toastStore.success('已重新连接')
} catch (e) {
toastStore.error('重连失败')
}
checkConnectionState()
}
}
// 开始定时检查连接状态
function startConnectionCheck() {
checkConnectionState()
connectionCheckInterval = window.setInterval(checkConnectionState, 5000)
}
// 停止定时检查
function stopConnectionCheck() {
if (connectionCheckInterval) {
clearInterval(connectionCheckInterval)
connectionCheckInterval = null
}
}
function getMsgSummary(msg: ChatMessage): string { return getMessageSummary(msg) }
// 复制文本
@@ -2100,6 +2182,18 @@ onMounted(async () => {
}
if (authStore.user) {
await wsManager.connect(authStore.user.id)
// 绑定 ClientID 和 UserID
const clientId = wsManager.getClientId()
if (clientId) {
try {
await systemApi.bindClient({ user_id: authStore.user.id, client_id: clientId })
console.log('✅ Client bound successfully')
} catch (error) {
console.warn('⚠️ Failed to bind client:', error)
}
}
wsManager.onMessage(handleWebSocketMessage)
wsManager.onSignal(webrtc.handleSignaling)
// 朋友圈通知已在 App.vue 中统一注册,避免重复
@@ -2108,6 +2202,8 @@ onMounted(async () => {
await conversationStore.loadConversations()
// 获取朋友圈未读数
momentStore.fetchUnreadCount()
// 开始检查连接状态
startConnectionCheck()
const savedRoomId = storage.getSelectedRoomId()
const savedTargetId = storage.getSelectedConversation()
@@ -2149,6 +2245,7 @@ onUnmounted(() => {
wsManager.offSignal(webrtc.handleSignaling)
wsManager.offMomentNotification(momentStore.handleWsNotification)
window.removeEventListener('app-tab-change', handleTabChangeEvent)
stopConnectionCheck()
})
</script>

View File

@@ -891,7 +891,7 @@ function handleAddFriend() {
showActionMenu.value = false
contactStore.setLeftPanelMode('friend-manager')
// 切换到添加好友视图ContactView
router.push('/contacts').catch(() => {})
router.push('/contact').catch(() => {})
}
// 创建群聊处理

View File

@@ -31,14 +31,26 @@
</h3>
<p class="text-sm text-gray-400 mb-2">{{ contact.user?.desc || '暂无签名' }}</p>
<div class="flex items-center justify-center gap-2 text-xs text-gray-500">
<span v-if="contact.user?.is_online" class="flex items-center gap-1">
<span class="w-2 h-2 bg-success rounded-full"></span>
<span v-if="checkingOnline" class="flex items-center gap-1 animate-pulse">
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
检查中...
</span>
<span v-else-if="isOnline || contact.user?.is_online" class="flex items-center gap-1">
<span class="w-2 h-2 bg-success rounded-full animate-pulse"></span>
在线
</span>
<span v-else class="flex items-center gap-1">
<span class="w-2 h-2 bg-gray-500 rounded-full"></span>
离线
</span>
<button
v-if="!checkingOnline"
@click="checkOnlineStatus"
class="text-gray-500 hover:text-primary transition ml-1"
title="刷新在线状态"
>
<i class="fas fa-sync-alt text-[10px]"></i>
</button>
</div>
</div>
@@ -221,13 +233,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
import { useWebRTCStore } from '@/stores/webrtc'
import { useAuthStore } from '@/stores/auth'
import * as contactApi from '@/api/modules/contact'
import * as systemApi from '@/api/modules/system'
import Avatar from '@/components/common/Avatar.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import type { Contact, ContactGroup } from '@/types/api'
@@ -249,6 +262,9 @@ const showGroupSelectModal = ref(false)
const showDeleteConfirm = ref(false)
const showRoomIdErrorModal = ref(false)
const remarkName = ref('')
const isOnline = ref(false)
const checkingOnline = ref(false)
let onlineCheckInterval: number | null = null
async function loadContactDetail() {
const contactId = route.params.id as string
@@ -275,6 +291,11 @@ async function loadContactDetail() {
console.log('Using room_id from chatStore.contacts:', existingContact.room_id)
}
}
// 检查在线状态
await checkOnlineStatus()
// 开启定时检查
startOnlineCheck()
} catch (error: any) {
console.error('Failed to load contact detail:', error)
toastStore.error(error.message || '加载失败')
@@ -284,6 +305,42 @@ async function loadContactDetail() {
}
}
// 检查用户在线状态
async function checkOnlineStatus() {
if (!contact.value) return
const userId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
if (!userId) return
checkingOnline.value = true
try {
const result = await systemApi.checkUserOnline(userId)
isOnline.value = result.is_online
// 同步更新 contact 的 is_online 状态
if (contact.value.user) {
contact.value.user.is_online = result.is_online
}
} catch (error) {
console.error('Failed to check online status:', error)
} finally {
checkingOnline.value = false
}
}
// 开启定时在线状态检查每30秒
function startOnlineCheck() {
stopOnlineCheck() // 先清除可能存在的定时器
onlineCheckInterval = window.setInterval(checkOnlineStatus, 30000)
}
// 停止定时在线状态检查
function stopOnlineCheck() {
if (onlineCheckInterval) {
clearInterval(onlineCheckInterval)
onlineCheckInterval = null
}
}
async function loadGroups() {
try {
const groupsList = await contactApi.getGroups()
@@ -430,6 +487,10 @@ async function handleDeleteContact() {
onMounted(async () => {
await Promise.all([loadContactDetail(), loadGroups()])
})
onUnmounted(() => {
stopOnlineCheck()
})
</script>

View File

@@ -86,11 +86,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import { useAuthStore } from '@/stores/auth'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
import type { User } from '@/types/api'
const toastStore = useToastStore()
const authStore = useAuthStore()
const searchKeyword = ref('')
const searchResults = ref<User[]>([])
@@ -120,7 +122,9 @@ async function handleSearch() {
async function handleAddFriend(user: User) {
try {
await contactApi.addFriend({ to_user_id: user.id, message: '你好,我是' + user.name })
// 使用当前用户的名字,而不是目标用户的名字
const myName = authStore.user?.name || '我'
await contactApi.addFriend({ to_user_id: user.id, message: `你好,我是${myName}` })
toastStore.success('好友申请已发送')
// 简单的视觉反馈,不移除卡片,防止误触
} catch (error: any) {

View File

@@ -130,6 +130,21 @@

View File

@@ -62,10 +62,39 @@
<input v-model="registerForm.password" type="password" class="bg-input rounded-2xl py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition hover:bg-white/5" placeholder="密码" />
<input v-model="registerForm.confirmPassword" type="password" class="bg-input rounded-2xl py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition hover:bg-white/5" placeholder="确认密码" />
</div>
<!-- 验证方式选择 -->
<div class="flex gap-2 px-2">
<button
class="flex-1 py-2 rounded-xl text-sm transition border"
:class="verifyType === 'email' ? 'bg-primary text-white border-primary' : 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
@click="verifyType = 'email'"
>
<i class="fas fa-envelope mr-2"></i>邮箱验证
</button>
<button
class="flex-1 py-2 rounded-xl text-sm transition border"
:class="verifyType === 'sms' ? 'bg-primary text-white border-primary' : 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
@click="verifyType = 'sms'"
>
<i class="fas fa-sms mr-2"></i>短信验证
</button>
</div>
<div class="flex gap-2">
<input v-model="registerForm.code" type="text" class="flex-1 bg-input rounded-2xl py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition hover:bg-white/5" placeholder="验证码" />
<button class="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-2xl transition active:scale-95" :disabled="codeLoading" @click="handleSendCode">
{{ codeLoading ? '发送中...' : '发送验证码' }}
<input v-model="registerForm.code" type="text" class="flex-1 bg-input rounded-2xl py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition hover:bg-white/5" :placeholder="verifyType === 'email' ? '邮箱验证码' : '短信验证码'" />
<button
class="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-2xl transition active:scale-95 min-w-[120px]"
:disabled="codeLoading || cooldown > 0"
@click="handleSendCode"
>
<template v-if="codeLoading">
<i class="fas fa-circle-notch fa-spin mr-1"></i>发送中
</template>
<template v-else-if="cooldown > 0">
{{ cooldown }}s
</template>
<template v-else>
发送验证码
</template>
</button>
</div>
<label class="flex items-center gap-2 text-gray-400 text-sm cursor-pointer px-2">
@@ -100,6 +129,9 @@ const toastStore = useToastStore()
const showRegister = ref(false)
const loading = ref(false)
const codeLoading = ref(false)
const verifyType = ref<'email' | 'sms'>('email')
const cooldown = ref(0)
let cooldownTimer: number | null = null
const loginForm = ref({
account: '',
@@ -165,19 +197,49 @@ async function handleRegister() {
}
async function handleSendCode() {
const target = registerForm.value.email || registerForm.value.phone
if (!target) { toastStore.warning('请先填写邮箱或手机号'); return }
// 根据验证类型确定目标
const target = verifyType.value === 'email' ? registerForm.value.email : registerForm.value.phone
if (!target) {
toastStore.warning(verifyType.value === 'email' ? '请先填写邮箱' : '请先填写手机号')
return
}
codeLoading.value = true
try {
const type = registerForm.value.email ? 'email' : 'sms'
await authApi.sendEmailCode({ target, type })
// 根据验证类型调用不同的 API
if (verifyType.value === 'email') {
await authApi.sendEmailCode({ target, type: 'email' })
} else {
await authApi.sendSmsCode({ target, type: 'sms' })
}
toastStore.success('验证码已发送')
// 开始倒计时
startCooldown()
} catch (error: any) {
toastStore.error(error.message || '发送失败')
} finally {
codeLoading.value = false
}
}
// 开始倒计时
function startCooldown() {
cooldown.value = 60
if (cooldownTimer) {
clearInterval(cooldownTimer)
}
cooldownTimer = window.setInterval(() => {
cooldown.value--
if (cooldown.value <= 0) {
if (cooldownTimer) {
clearInterval(cooldownTimer)
cooldownTimer = null
}
}
}, 1000)
}
</script>
<style scoped>