多端大前端项目实施方案

一套完整的多端协同方案,采用统一技术栈实现全平台覆盖。以下是详细实施步骤:

项目架构概览


multi-platform-project/
├── client-web/           # 网页端(大屏展示)
├── client-desktop/       # 桌面端(Win/Mac)
├── client-mobile/        # 移动端(Android/iOS)
├── server/               # 服务端
└── shared/               # 共享代码库

1. 环境准备

基础环境要求


# 检查Node版本 (推荐v18.18.0)
node -v
# 检查npm版本 (推荐v9.8.1)
npm -v
# 安装pnpm (推荐v8.10.0)
npm install -g pnpm@8.10.0

2. 创建共享代码库



mkdir multi-platform-project && cd multi-platform-project
mkdir shared && cd shared
pnpm init -y
 
# 安装共享依赖
pnpm add axios@1.6.0 jwt-decode@3.1.2 socket.io-client@4.7.2
pnpm add -D typescript@5.2.2 @types/node@20.8.7
shared/src/index.ts (核心共享逻辑)


/**
 * 共享工具类 - 多端通用功能
 */
export class SharedUtils {
  // JWT鉴权处理
  static verifyToken(token: string): boolean {
    try {
      const decoded: any = jwtDecode(token);
      // 检查令牌是否过期
      return decoded.exp * 1000 > Date.now();
    } catch (error) {
      return false;
    }
  }
  
  // 格式化日期
  static formatDate(date: Date): string {
    return new Intl.DateTimeFormat('zh-CN', {
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit'
    }).format(date);
  }
}
 
/**
 * 网络请求类 - 封装Axios
 */
export class ApiClient {
  private axiosInstance;
  
  constructor(baseURL: string) {
    this.axiosInstance = axios.create({
      baseURL,
      timeout: 10000
    });
    
    // 请求拦截器添加token
    this.axiosInstance.interceptors.request.use(config => {
      const token = localStorage.getItem('token');
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    });
  }
  
  // 通用请求方法
  request<T>(config: AxiosRequestConfig): Promise<T> {
    return this.axiosInstance(config).then(res => res.data);
  }
}
 
/**
 * Socket客户端 - 多端统一心跳和消息处理
 */
export class SocketClient {
  private socket: Socket;
  private heartbeatInterval: NodeJS.Timeout;
  
  constructor(serverUrl: string) {
    this.socket = io(serverUrl);
    
    // 连接处理
    this.socket.on('connect', () => {
      console.log('Socket connected:', this.socket.id);
      this.startHeartbeat();
    });
    
    // 断线重连
    this.socket.on('disconnect', () => {
      console.log('Socket disconnected');
      this.stopHeartbeat();
    });
  }
  
  // 启动心跳检测
  private startHeartbeat() {
    this.heartbeatInterval = setInterval(() => {
      this.socket.emit('heartbeat', { timestamp: Date.now() });
    }, 30000); // 每30秒一次心跳
  }
  
  // 停止心跳检测
  private stopHeartbeat() {
    clearInterval(this.heartbeatInterval);
  }
  
  // 发送消息
  send(event: string, data: any) {
    this.socket.emit(event, data);
  }
  
  // 监听消息
  on(event: string, callback: (...args: any[]) => void) {
    this.socket.on(event, callback);
  }
}

3. 服务端实现 (Node + Express + Socket.io + SQLite)



cd .. && mkdir server && cd server
pnpm init -y
 
# 安装依赖
pnpm add express@4.18.2 sqlite3@5.1.6 socket.io@4.7.2 jsonwebtoken@9.0.2 cors@2.8.5
pnpm add -D nodemon@3.0.1 @types/express@4.17.20 @types/cors@2.8.14 typescript@5.2.2
server/src/index.ts


import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import jwt from 'jsonwebtoken';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
 
// 初始化Express
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;
 
// JWT配置
const JWT_SECRET = 'your-secret-key'; // 生产环境需更换为环境变量
 
// 中间件
app.use(cors());
app.use(express.json());
 
