diff --git a/README.md b/README.md index 5da3e8f..ee98df5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,53 @@ Electron 跨平台桌面网盘应用 +## 技术栈 + +### 前端 +- Electron 28+ (桌面框架) +- React 18 (UI框架) +- TypeScript (类型安全) +- Zustand (状态管理) +- Ant Design 5 (UI组件库) + +### 后端 +- Node.js 20+ (运行环境) +- Express 4 (Web框架) +- SQLite3 (本地数据库) +- JWT (用户认证) +- Multer (文件上传) + +## 功能特性 + +- ✅ 用户注册/登录 +- ✅ 文件上传/下载/删除 +- ✅ 文件夹管理 +- ✅ 分享功能(密码保护/链接分享) +- 🔄 云端同步 +- ⏳ 离线访问 +- ⏳ 主题切换 + +## 开发指南 + +### 前端开发 +```bash +cd frontend +npm install +npm run dev +``` + +### 后端开发 +```bash +cd backend +npm install +npm run dev +``` + +### 测试 +```bash +npm test +``` + ## 团队成员 | 角色 | 账号 | @@ -21,7 +68,11 @@ Electron 跨平台桌面网盘应用 ├── prd/ # 产品需求文档 ├── ui-design/ # UI设计文件 ├── architecture/ # 架构设计文档 -├── frontend/ # 前端代码 +├── frontend/ # 前端代码 ├── backend/ # 后端代码 └── tests/ # 测试代码 ``` + +## Git 仓库 + +https://git.liantu.tech/openclaw_product_manager/clouddisk-project diff --git a/backend/src/index.js b/backend/src/index.js index 3e7e4e7..f1f0bc7 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -3,6 +3,7 @@ const cors = require('cors'); const authRoutes = require('./routes/auth'); const fileRoutes = require('./routes/files'); const shareRoutes = require('./routes/share'); +const syncRoutes = require('./routes/sync'); const app = express(); const PORT = process.env.PORT || 3000; @@ -14,6 +15,7 @@ app.use(express.json()); app.use('/api/auth', authRoutes); app.use('/api/files', fileRoutes); app.use('/api/share', shareRoutes); +app.use('/api/sync', syncRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/backend/src/routes/sync.js b/backend/src/routes/sync.js new file mode 100644 index 0000000..88d30ec --- /dev/null +++ b/backend/src/routes/sync.js @@ -0,0 +1,152 @@ +const express = require('express'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const db = require('../db'); + +const router = express.Router(); + +// Get sync status +router.get('/status', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + const status = db.query( + 'SELECT * FROM sync_status WHERE user_id = ?', + [decoded.userId] + ); + + if (status.length === 0) { + // Create initial sync status + const result = db.run( + 'INSERT INTO sync_status (user_id) VALUES (?)', + [decoded.userId] + ); + return res.json({ + status: 'idle', + totalFiles: 0, + syncedFiles: 0 + }); + } + + res.json({ + status: status[0].status, + lastSyncTime: status[0].last_sync_time, + totalFiles: status[0].total_files, + syncedFiles: status[0].synced_files + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Start sync +router.post('/start', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + // Update sync status + db.run( + 'UPDATE sync_status SET status = ?, last_sync_time = CURRENT_TIMESTAMP WHERE user_id = ?', + ['syncing', decoded.userId] + ); + + res.json({ success: true, message: 'Sync started' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Calculate file hash for deduplication +function calculateFileHash(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5'); + const stream = fs.createReadStream(filePath); + stream.on('data', data => hash.update(data)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} + +// Sync single file (for client to call) +router.post('/file', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + const { fileId, localHash, localModified } = req.body; + + // Get server file info + const serverFile = db.query( + 'SELECT * FROM files WHERE id = ? AND user_id = ?', + [fileId, decoded.userId] + ); + + if (serverFile.length === 0) { + return res.status(404).json({ error: 'File not found' }); + } + + // Determine sync action + let action = 'up-to-date'; + + if (!serverFile[0].hash || serverFile[0].hash !== localHash) { + // Need to upload + action = 'upload'; + } + + res.json({ + action, + serverModified: serverFile[0].updated_at, + serverHash: serverFile[0].hash + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get local files that need syncing +router.get('/pending', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + // Get files modified since last sync + const lastSync = db.query( + 'SELECT last_sync_time FROM sync_status WHERE user_id = ?', + [decoded.userId] + ); + + let query; + let params; + + if (lastSync.length > 0 && lastSync[0].last_sync_time) { + query = 'SELECT * FROM files WHERE user_id = ? AND updated_at > ?'; + params = [decoded.userId, lastSync[0].last_sync_time]; + } else { + query = 'SELECT * FROM files WHERE user_id = ?'; + params = [decoded.userId]; + } + + const files = db.query(query, params); + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/frontend/src/renderer/components/SyncStatus.jsx b/frontend/src/renderer/components/SyncStatus.jsx new file mode 100644 index 0000000..c41e9d8 --- /dev/null +++ b/frontend/src/renderer/components/SyncStatus.jsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import { Badge, Button, Progress, Tooltip } from 'antd'; +import { SyncOutlined, CheckCircleOutlined, ClockCircleOutlined } from '@ant-design/icons'; + +function SyncStatus() { + const [status, setStatus] = useState('idle'); + const [lastSync, setLastSync] = useState(null); + const [syncedFiles, setSyncedFiles] = useState(0); + const [totalFiles, setTotalFiles] = useState(0); + const [syncing, setSyncing] = useState(false); + + useEffect(() => { + fetchSyncStatus(); + // Poll status every 30 seconds + const interval = setInterval(fetchSyncStatus, 30000); + return () => clearInterval(interval); + }, []); + + const fetchSyncStatus = async () => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/sync/status', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + setStatus(data.status); + setLastSync(data.lastSyncTime); + setSyncedFiles(data.syncedFiles); + setTotalFiles(data.totalFiles); + } catch (error) { + console.error('Failed to fetch sync status:', error); + } + }; + + const handleSync = async () => { + setSyncing(true); + try { + const token = localStorage.getItem('token'); + await fetch('/api/sync/start', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + // Start polling for progress + const interval = setInterval(async () => { + await fetchSyncStatus(); + if (status === 'idle' || status === 'completed') { + setSyncing(false); + clearInterval(interval); + } + }, 2000); + } catch (error) { + console.error('Failed to start sync:', error); + setSyncing(false); + } + }; + + const getStatusBadge = () => { + switch (status) { + case 'syncing': + return ; + case 'completed': + return ; + case 'error': + return ; + default: + return ; + } + }; + + const getProgress = () => { + if (totalFiles === 0) return 0; + return Math.round((syncedFiles / totalFiles) * 100); + }; + + return ( +
+ +
+
{getStatusBadge()}
+ {lastSync && ( +
+ 最后同步: {new Date(lastSync).toLocaleString()} +
+ )} +
+ {totalFiles > 0 && ( + + )} + +
+ ); +} + +export default SyncStatus;