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.

12 KiB

前端结构

本文档详细介绍 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

设备列表页面,展示在线设备。

功能

  • 显示所有在线设备
  • 点击设备进入聊天
  • 自动刷新设备列表

模板结构

<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

聊天窗口页面,与指定设备聊天。

功能

  • 显示聊天历史
  • 发送/接收消息
  • 发送文件
  • 显示文件传输进度
  • 点击文件消息打开文件位置

关键逻辑

// 过滤当前设备的传输(仅显示进行中)
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

侧边导航栏。

<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

interface Props {
  device: DeviceInfo
}

显示内容

  • 设备名称
  • IP 地址
  • 在线状态指示

MessageBubble.vue

消息气泡组件。

Props

interface Props {
  message: ChatMessage
  isOwn: boolean  // 是否是自己发送的
}

事件

const emit = defineEmits<{
  'file-click': [fileName: string]
}>()

支持的消息类型

  • text: 文本消息
  • image: 图片消息
  • file: 文件消息(可点击)

ChatInput.vue

聊天输入框组件。

事件

const emit = defineEmits<{
  'send': [content: string]
  'send-file': []
}>()

功能

  • 文本输入
  • Enter 发送
  • 文件选择按钮

FileProgress.vue

文件传输进度组件。

Props

interface Props {
  transfer: FileTransfer
  isReceiver: boolean  // 是否是接收方
}

显示内容

  • 文件名
  • 进度条
  • 传输速度/状态
  • 操作按钮(取消/打开位置)

LoadingSpinner.vue

加载动画组件。

状态管理

deviceStore.ts

管理设备相关状态。

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

管理聊天和文件传输状态。

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

管理应用设置。

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

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

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?.()
  })
}

使用示例

// 在组件中监听设备发现事件
useEventListener<DeviceInfo>('device:found', (device) => {
  deviceStore.addDevice(device)
})

// 监听文件传输进度
useEventListener<TransferProgressEvent>('file:progress', (event) => {
  chatStore.updateTransferProgress(event)
})

路由配置

router/index.ts

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

// 设备信息
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

module.exports = {
  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
  darkMode: 'class',  // 支持 class 模式的深色主题
  theme: {
    extend: {
      colors: {
        primary: {...},
        secondary: {...},
      }
    }
  },
  plugins: []
}

深色模式

通过在 <html> 标签添加 dark class 来切换深色模式:

// 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')
  }
}

组件中使用

<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
  <!-- 内容 -->
</div>