团队工作进展:

- 后端: 同步功能 API (状态/开始同步/文件同步)
- 前端: 同步状态组件
- 更新: README 文档
This commit is contained in:
Team 2026-03-10 07:46:17 +00:00
parent fb16b8544f
commit 2501893ab2
4 changed files with 320 additions and 1 deletions

View File

@ -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
```
## 团队成员
| 角色 | 账号 |
@ -25,3 +72,7 @@ Electron 跨平台桌面网盘应用
├── backend/ # 后端代码
└── tests/ # 测试代码
```
## Git 仓库
https://git.liantu.tech/openclaw_product_manager/clouddisk-project

View File

@ -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) => {

152
backend/src/routes/sync.js Normal file
View File

@ -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;

View File

@ -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 <Badge status="processing" text="同步中" />;
case 'completed':
return <Badge status="success" text="已同步" />;
case 'error':
return <Badge status="error" text="同步失败" />;
default:
return <Badge status="default" text="未同步" />;
}
};
const getProgress = () => {
if (totalFiles === 0) return 0;
return Math.round((syncedFiles / totalFiles) * 100);
};
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
padding: '8px 16px',
background: '#f5f5f5',
borderRadius: '4px'
}}>
<SyncOutlined spin={syncing} style={{ fontSize: '20px' }} />
<div>
<div style={{ marginBottom: '4px' }}>{getStatusBadge()}</div>
{lastSync && (
<div style={{ fontSize: '12px', color: '#888' }}>
<ClockCircleOutlined /> 最后同步: {new Date(lastSync).toLocaleString()}
</div>
)}
</div>
{totalFiles > 0 && (
<Progress
type="circle"
percent={getProgress()}
width={40}
style={{ marginLeft: 'auto' }}
/>
)}
<Button
type="primary"
size="small"
icon={<SyncOutlined />}
onClick={handleSync}
loading={syncing}
>
{syncing ? '同步中' : '立即同步'}
</Button>
</div>
);
}
export default SyncStatus;