前段
Change-Id: I718d4d07ea03c6d2b6bcbd4d426c5d1af2201bf4
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..e605b6d
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,38 @@
+.App {
+ text-align: center;
+}
+
+.App-logo {
+ height: 40vmin;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .App-logo {
+ animation: App-logo-spin infinite 20s linear;
+ }
+}
+
+.App-header {
+ background-color: #282c34;
+ min-height: 10%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(10px + 2vmin);
+ color: white;
+}
+
+.App-link {
+ color: #61dafb;
+}
+
+@keyframes App-logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..66042bc
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,140 @@
+// src/App.jsx
+import React from 'react';
+import {
+ BrowserRouter as Router,
+ Routes,
+ Route,
+ Navigate
+} from 'react-router-dom';
+import Dashboard from './components/Dashboard';
+import Personal from './components/Personal/Personal';
+import AuthForm from './components/AuthForm';
+import AnnouncementDetail from './components/AnnouncementDetail';
+
+import TorrentDetail from './components/TorrentDetail'; // 确保路径正确
+import RequestDetail from './components/RequestDetail';
+import HelpDetail from './components/HelpDetail';
+import Favorite from './components/Personal/Favorite';
+import Upload from './components/Personal/Upload';
+import Notice from './components/Personal/Notice';
+import Setting from './components/Personal/Setting';
+
+import './App.css';
+
+function App() {
+ // 每次应用启动时强制清除 token
+ React.useEffect(() => {
+ localStorage.removeItem('token');
+ console.log('Token cleared on app startup');
+ localStorage.removeItem('username');
+ }, []); // 空依赖数组表示只在组件挂载时执行一次
+
+ const [isAuthenticated, setIsAuthenticated] = React.useState(
+ false
+ );
+
+ const handleLoginSuccess = (token) => {
+ setIsAuthenticated(true);
+ };
+
+ const handleLogout = () => {
+ localStorage.removeItem('token');
+ setIsAuthenticated(false);
+ };
+
+ return (
+ <Router>
+ <Routes>
+ {/* 修改点1:根路径强制跳转到登录页 */}
+ <Route path="/" element={<Navigate to="/login" replace />} />
+
+ {/* 登录页 */}
+ <Route path="/login" element={
+ isAuthenticated ? (
+ <Navigate to="/dashboard" replace />
+ ) : (
+ <AuthForm onLoginSuccess={handleLoginSuccess} />
+ )
+ } />
+
+ {/* 主面板 */}
+ <Route path="/dashboard/:tab?" element={
+ isAuthenticated ? (
+ <Dashboard onLogout={handleLogout} />
+ ) : (
+ <Navigate to="/login" replace />
+ )
+ } />
+
+ {/* 公告详情 */}
+ <Route path="/announcement/:id" element={
+ isAuthenticated ? (
+ <AnnouncementDetail onLogout={handleLogout} />
+ ) : (
+ <Navigate to="/login" replace />
+ )
+ } />
+
+ {/* 个人中心及子功能页面 */}
+ <Route path="/personal" element={
+ isAuthenticated ? <Personal onLogout={handleLogout} /> : <Navigate to="/login" replace />
+ } />
+ <Route path="/personal/favorite" element={
+ isAuthenticated ? <Favorite onLogout={handleLogout} /> : <Navigate to="/login" replace />
+ } />
+ <Route path="/personal/upload" element={
+ isAuthenticated ? <Upload onLogout={handleLogout} /> : <Navigate to="/login" replace />
+ } />
+ <Route path="/personal/notice" element={
+ isAuthenticated ? <Notice onLogout={handleLogout} /> : <Navigate to="/login" replace />
+ } />
+ <Route path="/personal/setting" element={
+ isAuthenticated ? <Setting onLogout={handleLogout} /> : <Navigate to="/login" replace />
+ } />
+
+
+ {/* 404 重定向 */}
+ <Route path="*" element={
+ isAuthenticated ? (
+ <Navigate to="/dashboard" replace />
+ ) : (
+ <Navigate to="/login" replace />
+ )
+ } />
+
+ {/* 求种区 */}
+ <Route path="/request/:id" element={
+ isAuthenticated ? (
+ <RequestDetail onLogout={handleLogout} />
+ ) : (
+ <Navigate to="/login" replace />
+ )
+ } />
+
+ {/* 求助区 */}
+ <Route path="/help/:id" element={
+ isAuthenticated ? (
+ <HelpDetail onLogout={handleLogout} />
+ ) : (
+ <Navigate to="/login" replace />
+ )
+ } />
+
+ <Route path="/torrent/:id" element={
+ isAuthenticated ? (
+ <TorrentDetail onLogout={handleLogout} />
+ ) : (
+ <Navigate to="/login" replace />
+ )
+ } />
+
+
+
+ </Routes>
+
+
+ </Router>
+ );
+}
+
+export default App;
\ No newline at end of file
diff --git a/src/api/auth.js b/src/api/auth.js
new file mode 100644
index 0000000..ad845bb
--- /dev/null
+++ b/src/api/auth.js
@@ -0,0 +1,37 @@
+// src/api/auth.js
+import axios from 'axios';
+
+// 创建并导出 axios 实例
+export const api = axios.create({
+ baseURL: 'http://localhost:8088',
+ timeout: 5000,
+});
+
+// 请求拦截器
+api.interceptors.request.use(config => {
+ const token = localStorage.getItem('token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+export const login = async (username, password) => {
+ const response = await api.post('/user/login', null, {
+ params: { username, password }
+ });
+ if (response.data.code === 200 && response.data.data.token) {
+ localStorage.setItem('token', response.data.data.token);
+ }
+ return response;
+};
+
+export const register = (username, password, code) => {
+ return api.post('/user/regist', null, {
+ params: { username, password, code }
+ });
+};
+
+export const getUserInfo = (token) => {
+ return api.get('/user/info', { params: { token } });
+};
\ No newline at end of file
diff --git a/src/api/auth.test.js b/src/api/auth.test.js
new file mode 100644
index 0000000..c9ace4d
--- /dev/null
+++ b/src/api/auth.test.js
@@ -0,0 +1,114 @@
+import MockAdapter from 'axios-mock-adapter';
+import { api, login, register, getUserInfo } from './auth';
+
+describe('auth API', () => {
+ let mockAxios;
+
+ beforeEach(() => {
+ // 确保使用我们导出的 api 实例
+ mockAxios = new MockAdapter(api);
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ describe('login', () => {
+ it('should send login request with username and password', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ token: 'mock-token',
+ // 确保响应结构与实际API一致
+ userInfo: { username: 'testuser' }
+ },
+ message: '登录成功'
+ };
+
+ mockAxios.onPost('/user/login').reply(200, mockResponse);
+
+ const response = await login('testuser', 'testpass');
+
+ expect(response.data).toEqual(mockResponse);
+ // 检查token是否存入localStorage
+ expect(localStorage.getItem('token')).toBe('mock-token');
+ });
+
+
+ it('should handle login failure', async () => {
+ mockAxios.onPost('/user/login').reply(401);
+
+ await expect(login('wronguser', 'wrongpass')).rejects.toThrow();
+ });
+ });
+
+ describe('register', () => {
+ it('should send register request with username, password and code', async () => {
+ const mockResponse = {
+ code: 200,
+ message: '注册成功'
+ };
+
+ mockAxios.onPost('/user/regist').reply(200, mockResponse);
+
+ const response = await register('newuser', 'newpass', 'invite123');
+
+ expect(response.data).toEqual(mockResponse);
+ });
+
+ it('should handle registration failure', async () => {
+ mockAxios.onPost('/user/regist').reply(400);
+
+ await expect(register('newuser', 'newpass', 'wrongcode')).rejects.toThrow();
+ });
+ });
+
+ describe('getUserInfo', () => {
+ it('should send request with token to get user info', async () => {
+ const mockResponse = {
+ code: 200,
+ data: { username: 'testuser', role: 'user' }
+ };
+
+ mockAxios.onGet('/user/info').reply(200, mockResponse);
+
+ const response = await getUserInfo('test-token');
+
+ expect(response.data).toEqual(mockResponse);
+ });
+
+ it('should handle unauthorized request', async () => {
+ mockAxios.onGet('/user/info').reply(401);
+
+ await expect(getUserInfo('invalid-token')).rejects.toThrow();
+ });
+ });
+
+ describe('request interceptor', () => {
+ it('should add Authorization header when token exists', async () => {
+ localStorage.setItem('token', 'test-token');
+ const mockResponse = { data: 'success' };
+
+ mockAxios.onGet('/test').reply((config) => {
+ expect(config.headers.Authorization).toBe('Bearer test-token');
+ return [200, mockResponse];
+ });
+
+ const response = await api.get('/test');
+ expect(response.data).toEqual(mockResponse);
+ });
+
+ it('should not add Authorization header when token does not exist', async () => {
+ const mockResponse = { data: 'success' };
+
+ mockAxios.onGet('/test').reply((config) => {
+ expect(config.headers.Authorization).toBeUndefined();
+ return [200, mockResponse];
+ });
+
+ const response = await api.get('/test');
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/api/helpComment.js b/src/api/helpComment.js
new file mode 100644
index 0000000..e711587
--- /dev/null
+++ b/src/api/helpComment.js
@@ -0,0 +1,13 @@
+import { api } from './auth';
+
+export const likePostComment = (commentId) => {
+ return api.post(`/help/comments/${commentId}/like`);
+};
+
+export const getCommentReplies = (commentId) => {
+ return api.get(`/help/comments/${commentId}/replies`);
+};
+
+export const addCommentReply = (commentId, replyData) => {
+ return api.post(`/help/comments/${commentId}/replies`, replyData);
+};
\ No newline at end of file
diff --git a/src/api/helpComment.test.js b/src/api/helpComment.test.js
new file mode 100644
index 0000000..2b7be4c
--- /dev/null
+++ b/src/api/helpComment.test.js
@@ -0,0 +1,45 @@
+import MockAdapter from 'axios-mock-adapter';
+import { api } from './auth'; // 添加api导入
+import { likePostComment, getCommentReplies, addCommentReply } from './helpComment';
+
+describe('求助帖评论API', () => {
+ let mockAxios;
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(api);
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ describe('likePostComment - 点赞求助帖评论', () => {
+ it('应该成功发送点赞请求', async () => {
+ const mockResponse = { code: 200, message: '点赞成功' };
+ mockAxios.onPost('/help/comments/123/like').reply(200, mockResponse);
+
+ const response = await likePostComment('123');
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+
+ describe('getCommentReplies - 获取评论回复', () => {
+ it('应该返回正确的回复数据结构', async () => {
+ const mockData = [{ id: '1', content: '回复内容' }];
+ mockAxios.onGet('/help/comments/456/replies').reply(200, { code: 200, data: mockData });
+
+ const response = await getCommentReplies('456');
+ expect(response.data.data).toEqual(mockData);
+ });
+ });
+
+ describe('addCommentReply - 添加评论回复', () => {
+ it('应该正确发送回复内容', async () => {
+ const testData = { content: '测试回复', author: 'user1' };
+ mockAxios.onPost('/help/comments/789/replies', testData).reply(200, { code: 200 });
+
+ const response = await addCommentReply('789', testData);
+ expect(response.status).toBe(200);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/api/helpPost.js b/src/api/helpPost.js
new file mode 100644
index 0000000..426e92b
--- /dev/null
+++ b/src/api/helpPost.js
@@ -0,0 +1,28 @@
+// src/api/helpPost.js
+import { api } from './auth'; // 复用已有的axios实例
+
+export const createPost = (title, content, authorId) => {
+ return api.post('/help/posts', {
+ title,
+ content,
+ authorId
+ });
+};
+
+export const getPosts = (page = 1, size = 5) => {
+ return api.get('/help/posts', {
+ params: { page, size }
+ });
+};
+
+export const getPostDetail = (postId) => {
+ return api.get(`/help/posts/${postId}`);
+};
+
+export const likePost = (postId) => {
+ return api.post(`/help/posts/${postId}/like`);
+};
+
+export const addPostComment = (postId, commentData) => {
+ return api.post(`/help/posts/${postId}/comments`, commentData);
+};
\ No newline at end of file
diff --git a/src/api/helpPost.test.js b/src/api/helpPost.test.js
new file mode 100644
index 0000000..e163090
--- /dev/null
+++ b/src/api/helpPost.test.js
@@ -0,0 +1,60 @@
+import MockAdapter from 'axios-mock-adapter';
+import { api } from './auth'; // 添加api导入
+import { createPost, getPosts, getPostDetail, likePost, addPostComment } from './helpPost';
+
+describe('求助帖API', () => {
+ let mockAxios;
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(api);
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ describe('createPost - 创建求助帖', () => {
+ it('应该正确发送帖子数据', async () => {
+ const postData = {
+ title: '测试标题',
+ content: '测试内容',
+ authorId: 'user123'
+ };
+ mockAxios.onPost('/help/posts', postData).reply(201, { code: 201 });
+
+ const response = await createPost(postData.title, postData.content, postData.authorId);
+ expect(response.status).toBe(201);
+ });
+ });
+
+ describe('getPosts - 获取求助帖列表', () => {
+ it('应该支持分页参数', async () => {
+ const page = 2, size = 10;
+ mockAxios.onGet('/help/posts', { params: { page, size } }).reply(200, {
+ code: 200,
+ data: []
+ });
+
+ const response = await getPosts(page, size);
+ expect(response.status).toBe(200);
+ });
+ });
+
+ describe('likePost - 点赞求助帖', () => {
+ it('应该正确发送点赞请求', async () => {
+ mockAxios.onPost('/help/posts/post123/like').reply(200, { code: 200 });
+ const response = await likePost('post123');
+ expect(response.status).toBe(200);
+ });
+ });
+
+ describe('addPostComment - 添加帖子评论', () => {
+ it('应该正确发送评论数据', async () => {
+ const comment = { content: '测试评论', author: 'user1' };
+ mockAxios.onPost('/help/posts/post456/comments', comment).reply(200, { code: 200 });
+
+ const response = await addPostComment('post456', comment);
+ expect(response.status).toBe(200);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/api/torrent.js b/src/api/torrent.js
new file mode 100644
index 0000000..7118436
--- /dev/null
+++ b/src/api/torrent.js
@@ -0,0 +1,48 @@
+// src/api/torrent.js
+import { api } from './auth'; // 复用已有的axios实例
+
+export const createTorrent = (torrentName, description, username, category, region, resolution, subtitle, filePath) => {
+ return api.post('/torrent', {
+ torrentName,
+ description,
+ username,
+ category,
+ region,
+ resolution,
+ subtitle,
+ filePath
+ });
+};
+
+export const getTorrents = (page = 1, size = 5) => {
+ return api.get('/torrent', {
+ params: { page, size }
+ });
+};
+
+export const getTorrentDetail = (torrentId) => {
+ return api.get(`/torrent/${torrentId}`).then(response => {
+ // 确保数据结构一致
+ if (response.data && response.data.data) {
+ return {
+ ...response,
+ data: {
+ ...response.data,
+ data: {
+ torrent: response.data.data.post || response.data.data.torrent,
+ comments: response.data.data.comments || []
+ }
+ }
+ };
+ }
+ return response;
+ });
+};
+
+export const likeTorrent = (torrentId) => {
+ return api.post(`/torrent/${torrentId}/like`);
+};
+
+export const addTorrentComment = (torrentId, commentData) => {
+ return api.post(`/torrent/${torrentId}/comments`, commentData);
+};
\ No newline at end of file
diff --git a/src/api/torrent.test.js b/src/api/torrent.test.js
new file mode 100644
index 0000000..c4de6c5
--- /dev/null
+++ b/src/api/torrent.test.js
@@ -0,0 +1,58 @@
+import MockAdapter from 'axios-mock-adapter';
+import { api } from './auth'; // 添加api导入
+import { createTorrent, getTorrents, getTorrentDetail, likeTorrent, addTorrentComment } from './torrent';
+
+describe('种子资源API', () => {
+ let mockAxios;
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(api);
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ describe('createTorrent - 创建种子', () => {
+ it('应该发送完整的种子数据', async () => {
+ const torrentData = {
+ torrentName: '测试种子',
+ description: '描述内容',
+ username: 'user1',
+ category: '电影',
+ region: '美国',
+ resolution: '1080p',
+ subtitle: '中文字幕',
+ filePath: '/path/to/file'
+ };
+ mockAxios.onPost('/torrent', torrentData).reply(201, { code: 201 });
+
+ const response = await createTorrent(...Object.values(torrentData));
+ expect(response.status).toBe(201);
+ });
+ });
+
+ describe('getTorrentDetail - 获取种子详情', () => {
+ it('应该规范化返回的数据结构', async () => {
+ const mockData = {
+ data: {
+ post: { id: '123', name: '测试种子' },
+ comments: [{ id: '1', content: '评论1' }]
+ }
+ };
+ mockAxios.onGet('/torrent/123').reply(200, mockData);
+
+ const response = await getTorrentDetail('123');
+ expect(response.data.data.torrent.name).toBe('测试种子');
+ expect(response.data.data.comments).toHaveLength(1);
+ });
+ });
+
+ describe('likeTorrent - 点赞种子', () => {
+ it('应该成功发送点赞请求', async () => {
+ mockAxios.onPost('/torrent/t123/like').reply(200, { code: 200 });
+ const response = await likeTorrent('t123');
+ expect(response.status).toBe(200);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/api/torrentComment.js b/src/api/torrentComment.js
new file mode 100644
index 0000000..f15ce4b
--- /dev/null
+++ b/src/api/torrentComment.js
@@ -0,0 +1,13 @@
+import { api } from './auth';
+
+export const likeTorrentComment = (commentId) => {
+ return api.post(`/torrent/comments/${commentId}/like`);
+};
+
+export const getCommentReplies = (commentId) => {
+ return api.get(`/torrent/comments/${commentId}/replies`);
+};
+
+export const addCommentReply = (commentId, replyData) => {
+ return api.post(`/torrent/comments/${commentId}/replies`, replyData);
+};
\ No newline at end of file
diff --git a/src/api/torrentComment.test.js b/src/api/torrentComment.test.js
new file mode 100644
index 0000000..585b0d3
--- /dev/null
+++ b/src/api/torrentComment.test.js
@@ -0,0 +1,60 @@
+import MockAdapter from 'axios-mock-adapter';
+import { api } from './auth'; // 添加api导入
+import { likeTorrentComment, getCommentReplies, addCommentReply } from './torrentComment';
+
+describe('种子评论API', () => {
+ let mockAxios;
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(api);
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ describe('likeTorrentComment - 点赞种子评论', () => {
+ it('应该成功发送点赞请求', async () => {
+ const commentId = '123';
+ const mockResponse = { code: 200, message: '点赞成功' };
+
+ mockAxios.onPost(`/torrent/comments/${commentId}/like`).reply(200, mockResponse);
+
+ const response = await likeTorrentComment(commentId);
+ expect(response.data).toEqual(mockResponse);
+ });
+
+ it('应该处理点赞失败的情况', async () => {
+ mockAxios.onPost('/torrent/comments/123/like').reply(500);
+ await expect(likeTorrentComment('123')).rejects.toThrow();
+ });
+ });
+
+ describe('getCommentReplies - 获取评论回复', () => {
+ it('应该成功获取回复列表', async () => {
+ const commentId = '456';
+ const mockResponse = {
+ code: 200,
+ data: [{ id: '1', content: '回复1' }, { id: '2', content: '回复2' }]
+ };
+
+ mockAxios.onGet(`/torrent/comments/${commentId}/replies`).reply(200, mockResponse);
+
+ const response = await getCommentReplies(commentId);
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+
+ describe('addCommentReply - 添加评论回复', () => {
+ it('应该成功添加回复', async () => {
+ const commentId = '789';
+ const replyData = { content: '测试回复' };
+ const mockResponse = { code: 200, data: { id: '3', ...replyData } };
+
+ mockAxios.onPost(`/torrent/comments/${commentId}/replies`, replyData).reply(200, mockResponse);
+
+ const response = await addCommentReply(commentId, replyData);
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/AnnouncementDetail.css b/src/components/AnnouncementDetail.css
new file mode 100644
index 0000000..5ca87c1
--- /dev/null
+++ b/src/components/AnnouncementDetail.css
@@ -0,0 +1,64 @@
+.announcement-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+
+ .back-button {
+ background: none;
+ border: none;
+ color: #1890ff;
+ font-size: 16px;
+ cursor: pointer;
+ margin-bottom: 20px;
+ }
+
+ .announcement-detail {
+ background: white;
+ padding: 25px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+
+ .announcement-header {
+ border-bottom: 1px solid #f0f0f0;
+ padding-bottom: 15px;
+ margin-bottom: 20px;
+ }
+
+ .announcement-header h1 {
+ margin: 0 0 10px 0;
+ font-size: 24px;
+ }
+
+ .announcement-meta {
+ display: flex;
+ gap: 15px;
+ color: #666;
+ font-size: 14px;
+ align-items: center;
+ }
+
+ .category-badge {
+ background: #e6f7ff;
+ color: #1890ff;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ }
+
+ .announcement-content {
+ line-height: 1.8;
+ font-size: 15px;
+ }
+
+ .announcement-content p {
+ margin: 0 0 16px 0;
+ }
+
+ .error-message {
+ text-align: center;
+ padding: 50px;
+ color: #ff4d4f;
+ font-size: 16px;
+ }
\ No newline at end of file
diff --git a/src/components/AnnouncementDetail.jsx b/src/components/AnnouncementDetail.jsx
new file mode 100644
index 0000000..8e90672
--- /dev/null
+++ b/src/components/AnnouncementDetail.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import './AnnouncementDetail.css';
+
+const AnnouncementDetail = () => {
+ const navigate = useNavigate();
+const location = useLocation();
+ const { state } = useLocation();
+ const announcement = state?.announcement;
+ const handleBack = () => {
+ const fromTab = location.state?.fromTab; // 从跳转时传递的 state 中获取
+ if (fromTab) {
+ navigate(`/dashboard/${fromTab}`); // 明确返回对应标签页
+ } else {
+ navigate(-1); // 保底策略
+ }
+}
+
+
+
+ if (!announcement) {
+ return (
+ <div className="announcement-container">
+ <button className="back-button" onClick={() => navigate(-1)}>
+ ← 返回公告区
+ </button>
+ <div className="error-message">公告加载失败,请返回重试</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="announcement-container">
+ <button className="back-button" onClick={handleBack}>
+ ← 返回
+ </button>
+
+ <div className="announcement-detail">
+ <div className="announcement-header">
+ <h1>{announcement.title}</h1>
+ <div className="announcement-meta">
+ <span className="category-badge">{announcement.category}</span>
+ <span>发布人:{announcement.author}</span>
+ <span>发布日期:{announcement.date}</span>
+ </div>
+ </div>
+
+ <div className="announcement-content">
+ {announcement.content.split('\n').map((paragraph, i) => (
+ <p key={i}>{paragraph}</p>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default AnnouncementDetail;
+
+
+
\ No newline at end of file
diff --git a/src/components/AuthForm.css b/src/components/AuthForm.css
new file mode 100644
index 0000000..8eb1d0a
--- /dev/null
+++ b/src/components/AuthForm.css
@@ -0,0 +1,109 @@
+/* src/components/AuthForm.css */
+.auth-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background-color: #f5f5f5;
+ padding: 20px;
+ }
+
+ .auth-title {
+ color: #333;
+ font-size: 2.5rem;
+ margin-bottom: 2rem;
+ text-align: center;
+ }
+
+ .auth-form-wrapper {
+ width: 100%;
+ max-width: 400px;
+ background: white;
+ padding: 2rem;
+ border-radius: 8px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ }
+
+ .auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .auth-form h2 {
+ color: #333;
+ text-align: center;
+ margin-bottom: 1.5rem;
+ }
+
+ .form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .form-group input {
+ padding: 0.8rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 1rem;
+ }
+
+ .form-group input:focus {
+ outline: none;
+ border-color: #007bff;
+ }
+
+ .auth-button {
+ padding: 0.8rem;
+ background-color: #007bff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background-color 0.3s;
+ }
+
+ .auth-button:hover {
+ background-color: #0056b3;
+ }
+
+ .auth-button:disabled {
+ background-color: #cccccc;
+ cursor: not-allowed;
+ }
+
+ .auth-switch {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ font-size: 0.9rem;
+ color: #666;
+ }
+
+ .switch-button {
+ background: none;
+ border: none;
+ color: #007bff;
+ cursor: pointer;
+ text-decoration: underline;
+ font-size: 0.9rem;
+ padding: 0;
+ }
+
+ .switch-button:hover {
+ color: #0056b3;
+ }
+
+ .error-message {
+ color: #dc3545;
+ background-color: #f8d7da;
+ padding: 0.5rem;
+ border-radius: 4px;
+ text-align: center;
+ margin-bottom: 1rem;
+ }
\ No newline at end of file
diff --git a/src/components/AuthForm.jsx b/src/components/AuthForm.jsx
new file mode 100644
index 0000000..ca28101
--- /dev/null
+++ b/src/components/AuthForm.jsx
@@ -0,0 +1,159 @@
+// src/components/AuthForm.jsx
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { login, register } from '../api/auth';
+import './AuthForm.css';
+
+const AuthForm = ({ onLoginSuccess }) => {
+ const [isLogin, setIsLogin] = useState(true);
+ const [formData, setFormData] = useState({
+ username: '',
+ password: '',
+ code: ''
+ });
+ const [error, setError] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const navigate = useNavigate();
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ try {
+ if (isLogin) {
+ const response = await login(formData.username, formData.password);
+ if (response.data.code === 200) {
+ localStorage.setItem('token', response.data.data.token);
+ localStorage.setItem('username', formData.username);
+ onLoginSuccess?.();
+ navigate('/dashboard');
+ } else {
+ setError(response.data.message || '登录失败');
+ }
+ } else {
+ const response = await register(
+ formData.username,
+ formData.password,
+ formData.code
+ );
+ if (response.data.code === 200) {
+ setError('');
+ setIsLogin(true);
+ setFormData({ username: '', password: '', code: '' });
+ } else {
+ setError(response.data.message || '注册失败');
+ }
+ }
+ } catch (err) {
+ setError(err.message || (isLogin ? '登录失败' : '注册失败'));
+ } finally {
+ setIsLoading(false);
+ }
+
+
+
+ };
+
+ return (
+ <div className="auth-container">
+ <h1 className="auth-title">PT影视资源站</h1>
+
+ <div className="auth-form-wrapper">
+ {isLogin ? (
+ <form className="auth-form" onSubmit={handleSubmit}>
+ <h2>用户登录</h2>
+ {error && <div className="error-message">{error}</div>}
+
+ <div className="form-group">
+ <input
+ type="text"
+ name="username"
+ placeholder="账号"
+ value={formData.username}
+ onChange={handleChange}
+ required
+ />
+ </div>
+
+ <div className="form-group">
+ <input
+ type="password"
+ name="password"
+ placeholder="密码"
+ value={formData.password}
+ onChange={handleChange}
+ required
+ />
+ </div>
+
+ <button type="submit" className="auth-button" disabled={isLoading}>
+ {isLoading ? '登录中...' : '登录'}
+ </button>
+
+ <div className="auth-switch">
+ <span>未有账户?</span>
+ <button type="button" onClick={() => setIsLogin(false)} className="switch-button">
+ 去注册
+ </button>
+ </div>
+ </form>
+ ) : (
+ <form className="auth-form" onSubmit={handleSubmit}>
+ <h2>用户注册</h2>
+ {error && <div className="error-message">{error}</div>}
+
+ <div className="form-group">
+ <input
+ type="text"
+ name="username"
+ placeholder="账号"
+ value={formData.username}
+ onChange={handleChange}
+ required
+ />
+ </div>
+
+ <div className="form-group">
+ <input
+ type="password"
+ name="password"
+ placeholder="密码"
+ value={formData.password}
+ onChange={handleChange}
+ required
+ />
+ </div>
+
+ <div className="form-group">
+ <input
+ type="text"
+ name="code"
+ placeholder="邀请码"
+ value={formData.code}
+ onChange={handleChange}
+ required
+ />
+ </div>
+
+ <button type="submit" className="auth-button" disabled={isLoading}>
+ {isLoading ? '注册中...' : '注册'}
+ </button>
+
+ <div className="auth-switch">
+ <span>已有账户?</span>
+ <button type="button" onClick={() => setIsLogin(true)} className="switch-button">
+ 去登录
+ </button>
+ </div>
+ </form>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default AuthForm;
\ No newline at end of file
diff --git a/src/components/Dashboard.css b/src/components/Dashboard.css
new file mode 100644
index 0000000..af9e421
--- /dev/null
+++ b/src/components/Dashboard.css
@@ -0,0 +1,649 @@
+/* src/components/Dashboard.css */
+.dashboard-container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ background-color: #f5f5f5;
+ }
+
+ .top-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 20px;
+ background-color: #fff;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ }
+
+ .search-container {
+ display: flex;
+ flex-grow: 1;
+ max-width: 600px;
+ margin: 0 auto;
+ }
+
+ .search-input {
+ flex-grow: 1;
+ padding: 8px 15px;
+ border: 1px solid #ddd;
+ border-radius: 20px 0 0 20px;
+ font-size: 16px;
+ outline: none;
+ }
+
+ .search-button {
+ padding: 8px 15px;
+ background-color: #007bff;
+ color: white;
+ border: none;
+ border-radius: 0 20px 20px 0;
+ cursor: pointer;
+ font-size: 16px;
+ }
+
+ .search-button:hover {
+ background-color: #0056b3;
+ }
+
+ .user-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .user-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+
+ .username {
+ font-weight: bold;
+ }
+
+ .logout-button {
+ padding: 5px 10px;
+ background-color: #f8f9fa;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-left: 10px;
+ }
+
+ .logout-button:hover {
+ background-color: #e9ecef;
+ }
+
+ .nav-tabs {
+ display: flex;
+ background-color: #fff;
+ border-bottom: 1px solid #ddd;
+ padding: 0 20px;
+ }
+
+ .tab-button {
+ padding: 12px 20px;
+ background: none;
+ border: none;
+ border-bottom: 3px solid transparent;
+ cursor: pointer;
+ font-size: 16px;
+ color: #666;
+ }
+
+ .tab-button:hover {
+ color: #007bff;
+ }
+
+ .tab-button.active {
+ color: #007bff;
+ border-bottom-color: #007bff;
+ font-weight: bold;
+ }
+
+ .content-area {
+ flex-grow: 1;
+ padding: 20px;
+ background-color: #fff;
+ margin: 20px;
+ border-radius: 4px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ }
+
+ .loading, .error {
+ padding: 20px;
+ text-align: center;
+ font-size: 18px;
+ }
+
+ .error {
+ color: #dc3545;
+ }
+
+/* 公告区样式 */
+ /* 轮播图样式 */
+.carousel-container {
+ position: relative;
+ width: 100%;
+ height: 200px;
+ overflow: hidden;
+ margin-bottom: 20px;
+ border-radius: 4px;
+ }
+
+ .carousel-slide {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ transition: opacity 1s ease-in-out;
+ }
+
+ .carousel-slide.active {
+ opacity: 1;
+ }
+
+ .carousel-image {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ font-weight: bold;
+ color: white;
+ }
+
+ .gray-bg {
+ background-color: #666;
+ }
+
+ .carousel-dots {
+ position: absolute;
+ bottom: 15px;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ }
+
+ .dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background-color: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ }
+
+ .dot.active {
+ background-color: white;
+ }
+
+ /* 公告网格样式 */
+ .announcement-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+ }
+
+ .announcement-card {
+ background-color: white;
+ padding: 15px;
+ border-radius: 4px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ transition: transform 0.2s, box-shadow 0.2s;
+ }
+
+ .announcement-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ .announcement-card h3 {
+ margin-top: 0;
+ color: #007bff;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 8px;
+ }
+
+ .announcement-card p {
+ margin-bottom: 0;
+ color: #666;
+ line-height: 1.5;
+ }
+/* 分享区样式 */
+ /* 上传头部样式 */
+.upload-header {
+ margin-bottom: 20px;
+ display: flex;
+ justify-content: flex-start;
+}
+
+.upload-btn {
+ padding: 10px 20px;
+ background: #1890ff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 16px;
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.upload-btn:hover {
+ background: #40a9ff;
+}
+
+/* 弹窗样式 */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.upload-modal {
+ background: white;
+ padding: 25px;
+ border-radius: 8px;
+ width: 500px;
+ max-width: 90%;
+ position: relative;
+}
+
+.close-btn {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ background: none;
+ border: none;
+ font-size: 20px;
+ cursor: pointer;
+}
+
+/* 表单样式 */
+.form-group {
+ margin-bottom: 15px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 5px;
+ font-weight: 500;
+}
+
+.form-group input[type="text"],
+.form-group select,
+.form-group textarea {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+}
+
+.form-group textarea {
+ min-height: 80px;
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.cancel-btn {
+ padding: 8px 15px;
+ background: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.cancel-btn:hover {
+ background: #e6e6e6;
+}
+ /* 筛选区域 */
+.filter-section {
+ background: #f9f9f9;
+ padding: 15px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.filter-group {
+ margin-bottom: 15px;
+}
+
+.filter-group h4 {
+ margin: 0 0 8px 0;
+ font-size: 15px;
+ color: #666;
+}
+
+/* 筛选按钮 */
+.filter-btn {
+ padding: 6px 12px;
+ margin-right: 8px;
+ margin-bottom: 8px;
+ border: 1px solid #ddd;
+ background: white;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.filter-btn:hover {
+ border-color: #1890ff;
+ color: #1890ff;
+}
+
+.filter-btn.active {
+ background: #1890ff;
+ color: white;
+ border-color: #1890ff;
+}
+
+/* 确定按钮 */
+.confirm-btn {
+ padding: 8px 20px;
+ background: #1890ff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: opacity 0.3s;
+}
+
+.confirm-btn:hover {
+ background: #007bff;
+}
+
+.confirm-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+
+/* 资源列表 */
+.resource-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.resource-item {
+ display: flex;
+ align-items: center;
+ padding: 15px;
+ background-color: white;
+ border-radius: 4px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.resource-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+}
+
+.resource-poster {
+ margin-right: 15px;
+}
+
+.poster-image {
+ width: 60px;
+ height: 80px;
+ background-color: #666;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 24px;
+ font-weight: bold;
+ border-radius: 4px;
+}
+
+.resource-info {
+ flex-grow: 1;
+}
+
+.resource-title {
+ margin: 0 0 5px 0;
+ font-size: 16px;
+ font-weight: bold;
+}
+
+.resource-meta {
+ margin: 0;
+ font-size: 14px;
+ color: #666;
+}
+
+.resource-stats {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ margin-right: 20px;
+ min-width: 150px;
+}
+
+.stat {
+ font-size: 13px;
+ color: #666;
+ margin-bottom: 5px;
+}
+
+.download-btn {
+ padding: 8px 15px;
+ background-color: #28a745;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.download-btn:hover {
+ background-color: #218838;
+}
+/* 发帖按钮样式 */
+.post-header {
+ margin-bottom: 20px;
+ text-align: right;
+}
+
+.create-post-btn {
+ background-color: #1890ff;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.3s;
+}
+
+.create-post-btn:hover {
+ background-color: #40a9ff;
+}
+
+/* 弹窗样式 */
+.post-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.post-modal {
+ background-color: white;
+ padding: 24px;
+ border-radius: 8px;
+ width: 500px;
+ max-width: 90%;
+ position: relative;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.post-modal h3 {
+ margin-top: 0;
+ margin-bottom: 20px;
+ font-size: 18px;
+ color: #333;
+}
+
+.modal-close-btn {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ background: none;
+ border: none;
+ font-size: 20px;
+ cursor: pointer;
+ color: #999;
+}
+
+.modal-close-btn:hover {
+ color: #666;
+}
+
+/* 表单样式 */
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: #333;
+}
+
+.form-group input[type="text"],
+.form-group textarea {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+.form-group textarea {
+ min-height: 120px;
+ resize: vertical;
+}
+
+.upload-image-btn {
+ display: flex;
+ align-items: center;
+}
+
+.upload-image-btn label {
+ background-color: #f5f5f5;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ border: 1px dashed #d9d9d9;
+ margin-right: 10px;
+ transition: all 0.3s;
+}
+
+.upload-image-btn label:hover {
+ border-color: #1890ff;
+ color: #1890ff;
+}
+
+.image-name {
+ font-size: 14px;
+ color: #666;
+}
+
+/* 按钮样式 */
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 24px;
+}
+
+.cancel-btn {
+ background-color: #f5f5f5;
+ color: #333;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-right: 10px;
+ transition: background-color 0.3s;
+}
+
+.cancel-btn:hover {
+ background-color: #e6e6e6;
+}
+
+.submit-btn {
+ background-color: #1890ff;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.submit-btn:hover {
+ background-color: #40a9ff;
+}
+
+.pagination {
+ display: flex;
+ justify-content: center;
+ margin: 20px 0;
+ gap: 5px;
+}
+
+.pagination button {
+ padding: 5px 10px;
+ border: 1px solid #ddd;
+ background: white;
+ cursor: pointer;
+}
+
+.pagination button.active {
+ background: #1890ff;
+ color: white;
+ border-color: #1890ff;
+}
+
+.pagination button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.filter-section {
+ position: sticky;
+ top: 0;
+ background: white;
+ z-index: 100;
+ padding: 15px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
+}
\ No newline at end of file
diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx
new file mode 100644
index 0000000..941210c
--- /dev/null
+++ b/src/components/Dashboard.jsx
@@ -0,0 +1,940 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate, useLocation,useParams } from 'react-router-dom';
+// import { getUserInfo } from '../api'; // 保留import但注释掉
+import { createTorrent, getTorrents} from '../api/torrent';
+import './Dashboard.css';
+import { createPost, getPosts } from '../api/helpPost';
+
+
+const Dashboard = ({ onLogout }) => {
+ const location = useLocation();
+ const [userInfo, setUserInfo] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [currentSlide, setCurrentSlide] = useState(0);
+ const navigate = useNavigate();
+ const {tab} = useParams();
+ const [showUploadModal, setShowUploadModal] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadData, setUploadData] = useState({
+ name: '',
+ type: '',
+ region: '',
+ subtitle: '',
+ resolution: '', // 新增分辨率字段
+ file: null,
+ description: ''
+ });
+ const [showPostModal, setShowPostModal] = useState(false);
+ const [postTitle, setPostTitle] = useState('');
+ const [postContent, setPostContent] = useState('');
+ const [selectedImage, setSelectedImage] = useState(null);
+ const [helpPosts, setHelpPosts] = useState([]);
+ const [helpLoading, setHelpLoading] = useState(false);
+ const [helpError, setHelpError] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+
+ // 添加状态
+ const [torrentPosts, setTorrentPosts] = useState([]);
+ const [torrentLoading, setTorrentLoading] = useState(false);
+ const [torrentError, setTorrentError] = useState(null);
+
+ const [filteredResources, setFilteredResources] = useState(torrentPosts);
+
+ const activeTab = tab || 'announcement'; // 如果没有tab参数,则默认为announcement
+ // 从location.state中初始化状态
+
+
+ const handleTabChange = (tabName) => {
+ navigate(`/dashboard/${tabName}`, {
+ state: {
+ savedFilters: selectedFilters, // 使用新的筛选状态 // 保留现有状态
+ activeTab: tabName // 可选,如果其他组件依赖这个 state
+ }
+ });
+ };
+
+ //公告区
+ const [announcements] = useState([
+ {
+ id: 1,
+ title: '系统维护与更新',
+ content: '2023-10-15 02:00-06:00将进行系统维护升级,期间网站将无法访问。本次更新包含:\n1. 数据库服务器迁移\n2. 安全补丁更新\n3. CDN节点优化\n\n请提前做好下载安排。',
+ author: '系统管理员',
+ date: '2023-10-10',
+ excerpt: '2023-10-15 02:00-06:00将进行系统维护,期间无法访问',
+ category: '系统'
+ },
+ {
+ id: 2,
+ title: '资源上新',
+ content: '最新热门电影《奥本海默》4K REMUX资源已上线,包含:\n- 4K HDR版本 (56.8GB)\n- 1080P标准版 (12.3GB)\n- 中英双语字幕\n\n欢迎下载保种!',
+ author: '资源组',
+ date: '2023-10-08',
+ excerpt: '最新热门电影《奥本海默》4K资源已上线',
+ category: '资源'
+ },
+ {
+ id: 3,
+ title: '积分规则调整',
+ content: '自11月1日起,上传资源积分奖励提升20%,具体规则如下:\n- 上传电影资源:每GB 10积分\n- 上传电视剧资源:每GB 8积分\n- 上传动漫资源:每GB 6积分\n\n感谢大家的支持与贡献!',
+ author: '管理员',
+ date: '2023-10-05',
+ excerpt: '自11月1日起,上传资源积分奖励提升20%',
+ category: '公告'
+ },
+ {
+ id: 4,
+ title: '违规处理公告',
+ content: '用户user123因发布虚假资源已被封禁,相关资源已删除。请大家遵守社区规则,维护良好的分享环境。',
+ author: '管理员',
+ date: '2023-10-03',
+ excerpt: '用户user123因发布虚假资源已被封禁',
+ category: '违规'
+ },
+ {
+ id: 5,
+ title: '节日活动',
+ content: '国庆期间所有资源下载积分减半,活动时间:2023年10月1日至2023年10月7日。',
+ author: '活动组',
+ date: '2023-09-30',
+ excerpt: '国庆期间所有资源下载积分减半',
+ category: '活动'
+ },
+ {
+ id: 6,
+ title: '客户端更新',
+ content: 'PT客户端v2.5.0已发布,修复多个BUG,新增资源搜索功能。请尽快更新到最新版本以获得更好的使用体验。',
+ author: '开发组',
+ date: '2023-09-28',
+ excerpt: 'PT客户端v2.5.0已发布,修复多个BUG',
+ category: '更新'
+ },
+ // 其他公告...
+ ]);
+
+
+ const handleAnnouncementClick = (announcement,e) => {
+ if (!e.target.closest('.exclude-click')) {
+ navigate(`/announcement/${announcement.id}`, {
+ state: {
+ announcement,
+ returnPath: `/dashboard/${activeTab}`,
+ scrollPosition: window.scrollY,
+ activeTab
+ }
+ });
+ }
+ };
+
+
+ //资源区
+
+
+ const handleFileChange = (e) => {
+ setUploadData({...uploadData, file: e.target.files[0]});
+ };
+
+ const handleUploadSubmit = async (e) => {
+ e.preventDefault();
+
+ try {
+ setIsUploading(true);
+ const username = localStorage.getItem('username');
+
+ // 创建种子数据
+ const torrentData = {
+ torrentName: uploadData.name,
+ category: uploadData.type,
+ regin: uploadData.region,
+ resolution: uploadData.resolution,
+ subtitle: uploadData.subtitle,
+ description: uploadData.description,
+ username: username,
+ filePath: "D:\\大学\\大三_下\\实训BT服务器下载\\G2-ptPlatform-backend\\torrents\\[电影天堂www.dytt89.com]两杆大烟枪BD中英双字.mp4.torrent" // 默认路径
+ };
+
+ // 调用API创建种子
+ await createTorrent(
+ torrentData.torrentName,
+ torrentData.description,
+ torrentData.username,
+ torrentData.category,
+ torrentData.regin,
+ torrentData.resolution,
+ torrentData.subtitle,
+ torrentData.filePath
+ );
+
+ // 上传成功处理
+ setShowUploadModal(false);
+ setUploadData({
+ name: '',
+ type: '',
+ region: '',
+ subtitle: '',
+ resolution: '',
+ file: null,
+ description: ''
+ });
+ alert('种子创建成功!');
+
+ // 刷新列表
+ fetchTorrentPosts(currentPage);
+
+ } catch (error) {
+ console.error('创建失败:', error);
+ alert('创建失败: ' + (error.response?.data?.message || error.message));
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const handlePostSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ const username = localStorage.getItem('username');
+ const response = await createPost(
+ postTitle,
+ postContent,
+ username
+ );
+
+ if (response.data.code === 200) {
+ // 刷新帖子列表
+ await fetchHelpPosts();
+ // 重置表单
+ setShowPostModal(false);
+ setPostTitle('');
+ setPostContent('');
+ setSelectedImage(null);
+ } else {
+ setHelpError(response.data.message || '发帖失败');
+ }
+ } catch (err) {
+ setHelpError(err.message || '发帖失败');
+ }
+ };
+
+ // 获取Torrent帖子列表
+ const fetchTorrentPosts = async (page = 1) => {
+ setTorrentLoading(true);
+ try {
+ const response = await getTorrents(page);
+ setTorrentPosts(response.data.data.records);
+ setTotalPages(Math.ceil(response.data.data.total / 5)); // 假设每页5条
+ setCurrentPage(page);
+ } catch (err) {
+ setTorrentError(err.message);
+ } finally {
+ setTorrentLoading(false);
+ }
+};
+
+ // 在useEffect中调用
+ useEffect(() => {
+ if (activeTab === 'share') {
+ fetchTorrentPosts();
+ }
+ }, [activeTab]);
+
+ const handleImageUpload = (e) => {
+ setSelectedImage(e.target.files[0]);
+ };
+
+
+
+
+ const fetchHelpPosts = async (page = 1) => {
+ setHelpLoading(true);
+ try {
+ const response = await getPosts(page);
+ if (response.data.code === 200) {
+ setHelpPosts(response.data.data.records);
+ setTotalPages(Math.ceil(response.data.data.total / 5)); // 假设每页5条
+ setCurrentPage(page);
+ } else {
+ setHelpError(response.data.message || '获取求助帖失败');
+ }
+ } catch (err) {
+ setHelpError(err.message || '获取求助帖失败');
+ } finally {
+ setHelpLoading(false);
+ }
+ };
+ useEffect(() => {
+ if (activeTab === 'help') {
+ fetchHelpPosts(currentPage);
+ }
+ }, [activeTab, currentPage]); // 添加 currentPage 作为依赖
+
+
+
+
+ // 分类维度配置
+ const filterCategories = {
+ type: {
+ label: '类型',
+ options: {
+ 'all': '全部',
+ '电影': '电影',
+ '电视剧': '电视剧',
+ '动漫': '动漫',
+ '综艺': '综艺'
+ }
+ },
+ subtitle: {
+ label: '字幕',
+ options: {
+ 'all': '全部',
+ 'yes': '有字幕',
+ 'no': '无字幕'
+ }
+ },
+ region: {
+ label: '地区',
+ options: {
+ 'all': '全部',
+ 'cn': '大陆',
+ 'us': '欧美',
+ 'jp': '日本'
+ }
+ }
+ };
+
+ const [selectedFilters, setSelectedFilters] = useState(
+ location.state?.savedFilters ||
+ Object.keys(filterCategories).reduce((acc, category) => {
+ acc[category] = 'all';
+ return acc;
+ }, {})
+ );
+
+
+
+// 处理筛选条件变更
+const handleFilterSelect = (category, value) => {
+ setSelectedFilters(prev => ({
+ ...prev,
+ [category]: prev[category] === value ? null : value // 点击已选中的则取消
+ }));
+};
+
+//应用筛选条件
+const applyFilters = () => {
+ const result = torrentPosts.filter(resource => {
+ return Object.entries(selectedFilters).every(([category, selectedValue]) => {
+ if (selectedValue === 'all') return true;
+ if (category === 'subtitle') {
+ return resource.subtitle === (selectedValue === 'yes');
+ }
+ return resource[category] === selectedValue;
+ });
+ });
+ setFilteredResources(result);
+};
+
+
+ // 恢复滚动位置
+ useEffect(() => {
+ if (location.state?.scrollPosition) {
+ window.scrollTo(0, location.state.scrollPosition);
+ }
+ }, [location.state]);
+
+
+ useEffect(() => {
+ const token = localStorage.getItem('token');
+ if (!token) {
+ navigate('/login');
+ return;
+ }
+
+ /* 保留但注释掉实际的用户信息获取
+ const fetchUserInfo = async () => {
+ try {
+ const response = await getUserInfo(token);
+ if (response.data.code === 200) {
+ setUserInfo(response.data.data);
+ } else {
+ setError('获取用户信息失败');
+ }
+ } catch (err) {
+ setError('获取用户信息失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchUserInfo();
+ */
+
+ // 模拟用户信息
+ setUserInfo({
+ name: localStorage.getItem('username') || '演示用户', // 确保这里读取的是最新值
+ avatar: 'https://via.placeholder.com/40'
+ });
+ setLoading(false);
+ }, [navigate]);
+
+ // 轮播图自动切换效果
+ useEffect(() => {
+ if (activeTab === 'announcement') {
+ const timer = setInterval(() => {
+ setCurrentSlide(prev => (prev + 1) % 3); // 3张轮播图循环
+ }, 3000);
+ return () => clearInterval(timer);
+ }
+ }, [activeTab]);
+
+ useEffect(() => {
+ if (activeTab === 'help') {
+ fetchHelpPosts();
+ }
+ }, [activeTab]);
+
+ const renderContent = () => {
+ switch(activeTab) {
+ case 'announcement':
+ return (
+ <div className="content-area" data-testid="announcement-section">
+ {/* 轮播图区域 */}
+ <div className="carousel-container">
+ <div className={`carousel-slide ${currentSlide === 0 ? 'active' : ''}`}>
+ <div className="carousel-image gray-bg">促销活动1</div>
+ </div>
+ <div className={`carousel-slide ${currentSlide === 1 ? 'active' : ''}`}>
+ <div className="carousel-image gray-bg">促销活动2</div>
+ </div>
+ <div className={`carousel-slide ${currentSlide === 2 ? 'active' : ''}`}>
+ <div className="carousel-image gray-bg">促销活动3</div>
+ </div>
+ <div className="carousel-dots">
+ <span className={`dot ${currentSlide === 0 ? 'active' : ''}`}></span>
+ <span className={`dot ${currentSlide === 1 ? 'active' : ''}`}></span>
+ <span className={`dot ${currentSlide === 2 ? 'active' : ''}`}></span>
+ </div>
+ </div>
+
+ {/* 公告区块区域 */}
+ <div className="announcement-grid">
+ {announcements.map(announcement => (
+ <div
+ key={announcement.id}
+ className="announcement-card"
+ onClick={(e) => handleAnnouncementClick(announcement, e)}
+ >
+ <h3>{announcement.title}</h3>
+ <p>{announcement.excerpt}</p>
+ <div className="announcement-footer exclude-click">
+ <span>{announcement.author}</span>
+ <span>{announcement.date}</span>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+ case 'share':
+ return (
+ <div className="content-area" data-testid="share-section">
+ {/* 上传按钮 - 添加在筛选区上方 */}
+ <div className="upload-header">
+ <button
+ className="upload-btn"
+ onClick={() => setShowUploadModal(true)}
+ >
+ + 上传种子
+ </button>
+ </div>
+ {/* 分类筛选区 */}
+ <div className="filter-section">
+ {Object.entries(filterCategories).map(([category, config]) => (
+ <div key={category} className="filter-group">
+ <h4>{config.label}:</h4>
+ <div className="filter-options">
+ {Object.entries(config.options).map(([value, label]) => (
+ <button
+ key={value}
+ className={`filter-btn ${
+ selectedFilters[category] === value ? 'active' : ''
+ }`}
+ onClick={() => handleFilterSelect(category, value)}
+ >
+ {label}
+ </button>
+ ))}
+ </div>
+ </div>
+ ))}
+
+ <button
+ className="confirm-btn"
+ onClick={applyFilters}
+ >
+ 确认筛选
+ </button>
+ </div>
+
+ {/* 上传弹窗 */}
+ {showUploadModal && (
+ <div className="modal-overlay">
+ <div className="upload-modal">
+ <h3>上传新种子</h3>
+ <button
+ className="close-btn"
+ onClick={() => setShowUploadModal(false)}
+ >
+ ×
+ </button>
+
+ <form onSubmit={handleUploadSubmit}>
+ <div className="form-group">
+ <label>种子名称</label>
+ <input
+ type="text"
+ value={uploadData.name}
+ onChange={(e) => setUploadData({...uploadData, name: e.target.value})}
+ required
+ />
+ </div>
+
+ <div className="form-group">
+ <label>资源类型</label>
+ <select
+ value={uploadData.type}
+ onChange={(e) => setUploadData({...uploadData, type: e.target.value})}
+ required
+ >
+ <option value="">请选择</option>
+ <option value="电影">电影</option>
+ <option value="电视剧">电视剧</option>
+ <option value="动漫">动漫</option>
+ <option value="综艺">综艺</option>
+ <option value="音乐">音乐</option>
+ </select>
+ </div>
+
+ {/* 新增地区输入框 */}
+ <div className="form-group">
+ <label>地区</label>
+ <input
+ type="text"
+ value={uploadData.region || ''}
+ onChange={(e) => setUploadData({...uploadData, region: e.target.value})}
+ placeholder="例如: 美国, 中国, 日本等"
+ required
+ />
+ </div>
+
+ {/* 添加分辨率下拉框 */}
+ <div className="form-group">
+ <label>分辨率</label>
+ <select
+ value={uploadData.resolution || ''}
+ onChange={(e) => setUploadData({...uploadData, resolution: e.target.value})}
+ required
+ >
+ <option value="">请选择</option>
+ <option value="4K">4K</option>
+ <option value="2K">2K</option>
+ <option value="1080P">1080P</option>
+ <option value="720P">720P</option>
+ <option value="SD">SD</option>
+ <option value="无损音源">无损音源</option>
+ <option value="杜比全景声">杜比全景声</option>
+ <option value="其他">其他</option>
+ </select>
+ </div>
+
+
+
+ {/* 新增字幕语言下拉框 */}
+ <div className="form-group">
+ <label>字幕语言</label>
+ <select
+ value={uploadData.subtitle || ''}
+ onChange={(e) => setUploadData({...uploadData, subtitle: e.target.value})}
+ required
+ >
+ <option value="">请选择</option>
+ <option value="无需字幕">无需字幕</option>
+ <option value="暂无字幕">暂无字幕</option>
+ <option value="自带中文字幕">自带中文字幕</option>
+ <option value="自带双语字幕(含中文)">自带双语字幕(含中文)</option>
+ <option value="附件中文字幕">附件中文字幕</option>
+ <option value="附件双语字幕">附件双语字幕</option>
+ </select>
+ </div>
+
+ <div className="form-group">
+ <label>种子文件</label>
+ <input
+ type="file"
+ accept=".torrent"
+ onChange={handleFileChange}
+
+ />
+ </div>
+
+ <div className="form-group">
+ <label>简介</label>
+ <textarea
+ value={uploadData.description}
+ onChange={(e) => setUploadData({...uploadData, description: e.target.value})}
+ />
+ </div>
+
+ <div className="form-actions">
+ <button
+ type="button"
+ className="cancel-btn"
+ onClick={() => setShowUploadModal(false)}
+ >
+ 取消
+ </button>
+ <button
+ type="submit"
+ className="confirm-btn"
+ disabled={isUploading}
+ >
+ {isUploading ? '上传中...' : '确认上传'}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ )}
+
+ <div className="resource-list">
+ {torrentPosts.map(torrent => (
+ <div
+ key={torrent.id}
+ className="resource-item"
+ onClick={() => navigate(`/torrent/${torrent.id}`)}
+ >
+ <div className="resource-poster">
+ <div className="poster-image gray-bg">{torrent.torrentName.charAt(0)}</div>
+ </div>
+ <div className="resource-info">
+ <h3 className="resource-title">{torrent.torrentName}</h3>
+ <p className="resource-meta">
+ {torrent.resolution} | {torrent.region} | {torrent.category}
+ </p>
+ <p className="resource-subtitle">字幕: {torrent.subtitle}</p>
+ </div>
+ <div className="resource-stats">
+ <span className="stat">{torrent.size}</span>
+ <span className="stat">发布者: {torrent.username}</span>
+ </div>
+ <button
+ className="download-btn"
+ onClick={(e) => {
+ e.stopPropagation();
+ // 下载逻辑
+ }}
+ >
+ 立即下载
+ </button>
+ </div>
+ ))}
+ </div>
+
+ {/* 分页控件 */}
+ <div className="pagination">
+ <button
+ onClick={() => fetchTorrentPosts(currentPage - 1)}
+ disabled={currentPage === 1}
+ >
+ 上一页
+ </button>
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
+ <button
+ key={page}
+ onClick={() => fetchTorrentPosts(page)}
+ className={currentPage === page ? 'active' : ''}
+ >
+ {page}
+ </button>
+ ))}
+
+ <button
+ onClick={() => fetchTorrentPosts(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ >
+ 下一页
+ </button>
+ </div>
+ </div>
+ );
+ // 在Dashboard.jsx的renderContent函数中修改case 'request'部分
+ case 'request':
+ return (
+ <div className="content-area" data-testid="request-section">
+ {/* 求种区帖子列表 */}
+ <div className="request-list">
+ {[
+ {
+ id: 1,
+ title: '求《药屋少女的呢喃》第二季全集',
+ content: '求1080P带中文字幕版本,最好是内嵌字幕不是外挂的',
+ author: '动漫爱好者',
+ authorAvatar: 'https://via.placeholder.com/40',
+ date: '2023-10-15',
+ likeCount: 24,
+ commentCount: 8
+ },
+ {
+ id: 2,
+ title: '求《奥本海默》IMAX版',
+ content: '最好是原盘或者高码率的版本,谢谢各位大佬',
+ author: '电影收藏家',
+ authorAvatar: 'https://via.placeholder.com/40',
+ date: '2023-10-14',
+ likeCount: 15,
+ commentCount: 5
+ }
+ ].map(post => (
+ <div
+ key={post.id}
+ className="request-post"
+ onClick={() => navigate(`/request/${post.id}`)}
+ >
+ <div className="post-header">
+ <img src={post.authorAvatar} alt={post.author} className="post-avatar" />
+ <div className="post-author">{post.author}</div>
+ <div className="post-date">{post.date}</div>
+ </div>
+ <h3 className="post-title">{post.title}</h3>
+ <p className="post-content">{post.content}</p>
+ <div className="post-stats">
+ <span className="post-likes">👍 {post.likeCount}</span>
+ <span className="post-comments">💬 {post.commentCount}</span>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+ // 在Dashboard.jsx的renderContent函数中修改case 'help'部分
+ case 'help':
+ return (
+ <div className="content-area" data-testid="help-section">
+ {/* 新增发帖按钮 */}
+ <div className="post-header">
+ <button
+ className="create-post-btn"
+ onClick={() => setShowPostModal(true)}
+ >
+ + 发帖求助
+ </button>
+ </div>
+
+ {/* 加载状态和错误提示 */}
+ {helpLoading && <div className="loading">加载中...</div>}
+ {helpError && <div className="error">{helpError}</div>}
+
+ {/* 求助区帖子列表 */}
+ <div className="help-list">
+ {helpPosts.map(post => (
+ <div
+ key={post.id}
+ className={`help-post ${post.isSolved ? 'solved' : ''}`}
+ onClick={() => navigate(`/help/${post.id}`)}
+ >
+ <div className="post-header">
+ <img
+ src={post.authorAvatar || 'https://via.placeholder.com/40'}
+ alt={post.authorId}
+ className="post-avatar"
+ />
+ <div className="post-author">{post.authorId}</div>
+ <div className="post-date">
+ {new Date(post.createTime).toLocaleDateString()}
+ </div>
+ {post.isSolved && <span className="solved-badge">已解决</span>}
+ </div>
+ <h3 className="post-title">{post.title}</h3>
+ <p className="post-content">{post.content}</p>
+ <div className="post-stats">
+ <span className="post-likes">👍 {post.likeCount || 0}</span>
+ <span className="post-comments">💬 {post.replyCount || 0}</span>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 在帖子列表后添加分页控件 */}
+ <div className="pagination">
+ <button
+ onClick={() => fetchHelpPosts(currentPage - 1)}
+ disabled={currentPage === 1}
+ >
+ 上一页
+ </button>
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
+ <button
+ key={page}
+ onClick={() => fetchHelpPosts(page)}
+ className={currentPage === page ? 'active' : ''}
+ >
+ {page}
+ </button>
+ ))}
+
+ <button
+ onClick={() => fetchHelpPosts(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ >
+ 下一页
+ </button>
+ </div>
+
+ {/* 新增发帖弹窗 */}
+ {showPostModal && (
+ <div className="post-modal-overlay">
+ <div className="post-modal">
+ <h3>发布求助帖</h3>
+ <button
+ className="modal-close-btn"
+ onClick={() => setShowPostModal(false)}
+ >
+ ×
+ </button>
+
+ <form onSubmit={handlePostSubmit}>
+ <div className="form-group">
+ <label>帖子标题</label>
+ <input
+ type="text"
+ value={postTitle}
+ onChange={(e) => setPostTitle(e.target.value)}
+ placeholder="请输入标题"
+ required
+ />
+ </div>
+
+ <div className="form-group">
+ <label>帖子内容</label>
+ <textarea
+ value={postContent}
+ onChange={(e) => setPostContent(e.target.value)}
+ placeholder="详细描述你的问题"
+ required
+ />
+ </div>
+
+ <div className="form-group">
+ <label>上传图片</label>
+ <div className="upload-image-btn">
+ <input
+ type="file"
+ id="image-upload"
+ accept="image/*"
+ onChange={handleImageUpload}
+ style={{ display: 'none' }}
+ />
+ <label htmlFor="image-upload">
+ {selectedImage ? '已选择图片' : '选择图片'}
+ </label>
+ {selectedImage && (
+ <span className="image-name">{selectedImage.name}</span>
+ )}
+ </div>
+ </div>
+
+ <div className="form-actions">
+ <button
+ type="button"
+ className="cancel-btn"
+ onClick={() => setShowPostModal(false)}
+ >
+ 取消
+ </button>
+ <button
+ type="submit"
+ className="submit-btn"
+ >
+ 确认发帖
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ default:
+ return <div className="content-area" data-testid="default-section">公告区内容</div>;
+ }
+ };
+
+ if (loading) return <div className="loading">加载中...</div>;
+ if (error) return <div className="error">{error}</div>;
+
+ return (
+ <div className="dashboard-container" data-testid="dashboard-container">
+ {/* 顶部栏 */}
+ <div className="top-bar" data-testid="top-bar">
+ {/* 搜索框 */}
+ <div className="search-container">
+ <input
+ type="text"
+ placeholder="搜索种子、用户..."
+ className="search-input"
+ />
+ <button className="search-button">搜索</button>
+ </div>
+
+ <div className="user-info" data-testid="user-info">
+ <img
+ src={userInfo?.avatar || 'https://via.placeholder.com/40'}
+ alt="用户头像"
+ className="user-avatar"
+ onClick={() => navigate('/personal')} // 添加点击跳转
+ style={{ cursor: 'pointer' }} // 添加手型指针
+ />
+ <span className="username">{userInfo?.name || '用户'}</span>
+ <button onClick={onLogout} className="logout-button">退出</button>
+ </div>
+ </div>
+
+ {/* 导航栏 */}
+ {/* handleTabchange函数替换了原本的setactivetab函数 */}
+ <div className="nav-tabs">
+ <button
+ className={`tab-button ${activeTab === 'announcement' ? 'active' : ''}`}
+ onClick={() => handleTabChange('announcement')}
+ >
+ 公告区
+ </button>
+ <button
+ className={`tab-button ${activeTab === 'share' ? 'active' : ''}`}
+ onClick={() => handleTabChange('share')}
+ >
+ 分享区
+ </button>
+ <button
+ className={`tab-button ${activeTab === 'request' ? 'active' : ''}`}
+ onClick={() => handleTabChange('request')}
+ >
+ 求种区
+ </button>
+ <button
+ className={`tab-button ${activeTab === 'help' ? 'active' : ''}`}
+ onClick={() => handleTabChange('help')}
+ >
+ 求助区
+ </button>
+ </div>
+
+ {/* 内容区 */}
+ {renderContent()}
+ </div>
+ );
+};
+
+export default Dashboard;
\ No newline at end of file
diff --git a/src/components/HelpDetail.css b/src/components/HelpDetail.css
new file mode 100644
index 0000000..94d791b
--- /dev/null
+++ b/src/components/HelpDetail.css
@@ -0,0 +1,557 @@
+/* HelpDetail.css */
+.help-detail-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+
+ .back-button {
+ background: none;
+ border: none;
+ color: #1890ff;
+ cursor: pointer;
+ font-size: 16px;
+ margin-bottom: 20px;
+ padding: 5px 0;
+ }
+
+ .help-post {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ }
+
+ .help-post.solved {
+ border-left: 4px solid #52c41a;
+ }
+
+ .post-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 15px;
+ position: relative;
+ }
+
+ .post-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ margin-right: 10px;
+ }
+
+ .post-meta {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .post-author {
+ font-weight: bold;
+ }
+
+ .post-date {
+ color: #888;
+ font-size: 14px;
+ }
+
+ .solved-badge {
+ position: absolute;
+ right: 0;
+ top: 0;
+ background: #52c41a;
+ color: white;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ }
+
+ .post-title {
+ font-size: 20px;
+ margin: 0 0 15px;
+ }
+
+ .post-content {
+ line-height: 1.6;
+ margin-bottom: 15px;
+ }
+
+ .post-actions {
+ display: flex;
+ gap: 15px;
+ }
+
+ .like-button, .favorite-button, .solve-button {
+ padding: 5px 15px;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ background: #f5f5f5;
+ cursor: pointer;
+ }
+
+ .like-button.liked {
+ background: #e6f7ff;
+ border-color: #91d5ff;
+ color: #1890ff;
+ }
+
+ .favorite-button.favorited {
+ background: #fff7e6;
+ border-color: #ffd591;
+ color: #fa8c16;
+ }
+
+ .solve-button.solved {
+ background: #f6ffed;
+ border-color: #b7eb8f;
+ color: #52c41a;
+ }
+
+ .comments-section {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ }
+
+ .comment-form {
+ margin-bottom: 20px;
+ }
+
+ .comment-form textarea {
+ width: 100%;
+ padding: 10px;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ resize: vertical;
+ min-height: 80px;
+ margin-bottom: 10px;
+ }
+
+ .image-upload {
+ margin-bottom: 10px;
+ }
+
+ .upload-button {
+ background: #f5f5f5;
+ border: 1px dashed #d9d9d9;
+ border-radius: 4px;
+ padding: 8px 16px;
+ cursor: pointer;
+ margin-bottom: 10px;
+ }
+
+ .image-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .preview-item {
+ position: relative;
+ width: 100px;
+ height: 100px;
+ }
+
+ .preview-item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 4px;
+ }
+
+ .remove-image {
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ width: 20px;
+ height: 20px;
+ background: #ff4d4f;
+ color: white;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ }
+
+ .submit-button {
+ background: #1890ff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 16px;
+ cursor: pointer;
+ }
+
+ .comment-list {
+ margin-top: 20px;
+ }
+
+ .comment-item {
+ display: flex;
+ padding: 15px 0;
+ border-bottom: 1px solid #f0f0f0;
+ }
+
+ .comment-item:last-child {
+ border-bottom: none;
+ }
+
+ .comment-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ margin-right: 15px;
+ flex-shrink: 0;
+ }
+
+ .comment-content {
+ flex-grow: 1;
+ }
+
+ .comment-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ }
+
+ .comment-author {
+ font-weight: bold;
+ }
+
+ .comment-date {
+ color: #888;
+ font-size: 14px;
+ }
+
+ .comment-text {
+ line-height: 1.6;
+ margin-bottom: 8px;
+ }
+
+ .comment-images {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 10px;
+ }
+
+ .comment-image {
+ max-width: 200px;
+ max-height: 150px;
+ border-radius: 4px;
+ }
+
+ .comment-actions {
+ margin-top: 10px;
+ }
+
+ .reply-form {
+ margin-top: 10px;
+ display: flex;
+ gap: 10px;
+ }
+
+ .reply-form textarea {
+ flex-grow: 1;
+ padding: 8px;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ resize: vertical;
+ min-height: 40px;
+ }
+
+ .submit-reply {
+ background: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ padding: 0 15px;
+ cursor: pointer;
+ align-self: flex-end;
+ margin-bottom: 5px;
+ }
+
+ .reply-list {
+ margin-top: 10px;
+ padding-left: 15px;
+ border-left: 2px solid #f0f0f0;
+ }
+
+ .reply-item {
+ display: flex;
+ margin-top: 10px;
+ }
+
+ .reply-avatar {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ margin-right: 10px;
+ }
+
+ .reply-content {
+ flex-grow: 1;
+ }
+
+ .reply-header {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 5px;
+ }
+
+ .reply-author {
+ font-weight: bold;
+ font-size: 14px;
+ }
+
+ .reply-date {
+ color: #888;
+ font-size: 12px;
+ }
+
+ .reply-text {
+ font-size: 14px;
+ line-height: 1.5;
+ }
+
+ /* 评论项样式 */
+.comment-item {
+ display: flex;
+ margin-bottom: 20px;
+ padding: 15px;
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.comment-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ margin-right: 15px;
+ object-fit: cover;
+}
+
+.comment-content {
+ flex: 1;
+}
+
+.comment-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.comment-author {
+ font-weight: bold;
+ margin-right: 10px;
+ color: #333;
+}
+
+.comment-date {
+ font-size: 12px;
+ color: #999;
+}
+
+.comment-text {
+ margin-bottom: 10px;
+ line-height: 1.5;
+ color: #333;
+}
+
+.comment-actions {
+ display: flex;
+ align-items: center;
+ margin-top: 10px;
+}
+
+.like-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #666;
+ font-size: 14px;
+ padding: 4px 8px;
+ display: flex;
+ align-items: center;
+}
+
+.like-button.liked {
+ color: #1877f2;
+}
+
+.like-button:hover {
+ text-decoration: underline;
+}
+
+/* 回复控制按钮 */
+.reply-controls {
+ margin: 10px 0;
+}
+
+.toggle-replies-btn {
+ background: none;
+ border: none;
+ color: #666;
+ cursor: pointer;
+ font-size: 14px;
+ padding: 4px 8px;
+}
+
+.toggle-replies-btn:hover {
+ color: #333;
+ text-decoration: underline;
+}
+
+.toggle-replies-btn:disabled {
+ color: #ccc;
+ cursor: not-allowed;
+}
+
+/* 回复表单 */
+.reply-form {
+ margin-top: 10px;
+ width: 100%;
+}
+
+.reply-form textarea {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ resize: vertical;
+ min-height: 60px;
+ margin-bottom: 8px;
+}
+
+.submit-reply {
+ background-color: #f0f2f5;
+ border: none;
+ border-radius: 4px;
+ padding: 6px 12px;
+ cursor: pointer;
+ color: #333;
+}
+
+.submit-reply:hover {
+ background-color: #e4e6eb;
+}
+
+/* 回复列表 */
+.reply-list {
+ margin-left: 55px; /* 头像宽度 + 边距 */
+ border-left: 2px solid #eee;
+ padding-left: 15px;
+ margin-top: 15px;
+}
+
+.reply-item {
+ display: flex;
+ margin-bottom: 15px;
+ padding: 12px;
+ background: #f9f9f9;
+ border-radius: 6px;
+}
+
+.reply-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ margin-right: 10px;
+ object-fit: cover;
+}
+
+.reply-content {
+ flex: 1;
+}
+
+.reply-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 5px;
+}
+
+.reply-author {
+ font-weight: bold;
+ margin-right: 8px;
+ font-size: 14px;
+ color: #333;
+}
+
+.reply-date {
+ font-size: 12px;
+ color: #999;
+}
+
+.reply-text {
+ font-size: 14px;
+ line-height: 1.4;
+ color: #333;
+ margin-bottom: 5px;
+}
+
+.reply-actions {
+ margin-top: 5px;
+}
+
+/* 回复弹窗样式 */
+.reply-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.reply-modal {
+ background: white;
+ padding: 20px;
+ border-radius: 8px;
+ width: 500px;
+ max-width: 90%;
+}
+
+.reply-modal h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+}
+
+.reply-modal textarea {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ resize: vertical;
+ min-height: 100px;
+ margin-bottom: 15px;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.cancel-button {
+ background: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ padding: 8px 16px;
+ cursor: pointer;
+}
+
+.submit-button {
+ background: #1890ff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 16px;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/src/components/HelpDetail.jsx b/src/components/HelpDetail.jsx
new file mode 100644
index 0000000..da50850
--- /dev/null
+++ b/src/components/HelpDetail.jsx
@@ -0,0 +1,368 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { useParams, useNavigate, useLocation } from 'react-router-dom';
+import {
+ getPostDetail,
+ addPostComment,
+ likePost
+} from '../api/helpPost';
+import {
+ likePostComment,
+ getCommentReplies,
+ addCommentReply
+} from '../api/helpComment';
+import './HelpDetail.css';
+
+const HelpDetail = () => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const fileInputRef = useRef(null);
+ const [post, setPost] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [newComment, setNewComment] = useState('');
+ const [newReply, setNewReply] = useState({});
+ const [images, setImages] = useState([]);
+ const [expandedReplies, setExpandedReplies] = useState({}); // 记录哪些评论的回复是展开的
+ const [loadingReplies, setLoadingReplies] = useState({});
+ const [setReplyingTo] = useState(null);
+ const [replyContent, setReplyContent] = useState('');
+
+ const [activeReplyId, setActiveReplyId] = useState(null);
+ const [replyModal, setReplyModal] = useState({
+ visible: false,
+ replyingTo: null,
+ replyingToUsername: '',
+ isReply: false
+ });
+
+ // 确保openReplyModal接收username参数
+ const openReplyModal = (commentId, username) => {
+ setReplyModal({
+ visible: true,
+ replyingTo: commentId,
+ replyingToUsername: username, // 确保这里接收username
+ isReply: false
+ });
+ };
+
+ // 关闭回复弹窗
+ const closeReplyModal = () => {
+ setReplyModal({
+ visible: false,
+ replyingTo: null,
+ replyingToUsername: '',
+ isReply: false
+ });
+ setReplyContent('');
+ };
+
+ const Comment = ({ comment, onLike, onReply, isReply = false }) => {
+ return (
+ <div className={`comment-container ${isReply ? "is-reply" : ""}`}>
+ <div className="comment-item">
+ <div className="comment-avatar">
+ {(comment.authorId || "?").charAt(0)} {/* 修复点 */}
+ </div>
+ <div className="comment-content">
+ <div className="comment-header">
+ <span className="comment-user">{comment.authorId || "匿名用户"}</span>
+ {comment.replyTo && (
+ <span className="reply-to">回复 @{comment.replyTo}</span>
+ )}
+ <span className="comment-time">
+ {new Date(comment.createTime).toLocaleString()}
+ </span>
+ </div>
+ <p className="comment-text">{comment.content}</p>
+ <div className="comment-actions">
+ <button onClick={() => onLike(comment.id)}>
+ 👍 ({comment.likeCount || 0})
+ </button>
+ <button onClick={() => onReply(comment.id, comment.authorId)}>
+ 回复
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ };
+
+ // 递归渲染评论组件
+ const renderComment = (comment, depth = 0) => {
+ return (
+ <div key={comment.id} style={{ marginLeft: `${depth * 30}px` }}>
+ <Comment
+ comment={comment}
+ onLike={handleLikeComment}
+ onReply={openReplyModal}
+ isReply={depth > 0}
+ />
+
+ {/* 递归渲染所有回复 */}
+ {comment.replies && comment.replies.map(reply =>
+ renderComment(reply, depth + 1)
+ )}
+ </div>
+ );
+ };
+
+
+ const fetchPostDetail = async () => {
+ try {
+ setLoading(true);
+ const response = await getPostDetail(id);
+ console.log('API Response:', JSON.parse(JSON.stringify(response.data.data.comments))); // 深度拷贝避免Proxy影响
+ setPost(response.data.data.post);
+ setComments(response.data.data.comments);
+ } catch (err) {
+ setError(err.response?.data?.message || '获取帖子详情失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchPostDetail();
+ }, [id]);
+
+ // 点赞帖子
+ const handleLikePost = async () => {
+ try {
+ await likePost(id);
+ setPost(prev => ({
+ ...prev,
+ likeCount: prev.likeCount + 1
+ }));
+ } catch (err) {
+ setError('点赞失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+
+ const handleCommentSubmit = async (e) => {
+ e.preventDefault();
+ if (!newComment.trim()) return;
+
+ try {
+ const username = localStorage.getItem('username');
+ const response = await addPostComment(id, {
+ content: newComment,
+ authorId: username
+ });
+
+ // 修改这里的响应处理逻辑
+ if (response.data && response.data.code === 200) {
+ await fetchPostDetail();
+
+ setNewComment('');
+ } else {
+ setError(response.data.message || '评论失败');
+ }
+ } catch (err) {
+ setError('评论失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+
+
+ const handleLikeComment = async (commentId) => {
+ try {
+ await likePostComment(commentId);
+
+ // 递归更新评论点赞数
+ const updateComments = (comments) => {
+ return comments.map(comment => {
+ // 当前评论匹配
+ if (comment.id === commentId) {
+ return { ...comment, likeCount: comment.likeCount + 1 };
+ }
+
+ // 递归处理回复
+ if (comment.replies && comment.replies.length > 0) {
+ return {
+ ...comment,
+ replies: updateComments(comment.replies)
+ };
+ }
+
+ return comment;
+ });
+ };
+
+ setComments(prev => updateComments(prev));
+ } catch (err) {
+ setError('点赞失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+
+
+ // 修改startReply函数
+ const startReply = (commentId) => {
+ if (activeReplyId === commentId) {
+ // 如果点击的是已经激活的回复按钮,则关闭
+ setActiveReplyId(null);
+ setReplyingTo(null);
+ } else {
+ // 否则打开新的回复框
+ setActiveReplyId(commentId);
+ setReplyingTo(commentId);
+ }
+ };
+
+ const handleReplySubmit = async (e) => {
+ e.preventDefault();
+ if (!replyContent.trim()) return;
+
+ try {
+ const username = localStorage.getItem('username');
+ const response = await addCommentReply(replyModal.replyingTo, {
+ content: replyContent,
+ authorId: username
+ });
+
+ console.log('回复响应:', response.data); // 调试
+
+ if (response.data && response.data.code === 200) {
+ await fetchPostDetail();
+
+ closeReplyModal();
+ }
+ } catch (err) {
+ console.error('回复错误:', err);
+ setError('回复失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+
+
+ // 返回按钮
+ const handleBack = () => {
+ const fromTab = location.state?.fromTab || 'share';
+ navigate(`/dashboard/help`);
+ };
+
+
+
+ const handleMarkSolved = () => {
+ // TODO: 实现标记为已解决的功能
+ setPost(prev => ({
+ ...prev,
+ isSolved: !prev.isSolved
+ }));
+ };
+
+ const handleImageUpload = (e) => {
+ const files = Array.from(e.target.files);
+ const newImages = files.map(file => URL.createObjectURL(file));
+ setImages(prev => [...prev, ...newImages]);
+ };
+
+ const handleRemoveImage = (index) => {
+ setImages(prev => prev.filter((_, i) => i !== index));
+ };
+
+
+
+ if (loading) return <div className="loading">加载中...</div>;
+ if (error) return <div className="error">{error}</div>;
+ if (!post) return <div className="error">帖子不存在</div>;
+
+ return (
+ <div className="help-detail-container">
+ <button className="back-button" onClick={handleBack}>
+ ← 返回求助区
+ </button>
+
+ <div className={`help-post ${post.isSolved ? 'solved' : ''}`}>
+ <div className="post-header">
+ <img
+ src={post.authorAvatar || 'https://via.placeholder.com/40'}
+ alt={post.authorId}
+ className="post-avatar"
+ />
+ <div className="post-meta">
+ <div className="post-author">{post.authorId}</div>
+ <div className="post-date">
+ {new Date(post.createTime).toLocaleString()}
+ </div>
+ </div>
+ {post.isSolved && <span className="solved-badge">已解决</span>}
+ </div>
+
+ <h1 className="post-title">{post.title}</h1>
+
+ <div className="post-content">
+ {post.content.split('\n').map((para, i) => (
+ <p key={i}>{para}</p>
+ ))}
+ </div>
+
+ <div className="post-actions">
+ <button
+ className={`like-button ${post.isLiked ? 'liked' : ''}`}
+ onClick={handleLikePost}
+ >
+ 👍 点赞 ({post.likeCount})
+ </button>
+ <button
+ className={`solve-button ${post.isSolved ? 'solved' : ''}`}
+ onClick={handleMarkSolved}
+ >
+ {post.isSolved ? '✓ 已解决' : '标记为已解决'}
+ </button>
+ </div>
+ </div>
+
+ <div className="comments-section">
+ <h2>评论 ({post.replyCount})</h2>
+
+ <form onSubmit={handleCommentSubmit} className="comment-form">
+ <textarea
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ placeholder="写下你的评论..."
+ rows="3"
+ required
+ />
+ <button type="submit">发表评论</button>
+ </form>
+
+ <div className="comment-list">
+ {comments.map(comment => renderComment(comment))}
+ </div>
+
+ {replyModal.visible && (
+ <div className="reply-modal-overlay">
+ <div className="reply-modal">
+ <div className="modal-header">
+ <h3>回复 @{replyModal.replyingToUsername}</h3>
+ <button onClick={closeReplyModal} className="close-modal">×</button>
+ </div>
+ <form onSubmit={handleReplySubmit}>
+ <textarea
+ value={replyContent}
+ onChange={(e) => setReplyContent(e.target.value)}
+ placeholder={`回复 @${replyModal.replyingToUsername}...`}
+ rows="5"
+ autoFocus
+ required
+ />
+ <div className="modal-actions">
+ <button type="button" onClick={closeReplyModal} className="cancel-btn">
+ 取消
+ </button>
+ <button type="submit" className="submit-btn">
+ 发送回复
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ )}
+
+ </div>
+ </div>
+ );
+};
+
+export default HelpDetail;
\ No newline at end of file
diff --git a/src/components/HelpDetail.test.jsx b/src/components/HelpDetail.test.jsx
new file mode 100644
index 0000000..6364be2
--- /dev/null
+++ b/src/components/HelpDetail.test.jsx
@@ -0,0 +1,187 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useNavigate, useLocation } from 'react-router-dom';
+import HelpDetail from './HelpDetail';
+import * as helpPostApi from '../api/helpPost';
+import * as helpCommentApi from '../api/helpComment';
+
+// Mock API 模块
+jest.mock('../api/helpPost');
+jest.mock('../api/helpComment');
+
+// Mock 路由钩子
+const mockNavigate = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => mockNavigate,
+ useLocation: jest.fn(),
+}));
+
+describe('HelpDetail 组件', () => {
+ const mockPost = {
+ id: '1',
+ title: '测试求助帖',
+ content: '这是一个测试求助内容',
+ authorId: 'user1',
+ createTime: '2023-01-01T00:00:00Z',
+ likeCount: 5,
+ replyCount: 3,
+ isSolved: false,
+ };
+
+ const mockComments = [
+ {
+ id: 'c1',
+ content: '测试评论1',
+ authorId: 'user2',
+ createTime: '2023-01-01T01:00:00Z',
+ likeCount: 2,
+ replies: [
+ {
+ id: 'c1r1',
+ content: '测试回复1',
+ authorId: 'user3',
+ createTime: '2023-01-01T02:00:00Z',
+ likeCount: 1,
+ }
+ ]
+ }
+ ];
+
+ beforeEach(() => {
+ // 重置 mock 函数
+ mockNavigate.mockClear();
+
+ // 设置模拟的 API 响应
+ helpPostApi.getPostDetail.mockResolvedValue({
+ data: {
+ code: 200,
+ data: {
+ post: mockPost,
+ comments: mockComments,
+ }
+ }
+ });
+
+ // 模拟 useLocation
+ useLocation.mockReturnValue({
+ state: { fromTab: 'help' }
+ });
+
+ // 模拟 localStorage
+ Storage.prototype.getItem = jest.fn((key) => {
+ if (key === 'username') return 'testuser';
+ return null;
+ });
+ });
+
+ const renderComponent = () => {
+ return render(
+ <MemoryRouter initialEntries={['/help/1']}>
+ <Routes>
+ <Route path="/help/:id" element={<HelpDetail />} />
+ {/* 添加一个模拟的求助列表路由用于导航测试 */}
+ <Route path="/dashboard/help" element={<div>求助列表</div>} />
+ </Routes>
+ </MemoryRouter>
+ );
+ };
+
+ it('应该正确加载和显示求助帖详情', async () => {
+ renderComponent();
+
+ // 检查加载状态
+ expect(screen.getByText('加载中...')).toBeInTheDocument();
+
+ // 等待数据加载完成
+ await waitFor(() => {
+ expect(screen.getByText('测试求助帖')).toBeInTheDocument();
+ expect(screen.getByText('这是一个测试求助内容')).toBeInTheDocument();
+ expect(screen.getByText('user1')).toBeInTheDocument();
+ });
+ });
+
+ it('应该能够点赞帖子', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试求助帖')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText(/点赞 \(5\)/));
+
+ await waitFor(() => {
+ expect(helpPostApi.likePost).toHaveBeenCalledWith('1');
+ });
+ });
+
+ it('应该能够提交评论', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试求助帖')).toBeInTheDocument();
+ });
+
+ const commentInput = screen.getByPlaceholderText('写下你的评论...');
+ fireEvent.change(commentInput, { target: { value: '新评论' } });
+ fireEvent.click(screen.getByText('发表评论'));
+
+ await waitFor(() => {
+ expect(helpPostApi.addPostComment).toHaveBeenCalledWith('1', {
+ content: '新评论',
+ authorId: 'testuser'
+ });
+ });
+ });
+
+ it('应该能够点赞评论', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试评论1')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getAllByText(/👍 \(2\)/)[0]);
+
+ await waitFor(() => {
+ expect(helpCommentApi.likePostComment).toHaveBeenCalledWith('c1');
+ });
+ });
+
+ it('应该能够打开和关闭回复模态框', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试评论1')).toBeInTheDocument();
+ });
+
+ // 点击回复按钮
+ fireEvent.click(screen.getAllByText('回复')[0]);
+
+ // 检查模态框是否打开
+ await waitFor(() => {
+ expect(screen.getByText('回复 @user2')).toBeInTheDocument();
+ });
+
+ // 点击关闭按钮
+ fireEvent.click(screen.getByText('×'));
+
+ // 检查模态框是否关闭
+ await waitFor(() => {
+ expect(screen.queryByText('回复 @user2')).not.toBeInTheDocument();
+ });
+ });
+
+ it('应该能够返回求助区', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试求助帖')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /返回/i }));
+
+ // 修正后的断言(不再验证 state)
+ expect(mockNavigate).toHaveBeenCalledWith('/dashboard/help');
+ });
+});
\ No newline at end of file
diff --git a/src/components/Personal/ActionCard.jsx b/src/components/Personal/ActionCard.jsx
new file mode 100644
index 0000000..7d3cd4f
--- /dev/null
+++ b/src/components/Personal/ActionCard.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const ActionCard = ({ title, subtitle, icon, onClick }) => {
+ return (
+ <div className="action-card" onClick={onClick}>
+ {icon && <div className="action-icon">{icon}</div>}
+ <div className="action-content">
+ <h3>{title}</h3>
+ <p>{subtitle}</p>
+ </div>
+ </div>
+ );
+};
+
+ActionCard.propTypes = {
+ title: PropTypes.string.isRequired,
+ subtitle: PropTypes.string,
+ icon: PropTypes.node,
+ onClick: PropTypes.func
+};
+
+export default ActionCard;
\ No newline at end of file
diff --git a/src/components/Personal/Favorite.jsx b/src/components/Personal/Favorite.jsx
new file mode 100644
index 0000000..97aa1e6
--- /dev/null
+++ b/src/components/Personal/Favorite.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import ActionCard from './ActionCard';
+import './personalSubpage.css';
+
+const Favorites = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ // 模拟数据
+ const [favorites] = React.useState([
+ { id: 1, name: '盗梦空间', type: 'movie', added: '2023-10-01' },
+ { id: 2, name: '权力的游戏', type: 'tv', added: '2023-09-15' }
+ ]);
+
+ const handleBack = () => {
+ // 返回个人中心,并携带来源标记
+ navigate('/personal', {
+ state: {
+ fromSubpage: true, // 标记来自子页面
+ dashboardTab: location.state?.dashboardTab // 保留Dashboard的标签页状态
+ },
+ replace: true // 替换当前历史记录
+ });
+ };
+
+ return (
+ <div className="personal-page">
+ <button className="back-button" onClick={(handleBack)}>
+ ← 返回个人中心
+ </button>
+
+ <h2>我的收藏</h2>
+ <div className="resource-grid">
+ {favorites.map(item => (
+ <ActionCard
+ key={item.id}
+ title={item.name}
+ subtitle={`收藏于 ${item.added}`}
+ onClick={() => console.log('查看详情', item.id)}
+ />
+ ))}
+ </div>
+ </div>
+ );
+};
+
+export default Favorites;
\ No newline at end of file
diff --git a/src/components/Personal/Notice.jsx b/src/components/Personal/Notice.jsx
new file mode 100644
index 0000000..55bc955
--- /dev/null
+++ b/src/components/Personal/Notice.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { useNavigate,useLocation } from 'react-router-dom';
+import './personalSubpage.css';
+
+const Notice = ({ onLogout }) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ // 模拟数据
+ const [notices] = React.useState([
+ {
+ id: 1,
+ title: '积分奖励到账',
+ content: '您上传的资源《盗梦空间》获得100积分奖励',
+ date: '2023-10-20',
+ read: false
+ },
+ {
+ id: 2,
+ title: '系统通知',
+ content: '服务器将于今晚2:00-4:00进行维护',
+ date: '2023-10-18',
+ read: true
+ }
+ ]);
+
+ const handleBack = () => {
+ // 返回个人中心,并携带来源标记
+ navigate('/personal', {
+ state: {
+ fromSubpage: true, // 标记来自子页面
+ dashboardTab: location.state?.dashboardTab // 保留Dashboard的标签页状态
+ },
+ replace: true // 替换当前历史记录
+ });
+ };
+
+ return (
+ <div className="subpage-container">
+ <button className="back-button" onClick={(handleBack)}>
+ ← 返回个人中心
+ </button>
+
+ <h2 className="page-title">消息通知</h2>
+
+ <div className="notice-list">
+ {notices.map(notice => (
+ <div key={notice.id} className={`list-item ${!notice.read ? 'unread' : ''}`}>
+ <div className="notice-header">
+ <h3>{notice.title}</h3>
+ <span className="notice-date">{notice.date}</span>
+ </div>
+ <p className="notice-content">{notice.content}</p>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+};
+
+export default Notice;
\ No newline at end of file
diff --git a/src/components/Personal/Personal.css b/src/components/Personal/Personal.css
new file mode 100644
index 0000000..c087ac6
--- /dev/null
+++ b/src/components/Personal/Personal.css
@@ -0,0 +1,162 @@
+/* Personal.css */
+.personal-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+
+ .back-button {
+ background: none;
+ border: none;
+ color: #1890ff;
+ cursor: pointer;
+ font-size: 16px;
+ margin-bottom: 20px;
+ padding: 5px 0;
+ }
+
+ .profile-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ }
+
+ .profile-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 20px;
+ }
+
+ .profile-avatar {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ margin-right: 20px;
+ object-fit: cover;
+ }
+
+ .profile-info {
+ flex-grow: 1;
+ }
+
+ .username {
+ font-size: 24px;
+ margin: 0 0 5px;
+ }
+
+ .user-meta {
+ display: flex;
+ gap: 15px;
+ color: #666;
+ font-size: 14px;
+ }
+
+ .stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 15px;
+ }
+
+ .stat-item {
+ background: #f5f5f5;
+ border-radius: 6px;
+ padding: 15px;
+ text-align: center;
+ }
+
+ .stat-label {
+ font-size: 14px;
+ color: #666;
+ margin-bottom: 5px;
+ }
+
+ .stat-value {
+ font-size: 18px;
+ font-weight: bold;
+ }
+
+ .quota-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ }
+
+ .quota-card h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+ }
+
+ .quota-info {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 10px;
+ }
+
+ .quota-used {
+ color: #1890ff;
+ }
+
+ .quota-remaining {
+ color: #52c41a;
+ }
+
+ .progress-bar {
+ height: 10px;
+ background: #f0f0f0;
+ border-radius: 5px;
+ margin-bottom: 10px;
+ overflow: hidden;
+ }
+
+ .progress-fill {
+ height: 100%;
+ background: #1890ff;
+ border-radius: 5px;
+ transition: width 0.3s ease;
+ }
+
+ .quota-total {
+ text-align: right;
+ color: #666;
+ font-size: 14px;
+ }
+
+ .action-cards {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 15px;
+ }
+
+ .action-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ cursor: pointer;
+ transition: transform 0.2s ease;
+ }
+
+ .action-card:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
+ }
+
+ .action-card h3 {
+ margin-top: 0;
+ color: #1890ff;
+ }
+
+ .action-card p {
+ color: #666;
+ margin-bottom: 0;
+ }
+
+ .subpage-container {
+ margin-top: 20px;
+ border-top: 1px solid #f0f0f0;
+ padding-top: 20px;
+ }
\ No newline at end of file
diff --git a/src/components/Personal/Personal.jsx b/src/components/Personal/Personal.jsx
new file mode 100644
index 0000000..033952d
--- /dev/null
+++ b/src/components/Personal/Personal.jsx
@@ -0,0 +1,149 @@
+import React from 'react';
+import { useNavigate,useLocation, Outlet } from 'react-router-dom';
+import './Personal.css';
+import ActionCard from './ActionCard';
+
+const Personal = () => {
+ const navigate = useNavigate();
+ const location = useLocation(); // 获取路由信息
+
+ // 模拟用户数据
+ const userData = {
+ username: 'PT爱好者',
+ avatar: 'https://via.placeholder.com/150',
+ joinDate: '2023-01-15',
+ level: '中级会员',
+ points: 1250,
+ upload: '3.2TB',
+ download: '1.5TB',
+ ratio: '2.13',
+ downloadQuota: {
+ total: 10, // 10GB
+ used: 3.7, // 已使用3.7GB
+ remaining: 6.3 // 剩余6.3GB
+ }
+ };
+
+ const features = [
+ {
+ title: '我的收藏',
+ description: '查看收藏的资源',
+ path: '/personal/Favorite' // 相对路径
+ },
+ {
+ title: '上传记录',
+ description: '管理上传的资源',
+ path: '/personal/Upload'
+ },
+ {
+ title: '消息通知',
+ description: '查看系统消息',
+ path: '/personal/Notice'
+ },
+ {
+ title: '设置',
+ description: '修改个人资料',
+ path: '/personal/Setting'
+ }
+ ];
+
+ const handleCardClick = (path) => {
+ navigate(path); // 相对导航
+ };
+
+ const handleBack = () => {
+ if (location.state?.fromSubpage) {
+ // 如果是从子页面返回,则继续返回到Dashboard
+ navigate(`/dashboard/${location.state.dashboardTab || ''}`, {
+ replace: true
+ });
+ } else {
+ // 普通返回逻辑
+ navigate(-1);
+ }
+ };
+
+ return (
+ <div className="personal-container">
+ {/* 返回按钮 */}
+ <button className="back-button" onClick={handleBack}>
+ ← 返回
+ </button>
+
+ {/* 用户基本信息卡片 */}
+ <div className="profile-card">
+ <div className="profile-header">
+ <img
+ src={userData.avatar}
+ alt={userData.username}
+ className="profile-avatar"
+ />
+ <div className="profile-info">
+ <h2 className="username">{userData.username}</h2>
+ <div className="user-meta">
+ <span>加入时间: {userData.joinDate}</span>
+ <span>等级: {userData.level}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 用户数据统计 */}
+ <div className="stats-grid">
+ <div className="stat-item">
+ <div className="stat-label">积分</div>
+ <div className="stat-value">{userData.points}</div>
+ </div>
+ <div className="stat-item">
+ <div className="stat-label">上传量</div>
+ <div className="stat-value">{userData.upload}</div>
+ </div>
+ <div className="stat-item">
+ <div className="stat-label">下载量</div>
+ <div className="stat-value">{userData.download}</div>
+ </div>
+ <div className="stat-item">
+ <div className="stat-label">分享率</div>
+ <div className="stat-value">{userData.ratio}</div>
+ </div>
+ </div>
+ </div>
+
+ {/* 下载额度卡片 */}
+ <div className="quota-card">
+ <h3>下载额度</h3>
+ <div className="quota-info">
+ <span className="quota-used">{userData.downloadQuota.used}GB 已使用</span>
+ <span className="quota-remaining">{userData.downloadQuota.remaining}GB 剩余</span>
+ </div>
+ <div className="progress-bar">
+ <div
+ className="progress-fill"
+ style={{ width: `${(userData.downloadQuota.used / userData.downloadQuota.total) * 100}%` }}
+ ></div>
+ </div>
+ <div className="quota-total">总额度: {userData.downloadQuota.total}GB</div>
+ </div>
+
+ {/* 功能卡片区 */}
+ <div className="action-cards">
+ {features.map((feature) => (
+ <div
+ key={feature.path}
+ className="action-card"
+ onClick={() => handleCardClick(feature.path)}
+ >
+ <h3>{feature.title}</h3>
+ <p>{feature.description}</p>
+ </div>
+ ))}
+ </div>
+
+ {/* 子路由出口 */}
+ <div className="subpage-container">
+ <Outlet />
+ </div>
+ </div>
+ );
+};
+
+export default Personal;
\ No newline at end of file
diff --git a/src/components/Personal/Setting.jsx b/src/components/Personal/Setting.jsx
new file mode 100644
index 0000000..967be6c
--- /dev/null
+++ b/src/components/Personal/Setting.jsx
@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import { useNavigate,useLocation } from 'react-router-dom';
+import './personalSubpage.css';
+
+const Setting = ({ onLogout }) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ // 模拟数据
+ const [formData, setFormData] = useState({
+ username: 'user123',
+ email: 'user@example.com',
+ notification: true
+ });
+
+ const handleChange = (e) => {
+ const { name, value, type, checked } = e.target;
+ setFormData(prev => ({
+ ...prev,
+ [name]: type === 'checkbox' ? checked : value
+ }));
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ alert('设置已保存');
+ };
+
+ const handleBack = () => {
+ // 返回个人中心,并携带来源标记
+ navigate('/personal', {
+ state: {
+ fromSubpage: true, // 标记来自子页面
+ dashboardTab: location.state?.dashboardTab // 保留Dashboard的标签页状态
+ },
+ replace: true // 替换当前历史记录
+ });
+ };
+
+ return (
+ <div className="subpage-container">
+ <button className="back-button" onClick={(handleBack)}>
+ ← 返回个人中心
+ </button>
+
+ <h2 className="page-title">账号设置</h2>
+
+ <form onSubmit={handleSubmit}>
+ <div className="form-group">
+ <label className="form-label">用户名</label>
+ <input
+ type="text"
+ name="username"
+ value={formData.username}
+ onChange={handleChange}
+ className="form-input"
+ />
+ </div>
+
+ <div className="form-group">
+ <label className="form-label">电子邮箱</label>
+ <input
+ type="email"
+ name="email"
+ value={formData.email}
+ onChange={handleChange}
+ className="form-input"
+ />
+ </div>
+
+ <div className="form-group">
+ <label className="form-label">
+ <input
+ type="checkbox"
+ name="notification"
+ checked={formData.notification}
+ onChange={handleChange}
+ />
+ 接收邮件通知
+ </label>
+ </div>
+
+ <div className="form-actions">
+ <button type="submit" className="action-btn">
+ 保存设置
+ </button>
+ <button
+ type="button"
+ className="action-btn danger-btn"
+ onClick={onLogout}
+ >
+ 退出登录
+ </button>
+ </div>
+ </form>
+ </div>
+ );
+};
+
+export default Setting;
\ No newline at end of file
diff --git a/src/components/Personal/Upload.jsx b/src/components/Personal/Upload.jsx
new file mode 100644
index 0000000..4d6e934
--- /dev/null
+++ b/src/components/Personal/Upload.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { useNavigate,useLocation } from 'react-router-dom';
+import './personalSubpage.css';
+
+const Upload = ({ onLogout }) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [uploads] = React.useState([
+ { id: 1, name: '星际穿越', status: '已发布', date: '2023-10-15', size: '15.2GB' },
+ { id: 2, name: '黑暗骑士', status: '审核中', date: '2023-10-18', size: '12.7GB' }
+ ]);
+
+ const handleBack = () => {
+ // 返回个人中心,并携带来源标记
+ navigate('/personal', {
+ state: {
+ fromSubpage: true, // 标记来自子页面
+ dashboardTab: location.state?.dashboardTab // 保留Dashboard的标签页状态
+ },
+ replace: true // 替换当前历史记录
+ });
+ };
+ return (
+ <div className="subpage-container">
+ <button className="back-button" onClick={(handleBack)}>
+ ← 返回个人中心
+ </button>
+
+ <h2 className="page-title">上传记录</h2>
+
+ <table className="uploads-table">
+ <thead>
+ <tr>
+ <th>资源名称</th>
+ <th>大小</th>
+ <th>状态</th>
+ <th>上传时间</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ {uploads.map(item => (
+ <tr key={item.id} className="list-item">
+ <td>{item.name}</td>
+ <td>{item.size}</td>
+ <td>
+ <span className={`status-badge ${
+ item.status === '已发布' ? 'published' : 'pending'
+ }`}>
+ {item.status}
+ </span>
+ </td>
+ <td>{item.date}</td>
+ <td>
+ <button className="action-btn">详情</button>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ );
+};
+
+export default Upload;
\ No newline at end of file
diff --git a/src/components/Personal/personalSubpage.css b/src/components/Personal/personalSubpage.css
new file mode 100644
index 0000000..a8e5638
--- /dev/null
+++ b/src/components/Personal/personalSubpage.css
@@ -0,0 +1,161 @@
+/* 基础布局 */
+.subpage-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+ }
+
+ .back-button {
+ background: none;
+ border: none;
+ color: #1890ff;
+ font-size: 16px;
+ cursor: pointer;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ }
+
+ .back-button:hover {
+ color: #40a9ff;
+ }
+
+ .page-title {
+ color: #333;
+ border-bottom: 1px solid #f0f0f0;
+ padding-bottom: 10px;
+ margin-bottom: 20px;
+ }
+
+ /* 列表项样式 */
+ .list-item {
+ padding: 15px;
+ border-bottom: 1px solid #f5f5f5;
+ transition: background 0.3s;
+ }
+
+ .list-item:hover {
+ background: #f9f9f9;
+ }
+
+ /* 表单样式 */
+ .form-group {
+ margin-bottom: 20px;
+ }
+
+ .form-label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ }
+
+ .form-input {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ }
+
+ /* 按钮样式 */
+ .action-btn {
+ padding: 8px 15px;
+ background: #1890ff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-right: 10px;
+ }
+
+ .action-btn:hover {
+ background: #40a9ff;
+ }
+
+ .danger-btn {
+ background: #ff4d4f;
+ }
+
+ .danger-btn:hover {
+ background: #ff7875;
+ }
+
+ /* 收藏列表 */
+.favorite-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .item-header {
+ margin-bottom: 8px;
+ }
+
+ .item-meta {
+ color: #666;
+ font-size: 14px;
+ }
+
+ .item-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 10px;
+ }
+
+ /* 上传表格 */
+ .uploads-table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .uploads-table th, .uploads-table td {
+ padding: 12px 15px;
+ text-align: left;
+ }
+
+ .status-badge {
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ }
+
+ .status-badge.published {
+ background: #f6ffed;
+ color: #52c41a;
+ }
+
+ .status-badge.pending {
+ background: #fff7e6;
+ color: #fa8c16;
+ }
+
+ /* 消息通知 */
+ .notice-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 5px;
+ }
+
+ .notice-date {
+ color: #999;
+ font-size: 14px;
+ }
+
+ .notice-content {
+ color: #666;
+ margin: 0;
+ }
+
+ .unread {
+ background: #f0f7ff;
+ }
+
+ /* 设置表单 */
+ .form-actions {
+ margin-top: 30px;
+ display: flex;
+ gap: 15px;
+ }
\ No newline at end of file
diff --git a/src/components/RequestDetail.css b/src/components/RequestDetail.css
new file mode 100644
index 0000000..0d47b71
--- /dev/null
+++ b/src/components/RequestDetail.css
@@ -0,0 +1,213 @@
+/* RequestDetail.css */
+.request-detail-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+
+ .back-button {
+ background: none;
+ border: none;
+ color: #1890ff;
+ cursor: pointer;
+ font-size: 16px;
+ margin-bottom: 20px;
+ padding: 5px 0;
+ }
+
+ .request-post {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ }
+
+ .post-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 15px;
+ }
+
+ .post-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ margin-right: 10px;
+ }
+
+ .post-meta {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .post-author {
+ font-weight: bold;
+ }
+
+ .post-date {
+ color: #888;
+ font-size: 14px;
+ }
+
+ .post-title {
+ font-size: 20px;
+ margin: 0 0 15px;
+ }
+
+ .post-content {
+ line-height: 1.6;
+ margin-bottom: 15px;
+ }
+
+ .post-actions {
+ display: flex;
+ gap: 15px;
+ }
+
+ .like-button, .favorite-button {
+ padding: 5px 15px;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ background: #f5f5f5;
+ cursor: pointer;
+ }
+
+ .like-button.liked {
+ background: #e6f7ff;
+ border-color: #91d5ff;
+ color: #1890ff;
+ }
+
+ .favorite-button.favorited {
+ background: #fff7e6;
+ border-color: #ffd591;
+ color: #fa8c16;
+ }
+
+ .comments-section {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ }
+
+ .comment-form {
+ margin-bottom: 20px;
+ }
+
+ .comment-form textarea {
+ width: 100%;
+ padding: 10px;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ resize: vertical;
+ min-height: 80px;
+ margin-bottom: 10px;
+ }
+
+ .form-actions {
+ display: flex;
+ gap: 10px;
+ }
+
+ .submit-comment, .submit-torrent {
+ padding: 8px 16px;
+ border-radius: 4px;
+ border: none;
+ cursor: pointer;
+ }
+
+ .submit-comment {
+ background: #1890ff;
+ color: white;
+ }
+
+ .submit-torrent {
+ background: #52c41a;
+ color: white;
+ }
+
+ .comment-list {
+ margin-top: 20px;
+ }
+
+ .comment-item {
+ display: flex;
+ padding: 15px 0;
+ border-bottom: 1px solid #f0f0f0;
+ }
+
+ .comment-item:last-child {
+ border-bottom: none;
+ }
+
+ .comment-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ margin-right: 15px;
+ flex-shrink: 0;
+ }
+
+ .comment-content {
+ flex-grow: 1;
+ }
+
+ .comment-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ }
+
+ .comment-author {
+ font-weight: bold;
+ }
+
+ .comment-date {
+ color: #888;
+ font-size: 14px;
+ }
+
+ .comment-text {
+ line-height: 1.6;
+ margin-bottom: 8px;
+ }
+
+ .torrent-comment {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ padding: 8px;
+ background: #f0f8ff;
+ border-radius: 4px;
+ }
+
+ .torrent-title {
+ color: #1890ff;
+ flex-grow: 1;
+ }
+
+ .torrent-size {
+ color: #666;
+ margin: 0 15px;
+ font-size: 14px;
+ }
+
+ .download-torrent {
+ background: #1890ff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 5px 10px;
+ cursor: pointer;
+ }
+
+ .comment-like {
+ background: none;
+ border: none;
+ color: #666;
+ cursor: pointer;
+ font-size: 14px;
+ padding: 0;
+ }
\ No newline at end of file
diff --git a/src/components/RequestDetail.jsx b/src/components/RequestDetail.jsx
new file mode 100644
index 0000000..993fe8e
--- /dev/null
+++ b/src/components/RequestDetail.jsx
@@ -0,0 +1,198 @@
+import React, { useState } from 'react';
+import { useParams, useNavigate,useLocation } from 'react-router-dom';
+import './RequestDetail.css';
+
+const RequestDetail = () => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ // 模拟数据
+ const [post, setPost] = useState({
+ id: 1,
+ title: '求《药屋少女的呢喃》第二季全集',
+ content: '求1080P带中文字幕版本,最好是内嵌字幕不是外挂的。\n\n希望有热心大佬能分享,可以给积分奖励!',
+ author: '动漫爱好者',
+ authorAvatar: 'https://via.placeholder.com/40',
+ date: '2023-10-15',
+ likeCount: 24,
+ isLiked: false,
+ isFavorited: false
+ });
+
+ const [comments, setComments] = useState([
+ {
+ id: 1,
+ type: 'text',
+ author: '资源达人',
+ authorAvatar: 'https://via.placeholder.com/40',
+ content: '我有第1-5集,需要的话可以私聊',
+ date: '2023-10-15 14:30',
+ likeCount: 5
+ },
+ {
+ id: 2,
+ type: 'torrent',
+ title: '药屋少女的呢喃第二季第8集',
+ size: '1.2GB',
+ author: '种子分享者',
+ authorAvatar: 'https://via.placeholder.com/40',
+ date: '2023-10-16 09:15',
+ likeCount: 8
+ }
+ ]);
+
+ const [newComment, setNewComment] = useState('');
+
+ const handleLikePost = () => {
+ setPost(prev => ({
+ ...prev,
+ likeCount: prev.isLiked ? prev.likeCount - 1 : prev.likeCount + 1,
+ isLiked: !prev.isLiked
+ }));
+ };
+
+ const handleFavoritePost = () => {
+ setPost(prev => ({
+ ...prev,
+ isFavorited: !prev.isFavorited
+ }));
+ };
+
+ const handleCommentSubmit = (e) => {
+ e.preventDefault();
+ if (!newComment.trim()) return;
+
+ const newCommentObj = {
+ id: comments.length + 1,
+ type: 'text',
+ author: '当前用户',
+ authorAvatar: 'https://via.placeholder.com/40',
+ content: newComment,
+ date: new Date().toLocaleString(),
+ likeCount: 0
+ };
+
+ setComments([...comments, newCommentObj]);
+ setNewComment('');
+ };
+
+ const handleDownloadTorrent = (commentId) => {
+ console.log('下载种子', commentId);
+ // 实际下载逻辑
+ };
+
+ const handleBack = () => {
+ const fromTab = location.state?.fromTab; // 从跳转时传递的 state 中获取
+ if (fromTab) {
+ navigate(`/dashboard/${fromTab}`); // 明确返回对应标签页
+ } else {
+ navigate(-1); // 保底策略
+ }
+ }
+
+ return (
+ <div className="request-detail-container">
+ <button className="back-button" onClick={handleBack}>
+ ← 返回求种区
+ </button>
+
+ <div className="request-post">
+ <div className="post-header">
+ <img src={post.authorAvatar} alt={post.author} className="post-avatar" />
+ <div className="post-meta">
+ <div className="post-author">{post.author}</div>
+ <div className="post-date">{post.date}</div>
+ </div>
+ </div>
+
+ <h1 className="post-title">{post.title}</h1>
+
+ <div className="post-content">
+ {post.content.split('\n').map((para, i) => (
+ <p key={i}>{para}</p>
+ ))}
+ </div>
+
+ <div className="post-actions">
+ <button
+ className={`like-button ${post.isLiked ? 'liked' : ''}`}
+ onClick={handleLikePost}
+ >
+ 👍 点赞 ({post.likeCount})
+ </button>
+ <button
+ className={`favorite-button ${post.isFavorited ? 'favorited' : ''}`}
+ onClick={handleFavoritePost}
+ >
+ {post.isFavorited ? '★ 已收藏' : '☆ 收藏'}
+ </button>
+ </div>
+ </div>
+
+ <div className="comments-section">
+ <h2>回应 ({comments.length})</h2>
+
+ <form onSubmit={handleCommentSubmit} className="comment-form">
+ <textarea
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ placeholder="写下你的回应..."
+ rows="3"
+ required
+ />
+ <div className="form-actions">
+ <button type="submit" className="submit-comment">发表文字回应</button>
+ <button
+ type="button"
+ className="submit-torrent"
+ onClick={() => console.log('打开种子上传对话框')}
+ >
+ 上传种子回应
+ </button>
+ </div>
+ </form>
+
+ <div className="comment-list">
+ {comments.map(comment => (
+ <div key={comment.id} className={`comment-item ${comment.type}`}>
+ <img
+ src={comment.authorAvatar}
+ alt={comment.author}
+ className="comment-avatar"
+ />
+
+ <div className="comment-content">
+ <div className="comment-header">
+ <span className="comment-author">{comment.author}</span>
+ <span className="comment-date">{comment.date}</span>
+ </div>
+
+ {comment.type === 'text' ? (
+ <p className="comment-text">{comment.content}</p>
+ ) : (
+ <div className="torrent-comment">
+ <span className="torrent-title">{comment.title}</span>
+ <span className="torrent-size">{comment.size}</span>
+ <button
+ className="download-torrent"
+ onClick={() => handleDownloadTorrent(comment.id)}
+ >
+ 立即下载
+ </button>
+ </div>
+ )}
+
+ <button className="comment-like">
+ 👍 ({comment.likeCount})
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default RequestDetail;
\ No newline at end of file
diff --git a/src/components/TorrentDetail.css b/src/components/TorrentDetail.css
new file mode 100644
index 0000000..f2b1ad2
--- /dev/null
+++ b/src/components/TorrentDetail.css
@@ -0,0 +1,516 @@
+/* 基础布局样式 */
+.torrent-detail-container {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 20px;
+ color: #333;
+}
+
+.back-button {
+ background: none;
+ border: none;
+ color: #3498db;
+ font-size: 16px;
+ cursor: pointer;
+ margin-bottom: 20px;
+ padding: 5px 10px;
+}
+
+.back-button:hover {
+ text-decoration: underline;
+}
+
+/* 种子信息区域 */
+.torrent-main {
+ display: flex;
+ gap: 30px;
+ margin-bottom: 40px;
+}
+
+.torrent-cover {
+ flex: 0 0 300px;
+}
+
+.cover-placeholder {
+ width: 100%;
+ height: 450px;
+ background-color: #eee;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 72px;
+ color: #999;
+ border-radius: 5px;
+}
+
+.torrent-info {
+ flex: 1;
+}
+
+.torrent-title {
+ font-size: 28px;
+ margin-bottom: 20px;
+ color: #222;
+}
+
+.uploader-info {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.uploader-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background-color: #3498db;
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+}
+
+.uploader-details {
+ flex: 1;
+}
+
+.uploader-name {
+ font-weight: bold;
+}
+
+.upload-time {
+ font-size: 14px;
+ color: #777;
+}
+
+.torrent-meta {
+ margin-bottom: 20px;
+}
+
+.torrent-meta p {
+ margin: 5px 0;
+}
+
+.torrent-description {
+ margin-bottom: 30px;
+ line-height: 1.6;
+}
+
+.interaction-buttons {
+ display: flex;
+ gap: 15px;
+}
+
+.interaction-buttons button {
+ padding: 8px 15px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.like-button {
+ background-color: #f0f0f0;
+}
+
+.like-button:hover {
+ background-color: #e0e0e0;
+}
+
+.download-button {
+ background-color: #3498db;
+ color: white;
+}
+
+.download-button:hover {
+ background-color: #2980b9;
+}
+
+/* 评论区域样式 */
+.comments-section {
+ margin-top: 40px;
+}
+
+.comments-section h2 {
+ font-size: 22px;
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #eee;
+}
+
+.comment-form {
+ margin-bottom: 30px;
+}
+
+.comment-form textarea {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ margin-bottom: 10px;
+ resize: vertical;
+ min-height: 80px;
+}
+
+.comment-form button {
+ background-color: #3498db;
+ color: white;
+ border: none;
+ padding: 8px 15px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.comment-form button:hover {
+ background-color: #2980b9;
+}
+
+.comment-list {
+ margin-top: 20px;
+}
+
+/* 评论项统一样式 */
+.comment-container {
+ margin-bottom: 15px;
+}
+
+.comment-item {
+ display: flex;
+ gap: 15px;
+ padding: 12px;
+ background: #fff;
+ border-radius: 8px;
+ border: 1px solid #eaeaea;
+}
+
+/* 副评论统一缩进 */
+.comment-container.is-reply {
+ margin-left: 40px;
+ position: relative;
+}
+
+/* 副评论连接线 */
+.comment-container.is-reply:before {
+ content: "";
+ position: absolute;
+ left: -20px;
+ top: 20px;
+ width: 15px;
+ height: 1px;
+ background: #ddd;
+}
+
+.comment-avatar {
+ flex: 0 0 40px;
+ height: 40px;
+ border-radius: 50%;
+ background-color: #e74c3c;
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+}
+
+.comment-content {
+ flex: 1;
+}
+
+.comment-header {
+ margin-bottom: 8px;
+}
+
+.comment-user {
+ font-weight: bold;
+ margin-right: 10px;
+}
+
+.reply-to {
+ color: #666;
+ font-size: 14px;
+ margin: 0 5px;
+}
+
+.comment-time {
+ font-size: 14px;
+ color: #777;
+}
+
+.comment-text {
+ margin-bottom: 10px;
+ line-height: 1.5;
+}
+
+.comment-actions {
+ display: flex;
+ gap: 15px;
+}
+
+.comment-actions button {
+ background: none;
+ border: none;
+ color: #3498db;
+ cursor: pointer;
+ font-size: 14px;
+ padding: 0;
+}
+
+.comment-actions button:hover {
+ text-decoration: underline;
+}
+
+/* 回复列表容器 */
+.replies-container {
+ margin-top: 15px;
+}
+
+/* 副评论背景色 */
+.comment-container.is-reply .comment-item {
+ background: #f9f9f9;
+}
+
+/* 回复表单样式 */
+.reply-form {
+ margin-top: 15px;
+}
+
+.reply-form textarea {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ margin-bottom: 10px;
+ resize: vertical;
+ min-height: 60px;
+}
+
+.reply-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+}
+
+.reply-actions button {
+ padding: 5px 10px;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.reply-actions button[type="button"] {
+ background-color: #f0f0f0;
+}
+
+.reply-actions button[type="submit"] {
+ background-color: #3498db;
+ color: white;
+}
+
+/* 加载和错误状态 */
+.loading {
+ text-align: center;
+ padding: 50px;
+ font-size: 18px;
+}
+
+.error {
+ color: #e74c3c;
+ text-align: center;
+ padding: 50px;
+ font-size: 18px;
+}
+/* 回复表单样式 */
+.reply-form {
+ margin-left: 50px;
+ margin-top: 10px;
+ background: #f5f5f5;
+ padding: 10px;
+ border-radius: 4px;
+}
+
+.reply-form textarea {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ resize: vertical;
+}
+
+.reply-form-buttons {
+ display: flex;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.reply-form-buttons button {
+ padding: 5px 10px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.reply-form-buttons button[type="submit"] {
+ background-color: #1890ff;
+ color: white;
+}
+
+.reply-form-buttons .cancel-reply {
+ background-color: #f5f5f5;
+ border: 1px solid #d9d9d9;
+}
+
+.comment-list {
+ margin-top: 20px;
+ border-top: 1px solid #eee;
+ padding-top: 20px;
+}
+
+.reply-form {
+ margin-left: 50px;
+ margin-top: 10px;
+ margin-bottom: 15px;
+ background: #f9f9f9;
+ padding: 12px;
+ border-radius: 6px;
+ border: 1px solid #eee;
+}
+
+.reply-form.nested-reply {
+ margin-left: 80px;
+}
+
+.reply-form textarea {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ resize: vertical;
+ min-height: 60px;
+ font-family: inherit;
+ font-size: 14px;
+}
+
+.reply-form-buttons {
+ display: flex;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.reply-form-buttons button {
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+ transition: all 0.2s;
+}
+
+.reply-form-buttons button[type="submit"] {
+ background-color: #1890ff;
+ color: white;
+ border: none;
+}
+
+.reply-form-buttons button[type="submit"]:hover {
+ background-color: #40a9ff;
+}
+
+.reply-form-buttons .cancel-reply {
+ background-color: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ color: #666;
+}
+
+.reply-form-buttons .cancel-reply:hover {
+ background-color: #e8e8e8;
+}
+
+/* 回复弹窗样式 */
+.reply-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.reply-modal {
+ background: white;
+ border-radius: 8px;
+ width: 90%;
+ max-width: 500px;
+ padding: 20px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.modal-header h3 {
+ margin: 0;
+ font-size: 18px;
+}
+
+.close-modal {
+ background: none;
+ border: none;
+ font-size: 24px;
+ cursor: pointer;
+ color: #666;
+}
+
+.reply-modal textarea {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ resize: vertical;
+ min-height: 120px;
+ font-size: 14px;
+ margin-bottom: 15px;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.modal-actions button {
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.cancel-btn {
+ background: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ color: #666;
+}
+
+.cancel-btn:hover {
+ background: #e8e8e8;
+}
+
+.submit-btn {
+ background: #1890ff;
+ color: white;
+ border: none;
+}
+
+.submit-btn:hover {
+ background: #40a9ff;
+}
diff --git a/src/components/TorrentDetail.jsx b/src/components/TorrentDetail.jsx
new file mode 100644
index 0000000..e743506
--- /dev/null
+++ b/src/components/TorrentDetail.jsx
@@ -0,0 +1,348 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate, useLocation } from 'react-router-dom';
+import {
+ getTorrentDetail,
+ likeTorrent,
+ addTorrentComment
+} from '../api/torrent';
+import {
+ likeTorrentComment,
+ addCommentReply
+} from '../api/torrentComment';
+import './TorrentDetail.css';
+
+
+const TorrentDetail = ({ onLogout }) => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [torrent, setTorrent] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [newComment, setNewComment] = useState('');
+ const [setReplyingTo] = useState(null);
+ const [replyContent, setReplyContent] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [activeReplyId, setActiveReplyId] = useState(null);
+ const [replyModal, setReplyModal] = useState({
+ visible: false,
+ replyingTo: null,
+ replyingToUsername: '',
+ isReply: false
+ });
+
+ // 确保openReplyModal接收username参数
+const openReplyModal = (commentId, username) => {
+ setReplyModal({
+ visible: true,
+ replyingTo: commentId,
+ replyingToUsername: username, // 确保这里接收username
+ isReply: false
+ });
+};
+
+ // 关闭回复弹窗
+ const closeReplyModal = () => {
+ setReplyModal({
+ visible: false,
+ replyingTo: null,
+ replyingToUsername: '',
+ isReply: false
+ });
+ setReplyContent('');
+ };
+
+ const Comment = ({ comment, onLike, onReply, isReply = false }) => {
+ return (
+ <div className={`comment-container ${isReply ? "is-reply" : ""}`}>
+ <div className="comment-item">
+ <div className="comment-avatar">
+ {(comment.authorId || "?").charAt(0)} {/* 修复点 */}
+ </div>
+ <div className="comment-content">
+ <div className="comment-header">
+ <span className="comment-user">{comment.authorId || "匿名用户"}</span>
+ {comment.replyTo && (
+ <span className="reply-to">回复 @{comment.replyTo}</span>
+ )}
+ <span className="comment-time">
+ {new Date(comment.createTime).toLocaleString()}
+ </span>
+ </div>
+ <p className="comment-text">{comment.content}</p>
+ <div className="comment-actions">
+ <button onClick={() => onLike(comment.id)}>
+ 👍 ({comment.likeCount || 0})
+ </button>
+ <button onClick={() => onReply(comment.id, comment.authorId)}>
+ 回复
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ };
+
+ // 递归渲染评论组件
+ const renderComment = (comment, depth = 0) => {
+ return (
+ <div key={comment.id} style={{ marginLeft: `${depth * 30}px` }}>
+ <Comment
+ comment={comment}
+ onLike={handleLikeComment}
+ onReply={openReplyModal}
+ isReply={depth > 0}
+ />
+
+ {/* 递归渲染所有回复 */}
+ {comment.replies && comment.replies.map(reply =>
+ renderComment(reply, depth + 1)
+ )}
+ </div>
+ );
+ };
+
+
+
+ const fetchTorrentDetail = async () => {
+ try {
+ setLoading(true);
+ const response = await getTorrentDetail(id);
+ console.log('API Response:', JSON.parse(JSON.stringify(response.data.data.comments))); // 深度拷贝避免Proxy影响
+ setTorrent(response.data.data.torrent);
+ setComments(response.data.data.comments);
+ } catch (err) {
+ setError(err.response?.data?.message || '获取种子详情失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchTorrentDetail();
+ }, [id]);
+
+ // 点赞种子
+ const handleLikeTorrent = async () => {
+ try {
+ await likeTorrent(id);
+ setTorrent(prev => ({
+ ...prev,
+ likeCount: prev.likeCount + 1
+ }));
+ } catch (err) {
+ setError('点赞失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+
+ const handleCommentSubmit = async (e) => {
+ e.preventDefault();
+ if (!newComment.trim()) return;
+
+ try {
+ const username = localStorage.getItem('username');
+ const response = await addTorrentComment(id, {
+ content: newComment,
+ authorId: username
+ });
+
+ // 修改这里的响应处理逻辑
+ if (response.data && response.data.code === 200) {
+ await fetchTorrentDetail();
+
+ setNewComment('');
+ } else {
+ setError(response.data.message || '评论失败');
+ }
+ } catch (err) {
+ setError('评论失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+
+
+ const handleLikeComment = async (commentId) => {
+ try {
+ await likeTorrentComment(commentId);
+
+ // 递归更新评论点赞数
+ const updateComments = (comments) => {
+ return comments.map(comment => {
+ // 当前评论匹配
+ if (comment.id === commentId) {
+ return { ...comment, likeCount: comment.likeCount + 1 };
+ }
+
+ // 递归处理回复
+ if (comment.replies && comment.replies.length > 0) {
+ return {
+ ...comment,
+ replies: updateComments(comment.replies)
+ };
+ }
+
+ return comment;
+ });
+ };
+
+ setComments(prev => updateComments(prev));
+ } catch (err) {
+ setError('点赞失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+ // 修改startReply函数
+ const startReply = (commentId) => {
+ if (activeReplyId === commentId) {
+ // 如果点击的是已经激活的回复按钮,则关闭
+ setActiveReplyId(null);
+ setReplyingTo(null);
+ } else {
+ // 否则打开新的回复框
+ setActiveReplyId(commentId);
+ setReplyingTo(commentId);
+ }
+ };
+
+ const handleReplySubmit = async (e) => {
+ e.preventDefault();
+ if (!replyContent.trim()) return;
+
+ try {
+ const username = localStorage.getItem('username');
+ const response = await addCommentReply(replyModal.replyingTo, {
+ content: replyContent,
+ authorId: username
+ });
+
+ console.log('回复响应:', response.data); // 调试
+
+ if (response.data && response.data.code === 200) {
+ await fetchTorrentDetail();
+
+ closeReplyModal();
+ }
+ } catch (err) {
+ console.error('回复错误:', err);
+ setError('回复失败: ' + (err.response?.data?.message || err.message));
+ }
+ };
+
+
+ // 返回按钮
+ const handleBack = () => {
+ const fromTab = location.state?.fromTab || 'share';
+ navigate(`/dashboard/${fromTab}`);
+ };
+
+ if (loading) return <div className="loading">加载中...</div>;
+ if (error) return <div className="error">{error}</div>;
+ if (!torrent) return <div className="error">种子不存在</div>;
+
+ return (
+ <div className="torrent-detail-container">
+ <button className="back-button" onClick={handleBack}>
+ ← 返回资源区
+ </button>
+
+ <div className="torrent-main">
+ <div className="torrent-cover">
+ <div className="cover-placeholder">
+ {torrent.torrentName.charAt(0)}
+ </div>
+ </div>
+
+ <div className="torrent-info">
+ <h1 className="torrent-title">{torrent.torrentName}</h1>
+
+ <div className="uploader-info">
+ <div className="uploader-avatar">
+ {torrent.username.charAt(0)}
+ </div>
+ <div className="uploader-details">
+ <span className="uploader-name">{torrent.username}</span>
+ <span className="upload-time">
+ {new Date(torrent.createTime).toLocaleString()}
+ </span>
+ </div>
+ </div>
+
+ <div className="torrent-meta">
+ <p><strong>类型:</strong> {torrent.category}</p>
+ <p><strong>地区:</strong> {torrent.region}</p>
+ <p><strong>分辨率:</strong> {torrent.resolution}</p>
+ <p><strong>字幕:</strong> {torrent.subtitle}</p>
+ {torrent.size && <p><strong>大小:</strong> {torrent.size}</p>}
+ </div>
+
+ <div className="torrent-description">
+ <h3>资源描述</h3>
+ <p>{torrent.description}</p>
+ </div>
+
+ <div className="interaction-buttons">
+ <button
+ className="like-button"
+ onClick={handleLikeTorrent}
+ >
+ <span>👍 点赞 ({torrent.likeCount})</span>
+ </button>
+ <button className="download-button">
+ <span>⬇️ 立即下载</span>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div className="comments-section">
+ <h2>评论 ({torrent.replyCount})</h2>
+
+ <form onSubmit={handleCommentSubmit} className="comment-form">
+ <textarea
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ placeholder="写下你的评论..."
+ rows="3"
+ required
+ />
+ <button type="submit">发表评论</button>
+ </form>
+
+ <div className="comment-list">
+ {comments.map(comment => renderComment(comment))}
+ </div>
+
+ {replyModal.visible && (
+ <div className="reply-modal-overlay">
+ <div className="reply-modal">
+ <div className="modal-header">
+ <h3>回复 @{replyModal.replyingToUsername}</h3>
+ <button onClick={closeReplyModal} className="close-modal">×</button>
+ </div>
+ <form onSubmit={handleReplySubmit}>
+ <textarea
+ value={replyContent}
+ onChange={(e) => setReplyContent(e.target.value)}
+ placeholder={`回复 @${replyModal.replyingToUsername}...`}
+ rows="5"
+ autoFocus
+ required
+ />
+ <div className="modal-actions">
+ <button type="button" onClick={closeReplyModal} className="cancel-btn">
+ 取消
+ </button>
+ <button type="submit" className="submit-btn">
+ 发送回复
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ )}
+
+ </div>
+ </div>
+ );
+};
+
+export default TorrentDetail;
\ No newline at end of file
diff --git a/src/components/TorrentDetail.test.jsx b/src/components/TorrentDetail.test.jsx
new file mode 100644
index 0000000..309942f
--- /dev/null
+++ b/src/components/TorrentDetail.test.jsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import TorrentDetail from './TorrentDetail';
+import * as torrentApi from '../api/torrent';
+import * as torrentCommentApi from '../api/torrentComment';
+
+// Mock API 模块
+jest.mock('../api/torrent');
+jest.mock('../api/torrentComment');
+
+// Mock useLocation 钩子
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useLocation: jest.fn(),
+}));
+
+describe('TorrentDetail 组件', () => {
+ const mockTorrent = {
+ id: '1',
+ torrentName: '测试种子',
+ category: '电影',
+ region: '美国',
+ resolution: '1080P',
+ subtitle: '中文字幕',
+ username: 'user1',
+ createTime: '2023-01-01T00:00:00Z',
+ likeCount: 5,
+ replyCount: 3,
+ description: '这是一个测试种子描述',
+ };
+
+ const mockComments = [
+ {
+ id: 'c1',
+ content: '测试评论1',
+ authorId: 'user2',
+ createTime: '2023-01-01T01:00:00Z',
+ likeCount: 2,
+ replies: [
+ {
+ id: 'c1r1',
+ content: '测试回复1',
+ authorId: 'user3',
+ createTime: '2023-01-01T02:00:00Z',
+ likeCount: 1,
+ }
+ ]
+ }
+ ];
+
+ beforeEach(() => {
+ // 设置模拟的 API 响应
+ torrentApi.getTorrentDetail.mockResolvedValue({
+ data: {
+ code: 200,
+ data: {
+ torrent: mockTorrent,
+ comments: mockComments,
+ }
+ }
+ });
+
+ torrentApi.likeTorrent.mockResolvedValue({ data: { code: 200 } });
+ torrentApi.addTorrentComment.mockResolvedValue({ data: { code: 200 } });
+ torrentCommentApi.likeTorrentComment.mockResolvedValue({ data: { code: 200 } });
+ torrentCommentApi.addCommentReply.mockResolvedValue({ data: { code: 200 } });
+
+ // 模拟 useLocation
+ useLocation.mockReturnValue({
+ state: { fromTab: 'share' }
+ });
+
+ // 设置 localStorage
+ Storage.prototype.getItem = jest.fn((key) => {
+ if (key === 'username') return 'testuser';
+ return null;
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = () => {
+ return render(
+ <MemoryRouter initialEntries={['/torrent/1']}>
+ <Routes>
+ <Route path="/torrent/:id" element={<TorrentDetail />} />
+ </Routes>
+ </MemoryRouter>
+ );
+ };
+
+ it('应该正确加载和显示种子详情', async () => {
+ renderComponent();
+
+ // 检查加载状态
+ expect(screen.getByText('加载中...')).toBeInTheDocument();
+
+ // 等待数据加载完成
+ await waitFor(() => {
+ expect(screen.getByText('测试种子')).toBeInTheDocument();
+ expect(screen.getByText('这是一个测试种子描述')).toBeInTheDocument();
+ expect(screen.getByText('user1')).toBeInTheDocument();
+ });
+ });
+
+ it('应该能够点赞种子', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试种子')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText(/点赞 \(5\)/));
+
+ await waitFor(() => {
+ expect(torrentApi.likeTorrent).toHaveBeenCalledWith('1');
+ });
+ });
+
+ it('应该能够提交评论', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试种子')).toBeInTheDocument();
+ });
+
+ const commentInput = screen.getByPlaceholderText('写下你的评论...');
+ fireEvent.change(commentInput, { target: { value: '新评论' } });
+ fireEvent.click(screen.getByText('发表评论'));
+
+ await waitFor(() => {
+ expect(torrentApi.addTorrentComment).toHaveBeenCalledWith('1', {
+ content: '新评论',
+ authorId: 'testuser'
+ });
+ });
+ });
+
+ it('应该能够点赞评论', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试评论1')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getAllByText(/👍 \(2\)/)[0]);
+
+ await waitFor(() => {
+ expect(torrentCommentApi.likeTorrentComment).toHaveBeenCalledWith('c1');
+ });
+ });
+
+ it('应该能够返回资源区', async () => {
+ const { container } = renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('测试种子')).toBeInTheDocument();
+ });
+
+ const backButton = container.querySelector('.back-button');
+ fireEvent.click(backButton);
+
+ // 这里可以添加导航验证,但需要更复杂的路由设置
+ });
+});
\ No newline at end of file
diff --git a/src/config/config.js b/src/config/config.js
new file mode 100644
index 0000000..a772747
--- /dev/null
+++ b/src/config/config.js
@@ -0,0 +1,7 @@
+global.AppConfig = {
+
+ //serverIP后端服务器地址
+
+ serverIP:'http://localhost:8088'
+
+ }
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..ec2585e
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,13 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..415194f
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,26 @@
+// 1. 首先放所有 import 语句
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import './index.css';
+import App from './App';
+import reportWebVitals from './reportWebVitals';
+import process from 'process/browser'; // 新增
+import { Buffer } from 'buffer'; // 新增
+
+// 2. 然后放 polyfill 代码
+if (typeof window.process === 'undefined') {
+ window.process = process;
+}
+if (typeof window.Buffer === 'undefined') {
+ window.Buffer = Buffer;
+}
+
+// 3. 最后放应用逻辑
+const root = ReactDOM.createRoot(document.getElementById('root'));
+root.render(
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+);
+
+reportWebVitals();
\ No newline at end of file
diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js
new file mode 100644
index 0000000..5253d3a
--- /dev/null
+++ b/src/reportWebVitals.js
@@ -0,0 +1,13 @@
+const reportWebVitals = onPerfEntry => {
+ if (onPerfEntry && onPerfEntry instanceof Function) {
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ getCLS(onPerfEntry);
+ getFID(onPerfEntry);
+ getFCP(onPerfEntry);
+ getLCP(onPerfEntry);
+ getTTFB(onPerfEntry);
+ });
+ }
+};
+
+export default reportWebVitals;
diff --git a/src/setupTests.js b/src/setupTests.js
new file mode 100644
index 0000000..f739037
--- /dev/null
+++ b/src/setupTests.js
@@ -0,0 +1,28 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
+
+// 模拟 localStorage
+const localStorageMock = (function() {
+ let store = {};
+ return {
+ getItem: jest.fn((key) => store[key] || null),
+ setItem: jest.fn((key, value) => {
+ store[key] = String(value);
+ }),
+ removeItem: jest.fn((key) => {
+ delete store[key];
+ }),
+ clear: jest.fn(() => {
+ store = {};
+ })
+ };
+ })();
+
+ // 挂载到全局
+ Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+ writable: true // 必须设置为可写
+ });
\ No newline at end of file
diff --git a/src/test-utils.jsx b/src/test-utils.jsx
new file mode 100644
index 0000000..768da69
--- /dev/null
+++ b/src/test-utils.jsx
@@ -0,0 +1,7 @@
+import { MemoryRouter } from 'react-router-dom';
+import { render } from '@testing-library/react';
+
+export const renderWithRouter = (ui, { route = '/' } = {}) => {
+ window.history.pushState({}, 'Test page', route);
+ return render(ui, { wrapper: MemoryRouter });
+};
\ No newline at end of file