You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

283 lines
10 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDeviceStore } from '@/stores/deviceStore'
import { useChatStore } from '@/stores/chatStore'
import MessageBubble from '@/components/MessageBubble.vue'
import ChatInput from '@/components/ChatInput.vue'
import FileProgress from '@/components/FileProgress.vue'
import { ArrowLeft, MoreVertical, Trash2 } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const deviceStore = useDeviceStore()
const chatStore = useChatStore()
const deviceId = computed(() => route.params.deviceId)
const device = computed(() => deviceStore.getDevice(deviceId.value))
const messages = computed(() => chatStore.messages.get(deviceId.value) || [])
const localDeviceId = computed(() => deviceStore.localDevice?.deviceId || '')
// 只显示与当前对话设备相关的、进行中的传输记录(完成后隐藏)
const deviceTransfers = computed(() => {
const result = []
chatStore.transfers.forEach((transfer, id) => {
// 我发送给对方 或 对方发送给我
const isRelevant =
(transfer.fromDevice === localDeviceId.value && transfer.toDevice === deviceId.value) ||
(transfer.fromDevice === deviceId.value && transfer.toDevice === localDeviceId.value)
// 只显示进行中的传输pending 或 transferring
const isInProgress = transfer.status === 'pending' || transfer.status === 'transferring'
if (isRelevant && isInProgress) {
result.push([id, transfer])
}
})
return result
})
const messagesContainer = ref(null)
const showMenu = ref(false)
// 滚动到底部
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 发送消息
async function sendMessage(content) {
try {
await chatStore.sendMessage(deviceId.value, content)
scrollToBottom()
} catch (error) {
console.error('Failed to send message:', error)
}
}
// 选择文件
async function selectFile() {
const file = await chatStore.selectFile()
if (file && device.value) {
// 发送文件,同时发送一条文件消息到聊天记录
await chatStore.sendFile(deviceId.value, file)
// 发送文件消息,让接收方也能在聊天记录中看到
await chatStore.sendMessage(
deviceId.value,
`📎 ${file.name}`,
'file'
)
}
}
// 删除聊天记录
async function deleteHistory() {
showMenu.value = false
const { ask } = await import('@tauri-apps/plugin-dialog')
const confirmed = await ask('确定要删除所有聊天记录吗?', {
title: '删除确认',
kind: 'warning'
})
if (confirmed) {
await chatStore.deleteHistory(deviceId.value)
}
}
// 返回
function goBack() {
router.push('/devices')
}
// 点击文件消息,打开文件位置(仅接收方有效)
async function handleFileClick(fileName) {
// 先在内存中查找
for (const [, transfer] of chatStore.transfers) {
// 接收到的文件toDevice 是本机)
if (transfer.name === fileName && transfer.toDevice === localDeviceId.value && transfer.localPath) {
await chatStore.openFileLocation(transfer.localPath)
return
}
}
// 内存中没有或 localPath 为空,强制从数据库加载
await chatStore.loadTransferHistory(deviceId.value, true)
for (const [, transfer] of chatStore.transfers) {
if (transfer.name === fileName && transfer.toDevice === localDeviceId.value && transfer.localPath) {
await chatStore.openFileLocation(transfer.localPath)
return
}
}
console.log('File not found:', fileName)
}
// 监听新消息
watch(messages, () => {
scrollToBottom()
}, { deep: true })
onMounted(async () => {
chatStore.setCurrentDevice(deviceId.value)
// 确保已连接
if (!deviceStore.isConnected(deviceId.value)) {
try {
await deviceStore.connectToDevice(deviceId.value)
} catch (error) {
console.error('Connection failed:', error)
}
}
// 加载历史记录
await chatStore.loadHistory(deviceId.value)
// 加载传输历史(用于点击文件消息时打开文件)
await chatStore.loadTransferHistory(deviceId.value)
scrollToBottom()
})
onUnmounted(() => {
chatStore.setCurrentDevice(null)
})
</script>
<template>
<div class="h-full flex flex-col bg-surface-50 dark:bg-surface-900 relative overflow-hidden">
<!-- 背景装饰 -->
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-primary-200/20 dark:bg-primary-900/10 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/2"></div>
<div class="absolute bottom-0 left-0 w-[300px] h-[300px] bg-primary-300/10 dark:bg-primary-800/5 rounded-full blur-[80px] pointer-events-none translate-y-1/3 -translate-x-1/3"></div>
<!-- 头部 -->
<header class="relative z-20 bg-white/80 dark:bg-surface-900/80 backdrop-blur-md border-b border-surface-200/50 dark:border-surface-700/50 px-4 py-3 flex items-center gap-3 shadow-[0_2px_15px_rgba(0,0,0,0.02)]">
<button
@click="goBack"
class="p-2.5 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 transition-colors"
>
<ArrowLeft :size="20" stroke-width="2.5" />
</button>
<div class="flex-1 min-w-0 flex items-center gap-3">
<div class="flex-1 min-w-0">
<h2 class="font-semibold text-surface-900 dark:text-white truncate text-base leading-tight">
{{ device?.deviceName || '未知设备' }}
</h2>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-surface-500 dark:text-surface-400 font-mono opacity-80">
{{ device?.ip }}
</span>
<!-- 在线状态指示器 -->
<div
class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-full bg-surface-100 dark:bg-surface-800"
:class="deviceStore.isConnected(deviceId) ? 'text-green-600 dark:text-green-400' : 'text-surface-400'"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="[
deviceStore.isConnected(deviceId) ? 'bg-green-500 animate-pulse' : 'bg-surface-400',
'shadow-[0_0_8px_currentColor]'
]"
/>
<span class="text-[10px] font-bold uppercase tracking-wider">
{{ deviceStore.isConnected(deviceId) ? 'Online' : 'Offline' }}
</span>
</div>
</div>
</div>
</div>
<!-- 更多菜单 -->
<div class="relative">
<button
@click="showMenu = !showMenu"
class="p-2.5 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 transition-colors"
>
<MoreVertical :size="20" />
</button>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="showMenu"
class="absolute right-0 top-full mt-2 w-48 bg-white dark:bg-surface-800 rounded-xl shadow-hard border border-surface-100 dark:border-surface-700 py-1.5 z-50 origin-top-right"
>
<button
@click="deleteHistory"
class="w-full px-4 py-2.5 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2.5 transition-colors"
>
<Trash2 :size="16" />
<span>删除聊天记录</span>
</button>
</div>
</transition>
</div>
</header>
<!-- 消息列表 -->
<div
ref="messagesContainer"
class="flex-1 overflow-y-auto px-4 py-6 relative z-10 scroll-smooth space-y-2"
>
<!-- 空状态 -->
<div
v-if="messages.length === 0"
class="h-full flex flex-col items-center justify-center text-surface-400 dark:text-surface-500 animate-fade-in"
>
<div class="w-16 h-16 bg-surface-100 dark:bg-surface-800 rounded-2xl flex items-center justify-center mb-4 shadow-inner transform rotate-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-8 h-8 opacity-60"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
</div>
<p class="font-medium">暂无消息</p>
<p class="text-sm mt-1 opacity-75">发送消息开始聊天</p>
</div>
<!-- 消息列表 -->
<template v-else>
<MessageBubble
v-for="message in messages"
:key="message.id"
:message="message"
:is-sent="message.from === localDeviceId"
@file-click="handleFileClick"
/>
</template>
</div>
<!-- 文件传输进度(浮动在输入框上方) -->
<div class="relative z-20 px-4 pb-2">
<transition-group
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform translate-y-4 opacity-0"
>
<FileProgress
v-for="[id, transfer] in deviceTransfers"
:key="id"
:transfer="transfer"
:is-receiver="transfer.toDevice === localDeviceId"
@open="chatStore.openFileLocation(transfer.localPath || '')"
@cancel="chatStore.cancelTransfer"
class="mb-2 last:mb-0 shadow-medium"
/>
</transition-group>
</div>
<!-- 输入区域 -->
<div class="relative z-30">
<ChatInput
@send="sendMessage"
@select-file="selectFile"
@select-image="selectFile"
/>
</div>
</div>
</template>