UI优化
文件无法发送 已知问题:
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user