bug修复
This commit is contained in:
13
src/App.vue
13
src/App.vue
@@ -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(() => {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
// 获取群成员列表
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加普通消息处理器
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
374
src/components/common/AttachmentManager.vue
Normal file
374
src/components/common/AttachmentManager.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 聊天会话类型定义
|
||||
*/
|
||||
|
||||
import type { User } from './api'
|
||||
import type { User, Room } from './api'
|
||||
|
||||
export interface Conversation {
|
||||
id: number // 数据库主键ID
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -891,7 +891,7 @@ function handleAddFriend() {
|
||||
showActionMenu.value = false
|
||||
contactStore.setLeftPanelMode('friend-manager')
|
||||
// 切换到添加好友视图(ContactView)
|
||||
router.push('/contacts').catch(() => {})
|
||||
router.push('/contact').catch(() => {})
|
||||
}
|
||||
|
||||
// 创建群聊处理
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -130,6 +130,21 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user