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

553 lines
12 KiB
Markdown

This file contains ambiguous Unicode characters!

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

# 前端结构
本文档详细介绍 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>
```