团队工作进展:

- 后端: 文件管理 API (上传/下载/删除/创建文件夹)
- 前端: Electron 主进程 + React 页面框架
- 测试: 认证和文件管理测试用例
This commit is contained in:
Team 2026-03-10 07:27:10 +00:00
parent f339513be8
commit 61117803e1
6 changed files with 540 additions and 0 deletions

169
backend/src/routes/files.js Normal file
View File

@ -0,0 +1,169 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const db = require('../db');
const router = express.Router();
// Configure multer for file upload
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(6).toString('hex');
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage,
limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit
});
// Get file list
router.get('/', (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 parentId = req.query.parentId || null;
const files = db.query(
'SELECT * FROM files WHERE user_id = ? AND parent_id IS ? AND deleted_at IS NULL ORDER BY is_folder DESC, name ASC',
[decoded.userId, parentId]
);
res.json({ files });
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
// Upload file
router.post('/upload', upload.single('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');
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Calculate file hash (simplified)
const hash = crypto.createHash('md5').update(req.file.filename).digest('hex');
// Check if file already exists (for deduplication)
const existing = db.query(
'SELECT id FROM files WHERE user_id = ? AND hash = ? AND name = ?',
[decoded.userId, hash, req.file.originalname]
);
let fileId;
if (existing.length > 0) {
// File exists, update
fileId = existing[0].id;
db.run(
'UPDATE files SET size = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[req.file.size, fileId]
);
} else {
// Insert new file
const result = db.run(
'INSERT INTO files (user_id, name, type, size, path, hash, is_folder) VALUES (?, ?, ?, ?, ?, ?, ?)',
[decoded.userId, req.file.originalname, path.extname(req.file.originalname).slice(1), req.file.size, req.file.path, hash, 0]
);
fileId = result.lastInsertRowid;
}
// Update user storage
db.run(
'UPDATE users SET storage_used = storage_used + ? WHERE id =',
[req.file.size, decoded.userId]
);
res.json({ success: true, fileId, filename: req.file.originalname });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Download file
router.get('/:id/download', (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 file = db.query(
'SELECT * FROM files WHERE id = ? AND user_id = ? AND is_folder = 0',
[req.params.id, decoded.userId]
);
if (file.length === 0) {
return res.status(404).json({ error: 'File not found' });
}
res.download(file[0].path, file[0].name);
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
// Delete file
router.delete('/:id', (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');
// Soft delete
db.run(
'UPDATE files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[req.params.id, decoded.userId]
);
res.json({ success: true });
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
// Create folder
router.post('/folder', (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 { name, parentId } = req.body;
const result = db.run(
'INSERT INTO files (user_id, name, parent_id, is_folder) VALUES (?, ?, ?, ?)',
[decoded.userId, name, parentId || null, 1]
);
res.json({ success: true, folderId: result.lastInsertRowid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@ -0,0 +1,66 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
// Load the app
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:3000');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// IPC handlers
ipcMain.handle('select-file', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections']
});
return result.filePaths;
});
ipcMain.handle('select-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
});
return result.filePaths[0];
});
ipcMain.handle('show-message', async (event, { title, message, type }) => {
const { dialog } = require('electron');
return dialog.showMessageBox(mainWindow, {
type: type || 'info',
title: title || 'CloudDisk',
message: message
});
});

View File

@ -0,0 +1,18 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electron', {
selectFile: () => ipcRenderer.invoke('select-file'),
selectFolder: () => ipcRenderer.invoke('select-folder'),
showMessage: (options) => ipcRenderer.invoke('show-message', options),
// File operations
uploadFile: (filePath) => {
const formData = new FormData();
formData.append('file', require('fs').createReadStream(filePath));
return fetch('/api/files/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: formData
});
}
});

View File

@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { ConfigProvider, Layout, Menu, Button, Upload, Table, Breadcrumb, Input } from 'antd';
import {
UploadOutlined,
FolderOutlined,
FileOutlined,
HomeOutlined,
DeleteOutlined,
DownloadOutlined,
ShareAltOutlined
} from '@ant-design/icons';
const { Header, Sider, Content } = Layout;
function App() {
const [user, setUser] = useState(null);
const [files, setFiles] = useState([]);
const [currentPath, setCurrentPath] = useState([]);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const login = async (username, password) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.token) {
localStorage.setItem('token', data.token);
setUser(data.user);
setIsLoggedIn(true);
loadFiles();
}
return data;
};
const loadFiles = async (parentId = null) => {
const token = localStorage.getItem('token');
const url = parentId ? `/api/files?parentId=${parentId}` : '/api/files';
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
setFiles(data.files || []);
};
const handleUpload = async (file) => {
const token = localStorage.getItem('token');
const formData = new FormData();
formData.append('file', file);
await fetch('/api/files/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
loadFiles();
};
const handleCreateFolder = async () => {
const name = prompt('请输入文件夹名称:');
if (!name) return;
const token = localStorage.getItem('token');
await fetch('/api/files/folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name })
});
loadFiles();
};
const handleDelete = async (fileId) => {
const token = localStorage.getItem('token');
await fetch(`/api/files/${fileId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
loadFiles();
};
const columns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<span>
{record.is_folder ? <FolderOutlined /> : <FileOutlined />} {text}
</span>
)
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
render: (size) => size ? `${(size / 1024 / 1024).toFixed(2)} MB` : '-'
},
{
title: '修改时间',
dataIndex: 'updated_at',
key: 'updated_at'
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<span>
<Button type="link" icon={<DownloadOutlined />} />
<Button type="link" icon={<ShareAltOutlined />} />
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} />
</span>
)
}
];
if (!isLoggedIn) {
return <LoginPage onLogin={login} />;
}
return (
<ConfigProvider>
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: '#1890ff', padding: '0 20px', color: 'white' }}>
<span style={{ fontSize: '20px', fontWeight: 'bold' }}>CloudDisk</span>
<span style={{ float: 'right' }}>{user?.username}</span>
</Header>
<Layout>
<Sider width={200} style={{ background: '#fff' }}>
<Menu mode="inline" defaultSelectedKeys={['files']}>
<Menu.Item key="files">我的文件</Menu.Item>
<Menu.Item key="recent">最近访问</Menu.Item>
<Menu.Item key="shared">共享文件</Menu.Item>
<Menu.Item key="trash">回收站</Menu.Item>
</Menu>
</Sider>
<Layout style={{ padding: '0' }}>
<Content style={{ background: '#fff', padding: '20px' }}>
<div style={{ marginBottom: '16px' }}>
<Upload customRequest={({ file }) => handleUpload(file)} showUploadList={false}>
<Button icon={<UploadOutlined />}>上传文件</Button>
</Upload>
<Button icon={<FolderOutlined />} onClick={handleCreateFolder} style={{ marginLeft: '8px' }}>
新建文件夹
</Button>
<Input.Search placeholder="搜索文件" style={{ width: '300px', float: 'right' }} />
</div>
<Table columns={columns} dataSource={files} rowKey="id" />
</Content>
</Layout>
</Layout>
</Layout>
</ConfigProvider>
);
}
function LoginPage({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f0f2f5' }}>
<div style={{ width: '400px', padding: '40px', background: 'white', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<h1 style={{ textAlign: 'center', marginBottom: '30px' }}>CloudDisk</h1>
<Input placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)} style={{ marginBottom: '16px' }} />
<Input.Password placeholder="密码" value={password} onChange={e => setPassword(e.target.value)} style={{ marginBottom: '16px' }} />
<Button type="primary" block onClick={() => onLogin(username, password)}>登录</Button>
</div>
</div>
);
}
export default App;

View File

@ -0,0 +1,62 @@
const request = require('supertest');
const app = require('../../backend/src/index');
describe('Auth API', () => {
const testUser = {
username: 'testuser' + Date.now(),
password: 'test123',
email: 'test@example.com'
};
describe('POST /api/auth/register', () => {
it('should register a new user', async () => {
const response = await request(app)
.post('/api/auth/register')
.send(testUser);
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('token');
expect(response.body).toHaveProperty('userId');
});
it('should not register duplicate username', async () => {
// First registration
await request(app)
.post('/api/auth/register')
.send(testUser);
// Duplicate registration
const response = await request(app)
.post('/api/auth/register')
.send(testUser);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
});
describe('POST /api/auth/login', () => {
it('should login with valid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
username: testUser.username,
password: testUser.password
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('token');
});
it('should fail with invalid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
username: testUser.username,
password: 'wrongpassword'
});
expect(response.status).toBe(401);
});
});
});

View File

@ -0,0 +1,47 @@
const request = require('supertest');
const app = require('../../backend/src/index');
describe('Files API', () => {
let token;
const testUser = {
username: 'filetest' + Date.now(),
password: 'test123'
};
beforeAll(async () => {
// Register and login
await request(app)
.post('/api/auth/register')
.send(testUser);
const loginRes = await request(app)
.post('/api/auth/login')
.send(testUser);
token = loginRes.body.token;
});
describe('GET /api/files', () => {
it('should get file list', async () => {
const response = await request(app)
.get('/api/files')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('files');
expect(Array.isArray(response.body.files)).toBe(true);
});
});
describe('POST /api/files/folder', () => {
it('should create a new folder', async () => {
const response = await request(app)
.post('/api/files/folder')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Test Folder' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
});
});
});