文件无法发送
已知问题:
This commit is contained in:
2025-12-03 18:30:55 +08:00
parent 6ddea9d988
commit a9f0ac1217
7 changed files with 528 additions and 475 deletions

View File

@@ -1,35 +1,74 @@
<template>
<div
class="px-3 py-2.5 rounded-2xl text-sm shadow-md transition-all relative group-hover:shadow-lg"
:class="message.isSelf ? 'bg-msg-self text-white bubble-self' : 'bg-msg-other text-gray-200 bubble-other'"
>
<!-- 文本消息 -->
<TextBubble v-if="message.message_type === 0" :message="message" />
<!-- 图片消息 -->
<ImageBubble v-else-if="message.message_type === 1" :message="message" />
<!-- 语音消息 -->
<AudioBubble v-else-if="message.message_type === 2" :message="message" />
<!-- 视频消息 -->
<VideoBubble v-else-if="message.message_type === 3" :message="message" />
<!-- 文件消息 -->
<FileBubble v-else-if="message.message_type === 8" :message="message" />
<div class="flex flex-col" :class="message.isSelf ? 'items-end' : 'items-start'">
<!-- 气泡主体 -->
<div
class="relative text-sm shadow-md transition-all duration-300 hover:shadow-lg group/bubble overflow-hidden"
:class="[
bubbleShapeClass,
message.isSelf
? 'bg-gradient-to-br from-primary to-indigo-600 text-white'
: 'bg-white/10 text-gray-200 border border-white/5 hover:bg-white/15'
]"
>
<!-- 文本消息 -->
<TextBubble v-if="message.message_type === 0" :message="message" class="px-4 py-3" />
<!-- 图片消息 (无Padding) -->
<ImageBubble v-else-if="message.message_type === 1" :message="message" />
<!-- 语音消息 -->
<AudioBubble v-else-if="message.message_type === 2" :message="message" class="px-4 py-3" />
<!-- 视频消息 (无Padding) -->
<VideoBubble v-else-if="message.message_type === 3" :message="message" />
<!-- 文件消息 -->
<FileBubble v-else-if="message.message_type === 8" :message="message" class="px-3 py-2" />
</div>
<!-- 底部时间显示 (格式化) -->
<div
class="text-[10px] text-gray-500 mt-1 px-1 transition-opacity opacity-60 group-hover:opacity-100"
:class="message.isSelf ? 'text-right' : 'text-left'"
>
{{ formattedTime }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
import { formatTime } from '@/utils/format'
import TextBubble from './bubble/Text.vue'
import ImageBubble from './bubble/Image.vue'
import AudioBubble from './bubble/Audio.vue'
import VideoBubble from './bubble/Video.vue'
import FileBubble from './bubble/File.vue'
defineProps<{
const props = defineProps<{
message: ChatMessage
}>()
</script>
// 更现代的圆角逻辑
const bubbleShapeClass = computed(() => {
if (props.message.isSelf) {
return 'rounded-2xl rounded-tr-sm' // 自己发的消息,右上角微尖
} else {
return 'rounded-2xl rounded-tl-sm' // 对方发的消息,左上角微尖
}
})
// 使用 format.ts 中的逻辑,但可以根据需要微调为 HH:mm
const formattedTime = computed(() => {
const date = new Date(props.message.created_at)
// 如果是当天,只显示时间,否则显示日期+时间
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
return formatTime(props.message.created_at)
})
</script>

View File

@@ -1,68 +1,78 @@
<template>
<div class="p-4 bg-panel border-t border-gray-800 shrink-0 z-20">
<div class="flex gap-5 text-gray-400 text-lg mb-3 px-1 items-center">
<div class="flex gap-6 text-gray-400 text-lg mb-3 px-2 items-center">
<i
class="far fa-smile hover:text-primary cursor-pointer transition transform hover:scale-110"
title="表情"
@click="insertEmoji('😊')"
class="far fa-smile hover:text-primary cursor-pointer transition-all transform hover:scale-125 hover:rotate-12"
title="表情"
@click="insertEmoji('😊')"
></i>
<i
class="far fa-image hover:text-primary cursor-pointer transition transform hover:scale-110"
@click="triggerFile(1)"
title="发送图片"
class="far fa-image hover:text-green-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(1)"
title="发送图片"
></i>
<!-- 新增视频上传按钮 -->
<i
class="fas fa-folder-plus hover:text-primary cursor-pointer transition transform hover:scale-110"
@click="triggerFile(8)"
title="发送文件"
class="fas fa-video hover:text-purple-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(3)"
title="发送视频"
></i>
<i
class="fas fa-folder-plus hover:text-yellow-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(8)"
title="发送文件"
></i>
<!-- 长按录音 -->
<div
class="relative flex items-center group cursor-pointer select-none"
@mousedown="handleRecordStart"
@touchstart.passive="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
class="relative flex items-center group cursor-pointer select-none ml-auto mr-2"
@mousedown="handleRecordStart"
@touchstart.passive="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center transition-all bg-gray-700 hover:bg-gray-600"
:class="{ 'bg-red-500 text-white recording-btn': isRecording }"
class="w-8 h-8 rounded-full flex items-center justify-center transition-all shadow-md"
:class="isRecording ? 'bg-red-500 text-white scale-110 shadow-red-500/50' : 'bg-gray-700 hover:bg-gray-600 text-gray-300'"
>
<i class="fas" :class="isRecording ? 'fa-microphone-lines' : 'fa-microphone'"></i>
<i class="fas" :class="isRecording ? 'fa-microphone-lines animate-pulse' : 'fa-microphone'"></i>
</div>
<span
v-if="isRecording"
class="ml-2 text-xs text-red-500 font-bold animate-pulse absolute -top-8 bg-black/80 px-2 py-1 rounded text-white whitespace-nowrap"
<div
v-if="isRecording"
class="absolute -top-10 right-0 bg-red-500 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg whitespace-nowrap animate-bounce font-bold"
>
松开发送...
</span>
松开 发送
<div class="absolute bottom-[-4px] right-3 w-2 h-2 bg-red-500 rotate-45"></div>
</div>
</div>
</div>
<div class="bg-input rounded-xl p-2 flex items-end ring-1 ring-white/5 focus-within:ring-primary/50 transition shadow-inner">
<div class="bg-input rounded-3xl p-2 pl-4 flex items-end ring-1 ring-white/5 focus-within:ring-primary/50 transition-all shadow-inner hover:shadow-lg hover:shadow-black/20">
<textarea
:value="modelValue"
class="w-full bg-transparent border-none text-white resize-none h-10 max-h-32 py-2 px-2 text-sm focus:outline-none scrollbar-thin placeholder-gray-500"
placeholder="输入消息 (Enter 发送Ctrl+V 粘贴)..."
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="$emit('send')"
@paste="handlePaste"
:value="modelValue"
class="w-full bg-transparent border-none text-white resize-none h-10 max-h-32 py-2 px-1 text-sm focus:outline-none scrollbar-thin placeholder-gray-500 leading-6"
placeholder="输入消息 (Enter 发送)..."
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="$emit('send')"
@paste="handlePaste"
></textarea>
<div
class="bg-primary hover:bg-indigo-500 text-white w-9 h-9 rounded-lg flex items-center justify-center ml-2 transition shadow-lg shadow-indigo-500/30 active:scale-95 cursor-pointer"
@click="$emit('send')"
class="w-10 h-10 rounded-2xl flex items-center justify-center ml-2 transition-all duration-300 cursor-pointer shadow-lg"
:class="modelValue.trim() ? 'bg-primary hover:bg-indigo-500 text-white scale-100 rotate-0' : 'bg-gray-700 text-gray-500 scale-90 rotate-90 opacity-50 cursor-not-allowed'"
@click="$emit('send')"
>
<i class="fas fa-paper-plane text-xs"></i>
<i class="fas fa-paper-plane text-sm transform -ml-0.5 mt-0.5"></i>
</div>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileChange"
ref="fileInput"
type="file"
class="hidden"
@change="handleFileChange"
/>
</div>
</template>
@@ -99,8 +109,12 @@ function insertEmoji(emoji: string) {
function triggerFile(type: number) {
if (!fileInput.value) return
// 类型映射1图片3视频8文件
fileInput.value.accept = type === 1 ? 'image/*' : type === 3 ? 'video/*' : '*/*'
fileInput.value.value = ''
// 保存当前触发的类型到 input 元素上,或者利用闭包,这里简单利用 data-type 属性不太方便,
// 我们直接利用闭包变量 capturing
// 更好的方式是创建一个 reactive state 存储当前上传类型,但这里为了简单,我们通过 accept 判断
fileInput.value.click()
}
@@ -108,7 +122,10 @@ function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
const type = file.type.startsWith('image/') ? 1 : file.type.startsWith('video/') ? 3 : 8
let type = 8
if (file.type.startsWith('image/')) type = 1
else if (file.type.startsWith('video/')) type = 3
emit('file', type, file)
}
}
@@ -128,6 +145,7 @@ function handlePaste(e: ClipboardEvent) {
}
}
// ... (录音逻辑保持不变)
async function handleRecordStart() {
if (props.isRecording) return
@@ -181,4 +199,3 @@ function handleRecordCancel() {
}
}
</script>

