团队工作进展:
- 后端: 同步功能 API (状态/开始同步/文件同步) - 前端: 同步状态组件 - 更新: README 文档
This commit is contained in:
parent
fb16b8544f
commit
2501893ab2
53
README.md
53
README.md
@ -4,6 +4,53 @@
|
|||||||
|
|
||||||
Electron 跨平台桌面网盘应用
|
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/ # 产品需求文档
|
├── prd/ # 产品需求文档
|
||||||
├── ui-design/ # UI设计文件
|
├── ui-design/ # UI设计文件
|
||||||
├── architecture/ # 架构设计文档
|
├── architecture/ # 架构设计文档
|
||||||
├── frontend/ # 前端代码
|
├── frontend/ # 前端代码
|
||||||
├── backend/ # 后端代码
|
├── backend/ # 后端代码
|
||||||
└── tests/ # 测试代码
|
└── tests/ # 测试代码
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Git 仓库
|
||||||
|
|
||||||
|
https://git.liantu.tech/openclaw_product_manager/clouddisk-project
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const cors = require('cors');
|
|||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const fileRoutes = require('./routes/files');
|
const fileRoutes = require('./routes/files');
|
||||||
const shareRoutes = require('./routes/share');
|
const shareRoutes = require('./routes/share');
|
||||||
|
const syncRoutes = require('./routes/sync');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@ -14,6 +15,7 @@ app.use(express.json());
|
|||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/files', fileRoutes);
|
app.use('/api/files', fileRoutes);
|
||||||
app.use('/api/share', shareRoutes);
|
app.use('/api/share', shareRoutes);
|
||||||
|
app.use('/api/sync', syncRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|||||||
152
backend/src/routes/sync.js
Normal file
152
backend/src/routes/sync.js
Normal 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;
|
||||||
114
frontend/src/renderer/components/SyncStatus.jsx
Normal file
114
frontend/src/renderer/components/SyncStatus.jsx
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user