|
|
# 前端结构
|
|
|
|
|
|
本文档详细介绍 Vue 3 前端的代码结构、组件功能和状态管理。
|
|
|
|
|
|
## 目录结构
|
|
|
|
|
|
```
|
|
|
src/
|
|
|
├── api/ # Tauri 命令封装
|
|
|
│ └── index.ts
|
|
|
├── components/ # 可复用组件
|
|
|
│ ├── Sidebar.vue
|
|
|
│ ├── DeviceCard.vue
|
|
|
│ ├── MessageBubble.vue
|
|
|
│ ├── ChatInput.vue
|
|
|
│ ├── FileProgress.vue
|
|
|
│ └── LoadingSpinner.vue
|
|
|
├── pages/ # 页面组件
|
|
|
│ ├── DeviceList.vue
|
|
|
│ ├── ChatWindow.vue
|
|
|
│ ├── FileTransfer.vue
|
|
|
│ └── Settings.vue
|
|
|
├── stores/ # Pinia 状态管理
|
|
|
│ ├── deviceStore.ts
|
|
|
│ ├── chatStore.ts
|
|
|
│ └── settingsStore.ts
|
|
|
├── hooks/ # 组合式函数
|
|
|
│ ├── useEventListener.ts
|
|
|
│ └── useTheme.ts
|
|
|
├── types/ # TypeScript 类型
|
|
|
│ └── index.ts
|
|
|
├── router/ # Vue Router
|
|
|
│ └── index.ts
|
|
|
├── styles/ # 全局样式
|
|
|
│ └── main.css
|
|
|
├── App.vue # 根组件
|
|
|
└── main.ts # 入口文件
|
|
|
```
|
|
|
|
|
|
## 页面组件
|
|
|
|
|
|
### DeviceList.vue
|
|
|
|
|
|
设备列表页面,展示在线设备。
|
|
|
|
|
|
**功能**:
|
|
|
- 显示所有在线设备
|
|
|
- 点击设备进入聊天
|
|
|
- 自动刷新设备列表
|
|
|
|
|
|
**模板结构**:
|
|
|
```vue
|
|
|
<template>
|
|
|
<div class="device-list">
|
|
|
<header>在线设备</header>
|
|
|
<div v-if="loading">
|
|
|
<LoadingSpinner />
|
|
|
</div>
|
|
|
<div v-else-if="devices.length === 0">
|
|
|
<EmptyState message="暂无在线设备" />
|
|
|
</div>
|
|
|
<div v-else class="device-grid">
|
|
|
<DeviceCard
|
|
|
v-for="device in devices"
|
|
|
:key="device.deviceId"
|
|
|
:device="device"
|
|
|
@click="goToChat(device)"
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
```
|
|
|
|
|
|
### ChatWindow.vue
|
|
|
|
|
|
聊天窗口页面,与指定设备聊天。
|
|
|
|
|
|
**功能**:
|
|
|
- 显示聊天历史
|
|
|
- 发送/接收消息
|
|
|
- 发送文件
|
|
|
- 显示文件传输进度
|
|
|
- 点击文件消息打开文件位置
|
|
|
|
|
|
**关键逻辑**:
|
|
|
```typescript
|
|
|
// 过滤当前设备的传输(仅显示进行中)
|
|
|
const deviceTransfers = computed(() => {
|
|
|
const result: FileTransfer[] = []
|
|
|
for (const [, transfer] of chatStore.transfers) {
|
|
|
const isRelated =
|
|
|
(transfer.fromDevice === localDeviceId.value && transfer.toDevice === deviceId.value) ||
|
|
|
(transfer.fromDevice === deviceId.value && transfer.toDevice === localDeviceId.value)
|
|
|
const isInProgress = transfer.status === 'pending' || transfer.status === 'transferring'
|
|
|
if (isRelated && isInProgress) {
|
|
|
result.push(transfer)
|
|
|
}
|
|
|
}
|
|
|
return result
|
|
|
})
|
|
|
|
|
|
// 点击文件消息打开位置
|
|
|
async function handleFileClick(fileName: string) {
|
|
|
// 查找传输记录,获取 localPath
|
|
|
await chatStore.openFileLocation(transfer.localPath)
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### FileTransfer.vue
|
|
|
|
|
|
文件传输页面,展示所有传输记录。
|
|
|
|
|
|
**功能**:
|
|
|
- 显示所有传输(进行中/已完成)
|
|
|
- 取消传输
|
|
|
- 打开文件位置
|
|
|
|
|
|
### Settings.vue
|
|
|
|
|
|
设置页面。
|
|
|
|
|
|
**功能**:
|
|
|
- 修改设备名称
|
|
|
- 切换主题(系统/浅色/深色)
|
|
|
- 修改下载目录
|
|
|
|
|
|
## 组件详解
|
|
|
|
|
|
### Sidebar.vue
|
|
|
|
|
|
侧边导航栏。
|
|
|
|
|
|
```vue
|
|
|
<template>
|
|
|
<aside class="sidebar">
|
|
|
<nav>
|
|
|
<router-link to="/devices">
|
|
|
<MonitorSmartphone />
|
|
|
<span>设备</span>
|
|
|
</router-link>
|
|
|
<router-link to="/transfer">
|
|
|
<FolderSync />
|
|
|
<span>传输</span>
|
|
|
</router-link>
|
|
|
<router-link to="/settings">
|
|
|
<Settings />
|
|
|
<span>设置</span>
|
|
|
</router-link>
|
|
|
</nav>
|
|
|
<div class="device-info">
|
|
|
<span>{{ deviceName }}</span>
|
|
|
</div>
|
|
|
</aside>
|
|
|
</template>
|
|
|
```
|
|
|
|
|
|
### DeviceCard.vue
|
|
|
|
|
|
设备卡片组件。
|
|
|
|
|
|
**Props**:
|
|
|
```typescript
|
|
|
interface Props {
|
|
|
device: DeviceInfo
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**显示内容**:
|
|
|
- 设备名称
|
|
|
- IP 地址
|
|
|
- 在线状态指示
|
|
|
|
|
|
### MessageBubble.vue
|
|
|
|
|
|
消息气泡组件。
|
|
|
|
|
|
**Props**:
|
|
|
```typescript
|
|
|
interface Props {
|
|
|
message: ChatMessage
|
|
|
isOwn: boolean // 是否是自己发送的
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**事件**:
|
|
|
```typescript
|
|
|
const emit = defineEmits<{
|
|
|
'file-click': [fileName: string]
|
|
|
}>()
|
|
|
```
|
|
|
|
|
|
**支持的消息类型**:
|
|
|
- `text`: 文本消息
|
|
|
- `image`: 图片消息
|
|
|
- `file`: 文件消息(可点击)
|
|
|
|
|
|
### ChatInput.vue
|
|
|
|
|
|
聊天输入框组件。
|
|
|
|
|
|
**事件**:
|
|
|
```typescript
|
|
|
const emit = defineEmits<{
|
|
|
'send': [content: string]
|
|
|
'send-file': []
|
|
|
}>()
|
|
|
```
|
|
|
|
|
|
**功能**:
|
|
|
- 文本输入
|
|
|
- Enter 发送
|
|
|
- 文件选择按钮
|
|
|
|
|
|
### FileProgress.vue
|
|
|
|
|
|
文件传输进度组件。
|
|
|
|
|
|
**Props**:
|
|
|
```typescript
|
|
|
interface Props {
|
|
|
transfer: FileTransfer
|
|
|
isReceiver: boolean // 是否是接收方
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**显示内容**:
|
|
|
- 文件名
|
|
|
- 进度条
|
|
|
- 传输速度/状态
|
|
|
- 操作按钮(取消/打开位置)
|
|
|
|
|
|
### LoadingSpinner.vue
|
|
|
|
|
|
加载动画组件。
|
|
|
|
|
|
## 状态管理
|
|
|
|
|
|
### deviceStore.ts
|
|
|
|
|
|
管理设备相关状态。
|
|
|
|
|
|
```typescript
|
|
|
export const useDeviceStore = defineStore('device', () => {
|
|
|
// 状态
|
|
|
const devices = ref<Map<string, DeviceInfo>>(new Map())
|
|
|
const localDevice = ref<DeviceInfo | null>(null)
|
|
|
const loading = ref(false)
|
|
|
|
|
|
// 计算属性
|
|
|
const deviceList = computed(() => Array.from(devices.value.values()))
|
|
|
|
|
|
// 方法
|
|
|
async function startDiscovery()
|
|
|
async function stopDiscovery()
|
|
|
async function refreshDevices()
|
|
|
function addDevice(device: DeviceInfo)
|
|
|
function removeDevice(deviceId: string)
|
|
|
|
|
|
return { devices, localDevice, loading, deviceList, ... }
|
|
|
})
|
|
|
```
|
|
|
|
|
|
### chatStore.ts
|
|
|
|
|
|
管理聊天和文件传输状态。
|
|
|
|
|
|
```typescript
|
|
|
export const useChatStore = defineStore('chat', () => {
|
|
|
// 状态
|
|
|
const messages = ref<Map<string, ChatMessage[]>>(new Map()) // deviceId -> messages
|
|
|
const transfers = ref<Map<string, FileTransfer>>(new Map()) // fileId -> transfer
|
|
|
const currentDevice = ref<string | null>(null)
|
|
|
const pendingFile = ref<FileMetadata | null>(null)
|
|
|
|
|
|
// 方法
|
|
|
async function loadHistory(deviceId: string)
|
|
|
async function sendMessage(deviceId: string, content: string)
|
|
|
async function sendFile(deviceId: string, file: FileMetadata)
|
|
|
function updateTransferProgress(event: TransferProgressEvent)
|
|
|
async function loadTransferHistory(deviceId: string, force?: boolean)
|
|
|
async function openFileLocation(path: string)
|
|
|
async function cancelTransfer(fileId: string)
|
|
|
|
|
|
return { messages, transfers, ... }
|
|
|
})
|
|
|
```
|
|
|
|
|
|
### settingsStore.ts
|
|
|
|
|
|
管理应用设置。
|
|
|
|
|
|
```typescript
|
|
|
export const useSettingsStore = defineStore('settings', () => {
|
|
|
// 状态
|
|
|
const theme = ref<'system' | 'light' | 'dark'>('system')
|
|
|
const deviceName = ref('')
|
|
|
const downloadDir = ref('')
|
|
|
|
|
|
// 方法
|
|
|
async function loadSettings()
|
|
|
async function updateTheme(newTheme: Theme)
|
|
|
async function updateDeviceName(name: string)
|
|
|
async function updateDownloadDir(path: string)
|
|
|
|
|
|
return { theme, deviceName, downloadDir, ... }
|
|
|
})
|
|
|
```
|
|
|
|
|
|
## API 封装
|
|
|
|
|
|
### api/index.ts
|
|
|
|
|
|
```typescript
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
|
|
|
|
// 设备发现 API
|
|
|
export const discoveryApi = {
|
|
|
start: () => invoke('start_discovery'),
|
|
|
stop: () => invoke('stop_discovery'),
|
|
|
getDevices: () => invoke<DeviceInfo[]>('get_online_devices'),
|
|
|
getLocalDevice: () => invoke<DeviceInfo>('get_local_device'),
|
|
|
}
|
|
|
|
|
|
// 聊天 API
|
|
|
export const chatApi = {
|
|
|
startServer: () => invoke('start_ws_server'),
|
|
|
connect: (deviceId: string) => invoke('connect_to_device', { deviceId }),
|
|
|
send: (deviceId: string, content: string, messageType?: string) =>
|
|
|
invoke<ChatMessage>('send_chat_message', { deviceId, content, messageType }),
|
|
|
getHistory: (deviceId: string, limit?: number, offset?: number) =>
|
|
|
invoke<ChatMessage[]>('get_chat_history', { deviceId, limit, offset }),
|
|
|
}
|
|
|
|
|
|
// 文件传输 API
|
|
|
export const fileApi = {
|
|
|
startServer: () => invoke('start_http_server'),
|
|
|
selectFile: () => invoke<FileMetadata | null>('select_file'),
|
|
|
send: (deviceId: string, fileId: string, filePath: string) =>
|
|
|
invoke<FileTransfer>('send_file', { deviceId, fileId, filePath }),
|
|
|
getHistory: (deviceId: string, limit?: number) =>
|
|
|
invoke<FileTransfer[]>('get_transfer_history', { deviceId, limit }),
|
|
|
openLocation: (path: string) => invoke('open_file_location', { path }),
|
|
|
cancelTransfer: (fileId: string) => invoke('cancel_transfer', { fileId }),
|
|
|
}
|
|
|
|
|
|
// 配置 API
|
|
|
export const configApi = {
|
|
|
get: () => invoke<AppConfig>('get_app_config'),
|
|
|
updateDeviceName: (name: string) => invoke('update_device_name', { name }),
|
|
|
updateDownloadDir: (path: string) => invoke('update_download_dir', { path }),
|
|
|
}
|
|
|
```
|
|
|
|
|
|
## 事件监听
|
|
|
|
|
|
### hooks/useEventListener.ts
|
|
|
|
|
|
```typescript
|
|
|
import { listen, UnlistenFn } from '@tauri-apps/api/event'
|
|
|
import { onMounted, onUnmounted } from 'vue'
|
|
|
|
|
|
export function useEventListener<T>(
|
|
|
eventName: string,
|
|
|
handler: (payload: T) => void
|
|
|
) {
|
|
|
let unlisten: UnlistenFn | null = null
|
|
|
|
|
|
onMounted(async () => {
|
|
|
unlisten = await listen<T>(eventName, (event) => {
|
|
|
handler(event.payload)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
unlisten?.()
|
|
|
})
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**使用示例**:
|
|
|
```typescript
|
|
|
// 在组件中监听设备发现事件
|
|
|
useEventListener<DeviceInfo>('device:found', (device) => {
|
|
|
deviceStore.addDevice(device)
|
|
|
})
|
|
|
|
|
|
// 监听文件传输进度
|
|
|
useEventListener<TransferProgressEvent>('file:progress', (event) => {
|
|
|
chatStore.updateTransferProgress(event)
|
|
|
})
|
|
|
```
|
|
|
|
|
|
## 路由配置
|
|
|
|
|
|
### router/index.ts
|
|
|
|
|
|
```typescript
|
|
|
import { createRouter, createWebHistory } from 'vue-router'
|
|
|
|
|
|
const routes = [
|
|
|
{
|
|
|
path: '/',
|
|
|
redirect: '/devices'
|
|
|
},
|
|
|
{
|
|
|
path: '/devices',
|
|
|
name: 'devices',
|
|
|
component: () => import('../pages/DeviceList.vue')
|
|
|
},
|
|
|
{
|
|
|
path: '/chat/:deviceId',
|
|
|
name: 'chat',
|
|
|
component: () => import('../pages/ChatWindow.vue'),
|
|
|
props: true
|
|
|
},
|
|
|
{
|
|
|
path: '/transfer',
|
|
|
name: 'transfer',
|
|
|
component: () => import('../pages/FileTransfer.vue')
|
|
|
},
|
|
|
{
|
|
|
path: '/settings',
|
|
|
name: 'settings',
|
|
|
component: () => import('../pages/Settings.vue')
|
|
|
}
|
|
|
]
|
|
|
|
|
|
export const router = createRouter({
|
|
|
history: createWebHistory(),
|
|
|
routes
|
|
|
})
|
|
|
```
|
|
|
|
|
|
## 类型定义
|
|
|
|
|
|
### types/index.ts
|
|
|
|
|
|
```typescript
|
|
|
// 设备信息
|
|
|
export interface DeviceInfo {
|
|
|
deviceId: string
|
|
|
deviceName: string
|
|
|
ip: string
|
|
|
wsPort: number
|
|
|
httpPort: number
|
|
|
lastSeen: number
|
|
|
}
|
|
|
|
|
|
// 聊天消息
|
|
|
export interface ChatMessage {
|
|
|
id: string
|
|
|
fromDevice: string
|
|
|
toDevice: string
|
|
|
content: string
|
|
|
messageType: MessageType
|
|
|
timestamp: number
|
|
|
isRead: boolean
|
|
|
}
|
|
|
|
|
|
export type MessageType = 'text' | 'image' | 'file' | 'system'
|
|
|
|
|
|
// 文件传输
|
|
|
export interface FileTransfer {
|
|
|
fileId: string
|
|
|
name: string
|
|
|
size: number
|
|
|
progress: number
|
|
|
status: TransferStatus
|
|
|
mimeType?: string
|
|
|
fromDevice: string
|
|
|
toDevice: string
|
|
|
localPath?: string
|
|
|
createdAt: number
|
|
|
completedAt?: number
|
|
|
transferredBytes: number
|
|
|
}
|
|
|
|
|
|
export type TransferStatus = 'pending' | 'transferring' | 'completed' | 'failed' | 'cancelled'
|
|
|
|
|
|
// 传输进度事件
|
|
|
export interface TransferProgressEvent {
|
|
|
fileId: string
|
|
|
progress: number
|
|
|
transferredBytes: number
|
|
|
totalBytes: number
|
|
|
status: TransferStatus
|
|
|
fileName?: string
|
|
|
localPath?: string
|
|
|
}
|
|
|
|
|
|
// 文件元数据
|
|
|
export interface FileMetadata {
|
|
|
name: string
|
|
|
path: string
|
|
|
size: number
|
|
|
mimeType?: string
|
|
|
}
|
|
|
|
|
|
// 应用配置
|
|
|
export interface AppConfig {
|
|
|
deviceId: string
|
|
|
deviceName: string
|
|
|
downloadDir: string
|
|
|
wsPort: number
|
|
|
httpPort: number
|
|
|
}
|
|
|
```
|
|
|
|
|
|
## 样式系统
|
|
|
|
|
|
使用 TailwindCSS 进行样式管理。
|
|
|
|
|
|
### tailwind.config.js
|
|
|
|
|
|
```javascript
|
|
|
module.exports = {
|
|
|
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
|
|
darkMode: 'class', // 支持 class 模式的深色主题
|
|
|
theme: {
|
|
|
extend: {
|
|
|
colors: {
|
|
|
primary: {...},
|
|
|
secondary: {...},
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
plugins: []
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 深色模式
|
|
|
|
|
|
通过在 `<html>` 标签添加 `dark` class 来切换深色模式:
|
|
|
|
|
|
```typescript
|
|
|
// settingsStore.ts
|
|
|
function applyTheme(theme: Theme) {
|
|
|
if (theme === 'dark' ||
|
|
|
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
|
document.documentElement.classList.add('dark')
|
|
|
} else {
|
|
|
document.documentElement.classList.remove('dark')
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**组件中使用**:
|
|
|
```vue
|
|
|
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
|
|
|
<!-- 内容 -->
|
|
|
</div>
|
|
|
```
|