View File

@@ -1,38 +1,101 @@
<template>
<div
class="flex items-center gap-3 cursor-pointer select-none py-1 pr-4 min-w-[100px]"
@click="playAudio"
class="flex items-center gap-3 cursor-pointer select-none min-w-[120px] group"
@click="toggleAudio"
>
<i class="fas fa-play-circle text-2xl opacity-90 hover:opacity-100"></i>
<div class="flex flex-col">
<span class="text-xs font-bold">语音消息</span>
<div
class="w-8 h-8 rounded-full flex items-center justify-center transition-all shadow-sm"
:class="isPlaying ? 'bg-white text-primary scale-110' : 'bg-white/20 group-hover:bg-white/30'"
>
<i class="fas text-sm" :class="isPlaying ? 'fa-pause' : 'fa-play'"></i>
</div>
<div class="flex flex-col flex-1">
<!-- 模拟声波动画 -->
<div class="flex items-center gap-1 h-3 mb-1" v-if="isPlaying">
<div class="w-1 bg-white animate-wave h-full" style="animation-delay: 0s"></div>
<div class="w-1 bg-white animate-wave h-2/3" style="animation-delay: 0.1s"></div>
<div class="w-1 bg-white animate-wave h-full" style="animation-delay: 0.2s"></div>
<div class="w-1 bg-white animate-wave h-1/2" style="animation-delay: 0.3s"></div>
</div>
<span class="text-xs font-bold" v-else>语音消息</span>
<span class="text-[10px] opacity-70">{{ duration }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, onUnmounted } from 'vue'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
message: ChatMessage
}>()
const isPlaying = ref(false)
let audio: HTMLAudioElement | null = null
const extra = computed(() => {
return typeof props.message.extra === 'object' ? props.message.extra : {}
})
const duration = computed(() => {
return extra.value.duration || `00:${Math.ceil(props.message.duration || 10)}`
// 如果有 duration 字段(数字),格式化它
if (typeof props.message.duration === 'number' && props.message.duration > 0) {
return `${props.message.duration}s`
}
return extra.value.duration || `00:${Math.ceil(10)}`
})
const audioUrl = computed(() => {
return extra.value.url || props.message.content
})
function playAudio() {
new Audio(audioUrl.value).play()
function toggleAudio() {
if (isPlaying.value && audio) {
audio.pause()
isPlaying.value = false
} else {
// 停止其他可能正在播放的(这里是一个简化的局部处理,全局互斥需要 Store
playAudio()
}
}
function playAudio() {
if (!audio) {
// 补充 Base URL 处理
const src = audioUrl.value.startsWith('http') ? audioUrl.value : `http://127.0.0.1:12080${audioUrl.value}`
audio = new Audio(src)
audio.onended = () => {
isPlaying.value = false
}
audio.onpause = () => {
isPlaying.value = false
}
}
audio.play().catch(e => {
console.error("播放失败", e)
isPlaying.value = false
})
isPlaying.value = true
}
onUnmounted(() => {
if (audio) {
audio.pause()
audio = null
}
})
</script>
<style scoped>
@keyframes wave {
0%, 100% { height: 30%; opacity: 0.5; }
50% { height: 100%; opacity: 1; }
}
.animate-wave {
animation: wave 0.8s infinite ease-in-out;
}
</style>

View File

@@ -1,8 +1,9 @@
<template>
<img
:src="`http://127.0.0.1:12080${imageUrl}`"
class="rounded-lg max-w-full cursor-zoom-in hover:brightness-90 transition border border-white/10"
@click="previewImage"
:src="`http://127.0.0.1:12080${imageUrl}`"
class="max-w-full cursor-zoom-in hover:brightness-90 transition duration-300 block max-h-[300px] object-cover"
loading="lazy"
@click="previewImage"
/>
</template>
@@ -22,8 +23,11 @@ const imageUrl = computed(() => {
function previewImage() {
const w = window.open('')
if (w) {
w.document.write(`<img src="http://127.0.0.1:12080${imageUrl.value}" style="max-width:100%">`)
w.document.write(`
<body style="margin:0;display:flex;align-items:center;justify-content:center;background:#000;height:100vh;">
<img src="http://127.0.0.1:12080${imageUrl.value}" style="max-width:100%;max-height:100vh;box-shadow:0 0 20px rgba(0,0,0,0.5)">
</body>
`)
}
}
</script>

View File

@@ -1,35 +1,34 @@
<template>
<!-- 保持原有模板代码不变 -->
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
<!-- 左侧功能导航 (保持不变) -->
<div class="w-16 bg-dark border-r border-gray-800 flex flex-col items-center py-6 gap-6 z-20 hidden md:flex shrink-0">
<!-- 左侧功能导航 (UI微调增加阴影和圆角) -->
<div class="w-16 bg-dark border-r border-gray-800 flex flex-col items-center py-6 gap-6 z-20 hidden md:flex shrink-0 shadow-xl">
<Avatar
:name="authStore.user?.name"
:avatar="authStore.user?.avatar"
size="md"
class="cursor-pointer hover:ring-2 ring-white transition shadow-lg"
class="cursor-pointer hover:ring-2 ring-primary transition-all duration-300 transform hover:scale-110 shadow-lg"
@click="showProfileModal = true"
/>
<!-- ...导航图标保持不变增加 hover scale... -->
<div
class="text-gray-400 hover:text-primary cursor-pointer transition relative"
:class="{ 'text-primary': currentTab === 'chat' }"
class="text-gray-400 hover:text-primary cursor-pointer transition transform hover:scale-110 relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
:class="{ 'text-primary bg-white/5': currentTab === 'chat' }"
@click="currentTab = 'chat'"
>
<i class="fas fa-comment-dots text-xl"></i>
<div v-if="chatStore.totalUnread > 0" class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"></div>
<div v-if="chatStore.totalUnread > 0" class="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
</div>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition"
:class="{ 'text-primary': currentTab === 'contact' }"
class="text-gray-400 hover:text-primary cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
:class="{ 'text-primary bg-white/5': currentTab === 'contact' }"
@click="currentTab = 'contact'"
>
<i class="fas fa-address-book text-xl"></i>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition"
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"
title="退出登录"
>
@@ -37,32 +36,33 @@
</div>
</div>
<!-- 中间侧边栏 (保持不变) -->
<!-- 中间侧边栏 (UI微调) -->
<div
v-show="currentTab === 'chat' && (!isMobile || !chatVisible)"
class="w-full md:w-72 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
class="w-full md:w-80 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
>
<div class="p-4 border-b border-gray-800">
<div class="p-5 border-b border-gray-800">
<div class="relative group">
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500 group-focus-within:text-primary transition"></i>
<i class="fas fa-search absolute left-4 top-3 text-gray-500 group-focus-within:text-primary transition"></i>
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
class="w-full bg-input rounded-2xl py-2.5 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500 shadow-inner"
placeholder="搜索联系人..."
/>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="flex-1 overflow-y-auto px-2 space-y-1 py-2 custom-scrollbar">
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="flex items-center p-3 cursor-pointer hover:bg-white/5 transition-colors relative group border-l-4 border-transparent"
class="flex items-center p-3 cursor-pointer rounded-2xl transition-all duration-200 relative group border border-transparent"
:class="{
'bg-white/10 border-primary': chatStore.currentTarget?.id === contact.id,
'bg-black/20': contact.is_top,
}"
'bg-gradient-to-r from-primary/20 to-transparent border-primary/30': chatStore.currentTarget?.id === contact.id,
'hover:bg-white/5 hover:border-white/5': chatStore.currentTarget?.id !== contact.id,
'bg-black/20': contact.is_top,
}"
@click="selectChat(contact)"
@contextmenu.stop="showContactMenu($event, contact)"
>
@@ -73,11 +73,11 @@
:color="contact.color"
size="contact"
rounded="xl"
class="transition transform group-hover:scale-105"
class="transition transform group-hover:scale-105 shadow-md"
/>
<div
v-if="(contact.unread || 0) > 0"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm animate-bounce"
>
{{ contact.unread }}
</div>
@@ -85,13 +85,14 @@
<div class="flex-1 min-w-0 ml-3">
<div class="flex justify-between items-center mb-0.5">
<span class="font-semibold truncate text-gray-200 text-sm">
<span class="font-semibold truncate text-gray-200 text-sm group-hover:text-white transition">
{{ contact.remark_name || contact.user?.name || '未知' }}
</span>
<span class="text-xs text-gray-500">{{ formatTime(contact.last_time || Date.now()) }}</span>
<span class="text-xs text-gray-500 group-hover:text-gray-400">{{ formatTime(contact.last_time || Date.now()) }}</span>
</div>
<div class="text-xs text-gray-400 truncate flex items-center h-4">
<div class="text-xs text-gray-400 truncate flex items-center h-4 group-hover:text-gray-300">
<i v-if="contact.is_top" class="fas fa-thumbtack text-yellow-500 mr-1 text-[10px]"></i>
<span v-if="contact.is_muted" class="fas fa-bell-slash text-gray-600 mr-1 text-[10px]"></span>
<span>{{ contact.lastMsg || contact.user?.desc || '' }}</span>
</div>
</div>
@@ -99,14 +100,14 @@
</div>
</div>
<!-- 右侧主聊天区 (保持不变) -->
<!-- 右侧主聊天区 -->
<div
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden"
>
<!-- 聊天头部 -->
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-4 bg-panel/80 backdrop-blur shrink-0 z-20 shadow-sm">
<div class="flex items-center gap-3 cursor-pointer" @click="backToList">
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-6 bg-panel/90 backdrop-blur shrink-0 z-20 shadow-sm">
<div class="flex items-center gap-3 cursor-pointer group" @click="backToList">
<i class="fas fa-chevron-left md:hidden text-gray-400 text-lg p-2 -ml-2"></i>
<Avatar
:name="chatStore.currentTarget.user?.name || chatStore.currentTarget.remark_name"
@@ -114,45 +115,47 @@
:color="chatStore.currentTarget.color"
size="sm"
rounded="full"
class="group-hover:ring-2 ring-primary transition"
/>
<div>
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">
{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
<span class="w-2 h-2 bg-success rounded-full animate-pulse"></span>
<span class="w-2 h-2 bg-success rounded-full animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
</h3>
</div>
</div>
<div class="flex gap-2 text-gray-400">
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="语音通话"
@click="startCall('audio')"
>
<i class="fas fa-phone"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="视频通话"
@click="startCall('video')"
>
<i class="fas fa-video"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
@click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"
>
<i class="fas fa-ellipsis-v"></i>
</button>
<button class="action-btn" title="语音通话" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
<button class="action-btn" title="视频通话" @click="startCall('video')"><i class="fas fa-video"></i></button>
<button class="action-btn" @click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"><i class="fas fa-ellipsis-v"></i></button>
</div>
</div>
<!-- 消息列表 -->
<MessageList
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
/>
<!-- 消息列表 (包含滚动逻辑) -->
<!-- 修复: 添加 flex flex-col 确保 MessageList flex-1 生效 -->
<div class="flex-1 overflow-hidden relative flex flex-col min-h-0">
<MessageList
ref="messageListRef"
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
@scroll="handleScroll"
/>
<!-- 新消息提示/回到底部按钮 -->
<Transition name="fade-slide">
<button
v-if="showScrollBottomBtn"
class="absolute bottom-6 right-6 bg-primary text-white px-4 py-2 rounded-full shadow-xl hover:bg-indigo-500 transition-all transform hover:scale-105 active:scale-95 flex items-center gap-2 z-30"
@click="scrollToBottom(true)"
>
<i class="fas fa-arrow-down"></i>
<span v-if="unreadCount > 0" class="text-xs font-bold">{{ unreadCount }} 条新消息</span>
<span v-else class="text-xs">回到底部</span>
</button>
</Transition>
</div>
<!-- 输入区 -->
<MessageInput
@@ -171,52 +174,22 @@
v-else-if="currentTab === 'chat'"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex"
>
<div class="w-24 h-24 bg-panel rounded-full flex items-center justify-center mb-6 shadow-xl border border-gray-800">
<i class="fas fa-comments text-4xl text-primary opacity-80"></i>
<div class="w-32 h-32 bg-panel rounded-full flex items-center justify-center mb-6 shadow-2xl border border-gray-800 animate-float">
<i class="fas fa-comments text-5xl text-primary opacity-80"></i>
</div>
<p class="text-xl font-medium text-gray-300">NL-IM</p>
<p class="text-sm mt-2 opacity-60">安全 · 极速 · 沉浸式体验</p>
<p class="text-2xl font-medium text-gray-300 tracking-widest">NL-IM</p>
<p class="text-sm mt-3 opacity-60 font-light">Design for Developer</p>
</div>
<!-- 联系人页面 -->
<ContactView v-if="currentTab === 'contact'" />
<!-- 右键菜单 -->
<ContextMenu />
<CallWindow ref="callWindowRef" :call="webrtc.call" :target="chatStore.currentTarget" :is-mobile="isMobile" :local-stream="webrtc.localStream.value" :remote-stream="webrtc.remoteStream.value" @end-call="webrtc.endCall" @accept-call="webrtc.acceptCall" @toggle-mute="webrtc.toggleMute" @toggle-camera="webrtc.toggleCamera" @toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized" />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
<!-- 通话窗口 -->
<CallWindow
ref="callWindowRef"
:call="webrtc.call"
:target="chatStore.currentTarget"
:is-mobile="isMobile"
:local-stream="webrtc.localStream.value"
:remote-stream="webrtc.remoteStream.value"
@end-call="webrtc.endCall"
@accept-call="webrtc.acceptCall"
@toggle-mute="webrtc.toggleMute"
@toggle-camera="webrtc.toggleCamera"
@toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized"
/>
<!-- 文件确认模态框 -->
<FileConfirmModal
:show="fileModal.show"
:type="fileModal.type"
:preview="fileModal.preview"
:name="fileModal.name"
:size="fileModal.size"
@close="fileModal.show = false"
@confirm="confirmSendFile"
/>
<!-- 用户资料模态框 -->
<div
v-if="showProfileModal && authStore.user"
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showProfileModal = false"
>
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden">
<!-- Profile Modal (kept simple) -->
<div v-if="showProfileModal && authStore.user" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showProfileModal = false">
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
<div class="h-24 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12">
<Avatar
@@ -242,12 +215,13 @@ import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
import { useContextMenu } from '@/composables/useContextMenu'
import { wsManager } from '@/api/websocket'
import * as contactApi from '@/api/modules/contact'
import * as messageApi from '@/api/modules/message'
import * as contactApi from '@/api/modules/contact'
import * as attachmentApi from '@/api/modules/attachment'
import { formatTime } from '@/utils/format'
import type { Contact, ChatMessage } from '@/types/api'
import { useWebRTC } from '@/composables/useWebRTC'
// Components...
import Avatar from '@/components/common/Avatar.vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
@@ -262,18 +236,16 @@ const chatStore = useChatStore()
const toastStore = useToastStore()
const { showContextMenu } = useContextMenu()
// WebRTC logic...
function handleIncomingCall(senderUserId: string) {
const contact = chatStore.contacts.find(
(c) => c.user_id === senderUserId || c.id === senderUserId
)
const contact = chatStore.contacts.find((c) => c.user_id === senderUserId || c.id === senderUserId)
if (contact && (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id)) {
selectChat(contact)
}
}
const webrtc = useWebRTC(authStore.user?.id || '', handleIncomingCall)
// ... (其他原有变量保持不变)
// State
const currentTab = ref<'chat' | 'contact'>('chat')
const searchQuery = ref('')
const inputText = ref('')
@@ -282,14 +254,12 @@ const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const isRecording = ref(false)
const callWindowRef = ref<InstanceType<typeof CallWindow> | null>(null)
const fileModal = ref({
show: false,
type: 0,
preview: '',
name: '',
size: 0,
file: null as File | null,
})
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
// Scroll Logic State
const showScrollBottomBtn = ref(false)
const unreadCount = ref(0)
const isNearBottom = ref(true)
const filteredContacts = computed(() => {
const query = searchQuery.value.toLowerCase()
@@ -310,9 +280,42 @@ function getRoomId(contact: Contact): string {
return userIds.join('_')
}
// 滚动处理
function handleScroll(e: Event) {
const target = e.target as HTMLElement
// 阈值设为 100px
const threshold = 100
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
isNearBottom.value = distanceToBottom < threshold
// 如果接近底部,隐藏按钮并清空未读
if (isNearBottom.value) {
showScrollBottomBtn.value = false
unreadCount.value = 0
} else {
showScrollBottomBtn.value = true
}
}
function scrollToBottom(smooth = true) {
nextTick(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
showScrollBottomBtn.value = false
unreadCount.value = 0
}
})
}
async function selectChat(contact: Contact) {
chatStore.setCurrentTarget(contact)
chatVisible.value = true
unreadCount.value = 0 // 切换聊天时重置
const roomId = getRoomId(contact)
@@ -325,74 +328,14 @@ async function selectChat(contact: Contact) {
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
}))
chatStore.setRoomMessages(roomId, msgs.reverse())
} catch (error) {
console.error('Failed to load messages:', error)
}
} catch (error) { console.error('Failed to load messages:', error) }
}
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTop = container.scrollHeight
}
}, 100)
scrollToBottom(false) // 初始进入直接跳到底部
}
function backToList() {
if (isMobile.value) {
chatVisible.value = false
}
}
function handleLogout() {
authStore.logout()
wsManager.disconnect()
router.push('/login')
}
// ... (发送消息相关逻辑保持不变)
function handleSendText() {
if (!inputText.value.trim() || !chatStore.currentTarget) return
sendMessage(0, inputText.value)
inputText.value = ''
}
function handleFileSelect(type: number, file: File) {
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) {
const reader = new FileReader()
reader.onload = (e) => { fileModal.value.preview = e.target?.result as string }
reader.readAsDataURL(file)
}
}
async function confirmSendFile() {
if (!fileModal.value.file || !chatStore.currentTarget) return
try {
const uploadType = fileModal.value.type === 1 ? 'image' : (fileModal.value.type === 3 ? 'video' : 'file')
toastStore.info('正在上传文件...')
const attachment = await attachmentApi.uploadAttachment(fileModal.value.file, uploadType)
const extra = { name: fileModal.value.name, size: fileModal.value.size, url: attachment.file_url, attachment_id: attachment.id }
await sendMessage(fileModal.value.type, attachment.file_url, extra, 0)
fileModal.value.show = false
toastStore.success('发送成功')
} catch (e: any) {
toastStore.error('发送失败')
}
}
function handleRecordStart() { isRecording.value = true }
async function handleRecordStop(blob: Blob, duration: number) {
isRecording.value = false
if (!chatStore.currentTarget) return
try {
const audioFile = new File([blob], `audio.webm`, { type: 'audio/webm' })
toastStore.info('正在上传语音...')
const attachment = await attachmentApi.uploadAttachment(audioFile, 'file')
await sendMessage(2, attachment.file_url, { duration: `00:${duration}`, url: attachment.file_url }, duration)
} catch (e) { toastStore.error('发送失败') }
}
function handleRecordCancel() { isRecording.value = false }
// ... Send Logic (Text, File, Audio) ...
// (保持大部分原有逻辑,但在发送成功后调用 scrollToBottom)
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
if (!chatStore.currentTarget) return
@@ -402,7 +345,8 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
}
await messageApi.sendMessage(payload)
// Optimistic UI update
const message: ChatMessage = {
id: Date.now(), room_id: roomId, sender_user_id: authStore.user!.id,
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
@@ -410,31 +354,15 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
}
chatStore.addMessage(roomId, message)
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now())
scrollToBottom(true) // 发送消息后自动滚动
try {
await messageApi.sendMessage(payload)
} catch(e) { toastStore.error('发送失败'); } // Should handle remove optimistic msg on fail
}
function getMsgSummary(msg: ChatMessage): string {
const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }
return types[msg.message_type] || msg.content
}
async function startCall(type: 'audio' | 'video') {
if (!chatStore.currentTarget) return
await webrtc.startCall(type, chatStore.currentTarget.user_id || chatStore.currentTarget.id)
}
function showContactMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{ label: '删除会话', icon: 'fas fa-trash', danger: true, action: () => {} }
])
}
function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{ label: '清空记录', icon: 'fas fa-eraser', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(contact)) }
])
}
// WebSocket 消息处理 & 系统通知触发
// WebSocket 消息处理核心优化
function handleWebSocketMessage(message: ChatMessage) {
if (message.message_type === 6) return
const roomId = message.room_id
@@ -442,14 +370,25 @@ function handleWebSocketMessage(message: ChatMessage) {
message.extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
chatStore.addMessage(roomId, message)
// 更新最后消息
const contact = chatStore.contacts.find(c => c.user_id === message.sender_user_id || c.id === message.sender_user_id)
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
if (!message.isSelf) {
chatStore.incrementUnread(contact.id)
// 如果不是当前聊天窗口,或者是当前窗口但用户不在底部
if (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id) {
chatStore.incrementUnread(contact.id)
} else {
// 当前窗口
if (isNearBottom.value) {
scrollToBottom(true)
} else {
unreadCount.value++
showScrollBottomBtn.value = true
}
}
// 触发聊天消息系统通知
// 系统通知
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
new Notification(contact.remark_name || contact.user?.name || '新消息', {
body: getMsgSummary(message),
@@ -458,34 +397,44 @@ function handleWebSocketMessage(message: ChatMessage) {
}
}
}
if (chatStore.currentTarget && roomId === getRoomId(chatStore.currentTarget)) {
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) container.scrollTop = container.scrollHeight
}, 100)
}
}
async function loadContacts() {
// Helper functions (backToList, handleLogout, getMsgSummary, etc.) keep same...
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
function getMsgSummary(msg: ChatMessage): string { const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }; return types[msg.message_type] || msg.content }
async function startCall(type: 'audio' | 'video') { if (!chatStore.currentTarget) return; await webrtc.startCall(type, chatStore.currentTarget.user_id || chatStore.currentTarget.id) }
function showContactMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '删除会话', icon: 'fas fa-trash', danger: true, action: () => {} }]) }
function showChatOptionsMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '清空记录', icon: 'fas fa-eraser', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(contact)) }]) }
// File handlers...
function handleSendText() { if (!inputText.value.trim() || !chatStore.currentTarget) return; sendMessage(0, inputText.value); inputText.value = '' }
function handleFileSelect(type: number, file: File) {
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) { const reader = new FileReader(); reader.onload = (e) => { fileModal.value.preview = e.target?.result as string }; reader.readAsDataURL(file) }
}
async function confirmSendFile() {
if (!fileModal.value.file || !chatStore.currentTarget) return
try {
const contacts = await contactApi.getContacts()
chatStore.setContacts(contacts)
} catch (e) { console.error(e) }
const uploadType = fileModal.value.type === 1 ? 'image' : (fileModal.value.type === 3 ? 'video' : 'file')
toastStore.info('正在上传...')
const attachment = await attachmentApi.uploadAttachment(fileModal.value.file, uploadType)
const extra = { name: fileModal.value.name, size: fileModal.value.size, url: attachment.file_url, attachment_id: attachment.id }
await sendMessage(fileModal.value.type, attachment.file_url, extra, 0)
fileModal.value.show = false; toastStore.success('发送成功')
} catch (e: any) { toastStore.error('发送失败') }
}
// Recording handlers same...
function handleRecordStart() { isRecording.value = true }
async function handleRecordStop(blob: Blob, duration: number) { isRecording.value = false; if (!chatStore.currentTarget) return; try { const audioFile = new File([blob], `audio.webm`, { type: 'audio/webm' }); toastStore.info('正在上传语音...'); const attachment = await attachmentApi.uploadAttachment(audioFile, 'file'); await sendMessage(2, attachment.file_url, { duration: `00:${duration}`, url: attachment.file_url }, duration) } catch (e) { toastStore.error('发送失败') } }
function handleRecordCancel() { isRecording.value = false }
async function loadContacts() { try { const contacts = await contactApi.getContacts(); chatStore.setContacts(contacts) } catch (e) { console.error(e) } }
onMounted(async () => {
if (!authStore.isAuthenticated) {
const isValid = await authStore.checkAuth()
if (!isValid) { router.push('/login'); return }
}
// 请求通知权限
if ('Notification' in window && Notification.permission !== 'granted' && Notification.permission !== 'denied') {
// toastStore.info('请允许浏览器通知权限')
Notification.requestPermission()
}
// Remove permission request here!
if (authStore.user) {
await wsManager.connect(authStore.user.id)
wsManager.onMessage(handleWebSocketMessage)
@@ -494,9 +443,22 @@ onMounted(async () => {
await loadContacts()
window.addEventListener('resize', () => isMobile.value = window.innerWidth < 768)
})
onUnmounted(() => {
wsManager.offMessage(handleWebSocketMessage)
wsManager.offSignal(webrtc.handleSignaling)
})
</script>
<style scoped>
.action-btn {
@apply w-10 h-10 rounded-xl hover:bg-white/10 hover:text-white transition flex items-center justify-center transform hover:scale-110 active:scale-95;
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.animate-float { animation: float 6s ease-in-out infinite; }
@keyframes float { 0% { transform: translateY(0px); } 50% { transform: translateY(-20px); } 100% { transform: translateY(0px); } }
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.3s ease; }
.fade-slide-enter-from, .fade-slide-leave-to { opacity: 0; transform: translateY(20px); }
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
</style>

View File

@@ -1,91 +1,101 @@
<template>
<div class="flex-1 flex flex-col bg-panel h-full">
<div class="p-4 border-b border-gray-800">
<h2 class="text-xl font-bold text-white">联系人</h2>
<div class="flex-1 flex flex-col bg-panel h-full relative overflow-hidden">
<!-- 装饰背景 -->
<div class="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl -z-0 pointer-events-none"></div>
<div class="p-6 border-b border-gray-800 z-10 flex justify-between items-center">
<h2 class="text-2xl font-bold text-white tracking-wide">联系人</h2>
<button class="w-10 h-10 rounded-full bg-input hover:bg-white/10 flex items-center justify-center transition" title="添加好友">
<i class="fas fa-user-plus text-gray-400 hover:text-white"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-4">
<div class="flex-1 overflow-y-auto p-6 z-10 custom-scrollbar">
<div class="space-y-6">
<!-- 搜索用户 -->
<div>
<div class="relative mb-4">
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500"></i>
<div class="bg-black/20 p-4 rounded-3xl border border-white/5 shadow-lg backdrop-blur-sm">
<div class="relative mb-4 group">
<i class="fas fa-search absolute left-4 top-3.5 text-gray-500 group-focus-within:text-primary transition"></i>
<input
v-model="searchKeyword"
type="text"
class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索用户ID、邮箱或手机号..."
@keyup.enter="handleSearch"
v-model="searchKeyword"
type="text"
class="w-full bg-input rounded-2xl py-3 pl-11 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索用户ID、邮箱或手机号..."
@keyup.enter="handleSearch"
/>
</div>
<button
class="w-full py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition"
@click="handleSearch"
class="w-full py-3 bg-gradient-to-r from-primary to-indigo-600 hover:from-indigo-500 hover:to-primary text-white rounded-2xl transition shadow-lg shadow-indigo-500/20 font-medium active:scale-95"
@click="handleSearch"
>
搜索
查找用户
</button>
</div>
<!-- 搜索结果 -->
<div v-if="searchResults.length > 0" class="space-y-2">
<h3 class="text-sm font-semibold text-gray-400">搜索结果</h3>
<TransitionGroup name="list" tag="div" v-if="searchResults.length > 0" class="space-y-3">
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider pl-2" key="title">搜索结果</h3>
<div
v-for="user in searchResults"
:key="user.id"
class="flex items-center p-3 bg-input rounded-lg hover:bg-white/5 transition"
v-for="user in searchResults"
:key="user.id"
class="flex items-center p-4 bg-input rounded-2xl hover:bg-white/5 transition border border-transparent hover:border-white/10 group"
>
<Avatar
:name="user.name"
:avatar="user.avatar"
size="md"
class="mr-3"
/>
<div class="flex-1">
<div class="font-semibold text-white">{{ user.name }}</div>
<div class="text-xs text-gray-400">{{ user.email || user.phone }}</div>
<Avatar :name="user.name" :avatar="user.avatar" size="md" rounded="xl" class="mr-4 shadow-md" />
<div class="flex-1 min-w-0">
<div class="font-bold text-white text-base truncate">{{ user.name }}</div>
<div class="text-xs text-gray-400 truncate mt-1">{{ user.email || user.phone }}</div>
</div>
<button
class="px-4 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition text-sm"
@click="handleAddFriend(user)"
class="px-5 py-2 bg-white/10 hover:bg-primary text-white rounded-xl transition text-sm font-medium backdrop-blur-md"
@click="handleAddFriend(user)"
>
添加好友
<i class="fas fa-plus mr-1"></i>添加
</button>
</div>
</div>
</TransitionGroup>
<!-- 好友申请 -->
<div v-if="friendRequests.length > 0" class="mt-6">
<h3 class="text-sm font-semibold text-gray-400 mb-2">好友申请</h3>
<TransitionGroup name="list" tag="div" v-if="friendRequests.length > 0" class="space-y-3">
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider pl-2 flex items-center gap-2" key="req-title">
新的朋友 <span class="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">{{ friendRequests.length }}</span>
</h3>
<div
v-for="request in friendRequests"
:key="request.id"
class="flex items-center p-3 bg-input rounded-lg hover:bg-white/5 transition mb-2"
v-for="request in friendRequests"
:key="request.id"
class="flex items-center p-4 bg-input/50 rounded-2xl border border-primary/20 hover:border-primary/50 transition relative overflow-hidden"
>
<Avatar
:name="request.from_user?.name"
:avatar="request.from_user?.avatar"
size="md"
class="mr-3"
/>
<div class="flex-1">
<div class="font-semibold text-white">{{ request.from_user?.name }}</div>
<div class="text-xs text-gray-400">{{ request.message || '请求添加你为好友' }}</div>
<div class="absolute left-0 top-0 bottom-0 w-1 bg-primary"></div>
<Avatar :name="request.from_user?.name" :avatar="request.from_user?.avatar" size="md" rounded="xl" class="mr-4" />
<div class="flex-1 min-w-0">
<div class="font-bold text-white">{{ request.from_user?.name }}</div>
<div class="text-xs text-gray-400 mt-1 flex items-center gap-1">
<i class="fas fa-quote-left text-[10px] opacity-50"></i>
{{ request.message || '请求添加你为好友' }}
</div>
</div>
<div class="flex gap-2">
<button
class="px-3 py-1 bg-success hover:bg-green-600 text-white rounded transition text-sm"
@click="handleAcceptRequest(request.id)"
class="w-9 h-9 bg-success/20 hover:bg-success text-success hover:text-white rounded-lg transition flex items-center justify-center"
@click="handleAcceptRequest(request.id)"
title="接受"
>
接受
<i class="fas fa-check"></i>
</button>
<button
class="px-3 py-1 bg-danger hover:bg-red-600 text-white rounded transition text-sm"
@click="handleRejectRequest(request.id)"
class="w-9 h-9 bg-danger/20 hover:bg-danger text-danger hover:text-white rounded-lg transition flex items-center justify-center"
@click="handleRejectRequest(request.id)"
title="拒绝"
>
拒绝
<i class="fas fa-times"></i>
</button>
</div>
</div>
</TransitionGroup>
<!-- 空状态提示 -->
<div v-if="searchResults.length === 0 && friendRequests.length === 0" class="text-center py-20 opacity-30">
<i class="fas fa-user-astronaut text-6xl mb-4"></i>
<p>探索更多好友</p>
</div>
</div>
</div>
@@ -109,29 +119,20 @@ const friendRequests = ref(contactStore.friendRequests)
async function handleSearch() {
if (!searchKeyword.value.trim()) return
try {
const results = await contactApi.searchUsers(searchKeyword.value)
searchResults.value = results
if (results.length === 0) {
toastStore.info('未找到相关用户')
}
if (results.length === 0) toastStore.info('未找到相关用户')
} catch (error: any) {
console.error('Search failed:', error)
toastStore.error(error.message || '搜索失败')
}
}
async function handleAddFriend(user: User) {
try {
await contactApi.addFriend({
to_user_id: user.id,
message: '你好,我想加你为好友',
})
await contactApi.addFriend({ to_user_id: user.id, message: '你好,我想加你为好友' })
toastStore.success('好友申请已发送')
} catch (error: any) {
toastStore.error(error.message || '添加失败')
}
} catch (error: any) { toastStore.error(error.message || '添加失败') }
}
async function handleAcceptRequest(requestId: number) {
@@ -140,9 +141,7 @@ async function handleAcceptRequest(requestId: number) {
contactStore.removeFriendRequest(requestId)
friendRequests.value = contactStore.friendRequests
toastStore.success('已接受好友申请')
} catch (error: any) {
toastStore.error(error.message || '操作失败')
}
} catch (error: any) { toastStore.error(error.message || '操作失败') }
}
async function handleRejectRequest(requestId: number) {
@@ -151,9 +150,7 @@ async function handleRejectRequest(requestId: number) {
contactStore.removeFriendRequest(requestId)
friendRequests.value = contactStore.friendRequests
toastStore.success('已拒绝好友申请')
} catch (error: any) {
toastStore.error(error.message || '操作失败')
}
} catch (error: any) { toastStore.error(error.message || '操作失败') }
}
onMounted(async () => {
@@ -161,9 +158,21 @@ onMounted(async () => {
const requests = await contactApi.getFriendRequests()
contactStore.setFriendRequests(requests)
friendRequests.value = requests
} catch (error) {
console.error('Failed to load friend requests:', error)
}
} catch (error) { console.error(error) }
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.list-enter-active,
.list-leave-active {
transition: all 0.4s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>

View File

@@ -1,130 +1,83 @@
<template>
<div class="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-4 backdrop-blur-md">
<div class="bg-panel p-8 rounded-2xl w-full max-w-2xl border border-gray-700 shadow-2xl">
<h1 class="text-3xl font-light mb-2 text-center text-white">
<div class="bg-panel p-8 rounded-3xl w-full max-w-2xl border border-gray-700 shadow-2xl hover:shadow-primary/20 transition-all duration-500">
<h1 class="text-3xl font-light mb-2 text-center text-white tracking-wider">
NL-IM <span class="text-primary font-bold">V2</span>
</h1>
<p class="text-gray-400 text-center mb-10">登录即时通讯系统</p>
<p class="text-gray-400 text-center mb-10 tracking-widest uppercase text-xs">沉浸式即时通讯系统</p>
<div class="space-y-4">
<div class="space-y-5">
<!-- 登录表单 -->
<div v-if="!showRegister" class="space-y-4">
<div>
<div v-if="!showRegister" class="space-y-5 animate-fade-in">
<div class="group">
<input
v-model="loginForm.account"
type="text"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="邮箱或手机号"
v-model="loginForm.account"
type="text"
class="w-full bg-input rounded-2xl py-4 px-6 text-white focus:ring-2 ring-primary outline-none transition-all placeholder-gray-500 group-hover:bg-white/5"
placeholder="邮箱或手机号"
/>
</div>
<div>
<div class="group">
<input
v-model="loginForm.password"
type="password"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="密码"
@keyup.enter="handleLogin"
v-model="loginForm.password"
type="password"
class="w-full bg-input rounded-2xl py-4 px-6 text-white focus:ring-2 ring-primary outline-none transition-all placeholder-gray-500 group-hover:bg-white/5"
placeholder="密码"
@keyup.enter="handleLogin"
/>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
<div class="flex items-center justify-between px-2">
<label class="flex items-center gap-2 text-gray-400 text-sm cursor-pointer hover:text-white transition">
<input
v-model="loginForm.remember"
type="checkbox"
class="w-4 h-4 rounded border-gray-600 bg-input text-primary focus:ring-primary"
v-model="loginForm.remember"
type="checkbox"
class="w-4 h-4 rounded border-gray-600 bg-input text-primary focus:ring-primary accent-primary"
/>
<span>记住我</span>
</label>
<button
class="text-primary text-sm hover:underline"
@click="showRegister = true"
class="text-primary text-sm hover:text-indigo-400 transition font-medium"
@click="showRegister = true"
>
注册账号
注册账号
</button>
</div>
<button
class="w-full py-3 bg-primary hover:bg-indigo-500 text-white font-medium rounded-lg transition shadow-lg shadow-indigo-500/30 active:scale-95"
:disabled="loading"
@click="handleLogin"
class="w-full py-4 bg-gradient-to-r from-primary to-indigo-600 hover:from-indigo-500 hover:to-primary text-white font-bold rounded-2xl transition-all transform hover:scale-[1.02] active:scale-95 shadow-lg shadow-primary/30"
:disabled="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
<i v-if="loading" class="fas fa-circle-notch fa-spin mr-2"></i>
{{ loading ? '登录中...' : '立即登录' }}
</button>
</div>
<!-- 注册表单 -->
<div v-else class="space-y-4">
<!-- 注册表单 (UI优化) -->
<div v-else class="space-y-4 animate-fade-in">
<div class="grid grid-cols-2 gap-4">
<div>
<input
v-model="registerForm.email"
type="email"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="邮箱"
/>
</div>
<div>
<input
v-model="registerForm.phone"
type="tel"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="手机号"
/>
</div>
<input v-model="registerForm.email" type="email" 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.phone" type="tel" 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="grid grid-cols-2 gap-4">
<div>
<input
v-model="registerForm.password"
type="password"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="密码"
/>
</div>
<div>
<input
v-model="registerForm.confirmPassword"
type="password"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="确认密码"
/>
</div>
<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">
<input
v-model="registerForm.code"
type="text"
class="flex-1 bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="验证码"
/>
<button
class="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition"
:disabled="codeLoading"
@click="handleSendCode"
>
<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 ? '发送中...' : '发送验证码' }}
</button>
</div>
<label class="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
<input
v-model="registerForm.agreeTerms"
type="checkbox"
class="w-4 h-4 rounded border-gray-600 bg-input text-primary focus:ring-primary"
/>
<span>同意服务条款</span>
<label class="flex items-center gap-2 text-gray-400 text-sm cursor-pointer px-2">
<input v-model="registerForm.agreeTerms" type="checkbox" class="w-4 h-4 rounded border-gray-600 bg-input text-primary focus:ring-primary accent-primary" />
<span>我已阅读并同意服务条款</span>
</label>
<div class="flex gap-4">
<button
class="flex-1 py-3 bg-gray-700 hover:bg-gray-600 text-white font-medium rounded-lg transition"
@click="showRegister = false"
>
返回登录
<div class="flex gap-4 pt-2">
<button class="flex-1 py-3 bg-gray-800 hover:bg-gray-700 text-gray-300 font-medium rounded-2xl transition active:scale-95" @click="showRegister = false">
返回
</button>
<button
class="flex-1 py-3 bg-primary hover:bg-indigo-500 text-white font-medium rounded-lg transition shadow-lg shadow-indigo-500/30 active:scale-95"
:disabled="loading"
@click="handleRegister"
>
{{ loading ? '注册中...' : '注册' }}
<button class="flex-1 py-3 bg-primary hover:bg-indigo-500 text-white font-medium rounded-2xl transition shadow-lg shadow-indigo-500/30 active:scale-95" :disabled="loading" @click="handleRegister">
{{ loading ? '注册中...' : '完成注册' }}
</button>
</div>
</div>
@@ -172,7 +125,13 @@ async function handleLogin() {
loading.value = true
try {
await authStore.login(loginForm.value)
toastStore.success('登录成功')
// 核心修改:登录成功后立即请求通知权限
if ('Notification' in window && Notification.permission === 'default') {
await Notification.requestPermission()
}
toastStore.success('欢迎回来')
router.push('/chat')
} catch (error: any) {
toastStore.error(error.message || '登录失败')
@@ -181,22 +140,17 @@ async function handleLogin() {
}
}
// ... (注册和发送验证码逻辑保持原有逻辑,仅模板做了 UI 美化)
async function handleRegister() {
if (!registerForm.value.email || !registerForm.value.phone || !registerForm.value.password) {
toastStore.warning('请填写完整信息')
return
toastStore.warning('请填写完整信息'); return
}
if (registerForm.value.password !== registerForm.value.confirmPassword) {
toastStore.warning('两次密码不一致')
return
toastStore.warning('两次密码不一致'); return
}
if (!registerForm.value.agreeTerms) {
toastStore.warning('请同意服务条款')
return
toastStore.warning('请同意服务条款'); return
}
loading.value = true
try {
await authStore.register(registerForm.value)
@@ -212,11 +166,7 @@ async function handleRegister() {
async function handleSendCode() {
const target = registerForm.value.email || registerForm.value.phone
if (!target) {
toastStore.warning('请先填写邮箱或手机号')
return
}
if (!target) { toastStore.warning('请先填写邮箱或手机号'); return }
codeLoading.value = true
try {
const type = registerForm.value.email ? 'email' : 'sms'
@@ -230,3 +180,12 @@ async function handleSendCode() {
}
</script>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>