update:重构消息系统,新增微信风格会话列表

main
蒋尚宏 2 months ago
parent 1d41132a29
commit 112c648773

@ -0,0 +1,23 @@
# Run second Flash Send dev instance for local testing
# Port offset: +100
$env:FLASH_SEND_PORT_OFFSET = "100"
$env:FLASH_SEND_INSTANCE = "LocalTest2"
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Flash Send - Dev Instance 2" -ForegroundColor White
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Port Config:" -ForegroundColor Yellow
Write-Host " UDP Broadcast: 53317 (shared)" -ForegroundColor Gray
Write-Host " UDP Listen: 53417 (53317 + 100)" -ForegroundColor Gray
Write-Host " WebSocket: 53418 (53318 + 100)" -ForegroundColor Gray
Write-Host " HTTP: 53419 (53319 + 100)" -ForegroundColor Gray
Write-Host ""
Write-Host "Make sure Instance 1 is running in another terminal" -ForegroundColor Green
Write-Host ""
Set-Location $PSScriptRoot\..
pnpm tauri dev

@ -3,7 +3,7 @@
use tauri::{AppHandle, Emitter};
use crate::database::Database;
use crate::models::{ChatMessage, event_names};
use crate::models::{ChatMessage, Conversation, event_names};
use crate::state::AppState;
use crate::utils::{CommandError, CommandResult, HEARTBEAT_INTERVAL_MS};
use crate::websocket::{HeartbeatManager, WsClient, WsConnectionEvent, WsServer};
@ -182,6 +182,11 @@ pub async fn send_chat_message(
device_id.clone(),
content,
),
Some("file") => ChatMessage::new_file(
local_device_id,
device_id.clone(),
content,
),
_ => ChatMessage::new_text(
local_device_id,
device_id.clone(),
@ -267,3 +272,23 @@ pub async fn delete_chat_history(device_id: String) -> CommandResult<usize> {
Ok(count)
}
/// 获取会话列表(所有有聊天记录的设备)
#[tauri::command]
pub async fn get_conversations() -> CommandResult<Vec<Conversation>> {
let state = AppState::get();
let local_device_id = state.local_device.read().device_id.clone();
let mut conversations = Database::get_conversations(&local_device_id)
.map_err(|e| CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
// 更新在线状态
for conv in &mut conversations {
conv.online = state.device_manager.is_device_online(&conv.device_id);
}
Ok(conversations)
}

@ -2,6 +2,7 @@
use tauri::{AppHandle, Emitter};
use crate::database::Database;
use crate::discovery::{DiscoveryEvent, DiscoveryService};
use crate::models::{DeviceInfo, event_names};
use crate::state::AppState;
@ -40,6 +41,11 @@ pub async fn start_discovery_service(app: AppHandle) -> CommandResult<()> {
while let Ok(event) = rx.recv().await {
match event {
DiscoveryEvent::DeviceFound(device) => {
// 保存到已知设备数据库
if let Err(e) = Database::save_known_device(&device) {
log::error!("Failed to save known device: {}", e);
}
device_manager.upsert_device(device.clone());
let _ = app_handle.emit(event_names::DEVICE_FOUND, &device);
}
@ -79,10 +85,21 @@ pub async fn stop_discovery_service() -> CommandResult<()> {
Ok(())
}
/// 获取已发现的设备列表
/// 获取已发现的设备列表(在线设备)
#[tauri::command]
pub async fn get_discovered_devices() -> CommandResult<Vec<DeviceInfo>> {
let state = AppState::get();
let devices = state.device_manager.get_all_devices();
Ok(devices)
}
/// 获取历史设备列表(从数据库)
/// 这些是曾经发现过的设备,可能当前已离线
#[tauri::command]
pub async fn get_known_devices() -> CommandResult<Vec<DeviceInfo>> {
let devices = Database::get_known_devices().map_err(|e| crate::utils::CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
Ok(devices)
}

@ -20,6 +20,7 @@ pub fn get_handlers() -> impl Fn(Invoke) -> bool {
start_discovery_service,
stop_discovery_service,
get_discovered_devices,
get_known_devices,
// WebSocket 命令
start_websocket_server,
stop_websocket_server,
@ -29,6 +30,7 @@ pub fn get_handlers() -> impl Fn(Invoke) -> bool {
send_chat_message,
get_chat_history,
delete_chat_history,
get_conversations,
// 文件传输命令
start_http_server,
stop_http_server,

@ -5,7 +5,7 @@ use parking_lot::Mutex;
use rusqlite::{params, Connection, OptionalExtension};
use std::path::PathBuf;
use crate::models::{ChatMessage, FileTransfer, MessageStatus, MessageType, TransferStatus, DeviceInfo};
use crate::models::{ChatMessage, Conversation, FileTransfer, MessageStatus, MessageType, TransferStatus, DeviceInfo};
use crate::utils::AppError;
use super::init_database;
@ -128,6 +128,72 @@ impl Database {
Ok(count)
}
/// 获取会话列表(所有有聊天记录的设备,按最后消息时间排序)
pub fn get_conversations(local_device_id: &str) -> Result<Vec<Conversation>, AppError> {
let conn = Self::conn().lock();
// 查询所有聊天过的设备及其最后一条消息
let mut stmt = conn.prepare(
r#"
WITH conversation_devices AS (
SELECT DISTINCT
CASE
WHEN from_device = ?1 THEN to_device
ELSE from_device
END as peer_device
FROM chat_messages
WHERE from_device = ?1 OR to_device = ?1
),
last_messages AS (
SELECT
cd.peer_device,
m.content as last_content,
m.message_type as last_type,
m.timestamp as last_time
FROM conversation_devices cd
LEFT JOIN chat_messages m ON (
(m.from_device = ?1 AND m.to_device = cd.peer_device) OR
(m.from_device = cd.peer_device AND m.to_device = ?1)
)
WHERE m.timestamp = (
SELECT MAX(m2.timestamp)
FROM chat_messages m2
WHERE (m2.from_device = ?1 AND m2.to_device = cd.peer_device)
OR (m2.from_device = cd.peer_device AND m2.to_device = ?1)
)
)
SELECT
lm.peer_device,
COALESCE(kd.device_name, lm.peer_device) as device_name,
COALESCE(kd.ip, '') as ip,
lm.last_content,
lm.last_type,
lm.last_time
FROM last_messages lm
LEFT JOIN known_devices kd ON kd.device_id = lm.peer_device
ORDER BY lm.last_time DESC
"#,
)?;
let conversations = stmt
.query_map(params![local_device_id], |row| {
let last_type_str: Option<String> = row.get(4)?;
Ok(Conversation {
device_id: row.get(0)?,
device_name: row.get(1)?,
ip: row.get(2)?,
online: false, // 将在命令层更新
last_message: row.get(3)?,
last_message_type: last_type_str.and_then(|s| serde_json::from_str(&s).ok()),
last_message_time: row.get(5)?,
unread_count: 0, // TODO: 实现未读计数
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(conversations)
}
// ==================== 文件传输操作 ====================
/// 保存文件传输记录

@ -113,6 +113,11 @@ impl DeviceManager {
}
}
/// 检查设备是否在线
pub fn is_device_online(&self, device_id: &str) -> bool {
self.devices.contains_key(device_id)
}
/// 清空所有设备
pub fn clear(&self) {
let device_ids: Vec<String> = self.devices.iter().map(|d| d.device_id.clone()).collect();

@ -1,4 +1,9 @@
//! UDP 广播服务实现
//!
//! 支持同机器多实例运行:
//! - 每个实例监听不同端口 (DEFAULT_UDP_PORT + offset)
//! - 所有实例都向 DEFAULT_UDP_PORT 广播
//! - 同时向 localhost 发送确保同机器实例能收到
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
@ -30,8 +35,10 @@ pub enum DiscoveryEvent {
pub struct DiscoveryService {
/// 本机设备信息
local_device: DeviceInfo,
/// UDP 端口
port: u16,
/// UDP 监听端口 (可能因端口偏移而不同)
listen_port: u16,
/// UDP 广播目标端口 (始终是 DEFAULT_UDP_PORT)
broadcast_port: u16,
/// 事件发送器
event_tx: broadcast::Sender<DiscoveryEvent>,
/// 停止信号
@ -42,9 +49,18 @@ impl DiscoveryService {
/// 创建新的发现服务
pub fn new(local_device: DeviceInfo) -> Self {
let (event_tx, _) = broadcast::channel(100);
// 从环境变量读取端口偏移量 (用于同机器多实例)
let port_offset: u16 = std::env::var("FLASH_SEND_PORT_OFFSET")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
Self {
// UDP 广播端口必须固定,所有实例使用同一端口才能互相发现
port: DEFAULT_UDP_PORT,
// 监听端口 = 默认端口 + 偏移量 (避免同机器绑定冲突)
listen_port: DEFAULT_UDP_PORT + port_offset,
// 广播端口 = 始终是默认端口 (确保所有实例都向同一端口广播)
broadcast_port: DEFAULT_UDP_PORT,
local_device,
event_tx,
stop_tx: None,
@ -58,7 +74,8 @@ impl DiscoveryService {
/// 启动发现服务
pub async fn start(&mut self) -> Result<(), AppError> {
let port = self.port;
let listen_port = self.listen_port;
let broadcast_port = self.broadcast_port;
let local_device = self.local_device.clone();
let event_tx = self.event_tx.clone();
@ -66,22 +83,11 @@ impl DiscoveryService {
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
self.stop_tx = Some(stop_tx);
// 使用 socket2 创建支持端口复用的 UDP 套接字
let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port);
let socket2_socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
socket2_socket.set_reuse_address(true)?;
#[cfg(unix)]
socket2_socket.set_reuse_port(true)?;
socket2_socket.set_broadcast(true)?;
socket2_socket.set_nonblocking(true)?;
socket2_socket.bind(&bind_addr.into())?;
// 创建主监听 socket
let socket = Self::create_udp_socket(listen_port)?;
log::info!("Discovery service: listening on {}, broadcasting to {}", listen_port, broadcast_port);
let std_socket: std::net::UdpSocket = socket2_socket.into();
let socket = UdpSocket::from_std(std_socket)?;
log::info!("Discovery service started on port {}", port);
let _ = event_tx.send(DiscoveryEvent::Started);
let socket = Arc::new(socket);
// 启动接收任务
@ -98,20 +104,60 @@ impl DiscoveryService {
let broadcast_device = local_device.clone();
let broadcast_handle = tokio::spawn(async move {
Self::broadcast_loop(broadcast_socket, broadcast_device, port).await;
Self::broadcast_loop(broadcast_socket, broadcast_device, broadcast_port).await;
});
// 如果监听端口与广播端口不同,需要额外监听广播端口以接收其他实例的消息
let extra_recv_handle = if listen_port != broadcast_port {
let extra_event_tx = event_tx.clone();
let extra_local_id = local_device.device_id.clone();
Some(tokio::spawn(async move {
// 尝试绑定广播端口(可能被主实例占用)
match Self::create_udp_socket(broadcast_port) {
Ok(extra_socket) => {
log::info!("Discovery service: also listening on broadcast port {}", broadcast_port);
Self::receive_loop(Arc::new(extra_socket), extra_event_tx, extra_local_id).await;
}
Err(_) => {
log::warn!("Could not bind broadcast port {} (occupied by main instance)", broadcast_port);
}
}
}))
} else {
None
};
// 等待停止信号
tokio::spawn(async move {
let _ = stop_rx.await;
recv_handle.abort();
broadcast_handle.abort();
if let Some(h) = extra_recv_handle {
h.abort();
}
log::info!("Discovery service stopped");
});
Ok(())
}
/// 创建 UDP socket
fn create_udp_socket(port: u16) -> Result<UdpSocket, AppError> {
let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port);
let socket2_socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
socket2_socket.set_reuse_address(true)?;
#[cfg(unix)]
socket2_socket.set_reuse_port(true)?;
socket2_socket.set_broadcast(true)?;
socket2_socket.set_nonblocking(true)?;
socket2_socket.bind(&bind_addr.into())?;
let std_socket: std::net::UdpSocket = socket2_socket.into();
let socket = UdpSocket::from_std(std_socket)?;
Ok(socket)
}
/// 停止发现服务
pub fn stop(&mut self) {
if let Some(stop_tx) = self.stop_tx.take() {
@ -160,17 +206,21 @@ impl DiscoveryService {
}
}
/// 广播循环
async fn broadcast_loop(socket: Arc<UdpSocket>, device: DeviceInfo, port: u16) {
let broadcast_addr = SocketAddr::V4(SocketAddrV4::new(BROADCAST_ADDR, port));
/// 广播循环 - 向广播地址和 localhost 发送
async fn broadcast_loop(socket: Arc<UdpSocket>, device: DeviceInfo, broadcast_port: u16) {
let broadcast_addr = SocketAddr::V4(SocketAddrV4::new(BROADCAST_ADDR, broadcast_port));
let localhost_addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), broadcast_port));
let interval = Duration::from_millis(DISCOVERY_INTERVAL_MS);
loop {
let packet = DiscoveryPacket::announce(device.clone());
if let Ok(data) = serde_json::to_vec(&packet) {
// 发送广播 (局域网内其他设备)
if let Err(e) = socket.send_to(&data, broadcast_addr).await {
log::error!("Failed to broadcast: {}", e);
}
// 发送到 localhost (同机器其他实例)
let _ = socket.send_to(&data, localhost_addr).await;
}
tokio::time::sleep(interval).await;
}
@ -182,11 +232,14 @@ impl DiscoveryService {
let socket = UdpSocket::bind(bind_addr).await?;
socket.set_broadcast(true)?;
let broadcast_addr = SocketAddr::V4(SocketAddrV4::new(BROADCAST_ADDR, self.port));
let broadcast_addr = SocketAddr::V4(SocketAddrV4::new(BROADCAST_ADDR, self.broadcast_port));
let localhost_addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), self.broadcast_port));
let packet = DiscoveryPacket::goodbye(self.local_device.clone());
let data = serde_json::to_vec(&packet)?;
socket.send_to(&data, broadcast_addr).await?;
let _ = socket.send_to(&data, localhost_addr).await;
Ok(())
}
}

@ -37,7 +37,7 @@ fn main() {
AppState::init(app_data_dir.clone())
.expect("Failed to initialize app state");
// 初始化数据库
// 初始化数据库(存储在 AppData 目录下)
Database::init(app_data_dir.clone())
.expect("Failed to initialize database");

@ -11,6 +11,8 @@ pub enum MessageType {
Text,
/// 图片消息 (base64 指针)
Image,
/// 文件消息 (文件名或描述)
File,
/// 系统事件 (上线/离线等)
Event,
/// 消息确认回执
@ -87,6 +89,19 @@ impl ChatMessage {
}
}
/// 创建文件消息
pub fn new_file(from: String, to: String, file_info: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message_type: MessageType::File,
content: file_info,
from,
to,
timestamp: chrono::Utc::now().timestamp(),
status: MessageStatus::Pending,
}
}
/// 创建事件消息
pub fn new_event(from: String, to: String, event: &str) -> Self {
Self {
@ -151,3 +166,25 @@ pub struct ConnectionState {
/// 最后心跳时间
pub last_heartbeat: i64,
}
/// 会话信息(用于会话列表显示)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conversation {
/// 对方设备 ID
pub device_id: String,
/// 对方设备名称
pub device_name: String,
/// 对方 IP 地址
pub ip: String,
/// 是否在线
pub online: bool,
/// 最后一条消息内容
pub last_message: Option<String>,
/// 最后一条消息类型
pub last_message_type: Option<MessageType>,
/// 最后一条消息时间
pub last_message_time: Option<i64>,
/// 未读消息数
pub unread_count: i32,
}

@ -5,10 +5,36 @@ use crate::models::{ChatMessage, MessageStatus, MessageType};
use super::ConnectionManager;
/// 最大重试次数
const MAX_SAVE_RETRIES: u32 = 3;
/// 消息处理器
pub struct MessageHandler;
impl MessageHandler {
/// 保存消息到数据库(带重试)
fn save_message_with_retry(message: &ChatMessage) -> bool {
for attempt in 1..=MAX_SAVE_RETRIES {
match Database::save_message(message) {
Ok(_) => {
log::debug!("Message {} saved successfully", message.id);
return true;
}
Err(e) => {
log::error!(
"Failed to save message {} (attempt {}/{}): {}",
message.id, attempt, MAX_SAVE_RETRIES, e
);
if attempt < MAX_SAVE_RETRIES {
// 短暂延迟后重试
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
}
}
false
}
/// 处理收到的消息
pub async fn handle_message(
message: ChatMessage,
@ -16,30 +42,34 @@ impl MessageHandler {
local_device_id: &str,
) {
match message.message_type {
MessageType::Text | MessageType::Image => {
// 保存消息到数据库
if let Err(e) = Database::save_message(&message) {
log::error!("Failed to save message: {}", e);
MessageType::Text | MessageType::Image | MessageType::File => {
// 保存消息到数据库(带重试机制)
let saved = Self::save_message_with_retry(&message);
if !saved {
log::error!("Message {} could not be saved after {} retries", message.id, MAX_SAVE_RETRIES);
}
// 发送确认回执
let ack = ChatMessage::new_ack(
local_device_id.to_string(),
message.from.clone(),
&message.id,
);
if let Err(e) = connection_manager.send_to(&message.from, &ack).await {
log::error!("Failed to send ack: {}", e);
// 发送确认回执(只有保存成功才发送)
if saved {
let ack = ChatMessage::new_ack(
local_device_id.to_string(),
message.from.clone(),
&message.id,
);
if let Err(e) = connection_manager.send_to(&message.from, &ack).await {
log::error!("Failed to send ack: {}", e);
}
}
// 触发消息接收事件
// 无论是否保存成功,都触发消息接收事件(让用户能看到消息)
connection_manager.emit_message_received(message);
}
MessageType::Event => {
// 处理事件消息 (如在线/离线通知)
// 这是内部握手消息,不发送到前端显示
log::info!("Received event: {} from {}", message.content, message.from);
connection_manager.emit_message_received(message);
// 可以在这里处理特定事件,如更新设备在线状态等
}
MessageType::Ack => {
// 处理确认回执,更新消息状态

@ -4,7 +4,7 @@
"version": "1.0.0",
"identifier": "com.flashsend.app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "pnpm run dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"

@ -13,8 +13,11 @@ export const discoveryApi = {
/** 停止设备发现服务 */
stop: () => invoke('stop_discovery_service'),
/** 获取已发现的设备列表 */
/** 获取已发现的设备列表(在线设备) */
getDevices: () => invoke('get_discovered_devices'),
/** 获取历史设备列表(包含离线设备) */
getKnownDevices: () => invoke('get_known_devices'),
}
/** WebSocket API */
@ -45,6 +48,10 @@ export const chatApi = {
/** 删除聊天历史 */
deleteHistory: (deviceId) =>
invoke('delete_chat_history', { deviceId }),
/** 获取会话列表(所有有聊天记录的设备) */
getConversations: () =>
invoke('get_conversations'),
}
/** 文件传输 API */

@ -37,47 +37,57 @@ function autoResize(e) {
</script>
<template>
<div class="bg-white dark:bg-gray-800 border-t border-surface-200 dark:border-gray-700 p-4">
<div class="flex items-end gap-3">
<!-- 附件按钮 -->
<div class="flex gap-1">
<div class="bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-md border-t border-surface-200/50 dark:border-surface-700/50 p-4 pb-6">
<div class="max-w-4xl mx-auto flex items-end gap-3 bg-white dark:bg-surface-800 p-2 rounded-2xl shadow-soft border border-surface-100 dark:border-surface-700/50 focus-within:ring-2 focus-within:ring-primary-500/20 focus-within:border-primary-500/50 transition-all duration-300">
<!-- 附件按钮 -->
<div class="flex gap-1 pb-1">
<button
@click="emit('selectFile')"
class="p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
class="p-2 rounded-xl text-surface-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all duration-200"
title="发送文件"
>
<Paperclip :size="20" />
<Paperclip :size="22" stroke-width="2" />
</button>
<button
@click="emit('selectImage')"
class="p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
class="p-2 rounded-xl text-surface-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all duration-200"
title="发送图片"
>
<Image :size="20" />
<Image :size="22" stroke-width="2" />
</button>
</div>
<!-- 输入框 -->
<div class="flex-1 relative">
<div class="flex-1 relative py-1">
<textarea
ref="inputRef"
v-model="inputText"
@keydown="handleKeydown"
@input="autoResize"
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
placeholder="输入消息..."
rows="1"
class="w-full px-4 py-2 bg-surface-50 dark:bg-gray-700 border border-surface-200 dark:border-gray-600 rounded-xl resize-none focus:outline-none focus:border-primary-400 transition-colors text-gray-900 dark:text-white placeholder-gray-400"
class="w-full max-h-32 px-2 py-1.5 bg-transparent border-none focus:ring-0 text-surface-900 dark:text-white placeholder-surface-400 resize-none text-[15px] leading-relaxed scrollbar-hide"
/>
</div>
<!-- 发送按钮 -->
<button
@click="sendMessage"
:disabled="!inputText.trim()"
class="p-3 rounded-xl bg-primary-500 text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary-600 transition-colors"
>
<Send :size="20" />
</button>
<div class="pb-1 pr-1">
<button
@click="sendMessage"
:disabled="!inputText.trim()"
class="p-2.5 rounded-xl bg-primary-500 text-white shadow-lg shadow-primary-500/30 disabled:opacity-50 disabled:shadow-none disabled:bg-surface-300 dark:disabled:bg-surface-700 hover:bg-primary-600 hover:scale-105 active:scale-95 transition-all duration-200"
>
<Send :size="20" stroke-width="2.5" class="ml-0.5" />
</button>
</div>
</div>
<div class="text-center mt-2">
<p class="text-[10px] text-surface-400 dark:text-surface-600">Enter 发送 · Shift + Enter 换行</p>
</div>
</div>
</template>
<style scoped>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>

@ -33,19 +33,23 @@ const deviceIcon = computed(() => {
//
async function openChat() {
if (!isConnected.value) {
// 线
if (props.device.online && !isConnected.value) {
try {
await deviceStore.connectToDevice(props.device.deviceId)
} catch (error) {
console.error('Connection failed:', error)
return
//
}
}
// 线线线
router.push(`/chat/${props.device.deviceId}`)
}
//
// 线
async function sendFile() {
if (!props.device.online) return
const file = await chatStore.selectFile()
if (file) {
router.push(`/chat/${props.device.deviceId}`)
@ -55,69 +59,76 @@ async function sendFile() {
<template>
<div
class="device-card bg-white dark:bg-gray-800 rounded-xl p-4 border border-surface-200 dark:border-gray-700 cursor-pointer hover:shadow-md dark:hover:bg-gray-750 transition-all"
class="device-card group relative bg-white dark:bg-surface-800 rounded-2xl p-5 border border-surface-100 dark:border-surface-700/50 cursor-pointer hover:shadow-hard hover:border-primary-200/50 dark:hover:border-primary-700/30 transition-all duration-300"
@click="openChat"
>
<div class="flex items-start gap-3">
<!-- 悬停时出现的背景光晕 -->
<div class="absolute inset-0 bg-gradient-to-br from-primary-50/50 to-transparent dark:from-primary-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-2xl pointer-events-none"></div>
<div class="relative flex items-start gap-4">
<!-- 设备图标 -->
<div
class="w-12 h-12 rounded-xl flex items-center justify-center"
:class="device.online ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400' : 'bg-surface-100 dark:bg-gray-700 text-gray-400'"
class="w-14 h-14 rounded-2xl flex items-center justify-center shadow-sm transition-transform duration-300 group-hover:scale-105 group-hover:rotate-[-2deg]"
:class="device.online ? 'bg-gradient-to-br from-primary-100 to-primary-50 dark:from-primary-900/40 dark:to-primary-800/20 text-primary-600 dark:text-primary-300' : 'bg-surface-100 dark:bg-surface-700 text-surface-400'"
>
<component :is="deviceIcon" :size="24" />
<component :is="deviceIcon" :size="26" stroke-width="1.5" />
<!-- 在线状态点 (集成在图标右下角) -->
<div class="absolute -bottom-1 -right-1 p-0.5 bg-white dark:bg-surface-800 rounded-full">
<span
class="block w-3 h-3 rounded-full border-2 border-white dark:border-surface-800 transition-colors duration-300"
:class="isConnected ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]' : (device.online ? 'bg-amber-400' : 'bg-surface-300 dark:bg-surface-600')"
/>
</div>
</div>
<!-- 设备信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-gray-900 dark:text-white truncate">
<div class="flex-1 min-w-0 pt-0.5">
<div class="flex items-center justify-between mb-1">
<h3 class="font-semibold text-base text-surface-900 dark:text-surface-50 truncate pr-2 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{{ device.deviceName }}
</h3>
<!-- 连接状态点 -->
<span
class="w-2 h-2 rounded-full flex-shrink-0"
:class="isConnected ? 'bg-green-500' : (device.online ? 'bg-yellow-500' : 'bg-gray-300 dark:bg-gray-600')"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
<p class="text-xs font-medium text-surface-400 dark:text-surface-500 font-mono tracking-wide mb-3">
{{ device.ip }}
</p>
<!-- 能力标签 -->
<div class="flex gap-1 mt-2">
<div class="flex flex-wrap gap-1.5">
<span
v-for="cap in device.capabilities"
:key="cap"
class="px-2 py-0.5 bg-surface-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs rounded"
class="px-2 py-0.5 bg-surface-50 dark:bg-surface-700/50 border border-surface-100 dark:border-surface-700 text-surface-500 dark:text-surface-400 text-[10px] uppercase font-bold tracking-wider rounded-md"
>
{{ cap }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex flex-col gap-2">
<!-- 操作按钮 (悬停时更明显) -->
<div class="flex flex-col gap-2 opacity-80 group-hover:opacity-100 transition-opacity">
<button
@click.stop="openChat"
class="p-2 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/30 text-primary-600 dark:text-primary-400 transition-colors relative"
title="发送消息"
class="relative w-9 h-9 rounded-xl flex items-center justify-center bg-surface-50 dark:bg-surface-700/50 hover:bg-primary-500 hover:text-white dark:hover:bg-primary-500 text-surface-500 dark:text-surface-400 transition-all duration-200 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95"
:title="device.online ? '发送消息' : '查看聊天记录'"
>
<MessageSquare :size="18" />
<MessageSquare :size="18" stroke-width="2" />
<span
v-if="unreadCount > 0"
class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white text-xs rounded-full flex items-center justify-center"
class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center border-2 border-white dark:border-surface-800 animate-bounce shadow-sm"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</button>
<button
v-if="device.online"
@click.stop="sendFile"
class="p-2 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 transition-colors"
class="w-9 h-9 rounded-xl flex items-center justify-center bg-surface-50 dark:bg-surface-700/50 hover:bg-green-500 hover:text-white dark:hover:bg-green-500 text-surface-500 dark:text-surface-400 transition-all duration-200 hover:shadow-lg hover:shadow-green-500/30 active:scale-95"
title="发送文件"
>
<Send :size="18" />
<Send :size="18" stroke-width="2" />
</button>
</div>
</div>

@ -1,6 +1,75 @@
<template>
<div class="bg-white/95 dark:bg-surface-800/95 backdrop-blur-sm rounded-xl p-3.5 border border-surface-200 dark:border-surface-700 shadow-soft animate-slide-up select-none">
<div class="flex items-center gap-3.5">
<!-- 图标 -->
<div
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-colors duration-300"
:class="transfer.status === 'completed' ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-surface-100 dark:bg-surface-700 text-primary-500'"
>
<component
:is="statusIcon"
:size="22"
:class="[
transfer.status === 'completed' ? '' : 'text-primary-500 dark:text-primary-400',
{ 'animate-spin': transfer.status === 'transferring' }
]"
/>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<h4 class="font-semibold text-sm text-surface-900 dark:text-white truncate pr-2" :title="transfer.name">{{ transfer.name }}</h4>
<!-- 进度百分比/状态 -->
<span class="text-xs font-bold shrink-0 tabular-nums" :class="statusClass">
{{ transfer.status === 'completed' ? '已完成' : progressPercent + '%' }}
</span>
</div>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-0.5 flex items-center gap-1">
<span class="tabular-nums">{{ formatSize(transfer.transferredBytes || 0) }}</span>
<span class="opacity-50">/</span>
<span class="tabular-nums">{{ formatSize(transfer.size) }}</span>
</p>
</div>
</div>
<!-- 进度条 -->
<div
v-if="transfer.status === 'transferring' || transfer.status === 'pending'"
class="mt-3 h-1.5 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden relative"
>
<div
class="h-full bg-primary-500 rounded-full transition-all duration-300 relative overflow-hidden"
:style="{ width: progressPercent + '%' }"
>
<!-- Shimmer effect on progress bar -->
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent w-full h-full -translate-x-full animate-[shimmer_1.5s_infinite]"></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-3 flex justify-end gap-3 pt-1 border-t border-surface-100 dark:border-surface-700/50" v-if="(transfer.status === 'completed' && isReceiver && transfer.localPath) || (transfer.status === 'transferring' || transfer.status === 'pending')">
<button
v-if="transfer.status === 'transferring' || transfer.status === 'pending'"
@click="emit('cancel', transfer.fileId)"
class="text-xs font-medium px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
取消传输
</button>
<button
v-if="transfer.status === 'completed' && isReceiver && transfer.localPath"
@click="emit('open')"
class="text-xs font-medium px-3 py-1.5 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors flex items-center gap-1.5"
>
<FolderOpen :size="14" />
打开文件位置
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { File, CheckCircle, XCircle, Loader } from 'lucide-vue-next'
import { File, CheckCircle, XCircle, Loader, FolderOpen } from 'lucide-vue-next'
const props = defineProps({
transfer: {
@ -29,10 +98,10 @@ const progressPercent = computed(() => Math.round(props.transfer.progress * 100)
//
const statusClass = computed(() => {
switch (props.transfer.status) {
case 'completed': return 'text-green-500'
case 'completed': return 'text-green-600 dark:text-green-400'
case 'failed': return 'text-red-500'
case 'cancelled': return 'text-gray-400'
default: return 'text-primary-500'
case 'cancelled': return 'text-surface-400'
default: return 'text-primary-600 dark:text-primary-400'
}
})
@ -46,65 +115,3 @@ const statusIcon = computed(() => {
}
})
</script>
<template>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 border border-surface-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<!-- 图标 -->
<div
class="w-10 h-10 rounded-lg flex items-center justify-center"
:class="transfer.status === 'completed' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-surface-100 dark:bg-gray-700'"
>
<component
:is="statusIcon"
:size="20"
:class="[statusClass, { 'animate-spin': transfer.status === 'transferring' }]"
/>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-white truncate">{{ transfer.name }}</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ formatSize(transfer.transferredBytes || 0) }} / {{ formatSize(transfer.size) }}
</p>
</div>
<!-- 进度/操作 -->
<div class="text-right">
<span class="text-sm font-medium" :class="statusClass">
{{ transfer.status === 'completed' ? '完成' : progressPercent + '%' }}
</span>
</div>
</div>
<!-- 进度条 -->
<div
v-if="transfer.status === 'transferring' || transfer.status === 'pending'"
class="mt-3 h-1.5 bg-surface-100 dark:bg-gray-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-500 rounded-full transition-all duration-300"
:style="{ width: progressPercent + '%' }"
/>
</div>
<!-- 操作按钮 -->
<div class="mt-3 flex justify-end gap-3">
<button
v-if="transfer.status === 'transferring' || transfer.status === 'pending'"
@click="emit('cancel', transfer.fileId)"
class="text-sm text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
>
取消传输
</button>
<button
v-if="transfer.status === 'completed' && isReceiver && transfer.localPath"
@click="emit('open')"
class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
打开文件位置
</button>
</div>
</div>
</template>

@ -68,31 +68,40 @@ function handleFileClick() {
<template>
<div
class="flex mb-3"
class="flex mb-4 animate-fade-in"
:class="isSent ? 'justify-end' : 'justify-start'"
>
<div
class="message-bubble"
class="message-bubble group"
:class="isSent ? 'sent' : 'received'"
>
<!-- 图片消息 -->
<template v-if="isImage">
<img
:src="message.content"
alt="图片"
class="max-w-xs rounded-lg"
loading="lazy"
/>
<div class="relative overflow-hidden rounded-lg cursor-pointer transition-transform hover:scale-[1.02]">
<img
:src="message.content"
alt="图片"
class="max-w-xs block"
loading="lazy"
/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors"></div>
</div>
</template>
<!-- 文件消息 -->
<template v-else-if="isFile">
<div
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
class="flex items-center gap-3 p-1 cursor-pointer hover:opacity-90 transition-opacity"
@click="handleFileClick"
title="点击打开文件位置"
>
<FileText :size="20" />
<span class="underline">{{ fileName }}</span>
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="isSent ? 'bg-white/20' : 'bg-primary-50 dark:bg-primary-900/20 text-primary-500'">
<FileText :size="24" />
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate max-w-[200px]">{{ fileName }}</p>
<p class="text-xs opacity-80">点击查看文件</p>
</div>
</div>
</template>
@ -103,8 +112,11 @@ function handleFileClick() {
<!-- 时间和状态 -->
<div
class="flex items-center gap-1 mt-1 text-xs"
:class="isSent ? 'justify-end text-white/70' : 'text-gray-400'"
class="flex items-center gap-1 mt-1.5 text-[10px] select-none"
:class="[
isSent ? 'justify-end text-white/70' : 'text-surface-400 dark:text-surface-500',
!isSent ? 'justify-end' : ''
]"
>
<span>{{ formattedTime }}</span>
<component

@ -25,51 +25,64 @@ const isActive = (path) => route.path.startsWith(path)
</script>
<template>
<aside class="w-16 bg-white dark:bg-gray-800 border-r border-surface-200 dark:border-gray-700 flex flex-col items-center py-4">
<aside class="w-20 bg-white/80 dark:bg-surface-900/90 backdrop-blur-xl border-r border-surface-200/50 dark:border-surface-700/50 flex flex-col items-center py-6 shadow-[4px_0_24px_rgba(0,0,0,0.02)] z-50 drag-region">
<!-- Logo -->
<div class="w-10 h-10 bg-primary-500 rounded-xl flex items-center justify-center mb-6">
<span class="text-white font-bold text-lg">F</span>
<div class="mb-8 relative group no-drag cursor-default">
<div class="w-11 h-11 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center shadow-lg shadow-primary-500/30 group-hover:scale-105 transition-transform duration-300">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6 text-white"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path></svg>
</div>
<!-- Glow effect -->
<div class="absolute inset-0 bg-primary-500/40 blur-lg rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500 -z-10"></div>
</div>
<!-- 连接状态 -->
<div
class="mb-6 p-2 rounded-lg"
:class="deviceStore.isServerRunning ? 'text-green-500' : 'text-gray-400 dark:text-gray-500'"
class="mb-8 p-2 rounded-full transition-all duration-300 no-drag"
:class="deviceStore.isServerRunning ? 'bg-green-100/50 dark:bg-green-900/20 text-green-600 dark:text-green-400' : 'bg-surface-100 dark:bg-surface-800 text-surface-400'"
:title="deviceStore.isServerRunning ? '服务正常运行' : '服务已断开'"
>
<Wifi v-if="deviceStore.isServerRunning" :size="20" />
<WifiOff v-else :size="20" />
<Wifi v-if="deviceStore.isServerRunning" :size="18" class="animate-pulse-slow" />
<WifiOff v-else :size="18" />
</div>
<!-- 导航菜单 -->
<nav class="flex-1 flex flex-col gap-2">
<nav class="flex-1 flex flex-col gap-4 w-full px-3 no-drag">
<button
v-for="item in navItems"
:key="item.path"
@click="router.push(item.path)"
class="w-10 h-10 rounded-xl flex items-center justify-center transition-all relative"
class="w-full aspect-square rounded-2xl flex flex-col items-center justify-center gap-1 transition-all duration-300 relative group"
:class="[
isActive(item.path)
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
: 'text-gray-500 dark:text-gray-400 hover:bg-surface-100 dark:hover:bg-gray-700'
? 'bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300 shadow-sm'
: 'text-surface-500 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800 hover:text-surface-700 dark:hover:text-surface-200'
]"
:title="item.label"
>
<component :is="item.icon" :size="20" />
<component
:is="item.icon"
:size="22"
class="transition-transform duration-300 group-hover:scale-110"
:class="{ 'group-active:scale-95': true }"
/>
<span class="text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity absolute -bottom-2 translate-y-full bg-surface-900 text-white px-2 py-0.5 rounded shadow-lg pointer-events-none z-50 whitespace-nowrap hidden sm:block">
{{ item.label }}
</span>
<!-- 未读消息徽章 (仅在设备页) -->
<span
v-if="item.path === '/devices' && chatStore.totalUnread > 0"
class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white text-xs rounded-full flex items-center justify-center"
class="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white dark:border-surface-900 shadow-sm"
>
{{ chatStore.totalUnread > 9 ? '9+' : chatStore.totalUnread }}
<span class="absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75 animate-ping"></span>
</span>
</button>
</nav>
<!-- 设备数量 -->
<div class="mt-auto text-center">
<div class="text-xs text-gray-400 dark:text-gray-500">在线</div>
<div class="text-lg font-semibold text-gray-700 dark:text-gray-300">
<div class="mt-auto mb-6 text-center no-drag">
<div class="text-[10px] uppercase tracking-wider text-surface-400 font-semibold mb-1">Online</div>
<div class="text-sm font-bold bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 px-3 py-1 rounded-full min-w-[2rem]">
{{ deviceStore.onlineDevices.length }}
</div>
</div>

@ -143,65 +143,97 @@ onUnmounted(() => {
</script>
<template>
<div class="h-full flex flex-col bg-surface-50 dark:bg-gray-900">
<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="bg-white dark:bg-gray-800 border-b border-surface-200 dark:border-gray-700 px-4 py-3 flex items-center gap-3">
<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 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
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" />
<ArrowLeft :size="20" stroke-width="2.5" />
</button>
<div class="flex-1 min-w-0">
<h2 class="font-medium text-gray-900 dark:text-white truncate">
{{ device?.deviceName || '未知设备' }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ device?.ip }}
<span
class="inline-block w-2 h-2 rounded-full ml-2"
:class="deviceStore.isConnected(deviceId) ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"
/>
</p>
<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 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
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>
<div
v-if="showMenu"
class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-surface-200 dark:border-gray-700 py-1 z-10"
<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"
>
<button
@click="deleteHistory"
class="w-full px-4 py-2 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"
<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"
>
<Trash2 :size="16" />
删除聊天记录
</button>
</div>
<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-4"
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-gray-400 dark:text-gray-500"
class="h-full flex flex-col items-center justify-center text-surface-400 dark:text-surface-500 animate-fade-in"
>
<p>暂无消息</p>
<p class="text-sm mt-1">发送消息开始聊天</p>
<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>
<!-- 消息列表 -->
@ -216,26 +248,35 @@ onUnmounted(() => {
</template>
</div>
<!-- 文件传输进度只显示与当前设备相关的 -->
<div
v-if="deviceTransfers.length > 0"
class="px-4 pb-2 space-y-2"
>
<FileProgress
v-for="[id, transfer] in deviceTransfers"
:key="id"
:transfer="transfer"
:is-receiver="transfer.toDevice === localDeviceId"
@open="chatStore.openFileLocation(transfer.localPath || '')"
@cancel="chatStore.cancelTransfer"
/>
<!-- 文件传输进度浮动在输入框上方 -->
<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>
<!-- 输入区域 -->
<ChatInput
@send="sendMessage"
@select-file="selectFile"
@select-image="selectFile"
/>
<div class="relative z-30">
<ChatInput
@send="sendMessage"
@select-file="selectFile"
@select-image="selectFile"
/>
</div>
</div>
</template>

@ -1,26 +1,155 @@
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useDeviceStore } from '@/stores/deviceStore'
import DeviceCard from '@/components/DeviceCard.vue'
import { Search, RefreshCw, Monitor } from 'lucide-vue-next'
import { useChatStore } from '@/stores/chatStore'
import { chatApi } from '@/api'
import { Search, RefreshCw, Monitor, Smartphone, Laptop, ScanLine } from 'lucide-vue-next'
const router = useRouter()
const deviceStore = useDeviceStore()
const chatStore = useChatStore()
const searchQuery = ref('')
const isRefreshing = ref(false)
const conversations = ref([])
//
const filteredDevices = computed(() => {
// 线
const mergedList = computed(() => {
const result = []
const conversationDeviceIds = new Set()
//
for (const conv of conversations.value) {
conversationDeviceIds.add(conv.deviceId)
// 线
const isOnline = deviceStore.devices.has(conv.deviceId)
result.push({
...conv,
online: isOnline,
// 线 deviceStore
...(isOnline ? { ip: deviceStore.devices.get(conv.deviceId)?.ip || conv.ip } : {})
})
}
// 线
for (const device of deviceStore.deviceList) {
if (!conversationDeviceIds.has(device.deviceId)) {
result.push({
deviceId: device.deviceId,
deviceName: device.deviceName,
ip: device.ip,
online: true,
lastMessage: null,
lastMessageType: null,
lastMessageTime: null,
unreadCount: 0,
isNewDevice: true //
})
}
}
// 线
result.sort((a, b) => {
// 线
if (a.online !== b.online) return b.online ? 1 : -1
//
const timeA = a.lastMessageTime || 0
const timeB = b.lastMessageTime || 0
return timeB - timeA
})
return result
})
//
const filteredList = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return deviceStore.deviceList
if (!query) return mergedList.value
return deviceStore.deviceList.filter(device =>
device.deviceName.toLowerCase().includes(query) ||
device.ip.includes(query)
return mergedList.value.filter(item =>
item.deviceName.toLowerCase().includes(query) ||
item.ip.includes(query)
)
})
//
//
function getDeviceIcon(name) {
const n = name.toLowerCase()
if (n.includes('phone') || n.includes('mobile') || n.includes('android') || n.includes('iphone')) {
return Smartphone
}
if (n.includes('laptop') || n.includes('macbook') || n.includes('notebook')) {
return Laptop
}
return Monitor
}
//
function formatTime(timestamp) {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diff = now - date
//
if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
//
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (date.getDate() === yesterday.getDate()) {
return '昨天'
}
//
if (diff < 7 * 24 * 60 * 60 * 1000) {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return days[date.getDay()]
}
//
return `${date.getMonth() + 1}/${date.getDate()}`
}
//
function formatLastMessage(item) {
if (!item.lastMessage) {
return item.isNewDevice ? '新发现的设备' : '暂无消息'
}
//
if (item.lastMessageType === 'file') {
return item.lastMessage.replace(/^📎\s*/, '[文件] ')
}
//
if (item.lastMessageType === 'image') {
return '[图片]'
}
//
const text = item.lastMessage
return text.length > 30 ? text.substring(0, 30) + '...' : text
}
//
async function openChat(item) {
// 线
if (item.online && !deviceStore.isConnected(item.deviceId)) {
try {
await deviceStore.connectToDevice(item.deviceId)
} catch (error) {
console.error('Connection failed:', error)
}
}
router.push(`/chat/${item.deviceId}`)
}
//
async function refresh() {
if (isRefreshing.value) return
@ -28,84 +157,150 @@ async function refresh() {
try {
await deviceStore.stopServices()
await deviceStore.startServices()
await loadConversations()
} finally {
setTimeout(() => {
isRefreshing.value = false
}, 1000)
}
}
//
async function loadConversations() {
try {
conversations.value = await chatApi.getConversations()
} catch (error) {
console.error('Failed to load conversations:', error)
}
}
onMounted(() => {
loadConversations()
})
</script>
<template>
<div class="h-full flex flex-col">
<div class="h-full flex flex-col bg-surface-50 dark:bg-surface-900">
<!-- 头部 -->
<header class="bg-white dark:bg-gray-800 border-b border-surface-200 dark:border-gray-700 px-6 py-4">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">附近设备</h1>
<button
@click="refresh"
class="p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
:class="{ 'animate-spin': isRefreshing }"
:disabled="isRefreshing"
>
<RefreshCw :size="20" />
</button>
<header class="flex-shrink-0 bg-white dark:bg-surface-800 border-b border-surface-200 dark:border-surface-700">
<!-- 标题栏 -->
<div class="flex items-center justify-between px-4 py-3">
<h1 class="text-lg font-semibold text-surface-900 dark:text-white">消息</h1>
<div class="flex items-center gap-2">
<button
@click="refresh"
class="p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-700 text-surface-500 dark:text-surface-400 transition-colors"
:class="{ 'cursor-not-allowed opacity-50': isRefreshing }"
:disabled="isRefreshing"
title="刷新"
>
<RefreshCw :size="20" :class="{ 'animate-spin': isRefreshing }" />
</button>
</div>
</div>
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" :size="18" />
<input
v-model="searchQuery"
type="text"
placeholder="搜索设备..."
class="w-full pl-10 pr-4 py-2 bg-surface-50 dark:bg-gray-700 border border-surface-200 dark:border-gray-600 rounded-lg focus:outline-none focus:border-primary-400 transition-colors text-gray-900 dark:text-white placeholder-gray-400"
/>
<div class="px-4 pb-3">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400" :size="18" />
<input
v-model="searchQuery"
type="text"
placeholder="搜索"
class="w-full pl-10 pr-4 py-2 bg-surface-100 dark:bg-surface-700 border-none rounded-lg text-sm text-surface-900 dark:text-white placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/50"
/>
</div>
</div>
</header>
<!-- 设备列表 -->
<div class="flex-1 overflow-y-auto p-6">
<!-- 加载状态 -->
<!-- 会话列表 -->
<div class="flex-1 overflow-y-auto">
<!-- 发现设备提示 -->
<div
v-if="deviceStore.isDiscovering && deviceStore.deviceCount === 0"
class="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500"
v-if="deviceStore.isDiscovering && deviceStore.deviceCount === 0 && filteredList.length === 0"
class="flex flex-col items-center justify-center h-48 text-surface-400"
>
<div class="w-16 h-16 border-4 border-gray-200 dark:border-gray-600 border-t-primary-500 dark:border-t-primary-400 rounded-full animate-spin mb-4" />
<p>正在搜索附近设备...</p>
<div class="relative mb-4">
<ScanLine :size="48" class="animate-pulse" />
</div>
<p class="text-sm">正在搜索附近设备...</p>
</div>
<!-- 空状态 -->
<div
v-else-if="filteredDevices.length === 0"
class="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500"
v-else-if="filteredList.length === 0"
class="flex flex-col items-center justify-center h-48 text-surface-400"
>
<Monitor :size="64" class="mb-4 opacity-50" />
<p class="text-lg">{{ searchQuery ? '未找到匹配的设备' : '暂无发现设备' }}</p>
<p class="text-sm mt-1">确保设备在同一局域网内并运行 Flash Send</p>
<Monitor :size="48" class="mb-4 opacity-50" />
<p class="text-sm">{{ searchQuery ? '未找到匹配的设备' : '暂无会话' }}</p>
</div>
<!-- 设备网格 -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<DeviceCard
v-for="device in filteredDevices"
:key="device.deviceId"
:device="device"
/>
<!-- 列表 -->
<div v-else>
<div
v-for="item in filteredList"
:key="item.deviceId"
@click="openChat(item)"
class="flex items-center gap-3 px-4 py-3 hover:bg-surface-100 dark:hover:bg-surface-800 cursor-pointer transition-colors border-b border-surface-100 dark:border-surface-800 last:border-b-0"
>
<!-- 头像/图标 -->
<div class="relative flex-shrink-0">
<div
class="w-12 h-12 rounded-lg flex items-center justify-center"
:class="item.online
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
: 'bg-surface-200 dark:bg-surface-700 text-surface-400'"
>
<component :is="getDeviceIcon(item.deviceName)" :size="24" />
</div>
<!-- 在线状态点 -->
<span
v-if="item.online"
class="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full border-2 border-white dark:border-surface-900"
/>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-0.5">
<span class="font-medium text-surface-900 dark:text-white truncate">
{{ item.deviceName }}
</span>
<span class="text-xs text-surface-400 flex-shrink-0 ml-2">
{{ formatTime(item.lastMessageTime) }}
</span>
</div>
<div class="flex items-center justify-between">
<p
class="text-sm truncate"
:class="item.lastMessage ? 'text-surface-500 dark:text-surface-400' : 'text-surface-400 dark:text-surface-500 italic'"
>
{{ formatLastMessage(item) }}
</p>
<!-- 未读数 -->
<span
v-if="item.unreadCount > 0 || chatStore.getUnreadCount(item.deviceId) > 0"
class="flex-shrink-0 ml-2 min-w-[20px] h-5 px-1.5 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center"
>
{{ (item.unreadCount || chatStore.getUnreadCount(item.deviceId)) > 99 ? '99+' : (item.unreadCount || chatStore.getUnreadCount(item.deviceId)) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 底部状态栏 -->
<footer class="bg-white dark:bg-gray-800 border-t border-surface-200 dark:border-gray-700 px-6 py-3 text-sm text-gray-500 dark:text-gray-400">
<div class="flex items-center justify-between">
<span>
发现 {{ deviceStore.deviceCount }} 台设备
<template v-if="deviceStore.onlineDevices.length > 0">
· {{ deviceStore.onlineDevices.length }} 台在线
</template>
</span>
<span v-if="deviceStore.localDevice">
本机: {{ deviceStore.localDevice.deviceName }} ({{ deviceStore.localDevice.ip }})
<!-- 底部状态 -->
<footer class="flex-shrink-0 px-4 py-2 bg-white dark:bg-surface-800 border-t border-surface-200 dark:border-surface-700">
<div class="flex items-center justify-between text-xs text-surface-400">
<div class="flex items-center gap-2">
<span class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
{{ deviceStore.deviceCount }} 台在线
</span>
</div>
<span v-if="deviceStore.localDevice" class="font-mono">
{{ deviceStore.localDevice.ip }}
</span>
</div>
</footer>

@ -1,9 +1,130 @@
<template>
<div class="h-full flex flex-col bg-surface-50/50 dark:bg-surface-900/50 relative">
<!-- 背景装饰 -->
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-blue-200/10 dark:bg-blue-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-purple-300/10 dark:bg-purple-800/10 rounded-full blur-[80px] pointer-events-none translate-y-1/3 -translate-x-1/3"></div>
<!-- 头部 -->
<header class="relative z-10 px-8 py-6 pb-4">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">文件传输</h1>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">
查看和管理所有文件传输记录
</p>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white/80 dark:bg-surface-800/80 backdrop-blur-sm rounded-2xl p-4 border border-surface-100 dark:border-surface-700/50 shadow-soft group hover:shadow-medium transition-all duration-300">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 rounded-lg bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-300">
<FileText :size="18" />
</div>
<span class="text-sm font-medium text-surface-500 dark:text-surface-400">总计</span>
</div>
<div class="text-2xl font-bold text-surface-900 dark:text-white pl-1">{{ stats.total }}</div>
</div>
<div class="bg-blue-50/80 dark:bg-blue-900/20 backdrop-blur-sm rounded-2xl p-4 border border-blue-100 dark:border-blue-800/30 shadow-soft group hover:shadow-medium transition-all duration-300">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-800/50 text-blue-600 dark:text-blue-300">
<ArrowUp :size="18" />
</div>
<span class="text-sm font-medium text-blue-600/70 dark:text-blue-300/70">发送</span>
</div>
<div class="text-2xl font-bold text-blue-700 dark:text-blue-100 pl-1">{{ stats.sending }}</div>
</div>
<div class="bg-green-50/80 dark:bg-green-900/20 backdrop-blur-sm rounded-2xl p-4 border border-green-100 dark:border-green-800/30 shadow-soft group hover:shadow-medium transition-all duration-300">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-800/50 text-green-600 dark:text-green-300">
<ArrowDown :size="18" />
</div>
<span class="text-sm font-medium text-green-600/70 dark:text-green-300/70">接收</span>
</div>
<div class="text-2xl font-bold text-green-700 dark:text-green-100 pl-1">{{ stats.receiving }}</div>
</div>
<div class="bg-purple-50/80 dark:bg-purple-900/20 backdrop-blur-sm rounded-2xl p-4 border border-purple-100 dark:border-purple-800/30 shadow-soft group hover:shadow-medium transition-all duration-300">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-800/50 text-purple-600 dark:text-purple-300">
<Activity :size="18" />
</div>
<span class="text-sm font-medium text-purple-600/70 dark:text-purple-300/70">进行中</span>
</div>
<div class="text-2xl font-bold text-purple-700 dark:text-purple-100 pl-1">{{ stats.inProgress }}</div>
</div>
</div>
<!-- 标签切换 -->
<div class="flex p-1 bg-surface-200/50 dark:bg-surface-800/50 rounded-xl w-fit backdrop-blur-sm">
<button
v-for="tab in [
{ key: 'all', label: '全部' },
{ key: 'sending', label: '发送' },
{ key: 'receiving', label: '接收' },
]"
:key="tab.key"
@click="activeTab = tab.key"
class="px-6 py-2 rounded-lg text-sm font-medium transition-all duration-300 relative"
:class="[
activeTab === tab.key
? 'text-primary-600 dark:text-primary-300 shadow-sm bg-white dark:bg-surface-700'
: 'text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200'
]"
>
{{ tab.label }}
</button>
</div>
</header>
<!-- 传输列表 -->
<div class="flex-1 overflow-y-auto px-8 py-4 relative z-0 scroll-smooth">
<!-- 空状态 -->
<div
v-if="filteredTransfers.length === 0"
class="h-64 flex flex-col items-center justify-center text-surface-400 dark:text-surface-500 animate-fade-in"
>
<div class="w-20 h-20 bg-surface-100 dark:bg-surface-800/50 rounded-3xl flex items-center justify-center mb-6 shadow-inner">
<Inbox :size="40" class="text-surface-300 dark:text-surface-600" />
</div>
<p class="text-lg font-medium text-surface-600 dark:text-surface-300">暂无传输记录</p>
<p class="text-sm mt-2 opacity-75">选择设备发送文件开始传输</p>
</div>
<!-- 传输列表 -->
<div v-else class="space-y-3 pb-8 animate-slide-up">
<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="transfer in filteredTransfers"
:key="transfer.fileId"
:transfer="transfer"
:is-receiver="transfer.toDevice === localDeviceId"
@open="openLocation(transfer.localPath || '')"
@cancel="cancelTransfer"
class="hover:scale-[1.01] transition-transform duration-200"
/>
</transition-group>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useChatStore } from '@/stores/chatStore'
import { useDeviceStore } from '@/stores/deviceStore'
import FileProgress from '@/components/FileProgress.vue'
import { File } from 'lucide-vue-next'
import { FileText, ArrowUp, ArrowDown, Activity, Inbox } from 'lucide-vue-next'
const chatStore = useChatStore()
const deviceStore = useDeviceStore()
@ -15,14 +136,16 @@ const localDeviceId = computed(() => deviceStore.localDevice?.deviceId || '')
const filteredTransfers = computed(() => {
const localId = deviceStore.localDevice?.deviceId || ''
const transfers = Array.from(chatStore.transfers.values())
//
const sortedTransfers = transfers.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
switch (activeTab.value) {
case 'sending':
return transfers.filter(t => t.fromDevice === localId)
return sortedTransfers.filter(t => t.fromDevice === localId)
case 'receiving':
return transfers.filter(t => t.toDevice === localId)
return sortedTransfers.filter(t => t.toDevice === localId)
default:
return transfers
return sortedTransfers
}
})
@ -36,7 +159,7 @@ const stats = computed(() => {
sending: transfers.filter(t => t.fromDevice === localId).length,
receiving: transfers.filter(t => t.toDevice === localId).length,
completed: transfers.filter(t => t.status === 'completed').length,
inProgress: transfers.filter(t => t.status === 'transferring').length,
inProgress: transfers.filter(t => t.status === 'transferring' || t.status === 'pending').length,
}
})
@ -57,78 +180,3 @@ onMounted(async () => {
}
})
</script>
<template>
<div class="h-full flex flex-col">
<!-- 头部 -->
<header class="bg-white dark:bg-gray-800 border-b border-surface-200 dark:border-gray-700 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">文件传输</h1>
<!-- 统计卡片 -->
<div class="grid grid-cols-4 gap-4 mb-4">
<div class="bg-surface-50 dark:bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ stats.total }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">总计</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 rounded-lg p-3 text-center">
<div class="text-2xl font-semibold text-blue-600 dark:text-blue-400">{{ stats.sending }}</div>
<div class="text-sm text-blue-500 dark:text-blue-400">发送</div>
</div>
<div class="bg-green-50 dark:bg-green-900/30 rounded-lg p-3 text-center">
<div class="text-2xl font-semibold text-green-600 dark:text-green-400">{{ stats.receiving }}</div>
<div class="text-sm text-green-500 dark:text-green-400">接收</div>
</div>
<div class="bg-purple-50 dark:bg-purple-900/30 rounded-lg p-3 text-center">
<div class="text-2xl font-semibold text-purple-600 dark:text-purple-400">{{ stats.inProgress }}</div>
<div class="text-sm text-purple-500 dark:text-purple-400">进行中</div>
</div>
</div>
<!-- 标签切换 -->
<div class="flex gap-2">
<button
v-for="tab in [
{ key: 'all', label: '全部' },
{ key: 'sending', label: '发送' },
{ key: 'receiving', label: '接收' },
]"
:key="tab.key"
@click="activeTab = tab.key"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
:class="[
activeTab === tab.key
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
: 'text-gray-500 dark:text-gray-400 hover:bg-surface-100 dark:hover:bg-gray-700'
]"
>
{{ tab.label }}
</button>
</div>
</header>
<!-- 传输列表 -->
<div class="flex-1 overflow-y-auto p-6">
<!-- 空状态 -->
<div
v-if="filteredTransfers.length === 0"
class="h-full flex flex-col items-center justify-center text-gray-400 dark:text-gray-500"
>
<File :size="64" class="mb-4 opacity-50" />
<p class="text-lg">暂无传输记录</p>
<p class="text-sm mt-1">选择设备发送文件开始传输</p>
</div>
<!-- 传输列表 -->
<div v-else class="space-y-3">
<FileProgress
v-for="transfer in filteredTransfers"
:key="transfer.fileId"
:transfer="transfer"
:is-receiver="transfer.toDevice === localDeviceId"
@open="openLocation(transfer.localPath || '')"
@cancel="cancelTransfer"
/>
</div>
</div>
</div>
</template>

@ -1,17 +1,234 @@
<template>
<div class="h-full overflow-y-auto bg-surface-50/50 dark:bg-surface-900/50 relative">
<!-- 背景装饰 -->
<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>
<div class="max-w-3xl mx-auto p-8 relative z-10 animate-fade-in">
<header class="mb-8">
<h1 class="text-3xl font-bold text-surface-900 dark:text-white tracking-tight">设置</h1>
<p class="text-surface-500 dark:text-surface-400 mt-2">管理您的设备偏好和应用配置</p>
</header>
<!-- 设备信息 -->
<section class="glass-panel rounded-2xl p-1 mb-8">
<div class="px-5 py-4 border-b border-surface-100 dark:border-surface-700/50">
<h2 class="font-semibold text-lg text-surface-900 dark:text-white flex items-center gap-2.5">
<div class="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
<User :size="20" />
</div>
设备信息
</h2>
</div>
<div class="p-6 space-y-6">
<!-- 设备名称 -->
<div class="flex items-center justify-between group">
<div>
<span class="text-sm font-medium text-surface-700 dark:text-surface-300 block">设备名称</span>
<p class="text-xs text-surface-400 dark:text-surface-500 mt-0.5">其他设备将看到此名称</p>
</div>
<div class="flex items-center gap-3">
<template v-if="isEditingName">
<div class="relative">
<input
v-model="editedName"
type="text"
class="px-4 py-2 bg-surface-50 dark:bg-surface-800 border border-primary-500 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500/20 text-surface-900 dark:text-white w-48 transition-all"
@keyup.enter="saveName"
ref="nameInput"
/>
</div>
<div class="flex gap-1">
<button
@click="saveName"
:disabled="isSaving"
class="p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-50 transition-colors shadow-md shadow-primary-500/20"
title="保存"
>
<Check :size="18" />
</button>
<button
@click="isEditingName = false"
class="p-2 rounded-lg bg-surface-100 dark:bg-surface-700 text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors"
title="取消"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
</template>
<template v-else>
<span class="font-semibold text-surface-900 dark:text-white text-lg tracking-tight">
{{ deviceStore.localDevice?.deviceName }}
</span>
<button
@click="startEditName"
class="p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-700 text-surface-400 hover:text-primary-500 transition-all opacity-0 group-hover:opacity-100"
title="编辑名称"
>
<Edit2 :size="18" />
</button>
</template>
</div>
</div>
<div class="w-full h-px bg-surface-100 dark:bg-surface-700/50"></div>
<!-- 设备 ID -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-surface-700 dark:text-surface-300">设备 ID</span>
<span class="font-mono text-sm bg-surface-100 dark:bg-surface-800 px-3 py-1 rounded-md text-surface-600 dark:text-surface-400 select-all">
{{ deviceStore.localDevice?.deviceId }}
</span>
</div>
<!-- IP 地址 -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-surface-700 dark:text-surface-300">IP 地址</span>
<span class="font-mono text-sm bg-surface-100 dark:bg-surface-800 px-3 py-1 rounded-md text-surface-600 dark:text-surface-400">
{{ deviceStore.localDevice?.ip || '获取中...' }}
</span>
</div>
<!-- 端口 -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-surface-700 dark:text-surface-300">服务端口</span>
<div class="flex gap-2 text-sm text-surface-500">
<span class="bg-surface-50 dark:bg-surface-800/50 px-2 py-0.5 rounded border border-surface-200 dark:border-surface-700">WS: {{ deviceStore.config?.wsPort }}</span>
<span class="bg-surface-50 dark:bg-surface-800/50 px-2 py-0.5 rounded border border-surface-200 dark:border-surface-700">HTTP: {{ deviceStore.config?.httpPort }}</span>
</div>
</div>
</div>
</section>
<!-- 外观设置 -->
<section class="glass-panel rounded-2xl p-1 mb-8">
<div class="px-5 py-4 border-b border-surface-100 dark:border-surface-700/50">
<h2 class="font-semibold text-lg text-surface-900 dark:text-white flex items-center gap-2.5">
<div class="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
<Sun :size="20" />
</div>
外观设置
</h2>
</div>
<div class="p-6">
<span class="text-sm font-medium text-surface-700 dark:text-surface-300 block mb-4">主题模式</span>
<div class="grid grid-cols-3 gap-4">
<button
@click="setTheme('system')"
class="relative group flex flex-col items-center justify-center gap-3 p-4 rounded-xl border-2 transition-all duration-300"
:class="themeMode === 'system' ? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20' : 'border-transparent bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'"
>
<div class="w-10 h-10 rounded-full flex items-center justify-center transition-colors" :class="themeMode === 'system' ? 'bg-primary-100 dark:bg-primary-800 text-primary-600 dark:text-primary-300' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'">
<Monitor :size="20" />
</div>
<span class="text-sm font-medium" :class="themeMode === 'system' ? 'text-primary-700 dark:text-primary-300' : 'text-surface-600 dark:text-surface-400'">跟随系统</span>
<div v-if="themeMode === 'system'" class="absolute top-2 right-2 text-primary-500"><CheckCircleIcon class="w-5 h-5" /></div>
</button>
<button
@click="setTheme('light')"
class="relative group flex flex-col items-center justify-center gap-3 p-4 rounded-xl border-2 transition-all duration-300"
:class="themeMode === 'light' ? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20' : 'border-transparent bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'"
>
<div class="w-10 h-10 rounded-full flex items-center justify-center transition-colors" :class="themeMode === 'light' ? 'bg-primary-100 dark:bg-primary-800 text-primary-600 dark:text-primary-300' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'">
<Sun :size="20" />
</div>
<span class="text-sm font-medium" :class="themeMode === 'light' ? 'text-primary-700 dark:text-primary-300' : 'text-surface-600 dark:text-surface-400'">明亮模式</span>
<div v-if="themeMode === 'light'" class="absolute top-2 right-2 text-primary-500"><CheckCircleIcon class="w-5 h-5" /></div>
</button>
<button
@click="setTheme('dark')"
class="relative group flex flex-col items-center justify-center gap-3 p-4 rounded-xl border-2 transition-all duration-300"
:class="themeMode === 'dark' ? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20' : 'border-transparent bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'"
>
<div class="w-10 h-10 rounded-full flex items-center justify-center transition-colors" :class="themeMode === 'dark' ? 'bg-primary-100 dark:bg-primary-800 text-primary-600 dark:text-primary-300' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'">
<Moon :size="20" />
</div>
<span class="text-sm font-medium" :class="themeMode === 'dark' ? 'text-primary-700 dark:text-primary-300' : 'text-surface-600 dark:text-surface-400'">暗黑模式</span>
<div v-if="themeMode === 'dark'" class="absolute top-2 right-2 text-primary-500"><CheckCircleIcon class="w-5 h-5" /></div>
</button>
</div>
</div>
</section>
<!-- 文件设置 -->
<section class="glass-panel rounded-2xl p-1 mb-8">
<div class="px-5 py-4 border-b border-surface-100 dark:border-surface-700/50">
<h2 class="font-semibold text-lg text-surface-900 dark:text-white flex items-center gap-2.5">
<div class="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
<FolderOpen :size="20" />
</div>
文件设置
</h2>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex-1 mr-4">
<span class="text-sm font-medium text-surface-700 dark:text-surface-300 block">默认下载目录</span>
<div class="mt-2 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 flex items-center gap-2 text-sm text-surface-600 dark:text-surface-400 break-all">
<FolderOpen :size="16" class="shrink-0 opacity-50" />
{{ deviceStore.config?.downloadDir }}
</div>
</div>
<button
@click="selectDownloadDir"
class="shrink-0 px-4 py-2.5 bg-white dark:bg-surface-700 border border-surface-200 dark:border-surface-600 text-surface-700 dark:text-surface-200 rounded-xl hover:bg-surface-50 dark:hover:bg-surface-600 hover:border-surface-300 dark:hover:border-surface-500 transition-all shadow-sm font-medium"
>
更改目录
</button>
</div>
</div>
</section>
<!-- 关于 -->
<section class="glass-panel rounded-2xl p-1 mb-12">
<div class="px-5 py-4 border-b border-surface-100 dark:border-surface-700/50">
<h2 class="font-semibold text-lg text-surface-900 dark:text-white flex items-center gap-2.5">
<div class="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
<Info :size="20" />
</div>
关于应用
</h2>
</div>
<div class="p-6 space-y-4">
<div class="flex items-center justify-between">
<span class="text-surface-600 dark:text-surface-400">应用名称</span>
<span class="font-semibold text-surface-900 dark:text-white">Flash Send</span>
</div>
<div class="w-full h-px bg-surface-100 dark:bg-surface-700/50"></div>
<div class="flex items-center justify-between">
<span class="text-surface-600 dark:text-surface-400">当前版本</span>
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-800 text-xs font-bold text-surface-600 dark:text-surface-400 uppercase">Beta</span>
<span class="font-mono text-surface-900 dark:text-white">v1.0.0</span>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, watch, nextTick } from 'vue'
import { useDeviceStore } from '@/stores/deviceStore'
import { open } from '@tauri-apps/plugin-dialog'
import {
User,
FolderOpen,
Info,
Shield,
Check,
Edit2,
Sun,
Moon,
Monitor
Monitor,
CheckCircle as CheckCircleIcon
} from 'lucide-vue-next'
const deviceStore = useDeviceStore()
@ -19,6 +236,7 @@ const deviceStore = useDeviceStore()
const isEditingName = ref(false)
const editedName = ref('')
const isSaving = ref(false)
const nameInput = ref(null)
// ('system' | 'light' | 'dark')
const themeMode = ref(localStorage.getItem('theme') || 'system')
@ -48,6 +266,9 @@ watch(themeMode, (mode) => applyTheme(mode), { immediate: true })
function startEditName() {
editedName.value = deviceStore.localDevice?.deviceName || ''
isEditingName.value = true
nextTick(() => {
nameInput.value?.focus()
})
}
//
@ -90,184 +311,3 @@ onMounted(() => {
})
})
</script>
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">设置</h1>
<!-- 设备信息 -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-surface-200 dark:border-gray-700 mb-6">
<div class="p-4 border-b border-surface-100 dark:border-gray-700">
<h2 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<User :size="18" />
设备信息
</h2>
</div>
<div class="p-4 space-y-4">
<!-- 设备名称 -->
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">设备名称</span>
<div class="flex items-center gap-2">
<template v-if="isEditingName">
<input
v-model="editedName"
type="text"
class="px-3 py-1 border border-surface-200 dark:border-gray-600 dark:bg-gray-700 rounded-lg focus:outline-none focus:border-primary-400 dark:text-white"
@keyup.enter="saveName"
/>
<button
@click="saveName"
:disabled="isSaving"
class="p-1.5 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-50"
>
<Check :size="16" />
</button>
</template>
<template v-else>
<span class="font-medium text-gray-900 dark:text-white">
{{ deviceStore.localDevice?.deviceName }}
</span>
<button
@click="startEditName"
class="p-1.5 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-400"
>
<Edit2 :size="16" />
</button>
</template>
</div>
</div>
<!-- 设备 ID -->
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">设备 ID</span>
<span class="font-mono text-sm text-gray-500 dark:text-gray-400">
{{ deviceStore.localDevice?.deviceId?.slice(0, 8) }}...
</span>
</div>
<!-- IP 地址 -->
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">IP 地址</span>
<span class="font-mono text-gray-900 dark:text-white">
{{ deviceStore.localDevice?.ip || '未知' }}
</span>
</div>
<!-- 端口 -->
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">服务端口</span>
<span class="text-gray-900 dark:text-white">
WS: {{ deviceStore.config?.wsPort }} / HTTP: {{ deviceStore.config?.httpPort }}
</span>
</div>
</div>
</section>
<!-- 外观设置 -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-surface-200 dark:border-gray-700 mb-6">
<div class="p-4 border-b border-surface-100 dark:border-gray-700">
<h2 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Sun :size="18" />
外观设置
</h2>
</div>
<div class="p-4">
<span class="text-gray-600 dark:text-gray-400 block mb-3">主题模式</span>
<div class="flex gap-2">
<button
@click="setTheme('system')"
:class="[
'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors',
themeMode === 'system'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'border-surface-200 dark:border-gray-600 hover:bg-surface-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
]"
>
<Monitor :size="18" />
<span>跟随系统</span>
</button>
<button
@click="setTheme('light')"
:class="[
'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors',
themeMode === 'light'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'border-surface-200 dark:border-gray-600 hover:bg-surface-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
]"
>
<Sun :size="18" />
<span>明亮</span>
</button>
<button
@click="setTheme('dark')"
:class="[
'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors',
themeMode === 'dark'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'border-surface-200 dark:border-gray-600 hover:bg-surface-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
]"
>
<Moon :size="18" />
<span>暗黑</span>
</button>
</div>
</div>
</section>
<!-- 文件设置 -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-surface-200 dark:border-gray-700 mb-6">
<div class="p-4 border-b border-surface-100 dark:border-gray-700">
<h2 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<FolderOpen :size="18" />
文件设置
</h2>
</div>
<div class="p-4 space-y-4">
<!-- 下载目录 -->
<div class="flex items-center justify-between">
<div>
<span class="text-gray-600 dark:text-gray-400">下载保存位置</span>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-0.5">
{{ deviceStore.config?.downloadDir }}
</p>
</div>
<button
@click="selectDownloadDir"
class="px-4 py-2 bg-surface-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-surface-200 dark:hover:bg-gray-600 transition-colors"
>
更改
</button>
</div>
</div>
</section>
<!-- 关于 -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-surface-200 dark:border-gray-700">
<div class="p-4 border-b border-surface-100 dark:border-gray-700">
<h2 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Info :size="18" />
关于
</h2>
</div>
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">应用名称</span>
<span class="font-medium text-gray-900 dark:text-white">Flash Send</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">版本</span>
<span class="text-gray-900 dark:text-white">1.0.0</span>
</div>
</div>
</section>
</div>
</div>
</template>

@ -41,7 +41,28 @@ export const useChatStore = defineStore('chat', () => {
const history = await chatApi.getHistory(deviceId, 100, 0)
// 按时间正序排列
history.sort((a, b) => a.timestamp - b.timestamp)
messages.value.set(deviceId, history)
// 获取当前内存中的消息(可能包含实时接收但尚未持久化的消息)
const existingMessages = messages.value.get(deviceId) || []
// 合并历史记录和内存消息,使用 Map 去重(以 id 为 key
const messageMap = new Map()
// 先添加数据库历史记录
for (const msg of history) {
messageMap.set(msg.id, msg)
}
// 再添加内存中的消息(覆盖同 id 的记录,保留新消息)
for (const msg of existingMessages) {
messageMap.set(msg.id, msg)
}
// 转换回数组并按时间排序
const mergedMessages = Array.from(messageMap.values())
mergedMessages.sort((a, b) => a.timestamp - b.timestamp)
messages.value.set(deviceId, mergedMessages)
} catch (error) {
console.error('Failed to load chat history:', error)
}

@ -9,6 +9,7 @@ import { discoveryApi, websocketApi, fileApi, configApi } from '@/api'
export const useDeviceStore = defineStore('device', () => {
// 状态
const devices = ref(new Map())
const knownDevices = ref(new Map()) // 历史设备(包含离线设备)
const localDevice = ref(null)
const config = ref(null)
const isDiscovering = ref(false)
@ -19,6 +20,18 @@ export const useDeviceStore = defineStore('device', () => {
const deviceList = computed(() => Array.from(devices.value.values()))
const onlineDevices = computed(() => deviceList.value.filter(d => d.online))
const deviceCount = computed(() => devices.value.size)
// 历史设备列表(排除在线设备,避免重复)
const offlineKnownDevices = computed(() => {
const result = []
knownDevices.value.forEach((device, id) => {
// 只显示不在在线列表中的设备
if (!devices.value.has(id)) {
result.push({ ...device, online: false })
}
})
return result
})
// 初始化
async function initialize() {
@ -29,6 +42,9 @@ export const useDeviceStore = defineStore('device', () => {
// 获取配置
config.value = await configApi.get()
// 加载历史设备(用于显示离线设备的聊天记录)
await loadKnownDevices()
// 启动服务
await startServices()
} catch (error) {
@ -80,17 +96,32 @@ export const useDeviceStore = defineStore('device', () => {
// 添加设备
function addDevice(device) {
devices.value.set(device.deviceId, device)
// 同时更新到已知设备
knownDevices.value.set(device.deviceId, device)
}
// 移除设备
function removeDevice(deviceId) {
devices.value.delete(deviceId)
connectedDevices.value.delete(deviceId)
// 注意:不从 knownDevices 中删除,保留历史记录
}
// 获取设备
// 获取设备(优先从在线设备获取,否则从历史设备获取)
function getDevice(deviceId) {
return devices.value.get(deviceId)
return devices.value.get(deviceId) || knownDevices.value.get(deviceId)
}
// 加载历史设备
async function loadKnownDevices() {
try {
const devices = await discoveryApi.getKnownDevices()
devices.forEach(device => {
knownDevices.value.set(device.deviceId, device)
})
} catch (error) {
console.error('Failed to load known devices:', error)
}
}
// 设置设备连接状态
@ -155,6 +186,7 @@ export const useDeviceStore = defineStore('device', () => {
return {
// 状态
devices,
knownDevices,
localDevice,
config,
isDiscovering,
@ -163,6 +195,7 @@ export const useDeviceStore = defineStore('device', () => {
// 计算属性
deviceList,
onlineDevices,
offlineKnownDevices,
deviceCount,
// 方法
initialize,
@ -171,6 +204,7 @@ export const useDeviceStore = defineStore('device', () => {
addDevice,
removeDevice,
getDevice,
loadKnownDevices,
setDeviceConnected,
isConnected,
connectToDevice,

@ -3,6 +3,10 @@
@tailwind utilities;
/* 基础样式 */
:root {
--sidebar-width: 280px;
}
* {
margin: 0;
padding: 0;
@ -16,9 +20,10 @@ html, body, #app {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@apply bg-surface-50 text-surface-900 dark:bg-surface-900 dark:text-surface-50 transition-colors duration-300;
}
/* 自定义滚动条 */
@ -32,21 +37,22 @@ body {
}
::-webkit-scrollbar-thumb {
background: #d4d4d8;
border-radius: 3px;
@apply bg-surface-300 dark:bg-surface-700 rounded-full transition-colors;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1aa;
@apply bg-surface-400 dark:bg-surface-600;
}
/* 暗黑模式滚动条 */
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
/* 玻璃拟态效果 */
.glass {
@apply bg-white/70 dark:bg-surface-900/70 backdrop-blur-md border border-white/20 dark:border-surface-700/30;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #6b7280;
.glass-panel {
@apply bg-white/50 dark:bg-surface-800/50 backdrop-blur-sm border border-white/10 dark:border-white/5 shadow-soft;
}
/* 禁止选中文本 (Tauri 窗口) */
@ -64,67 +70,74 @@ body {
-webkit-app-region: no-drag;
}
/* 动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
/* 动画过渡 */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.2s ease;
.page-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.slide-enter-from {
transform: translateX(-100%);
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.slide-leave-to {
transform: translateX(-100%);
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 消息气泡 */
/* 消息气泡 - 增强版 */
.message-bubble {
@apply rounded-lg px-3 py-2 max-w-[70%] break-words;
@apply relative px-4 py-2.5 max-w-[75%] break-words shadow-sm text-[15px] leading-relaxed transition-all duration-200;
}
.message-bubble.sent {
@apply bg-primary-500 text-white ml-auto;
@apply bg-gradient-to-br from-primary-500 to-primary-600 text-white ml-auto rounded-2xl rounded-tr-sm;
box-shadow: 0 4px 15px -3px rgba(124, 58, 237, 0.3);
}
.message-bubble.received {
@apply bg-white text-gray-800 mr-auto shadow-sm;
}
/* 暗黑模式消息气泡 */
.dark .message-bubble.received {
@apply bg-gray-700 text-gray-100;
@apply bg-white dark:bg-surface-800 text-surface-800 dark:text-surface-100 mr-auto rounded-2xl rounded-tl-sm border border-surface-100 dark:border-surface-700;
box-shadow: 0 2px 10px -2px rgba(0, 0, 0, 0.05);
}
/* 设备卡片悬停效果 */
.device-card {
@apply transition-all duration-200 ease-in-out;
@apply transition-all duration-300 ease-out border border-transparent;
}
.device-card:hover {
@apply shadow-md transform scale-[1.02];
@apply transform -translate-y-1 shadow-medium border-primary-200 dark:border-primary-800/50;
}
/* 进度条动画 */
@keyframes progress-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.progress-indeterminate {
animation: progress-indeterminate 1.5s infinite linear;
.shimmer-effect {
position: relative;
overflow: hidden;
}
.shimmer-effect::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: shimmer 1.5s infinite;
}

@ -20,6 +20,14 @@
*/
/**
* 消息类型
* - text: 文本消息
* - image: 图片消息
* - file: 文件消息
* - event: 系统事件
* - ack: 确认回执
* - ping: 心跳 ping
* - pong: 心跳 pong
* @typedef {'text' | 'image' | 'file' | 'event' | 'ack' | 'ping' | 'pong'} MessageType
*/

@ -4,27 +4,72 @@ export default {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
colors: {
// 主题色 - 参考微信/LocalSend风格
// 主题色 - 使用更现代的 Violet/Indigo 色系,不仅专业而且富有科技感
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
950: '#2e1065',
},
// 背景色
// 背景色 - 使用 Slate 色系,比纯灰更有质感
surface: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
boxShadow: {
'soft': '0 2px 10px rgba(0, 0, 0, 0.03)',
'medium': '0 4px 20px rgba(0, 0, 0, 0.06)',
'hard': '0 8px 30px rgba(0, 0, 0, 0.12)',
'glow': '0 0 15px rgba(139, 92, 246, 0.5)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.4s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'float': 'float 3s ease-in-out infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' },
},
},
},

Loading…
Cancel
Save