团队工作进展:
- 后端: 文件管理 API (上传/下载/删除/创建文件夹) - 前端: Electron 主进程 + React 页面框架 - 测试: 认证和文件管理测试用例
This commit is contained in:
parent
f339513be8
commit
61117803e1
169
backend/src/routes/files.js
Normal file
169
backend/src/routes/files.js
Normal 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;
|
||||
66
frontend/src/main/index.js
Normal file
66
frontend/src/main/index.js
Normal 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
|
||||
});
|
||||
});
|
||||
18
frontend/src/main/preload.js
Normal file
18
frontend/src/main/preload.js
Normal 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
|
||||
});
|
||||
}
|
||||
});
|
||||
178
frontend/src/renderer/App.jsx
Normal file
178
frontend/src/renderer/App.jsx
Normal 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;
|
||||
62
tests/backend/auth.test.js
Normal file
62
tests/backend/auth.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
47
tests/backend/files.test.js
Normal file
47
tests/backend/files.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user