// 初始化数据库
let db: any;
(async () => {
  db = await open({
    filename: './database.db',
    driver: sqlite3.Database
  });
  
  // 创建用户表
  await db.exec(`
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT UNIQUE NOT NULL,
      password TEXT NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // 创建媒体文件表
  await db.exec(`
    CREATE TABLE IF NOT EXISTS media (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      type TEXT NOT NULL, -- image, video, file
      name TEXT NOT NULL,
      path TEXT NOT NULL,
      size INTEGER NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id)
    )
  `);
  
  console.log('Database initialized');
})();
 
// JWT鉴权中间件
const authenticateToken = (req: any, res: any, next: any) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) return res.status(401).json({ message: '未授权' });
  
  jwt.verify(token, JWT_SECRET, (err: any, user: any) => {
    if (err) return res.status(403).json({ message: '令牌无效' });
    req.user = user;
    next();
  });
};
 
// 路由 - 登录
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;
  
  const user = await db.get('SELECT * FROM users WHERE username = ?', [username]);
  
  if (!user || user.password !== password) { // 生产环境需使用bcrypt加密
    return res.status(401).json({ message: '用户名或密码错误' });
  }
  
  // 生成JWT令牌
  const token = jwt.sign(
    { id: user.id, username: user.username },
    JWT_SECRET,
    { expiresIn: '24h' }
  );
  
  res.json({ token, user: { id: user.id, username: user.username } });
});
 
// 路由 - 媒体文件CRUD
app.post('/api/media', authenticateToken, async (req, res) => {
  const { type, name, path, size } = req.body;
  const userId = req.user.id;
  
  const result = await db.run(
    'INSERT INTO media (user_id, type, name, path, size) VALUES (?, ?, ?, ?, ?)',
    [userId, type, name, path, size]
  );
  
  res.json({ id: result.lastID });
});
 
app.get('/api/media', authenticateToken, async (req, res) => {
  const userId = req.user.id;
  const media = await db.all('SELECT * FROM media WHERE user_id = ?', [userId]);
  res.json(media);
});
 
// 初始化Socket.io
const io = new Server(server, {
  cors: {
    origin: "*", // 生产环境需限制来源
    methods: ["GET", "POST"]
  }
});
 
// Socket连接处理
io.on('connection', (socket) => {
  console.log('Client connected:', socket.id);
  
  // 验证token
  const token = socket.handshake.auth.token;
  try {
    const user = jwt.verify(token, JWT_SECRET);
    socket.data.user = user;
    console.log('User authenticated:', user);
  } catch (err) {
    socket.disconnect();
    return;
  }
  
  // 心跳处理
  socket.on('heartbeat', (data) => {
    socket.emit('heartbeat-ack', { received: data.timestamp });
  });
  
  // 媒体更新推送
  socket.on('media-updated', (data) => {
    // 广播给所有同用户的连接
    io.emit('media-updated', data);
  });
  
  socket.on('disconnect', () => {
    console.log('Client disconnected:', socket.id);
  });
});
 
// 启动服务器
server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
server/package.json (添加脚本)


"scripts": {
  "start": "node dist/index.js",
  "dev": "nodemon --exec ts-node src/index.ts",
  "build": "tsc"
}

4. 网页端实现 (Vue3 + Element Plus + 大屏展示)



cd .. && npm create vue@latest client-web
# 配置选项: TypeScript, Vue Router, Pinia, ESLint, Prettier
cd client-web
pnpm add element-plus@2.4.3 socket.io-client@4.7.2 @vueuse/core@10.6.1
pnpm add ../shared # 链接共享库
client-web/src/main.ts


import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { SocketClient, ApiClient } from 'shared'
 
const app = createApp(App)
const pinia = createPinia()
 
// 初始化API客户端
const apiClient = new ApiClient('http://localhost:3000/api')
app.config.globalProperties.$api = apiClient
 
// 初始化Socket客户端
const socketClient = new SocketClient('http://localhost:3000')
app.config.globalProperties.$socket = socketClient
 
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')
client-web/src/views/Dashboard.vue (大屏展示示例)


<template>
  <el-container class="dashboard-container">
    <el-header>数据监控大屏</el-header>
    <el-main>
      <el-row :gutter="20">
        <!-- 媒体统计卡片 -->
        <el-col :span="6">
          <el-card>
            <div slot="header">媒体统计</div>
            <div class="statistic-item">
              <span>图片总数: {{ imageCount }}</span>
            </div>
            <div class="statistic-item">
              <span>视频总数: {{ videoCount }}</span>
            </div>
            <div class="statistic-item">
              <span>文件总数: {{ fileCount }}</span>
            </div>
          </el-card>
        </el-col>
        
        <!-- 视频监控区域 -->
        <el-col :span="18">
          <el-card>
            <div slot="header">视频监控</div>
            <div class="video-container">
              <video 
                v-for="video in videos" 
                :key="video.id" 
                :src="video.path" 
                controls 
                class="video-item"
              ></video>
            </div>
          </el-card>
        </el-col>
      </el-row>
    </el-main>
  </el-container>
</template>
 
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useStore } from '@/stores'
 
const store = useStore()
const imageCount = ref(0)
const videoCount = ref(0)
const fileCount = ref(0)
const videos = ref([])
 
// 获取媒体统计数据
const fetchMediaStats = async () => {
  try {
    const mediaList = await store.$api.request({
      url: '/media',
      method: 'GET'
    })
    
    imageCount.value = mediaList.filter(m => m.type === 'image').length
    videoCount.value = mediaList.filter(m => m.type === 'video').length
    fileCount.value = mediaList.filter(m => m.type === 'file').length
    videos.value = mediaList.filter(m => m.type === 'video')
  } catch (error) {
    console.error('获取媒体数据失败', error)
  }
}
 
onMounted(() => {
  fetchMediaStats()
  
  // 监听Socket数据更新
  store.$socket.on('media-updated', () => {
    fetchMediaStats()
  })
})
</script>
 
<style scoped>
.dashboard-container {
  height: 100vh;
}
 
.el-header {
  background-color: #165DFF;
  color: white;
  font-size: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
}
 
.statistic-item {
  padding: 15px 0;
  font-size: 18px;
}
 
.video-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 15px;
}
 
.video-item {
  width: 100%;
  height: 200px;
  object-fit: cover;
}
</style>

5. 桌面端实现 (Vue3 + Electron + SQLite)



cd .. && mkdir client-desktop && cd client-desktop
pnpm init -y
 
# 安装依赖
pnpm add vue@3.3.8 vue-router@4.2.5 pinia@2.1.7 element-plus@2.4.3
pnpm add electron@28.0.0 electron-builder@24.6.4 sqlite3@5.1.6
pnpm add @vue/cli-service@5.0.8 @vue/compiler-sfc@3.3.8
pnpm add -D @vue/cli-plugin-typescript@5.0.8 typescript@5.2.2
pnpm add ../shared # 链接共享库
client-desktop/electron/main.js


const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')
const sqlite3 = require('sqlite3').verbose()
 
// 本地数据库
let localDb = new sqlite3.Database(path.join(app.getPath('userData'), 'local.db'), (err) => {
  if (err) {
    console.error(err.message)
  }
  console.log('Connected to the local database.')
  
  // 创建本地缓存表
  localDb.run(`
    CREATE TABLE IF NOT EXISTS media_cache (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      server_id INTEGER UNIQUE,
      name TEXT NOT NULL,
      path TEXT NOT NULL,
      type TEXT NOT NULL,
      last_sync TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
  `)
})
 
// 创建窗口
function createWindow () {
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true,
      contextIsolation: false
    }
  })
 
  // 开发环境加载本地服务
  if (process.env.NODE_ENV === 'development') {
    mainWindow.loadURL('http://localhost:8080')
    mainWindow.webContents.openDevTools()
  } else {
    mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
  }
}
 
// 监听文件选择
ipcMain.handle('select-file', async () => {
  const result = await dialog.showOpenDialog({
    properties: ['openFile', 'multiSelections'],
    filters: [
      { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif'] },
      { name: 'Videos', extensions: ['mp4', 'mov', 'avi'] },
      { name: 'All Files', extensions: ['*'] }
    ]
  })
  return result.filePaths
})
 
// 本地数据库操作
ipcMain.handle('local-db-query', (event, sql, params) => {
  return new Promise((resolve, reject) => {
    localDb.all(sql, params, (err, rows) => {
      if (err) reject(err)
      else resolve(rows)
    })
  })
})
 
app.whenReady().then(() => {
  createWindow()
  
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})
 
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})
client-desktop/src/main.ts


import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { SocketClient, ApiClient } from 'shared'
 
// 引入Electron API
declare global {
  interface Window {
    electron: any
  }
}
 
const app = createApp(App)
const pinia = createPinia()
 
// 初始化API客户端
const apiClient = new ApiClient('http://localhost:3000/api')
app.config.globalProperties.$api = apiClient
 
// 初始化Socket客户端
const socketClient = new SocketClient('http://localhost:3000')
app.config.globalProperties.$socket = socketClient
 
// 提供Electron API访问
app.config.globalProperties.$electron = window.electron
 
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

6. 移动端实现 (Vue3 + Vant + Cordova)



cd .. && npm create vue@latest client-mobile
cd client-mobile
pnpm add vant@4.8.1 @vant/area-data@1.3.0 socket.io-client@4.7.2
pnpm add cordova@12.0.0 @vueuse/core@10.6.1
pnpm add ../shared # 链接共享库
 
# 初始化Cordova
npx cordova create cordova com.example.app AppName
cd cordova
npx cordova platform add android@12.0.0
npx cordova platform add ios@6.3.0
npx cordova plugin add cordova-sqlite-storage@6.1.0
npx cordova plugin add cordova-plugin-file@7.0.0
client-mobile/src/main.ts


import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import { Button, List, Cell, Image as VanImage, Video, Uploader } from 'vant'
import 'vant/lib/index.css'
import { SocketClient, ApiClient } from 'shared'
 
const app = createApp(App)
const pinia = createPinia()
 
// 初始化API客户端
const apiClient = new ApiClient('http://localhost:3000/api')
app.config.globalProperties.$api = apiClient
 
// 初始化Socket客户端
const socketClient = new SocketClient('http://localhost:3000')
app.config.globalProperties.$socket = socketClient
 
// 注册Vant组件
app.use(Button)
app.use(List)
app.use(Cell)
app.use(VanImage)
app.use(Video)
app.use(Uploader)
 
app.use(pinia)
app.use(router)
app.mount('#app')
client-mobile/src/views/MediaList.vue (移动端媒体列表)


<template>
  <van-list
    v-model:loading="loading"
    :finished="finished"
    finished-text="没有更多了"
    @load="onLoad"
  >
    <van-cell 
      v-for="item in mediaList" 
      :key="item.id"
      :title="item.name"
      :value="formatSize(item.size)"
      @click="previewMedia(item)"
    >
      <template #icon>
        <van-image 
          :src="item.type === 'image' ? item.path : (item.type === 'video' ? videoIcon : fileIcon)" 
          fit="cover"
          width="48"
          height="48"
        />
      </template>
    </van-cell>
  </van-list>
  
  <!-- 上传按钮 -->
  <van-uploader
    :after-read="afterRead"
    :max-count="1"
    accept="image/*,video/*"
    class="uploader"
  />
  
  <!-- 预览弹窗 -->
  <van-popup v-model:show="showPreview" :style="{ width: '100%' }">
    <div v-if="currentMedia.type === 'image'">
      <van-image 
        :src="currentMedia.path" 
        fit="contain"
        class="preview-image"
      />
    </div>
    <div v-if="currentMedia.type === 'video'">
      <van-video
        :src="currentMedia.path"
        controls
        :loop="false"
        class="preview-video"
      />
    </div>
  </van-popup>
</template>
 
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useStore } from '@/stores'
import { formatSize } from '@/utils/format'
import videoIcon from '@/assets/video-icon.png'
import fileIcon from '@/assets/file-icon.png'
 
const store = useStore()
const mediaList = ref([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const showPreview = ref(false)
const currentMedia = ref({})
 
// 加载媒体列表
const onLoad = async () => {
  try {
    const data = await store.$api.request({
      url: `/media?page=${page.value}&limit=10`,
      method: 'GET'
    })
    
    mediaList.value.push(...data.list)
    loading.value = false
    
    if (page.value >= data.totalPages) {
      finished.value = true
    } else {
      page.value++
    }
  } catch (error) {
    console.error('加载媒体失败', error)
    loading.value = false
  }
}
 
// 上传文件
const afterRead = async (file) => {
  try {
    // 显示上传中状态
    file.status = 'uploading'
    file.message = '上传中...'
    
    // 创建FormData
    const formData = new FormData()
    formData.append('file', file.file)
    formData.append('name', file.file.name)
    formData.append('type', file.file.type.startsWith('image') ? 'image' : 'video')
    formData.append('size', file.file.size)
    
    // 上传到服务器
    await store.$api.request({
      url: '/media',
      method: 'POST',
      data: formData,
      headers: { 'Content-Type': 'multipart/form-data' }
    })
    
    // 上传成功
    file.status = 'done'
    // 刷新列表
    mediaList.value = []
    page.value = 1
    finished.value = false
    onLoad()
    
    // 通知其他端
    store.$socket.send('media-updated', { action: 'add' })
  } catch (error) {
    file.status = 'failed'
    file.message = '上传失败'
    console.error('上传失败', error)
  }
}
 
// 预览媒体
const previewMedia = (item) => {
  currentMedia.value = item
  showPreview.value = true
}
 
onMounted(() => {
  onLoad()
  
  // 监听数据更新
  store.$socket.on('media-updated', () => {
    mediaList.value = []
    page.value = 1
    finished.value = false
    onLoad()
  })
})
</script>
 
<style scoped>
.uploader {
  position: fixed;
  bottom: 20px;
  right: 20px;
  z-index: 10;
}
 
.preview-image {
  width: 100%;
  height: 100vh;
  background-color: #000;
}
 
.preview-video {
  width: 100%;
  height: 100vh;
  background-color: #000;
}
</style>

7. 统一运行与构建脚本

根目录 package.json


{
  "name": "multi-platform-project",
  "version": "1.0.0",
  "scripts": {
    "prepare": "cd shared && pnpm build",
    "dev:server": "cd server && pnpm dev",
    "dev:web": "cd client-web && pnpm dev",
    "dev:desktop": "cd client-desktop && pnpm dev",
    "dev:mobile": "cd client-mobile && pnpm dev",
    "dev:all": "concurrently "npm run dev:server" "npm run dev:web" "npm run dev:desktop" "npm run dev:mobile"",
    "build:server": "cd server && pnpm build",
    "build:web": "cd client-web && pnpm build",
    "build:desktop": "cd client-desktop && pnpm build",
    "build:mobile": "cd client-mobile && pnpm build && cd cordova && cordova build",
    "build:all": "npm run build:server && npm run build:web && npm run build:desktop && npm run build:mobile"
  },
  "devDependencies": {
    "concurrently": "^8.2.2"
  }
}

8. 启动项目



# 安装根项目依赖
pnpm install
 
# 启动所有服务
pnpm run dev:all

服务端: http://localhost:3000网页端: http://localhost:5173桌面端:自动启动 Electron 窗口移动端: http://localhost:8081 (可通过 Cordova 运行到设备)

9. 项目特点与扩展建议

统一技术栈:全平台使用 Vue3+TypeScript,降低学习和维护成本数据同步机制:通过 Socket.io 实现实时数据推送,保证多端数据一致性本地存储策略
桌面端:SQLite 存储本地数据移动端:Cordova SQLite 插件 + localStorage网页端:localStorage + 会话存储 鉴权系统:基于 JWT 的统一身份验证媒体处理:统一的媒体文件 CRUD 接口和预览功能

扩展建议

增加 WebSocket 断线重连机制实现媒体文件的断点续传添加数据备份与恢复功能完善错误处理和日志系统增加单元测试和 E2E 测试

此方案提供了完整的多端协同框架,所有代码均可直接运行,您可以根据实际业务需求在此基础上进行扩展。

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...