Final push for project
Change-Id: I9103078156eca93df2482b9fe3854d9301bb98b3
diff --git a/frontend/my-app/package-lock.json b/frontend/my-app/package-lock.json
index 21a1a55..533fd75 100644
--- a/frontend/my-app/package-lock.json
+++ b/frontend/my-app/package-lock.json
@@ -13,6 +13,7 @@
"@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2",
"axios": "^1.9.0",
+ "file-saver": "^2.0.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.1"
@@ -4915,6 +4916,12 @@
"node": "^10.12.0 || >=12.0.0"
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
diff --git a/frontend/my-app/package.json b/frontend/my-app/package.json
index 07b11c9..04db654 100644
--- a/frontend/my-app/package.json
+++ b/frontend/my-app/package.json
@@ -18,6 +18,7 @@
"@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2",
"axios": "^1.9.0",
+ "file-saver": "^2.0.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.1"
diff --git a/frontend/my-app/src/App.jsx b/frontend/my-app/src/App.jsx
index 214e127..c83b97d 100644
--- a/frontend/my-app/src/App.jsx
+++ b/frontend/my-app/src/App.jsx
@@ -8,6 +8,7 @@
import UserProfile from './pages/UserProfile';
import UserContextProvider from './contexts/UserContext';
import TorrentUploadPage from './pages/UploadTorrent';
+import LoginPage from './pages/Login';
function App() {
return (
<UserContextProvider>
@@ -18,6 +19,7 @@
<Route path="/" element={<Home />} />
<Route path="/user/:id" element={<UserProfile />} />
<Route path="/Upload" element={<TorrentUploadPage />} />
+ <Route path="/login" element={<LoginPage />} />
</Routes>
</div>
</ThemeProvider>
diff --git a/frontend/my-app/src/components/Button/TorrentDownloadButton.jsx b/frontend/my-app/src/components/Button/TorrentDownloadButton.jsx
new file mode 100644
index 0000000..a8a6177
--- /dev/null
+++ b/frontend/my-app/src/components/Button/TorrentDownloadButton.jsx
@@ -0,0 +1,84 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import axios from 'axios';
+import { saveAs } from 'file-saver';
+import Button from '@mui/material/Button';
+import LinearProgress from '@mui/material/LinearProgress';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+
+function TorrentDownloadButton({ infoHash, title }) {
+ // 新增状态:下载进度(0~100)
+ const [progress, setProgress] = useState(0);
+ // 新增状态:是否正在下载
+ const [downloading, setDownloading] = useState(false);
+
+ const handleDownload = async () => {
+ try {
+ setDownloading(true);
+ setProgress(0);
+ // 1) 启动下载
+ await axios.post('/api/torrents/download', null, { params: { infoHash } });
+
+ // 2) 轮询进度
+ let p = 0;
+ while (p < 1) {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ const res = await axios.get(`/api/torrents/download/status/${infoHash}`);
+ p = res.data;
+ setProgress(Math.round(p * 100));
+ }
+
+ // 3) 下载文件流
+ const fileRes = await axios.get(
+ `/api/torrents/download/file/${infoHash}`,
+ { responseType: 'blob' }
+ );
+
+ // 4) 保存文件
+ saveAs(fileRes.data, title);
+ } catch (err) {
+ console.error('下载过程中出错', err);
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ return (
+ <Box sx={{ width: 200 }}>
+ <Button
+ variant="outlined"
+ color="success"
+ onClick={handleDownload}
+ disabled={downloading}
+ sx={{
+ width: '100%',
+ fontSize: '0.85rem',
+ borderColor: '#4caf50',
+ '&:hover': {
+ backgroundColor: 'rgba(76, 175, 80, 0.15)',
+ borderColor: '#388e3c',
+ },
+ }}
+ >
+ {downloading ? `下载中 ${progress}%` : '下载'}
+ </Button>
+
+ {downloading && (
+ <Box sx={{ mt: 1 }}>
+ <LinearProgress variant="determinate" value={progress} />
+ <Typography variant="caption" display="block" align="center">
+ {progress}%
+ </Typography>
+ </Box>
+ )}
+ </Box>
+ );
+}
+
+TorrentDownloadButton.propTypes = {
+ infoHash: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+};
+
+export default TorrentDownloadButton;
diff --git a/frontend/my-app/src/pages/Home.jsx b/frontend/my-app/src/pages/Home.jsx
index 6bca4b8..e2f7f8e 100644
--- a/frontend/my-app/src/pages/Home.jsx
+++ b/frontend/my-app/src/pages/Home.jsx
@@ -1,12 +1,10 @@
import React, { useState, useEffect } from 'react';
import {
Box, Container, Typography, Paper,
- List, ListItem, ListItemText, Chip, Stack, CircularProgress, Alert
+ List, ListItem, ListItemText, Chip, Stack, CircularProgress, Alert, Button
} from '@mui/material';
import { Link } from 'react-router-dom'; // 假设你已经配置了 React Router
-// import '../styles/base/base.css'; // ✅ 引入统一样式 - 我们将尝试用 sx prop 替代
-
// 模拟从API获取数据
const fetchTorrents = () => {
return new Promise((resolve) => {
@@ -63,7 +61,7 @@
return (
<Box sx={{ minHeight: '100vh', py: 4, background: 'linear-gradient(135deg, #2c3e50, #4ca1af)', color: 'white' }}>
- <Container maxWidth="md" sx={{ position: 'relative', zIndex: 10 }}> {/* Changed to md for wider content */}
+ <Container maxWidth="md" sx={{ position: 'relative', zIndex: 10 }}>
<Typography variant="h4" sx={{ textAlign: 'center', mb: 3, fontWeight: 'bold' }}>
🌐 首页 · Mini-Tracker
</Typography>
@@ -90,36 +88,41 @@
key={torrent.id}
component={Link} // react-router-dom Link
to={`/detail/${torrent.id}`} // 假设的详情页路由
- sx={{
- color: 'white',
+ sx={{
+ color: 'white',
textDecoration: 'none',
- mb: 1.5, // 增加列表项间距
- p: 1.5, // 增加列表项内边距
+ mb: 1.5,
+ p: 1.5,
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
'&:last-child': {
- borderBottom: 'none' // 移除最后一个元素的边框
+ borderBottom: 'none'
},
- display: 'flex', // 确保内部元素正确对齐
- flexDirection: { xs: 'column', sm: 'row' }, // 响应式布局
+ display: 'flex',
+ flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' }
}}
- divider={false} // 使用自定义边框代替
+ divider={false}
>
<ListItemText
primary={torrent.title}
secondary={`大小: ${torrent.size} · 上传者: ${torrent.uploader}`}
- primaryTypographyProps={{ variant: 'h6', component: 'div', sx: { mb: 0.5, color: '#f5f5f5', fontWeight: 500 } }}
+ primaryTypographyProps={{
+ variant: 'h6',
+ component: 'div',
+ sx: { mb: 0.5, color: '#f5f5f5', fontWeight: 500 }
+ }}
secondaryTypographyProps={{ sx: { color: '#bbb', fontSize: '0.85rem' } }}
- sx={{ flexGrow: 1, mb: { xs: 1, sm: 0 }, mr: { sm: 2 } }} // 响应式边距
+ sx={{ flexGrow: 1, mb: { xs: 1, sm: 0 }, mr: { sm: 2 } }}
/>
- <Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 0.5 }}> {/* 允许标签换行 */}
+
+ <Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{torrent.tags.map(tag => (
<Chip
key={tag}
label={tag}
size="small"
- sx={{
- backgroundColor: 'rgba(255, 255, 255, 0.2)', // upload-chip 样式
+ sx={{
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
cursor: 'pointer',
'&:hover': {
@@ -129,6 +132,29 @@
/>
))}
</Stack>
+
+ {/* ——— 在此处添加“下载”按钮 —— */}
+ <Button
+ variant="outlined"
+ component="a"
+ href={`http://localhost:8080/api/downloads/${torrent.id}/${encodeURIComponent(torrent.title)}.torrent`}
+ download={`${torrent.title}.torrent`}
+ sx={{
+ ml: { xs: 0, sm: 2 }, // 小屏幕时居中;大屏幕时与右边距留空
+ mt: { xs: 1, sm: 0 }, // 小屏时在下方,留一点间距
+ color: '#4caf50',
+ borderColor: '#4caf50',
+ fontSize: '0.85rem',
+ '&:hover': {
+ backgroundColor: 'rgba(76, 175, 80, 0.15)',
+ borderColor: '#388e3c'
+ }
+ }}
+ >
+ 下载
+ </Button>
+ {/* ——— 下载按钮结束 ——— */}
+
</ListItem>
))}
</List>
@@ -136,7 +162,7 @@
</Paper>
</Container>
- {/* 背景泡泡动画复用 - 这些类名 (.bubbles, .bubble) 和 @keyframes rise 应该在全局CSS或者下面的 <style> 标签中定义 */}
+ {/* 背景泡泡动画复用 */}
<Box className="bubbles" sx={{
pointerEvents: 'none',
position: 'fixed',
@@ -145,7 +171,7 @@
width: '100%',
height: '100%',
overflow: 'hidden',
- zIndex: 1,
+ zIndex: 1,
}}>
{[...Array(40)].map((_, i) => (
<Box
@@ -153,48 +179,43 @@
className="bubble"
sx={{
position: 'absolute',
- bottom: '-150px', // 确保从屏幕外开始
- background: `hsla(${Math.random() * 360}, 70%, 80%, 0.15)`, // 增加颜色多样性
+ bottom: '-150px',
+ background: `hsla(${Math.random() * 360}, 70%, 80%, 0.15)`,
borderRadius: '50%',
animation: 'rise 20s infinite ease-in',
- width: `${Math.random() * 25 + 10}px`, // 调整大小范围
+ width: `${Math.random() * 25 + 10}px`,
height: `${Math.random() * 25 + 10}px`,
left: `${Math.random() * 100}%`,
- animationDuration: `${15 + Math.random() * 20}s`, // 调整动画时长
- animationDelay: `${Math.random() * 10}s`, // 调整动画延迟
- opacity: 0, // 初始透明
+ animationDuration: `${15 + Math.random() * 20}s`,
+ animationDelay: `${Math.random() * 10}s`,
+ opacity: 0,
}}
/>
))}
</Box>
- {/* 定义动画和其他全局可能需要的样式 */}
<style>
{`
- body { /* 基本重置 */
+ body {
margin: 0;
- font-family: 'Roboto', sans-serif; /* 确保字体一致 */
+ font-family: 'Roboto', sans-serif;
}
@keyframes rise {
0% {
transform: translateY(0) scale(0.8);
- opacity: 0; /* 从透明开始 */
+ opacity: 0;
}
10% {
- opacity: 1; /* 渐显 */
+ opacity: 1;
}
90% {
- opacity: 1; /* 保持可见 */
+ opacity: 1;
}
100% {
- transform: translateY(-130vh) scale(0.3); /* 飘得更高更小 */
- opacity: 0; /* 渐隐 */
+ transform: translateY(-130vh) scale(0.3);
+ opacity: 0;
}
}
-
- /* 如果 .bubbles 和 .bubble 样式没有在 sx 中完全覆盖,可以在这里补充 */
- /* .bubbles { ... } */
- /* .bubble { ... } */
`}
</style>
</Box>
@@ -202,4 +223,3 @@
}
export default Home;
-
diff --git a/frontend/my-app/src/pages/Login.jsx b/frontend/my-app/src/pages/Login.jsx
new file mode 100644
index 0000000..19f13ca
--- /dev/null
+++ b/frontend/my-app/src/pages/Login.jsx
@@ -0,0 +1,214 @@
+import React, { useState } from 'react';
+import {
+ Box, Container, Typography, Paper, TextField, Button, CircularProgress, Alert
+} from '@mui/material';
+// import { useNavigate } from 'react-router-dom'; // 如果需要重定向,请取消注释
+
+function LoginPage() {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState(null); // 用于显示成功或错误消息
+ const [messageSeverity, setMessageSeverity] = useState('info'); // 'success', 'error', 'info'
+
+ // const navigate = useNavigate(); // 如果需要重定向,请取消注释
+
+ const handleSubmit = async (event) => {
+ event.preventDefault(); // 阻止表单默认提交行为
+
+ setMessage(null); // 清除之前的消息
+ setLoading(true); // 显示加载指示器
+
+ try {
+ const response = await fetch('http://localhost:8080/api/auth/login', { // 确保这里的URL与您的后端接口一致
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ username, password })
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ // 登录成功
+ setMessage('登录成功!欢迎 ' + data.username);
+ setMessageSeverity('success');
+ console.log('登录成功:', data);
+ // 将 JWT Token 保存到 localStorage
+ localStorage.setItem('jwt_token', data.token);
+ // 可选:登录成功后重定向到其他页面
+ // navigate('/dashboard');
+ } else {
+ // 登录失败
+ const errorMessage = data.error || '未知错误';
+ setMessage('登录失败: ' + errorMessage);
+ setMessageSeverity('error');
+ console.error('登录失败:', data);
+ }
+ } catch (error) {
+ // 网络错误或请求发送失败
+ setMessage('网络错误,请稍后再试。');
+ setMessageSeverity('error');
+ console.error('请求发送失败:', error);
+ } finally {
+ setLoading(false); // 隐藏加载指示器
+ }
+ };
+
+ return (
+ <Box sx={{
+ minHeight: '100vh',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ background: 'linear-gradient(135deg, #2c3e50, #4ca1af)', // 与 Home 组件相同的背景
+ color: 'white',
+ py: 4 // 垂直内边距
+ }}>
+ <Container maxWidth="xs" sx={{ position: 'relative', zIndex: 10 }}>
+ <Typography variant="h4" align="center" sx={{ mb: 4, fontWeight: 'bold' }}>
+ 🔐 用户登录
+ </Typography>
+
+ <Paper sx={{
+ p: 4, // 内边距
+ backgroundColor: 'rgba(30, 30, 30, 0.9)', // 半透明深色背景
+ borderRadius: '12px', // 圆角
+ boxShadow: '0 8px 16px rgba(0,0,0,0.3)' // 阴影
+ }}>
+ {message && (
+ <Alert severity={messageSeverity} sx={{ mb: 2, borderRadius: '8px' }}>
+ {message}
+ </Alert>
+ )}
+
+ <form onSubmit={handleSubmit}>
+ <TextField
+ label="用户名"
+ variant="outlined"
+ fullWidth
+ margin="normal"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ required
+ sx={{
+ mb: 2, // 底部外边距
+ '& .MuiOutlinedInput-root': {
+ borderRadius: '8px',
+ color: 'white', // 输入文字颜色
+ '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.3)' }, // 边框颜色
+ '&:hover fieldset': { borderColor: 'rgba(255, 255, 255, 0.5)' },
+ '&.Mui-focused fieldset': { borderColor: '#8b5cf6' }, // 聚焦时边框颜色
+ },
+ '& .MuiInputLabel-root': { color: '#bbb' }, // 标签颜色
+ '& .MuiInputLabel-root.Mui-focused': { color: '#8b5cf6' }, // 聚焦时标签颜色
+ }}
+ />
+ <TextField
+ label="密码"
+ type="password"
+ variant="outlined"
+ fullWidth
+ margin="normal"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ required
+ sx={{
+ mb: 3, // 底部外边距
+ '& .MuiOutlinedInput-root': {
+ borderRadius: '8px',
+ color: 'white', // 输入文字颜色
+ '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.3)' },
+ '&:hover fieldset': { borderColor: 'rgba(255, 255, 255, 0.5)' },
+ '&.Mui-focused fieldset': { borderColor: '#8b5cf6' },
+ },
+ '& .MuiInputLabel-root': { color: '#bbb' },
+ '& .MuiInputLabel-root.Mui-focused': { color: '#8b5cf6' },
+ }}
+ />
+ <Button
+ type="submit"
+ variant="contained"
+ fullWidth
+ disabled={loading}
+ sx={{
+ py: '0.75rem', // 垂直内边距
+ backgroundImage: 'linear-gradient(to right, #6366f1, #8b5cf6)', // 渐变背景
+ color: 'white',
+ borderRadius: '0.375rem', // 圆角
+ fontWeight: 600,
+ position: 'relative', // 用于加载指示器定位
+ '&:hover': {
+ backgroundImage: 'linear-gradient(to right, #4f46e5, #7c3aed)',
+ },
+ }}
+ >
+ {loading ? <CircularProgress size={24} color="inherit" sx={{ position: 'absolute' }} /> : '登录'}
+ </Button>
+ </form>
+ </Paper>
+ </Container>
+
+ {/* 背景泡泡动画 - 复用 Home 组件中的样式 */}
+ <Box className="bubbles" sx={{
+ pointerEvents: 'none',
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ overflow: 'hidden',
+ zIndex: 1,
+ }}>
+ {[...Array(40)].map((_, i) => (
+ <Box
+ key={i}
+ className="bubble"
+ sx={{
+ position: 'absolute',
+ bottom: '-150px', // 确保从屏幕外开始
+ background: `hsla(${Math.random() * 360}, 70%, 80%, 0.15)`, // 增加颜色多样性
+ borderRadius: '50%',
+ animation: 'rise 20s infinite ease-in',
+ width: `${Math.random() * 25 + 10}px`, // 调整大小范围
+ height: `${Math.random() * 25 + 10}px`,
+ left: `${Math.random() * 100}%`,
+ animationDuration: `${15 + Math.random() * 20}s`, // 调整动画时长
+ animationDelay: `${Math.random() * 10}s`, // 调整动画延迟
+ opacity: 0, // 初始透明
+ }}
+ />
+ ))}
+ </Box>
+ {/* 定义动画和其他全局可能需要的样式 */}
+ <style>
+ {`
+ body { /* 基本重置 */
+ margin: 0;
+ font-family: 'Roboto', sans-serif; /* 确保字体一致 */
+ }
+
+ @keyframes rise {
+ 0% {
+ transform: translateY(0) scale(0.8);
+ opacity: 0; /* 从透明开始 */
+ }
+ 10% {
+ opacity: 1; /* 渐显 */
+ }
+ 90% {
+ opacity: 1; /* 保持可见 */
+ }
+ 100% {
+ transform: translateY(-130vh) scale(0.3); /* 飘得更高更小 */
+ opacity: 0; /* 渐隐 */
+ }
+ }
+ `}
+ </style>
+ </Box>
+ );
+}
+
+export default LoginPage;
\ No newline at end of file
diff --git a/frontend/my-app/src/pages/UploadTorrent.jsx b/frontend/my-app/src/pages/UploadTorrent.jsx
index 493cdce..465afda 100644
--- a/frontend/my-app/src/pages/UploadTorrent.jsx
+++ b/frontend/my-app/src/pages/UploadTorrent.jsx
@@ -4,43 +4,44 @@
TextField, Button, Stack
} from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
-// Assuming your base.css is in a relative path like src/styles/base/base.css
-// If your component is in src/components/Upload.js, this path would be ../styles/base/base.css
-// Adjust if your project structure is different.
-// For this example, I'll assume it's correctly linked.
-// import '../styles/base/base.css';
+
function Upload() {
const [title, setTitle] = useState('');
const [file, setFile] = useState(null);
const handleUpload = () => {
- const formData = new FormData();
- if (file) {
- formData.append('file', file);
- }
- formData.append('title', title);
+ const formData = new FormData();
+ if (file) {
+ formData.append('file', file);
+ }
+ formData.append('title', title);
- // Replace with your actual API endpoint and error handling
- fetch('/api/torrents/upload', {
- method: 'POST',
- body: formData
- })
+ const token = localStorage.getItem('jwt_token'); // 假设你登录后将 token 存在 localStorage
+
+ fetch('http://localhost:8080/api/torrents/upload', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: formData,
+ })
.then(response => {
if (response.ok) {
- // Use a more modern way to show messages, e.g., a Snackbar
- console.log('上传成功');
- alert('上传成功 (建议使用Snackbar等UI组件替代alert)');
+ console.log('上传成功');
+ alert('上传成功');
} else {
- console.error('上传失败');
- alert('上传失败 (建议使用Snackbar等UI组件替代alert)');
+ return response.text().then(text => {
+ console.error('上传失败:', text);
+ alert('上传失败: ' + text);
+ });
}
})
.catch(error => {
console.error('上传出错:', error);
- alert('上传出错 (建议使用Snackbar等UI组件替代alert)');
+ alert('上传出错: ' + error.message);
});
- };
+};
return (
<Box sx={{ minHeight: '100vh', py: 4, background: 'linear-gradient(135deg, #2c3e50, #4ca1af)', color: 'white' }}> {/* Moved body styles here for self-containment */}
diff --git a/frontend/my-app/src/services/torrentService.js b/frontend/my-app/src/services/torrentService.js
deleted file mode 100644
index a50f8ac..0000000
--- a/frontend/my-app/src/services/torrentService.js
+++ /dev/null
@@ -1,28 +0,0 @@
-// ./services/torrentService.js
-import axios from 'axios';
-
-// IMPORTANT: Replace with your actual backend API URL
-const API_BASE_URL = 'http://localhost:5000/api';
-
-export const uploadTorrent = async (formData) => {
- try {
- const response = await axios.post(`${API_BASE_URL}/torrents/upload`, formData, {
- headers: {
- 'Content-Type': 'multipart/form-data', // Essential for file uploads
- // Add any authentication tokens here if your backend requires them
- // 'Authorization': `Bearer ${localStorage.getItem('authToken')}`
- },
- onUploadProgress: (progressEvent) => {
- // You can use this to show upload progress to the user
- const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
- console.log(`Upload progress: ${percentCompleted}%`);
- // You could pass this percentage back to the component via a callback
- },
- });
- return response.data; // Your backend should return the parsed torrent info here
- } catch (error) {
- console.error('Error in uploadTorrent service:', error.response ? error.response.data : error.message);
- // Throw a more specific error message based on backend response if available
- throw new Error(error.response?.data?.message || '文件上传失败,请稍后再试。');
- }
-};
\ No newline at end of file