个人中心全部,模糊乱序搜索,类型筛选
Change-Id: Id635654fccccaea80bfbf4d1480abd55f7d12046
diff --git a/src/App.jsx b/src/App.jsx
index dba8f84..54113ea 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -14,7 +14,7 @@
import TorrentDetail from './components/TorrentDetail'; // 确保路径正确
import RequestDetail from './components/RequestDetail';
import HelpDetail from './components/HelpDetail';
-import Favorite from './components/Personal/Favorite';
+import Exchange from './components/Personal/Exchange';
import Upload from './components/Personal/Upload';
import Notice from './components/Personal/Notice';
import Setting from './components/Personal/Setting';
@@ -80,8 +80,8 @@
<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/exchange" element={
+ isAuthenticated ? <Exchange onLogout={handleLogout} /> : <Navigate to="/login" replace />
} />
<Route path="/personal/upload" element={
isAuthenticated ? <Upload onLogout={handleLogout} /> : <Navigate to="/login" replace />
diff --git a/src/api/auth.js b/src/api/auth.js
index 4b569ef..c094a44 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -32,21 +32,25 @@
});
};
-
-export const getUserInfo = (token) => {
- return api.get('/user/info', { params: { token } });
+export const getUserInfo = async () => {
+ try {
+ const response = await api.get('/user/userInfo');
+ if (response.data.code === 200) {
+ return response.data.data;
+ }
+ throw new Error(response.data.message || '获取用户信息失败');
+ } catch (error) {
+ console.error('获取用户信息失败:', error);
+ throw error;
+ }
};
-// // 修改你的 API 请求
-// export const getUserInfo = () => {
-// const token = localStorage.getItem('token');
-// if (!token) {
-// throw new Error("Token 不存在");
-// }
-
-// return api.get('/user/info', {
-// headers: {
-// 'Authorization': `Bearer ${token}` // 必须带 Bearer 前缀
-// }
-// });
-// };
+export const isAdmin = async () => {
+ try {
+ const userInfo = await getUserInfo();
+ return userInfo.authority === 'ADMIN';
+ } catch (error) {
+ console.error('检查管理员权限失败:', error);
+ return false;
+ }
+};
diff --git a/src/api/auth.test.js b/src/api/auth.test.js
index c9ace4d..9de5691 100644
--- a/src/api/auth.test.js
+++ b/src/api/auth.test.js
@@ -1,11 +1,10 @@
import MockAdapter from 'axios-mock-adapter';
-import { api, login, register, getUserInfo } from './auth';
+import { api, login, register, getUserInfo, isAdmin } from './auth';
describe('auth API', () => {
let mockAxios;
beforeEach(() => {
- // 确保使用我们导出的 api 实例
mockAxios = new MockAdapter(api);
localStorage.clear();
});
@@ -16,28 +15,30 @@
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 mockResponse = {
+ code: 200,
+ data: {
+ token: 'mock-token',
+ userInfo: { username: 'testuser' }
+ },
+ message: '登录成功'
+ };
+
+ mockAxios.onPost('/user/login', undefined, {
+ params: { username: 'testuser', password: 'testpass' }
+ }).reply(200, mockResponse);
- const response = await login('testuser', 'testpass');
-
- expect(response.data).toEqual(mockResponse);
- // 检查token是否存入localStorage
- expect(localStorage.getItem('token')).toBe('mock-token');
- });
-
+ const response = await login('testuser', 'testpass');
+
+ expect(response.data).toEqual(mockResponse);
+ expect(localStorage.getItem('token')).toBe('mock-token');
+ });
it('should handle login failure', async () => {
- mockAxios.onPost('/user/login').reply(401);
+ mockAxios.onPost('/user/login').reply(401, {
+ code: 401,
+ message: '登录失败'
+ });
await expect(login('wronguser', 'wrongpass')).rejects.toThrow();
});
@@ -50,7 +51,9 @@
message: '注册成功'
};
- mockAxios.onPost('/user/regist').reply(200, mockResponse);
+ mockAxios.onPost('/user/regist', undefined, {
+ params: { username: 'newuser', password: 'newpass', code: 'invite123' }
+ }).reply(200, mockResponse);
const response = await register('newuser', 'newpass', 'invite123');
@@ -58,30 +61,66 @@
});
it('should handle registration failure', async () => {
- mockAxios.onPost('/user/regist').reply(400);
+ mockAxios.onPost('/user/regist').reply(400, {
+ code: 400,
+ message: '注册失败'
+ });
await expect(register('newuser', 'newpass', 'wrongcode')).rejects.toThrow();
});
});
describe('getUserInfo', () => {
- it('should send request with token to get user info', async () => {
+ it('should send request to get user info', async () => {
const mockResponse = {
code: 200,
- data: { username: 'testuser', role: 'user' }
+ data: { username: 'testuser', authority: 'USER' }
};
- mockAxios.onGet('/user/info').reply(200, mockResponse);
-
- const response = await getUserInfo('test-token');
+ mockAxios.onGet('/user/userInfo').reply(200, mockResponse);
+
+ const response = await getUserInfo();
- expect(response.data).toEqual(mockResponse);
+ expect(response).toEqual(mockResponse.data);
+ });
+
+ it('should handle unauthorized request', async () => {
+ mockAxios.onGet('/user/userInfo').reply(401);
+
+ await expect(getUserInfo()).rejects.toThrow('Request failed with status code 401');
+ });
+ });
+
+ describe('isAdmin', () => {
+ it('should return true when user is admin', async () => {
+ const mockResponse = {
+ code: 200,
+ data: { username: 'admin', authority: 'ADMIN' }
+ };
+
+ mockAxios.onGet('/user/userInfo').reply(200, mockResponse);
+
+ const result = await isAdmin();
+ expect(result).toBe(true);
});
- it('should handle unauthorized request', async () => {
- mockAxios.onGet('/user/info').reply(401);
+ it('should return false when user is not admin', async () => {
+ const mockResponse = {
+ code: 200,
+ data: { username: 'user', authority: 'USER' }
+ };
- await expect(getUserInfo('invalid-token')).rejects.toThrow();
+ mockAxios.onGet('/user/userInfo').reply(200, mockResponse);
+
+ const result = await isAdmin();
+ expect(result).toBe(false);
+ });
+
+ it('should return false when request fails', async () => {
+ mockAxios.onGet('/user/userInfo').reply(401);
+
+ const result = await isAdmin();
+ expect(result).toBe(false);
});
});
diff --git a/src/api/helpPost.js b/src/api/helpPost.js
index 4813aa3..92d6402 100644
--- a/src/api/helpPost.js
+++ b/src/api/helpPost.js
@@ -50,4 +50,11 @@
return api.delete(`/help/posts/${postId}`, {
params: { authorId }
});
+};
+
+
+export const searchPosts = (keyword, page = 1, size = 5) => {
+ return api.get('/help/posts/search', {
+ params: { keyword, page, size }
+ });
};
\ No newline at end of file
diff --git a/src/api/personal.js b/src/api/personal.js
new file mode 100644
index 0000000..949afb4
--- /dev/null
+++ b/src/api/personal.js
@@ -0,0 +1,181 @@
+// src/api/personal.js
+import { api } from './auth';
+
+/**
+ * 获取用户信息
+ * @returns {Promise<Object>} 用户信息对象
+ */
+export const getUserInfo = async () => {
+ try {
+ const response = await api.get('/user/userInfo');
+ if (response.data.code === 200) {
+ const userData = response.data.data;
+ return {
+ username: userData.username,
+ level: userData.level,
+ registTime: formatDate(userData.registTime),
+ magicPoints: userData.magicPoints,
+ upload: userData.upload,
+ download: userData.download,
+ shareRate: userData.shareRate.toFixed(2)
+ };
+ }
+ throw new Error(response.data.message || '获取用户信息失败');
+ } catch (error) {
+ console.error('获取用户信息失败:', error);
+ throw error;
+ }
+};
+
+export const formatFileSize = (bytes) => {
+ if (bytes < 1024) {
+ return bytes + ' B';
+ }
+ const kb = bytes / 1024;
+ if (kb < 1024) {
+ return kb.toFixed(2) + ' KB';
+ }
+ const mb = kb / 1024;
+ if (mb < 1024) {
+ return mb.toFixed(2) + ' MB';
+ }
+ const gb = mb / 1024;
+ return gb.toFixed(2) + ' GB';
+ };
+
+
+ export const getDownloadQuota = async () => {
+ try {
+ const response = await api.get('/user/allowDownload');
+ if (response.data.code === 200) {
+ const data = response.data.data;
+ return {
+ total: data.total, // 已经是字节
+ used: data.used,
+ remaining: data.remaining
+ };
+ }
+ throw new Error(response.data.message || '获取下载额度失败');
+ } catch (error) {
+ console.error('获取下载额度失败:', error);
+ throw error;
+ }
+ };
+
+// 修正后的时间格式化(正确处理时区)
+const formatDate = (dateString) => {
+ const date = new Date(dateString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
+
+ export const getDownloadProgress = async () => {
+ try {
+ const response = await api.get('/torrent/getProgress');
+ if (response.data.code === 200) {
+ return response.data.data.progresses;
+ }
+ throw new Error(response.data.message || '获取下载进度失败');
+ } catch (error) {
+ console.error('获取下载进度失败:', error);
+ throw error;
+ }
+ };
+
+ export const getUserTorrents = async (page = 1, size = 5) => {
+ try {
+ const response = await api.get('/torrent/get/torrentMyself', {
+ params: { page, size }
+ });
+ if (response.data.code === 200) {
+ const records = response.data.data.records.map(item => ({
+ ...item.torrent,
+ downloadCount: item.downloadCount,
+ formattedSize: item.formattedSize
+ }));
+ return {
+ records: records,
+ total: response.data.data.total
+ };
+ }
+ throw new Error(response.data.message || '获取上传记录失败');
+ } catch (error) {
+ console.error('获取上传记录失败:', error);
+ throw error;
+ }
+ };
+
+
+ export const deleteTorrent = async (id) => {
+ try {
+ const response = await api.delete(`/torrent/deleteTorrent/${id}`);
+ if (response.data.code === 200) {
+ return response.data;
+ }
+ throw new Error(response.data.message || '删除种子失败');
+ } catch (error) {
+ console.error('删除种子失败:', error);
+ throw error;
+ }
+ };
+
+
+ export const generateInviteCode = async () => {
+ try {
+ const response = await api.post('/invitecode/generate');
+ if (response.data.code === 200) {
+ return response.data.data.inviteCode;
+ }
+ throw new Error(response.data.message || '生成邀请码失败');
+ } catch (error) {
+ console.error('生成邀请码失败:', error);
+ throw error;
+ }
+ };
+
+ export const getUserInviteCodes = async () => {
+ try {
+ const response = await api.get('/invitecode/userInviteCode');
+ if (response.data.code === 200) {
+ return response.data.data.inviteCode;
+ }
+ throw new Error(response.data.message || '获取邀请码列表失败');
+ } catch (error) {
+ console.error('获取邀请码列表失败:', error);
+ throw error;
+ }
+ };
+
+ export const exchangeUpload = async (magicPoints) => {
+ try {
+ const response = await api.post('/user/exchangeUpload', {
+ magicPoint: magicPoints
+ });
+ if (response.data.code === 200) {
+ return response.data;
+ }
+ throw new Error(response.data.message || '兑换上传量失败');
+ } catch (error) {
+ console.error('兑换上传量失败:', error);
+ throw error;
+ }
+ };
+
+ export const updatePassword = async (oldPassword, newPassword) => {
+ try {
+ const response = await api.put('/user/password', {
+ oldPassword,
+ newPassword
+ });
+ if (response.data.code === 200) {
+ return response.data;
+ }
+ throw new Error(response.data.message || '修改密码失败');
+ } catch (error) {
+ console.error('修改密码失败:', error);
+ throw error;
+ }
+ };
diff --git a/src/api/torrent.js b/src/api/torrent.js
index 6c56fce..8a00e54 100644
--- a/src/api/torrent.js
+++ b/src/api/torrent.js
@@ -36,7 +36,7 @@
data: {
...response.data,
data: {
- torrent: response.data.data.post || response.data.data.torrent,
+ torrent: response.data.data.torrent, // 直接使用后端返回的格式化数据
comments: response.data.data.comments || []
}
}
@@ -52,4 +52,10 @@
export const addTorrentComment = (torrentId, commentData) => {
return api.post(`/torrent/${torrentId}/comments`, commentData);
+};
+
+export const searchTorrents = (keyword, page = 1, size = 5) => {
+ return api.get('/torrent/search', {
+ params: { keyword, page, size }
+ });
};
\ No newline at end of file
diff --git a/src/api/torrent.test.js b/src/api/torrent.test.js
index ca755c3..6515bdc 100644
--- a/src/api/torrent.test.js
+++ b/src/api/torrent.test.js
@@ -1,41 +1,134 @@
import MockAdapter from 'axios-mock-adapter';
-import { api } from './auth'; // Import api from auth
-import { createTorrent, getTorrents, getTorrentDetail, likeTorrent, addTorrentComment } from './torrent';
+import { api } from './auth';
+import {
+ createTorrent,
+ getTorrents,
+ getTorrentDetail,
+ likeTorrent,
+ addTorrentComment,
+ searchTorrents
+} from './torrent';
describe('种子资源API', () => {
let mockAxios;
beforeEach(() => {
mockAxios = new MockAdapter(api);
+ localStorage.setItem('token', 'test-token');
});
afterEach(() => {
mockAxios.restore();
+ localStorage.clear();
});
- // Test for getting torrent detail
+ describe('createTorrent - 创建种子', () => {
+ it('应该正确发送包含文件和数据的表单请求', async () => {
+ const mockFile = new File(['test'], 'test.torrent');
+ const torrentData = { name: '测试种子', description: '测试描述' };
+ const mockResponse = { code: 200, message: '创建成功' };
+
+ mockAxios.onPost('/torrent').reply((config) => {
+ expect(config.headers['Content-Type']).toBe('multipart/form-data');
+ expect(config.headers['Authorization']).toBe('Bearer test-token'); // 修改为包含Bearer
+ return [200, mockResponse];
+ });
+
+ const response = await createTorrent(torrentData, mockFile);
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+
+ describe('getTorrents - 获取种子列表', () => {
+ it('应该发送带分页参数的请求', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ list: [{ id: 1, name: '种子1' }, { id: 2, name: '种子2' }],
+ total: 2
+ }
+ };
+
+ mockAxios.onGet('/torrent', { params: { page: 2, size: 10 } })
+ .reply(200, mockResponse);
+
+ const response = await getTorrents(2, 10);
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+
describe('getTorrentDetail - 获取种子详情', () => {
it('应该规范化返回的数据结构', async () => {
- const mockData = {
+ const mockResponse = {
+ code: 200,
data: {
- post: { id: '123', name: '测试种子' },
+ torrent: { id: '123', name: '测试种子' },
comments: [{ id: '1', content: '评论1' }]
}
};
- mockAxios.onGet('/torrent/123').reply(200, mockData);
+
+ mockAxios.onGet('/torrent/123').reply(200, mockResponse);
const response = await getTorrentDetail('123');
- expect(response.data.data.torrent.name).toBe('测试种子'); // Corrected key to `post.name`
+ expect(response.data.data.torrent.name).toBe('测试种子');
expect(response.data.data.comments).toHaveLength(1);
});
+
+ it('应该处理没有评论的情况', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ torrent: { id: '123', name: '测试种子' },
+ comments: null
+ }
+ };
+
+ mockAxios.onGet('/torrent/123').reply(200, mockResponse);
+
+ const response = await getTorrentDetail('123');
+ expect(response.data.data.comments).toEqual([]);
+ });
});
- // Test for liking a torrent
describe('likeTorrent - 点赞种子', () => {
- it('应该成功发送点赞请求', async () => {
- mockAxios.onPost('/torrent/t123/like').reply(200, { code: 200 });
- const response = await likeTorrent('t123');
- expect(response.status).toBe(200);
+ it('应该发送点赞请求', async () => {
+ const mockResponse = { code: 200, message: '点赞成功' };
+ mockAxios.onPost('/torrent/123/like').reply(200, mockResponse);
+
+ const response = await likeTorrent('123');
+ expect(response.data).toEqual(mockResponse);
});
});
-});
+
+ describe('addTorrentComment - 添加种子评论', () => {
+ it('应该发送评论数据', async () => {
+ const commentData = { content: '测试评论' };
+ const mockResponse = { code: 200, message: '评论成功' };
+
+ mockAxios.onPost('/torrent/123/comments', commentData)
+ .reply(200, mockResponse);
+
+ const response = await addTorrentComment('123', commentData);
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+
+ describe('searchTorrents - 搜索种子', () => {
+ it('应该发送带搜索关键词和分页的请求', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ list: [{ id: 1, name: '匹配的种子' }],
+ total: 1
+ }
+ };
+
+ mockAxios.onGet('/torrent/search', {
+ params: { keyword: '测试', page: 1, size: 5 }
+ }).reply(200, mockResponse);
+
+ const response = await searchTorrents('测试');
+ expect(response.data).toEqual(mockResponse);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/Dashboard.css b/src/components/Dashboard.css
index 09ab88f..a6b3a5a 100644
--- a/src/components/Dashboard.css
+++ b/src/components/Dashboard.css
@@ -715,4 +715,58 @@
.upload-btn:hover {
background-color: #218838;
+}
+
+/* 平台名称样式 */
+.platform-name {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ padding-left: 20px;
+}
+
+.platform-name h2 {
+ margin: 0;
+ color: #333;
+ font-size: 24px;
+ font-weight: bold;
+}
+
+/* 分区搜索框样式 */
+.section-search-container {
+ padding: 10px 20px;
+ margin-bottom: 20px;
+}
+
+.section-search-input {
+ width: 100%;
+ padding: 8px 15px;
+ border: 1px solid #ddd;
+ border-radius: 20px;
+ font-size: 14px;
+ outline: none;
+}
+
+.section-search-input:focus {
+ border-color: #1890ff;
+}
+
+.no-results {
+ text-align: center;
+ padding: 20px;
+ color: #888;
+ font-size: 16px;
+}
+
+.reset-button {
+ padding: 8px 15px;
+ background-color: #f0f0f0;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.reset-button:hover {
+ background-color: #e0e0e0;
}
\ No newline at end of file
diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx
index 836daa7..f8f5dd3 100644
--- a/src/components/Dashboard.jsx
+++ b/src/components/Dashboard.jsx
@@ -1,9 +1,10 @@
import React, {useEffect, useState} from 'react';
-import {useNavigate, useLocation, useParams} from 'react-router-dom';
-// import { getUserInfo } from '../api/auth';
-import {createTorrent, getTorrents} from '../api/torrent';
+import {useNavigate, useLocation, useParams} from 'react-router-dom';
+import {createTorrent, getTorrents, searchTorrents} from '../api/torrent';
import './Dashboard.css';
-import {createPost, getPosts, getPostDetail} from '../api/helpPost';
+import {createPost, getPosts, getPostDetail, searchPosts} from '../api/helpPost';
+import { getUserInfo, isAdmin } from '../api/auth';
+import { api } from '../api/auth';
const Dashboard = ({onLogout}) => {
@@ -45,6 +46,12 @@
const [filteredResources, setFilteredResources] = useState(torrentPosts);
const [isAdmin, setIsAdmin] = useState(false);
+ // 新增搜索状态
+ const [announcementSearch, setAnnouncementSearch] = useState('');
+ const [shareSearch, setShareSearch] = useState('');
+ const [requestSearch, setRequestSearch] = useState('');
+ const [helpSearch, setHelpSearch] = useState('');
+
const activeTab = tab || 'announcement'; // 如果没有tab参数,则默认为announcement
// 从location.state中初始化状态
@@ -118,6 +125,90 @@
// 其他公告...
]);
+ // 公告区搜索处理
+ const handleSearchAnnouncement = (e) => {
+ setAnnouncementSearch(e.target.value);
+ };
+
+ // 修改后的搜索函数
+ const handleSearchShare = async () => {
+ try {
+ setTorrentLoading(true);
+ const response = await searchTorrents(shareSearch, 1);
+ if (response.data.code === 200) {
+ setTorrentPosts(response.data.data.records);
+ const total = response.data.data.total;
+ setTotalPages(Math.ceil(total / 5));
+ setCurrentPage(1);
+ } else {
+ setTorrentError(response.data.message || '搜索失败');
+ }
+ } catch (err) {
+ setTorrentError(err.message || '搜索失败');
+ } finally {
+ setTorrentLoading(false);
+ }
+ };
+
+ const handleResetShareSearch = async () => {
+ setShareSearch('');
+ setSelectedFilters(
+ Object.keys(filterCategories).reduce((acc, category) => {
+ acc[category] = 'all';
+ return acc;
+ }, {})
+ );
+ await fetchTorrentPosts(1, true);
+ };
+
+ // 求种区搜索处理
+ const handleSearchRequest = (e) => {
+ setRequestSearch(e.target.value);
+ };
+
+ // 添加搜索函数
+ const handleSearchHelp = async () => {
+ try {
+ setHelpLoading(true);
+ const response = await searchPosts(helpSearch, currentPage);
+ if (response.data.code === 200) {
+ const postsWithCounts = await Promise.all(
+ response.data.data.records.map(async (post) => {
+ try {
+ const detailResponse = await getPostDetail(post.id);
+ if (detailResponse.data.code === 200) {
+ return {
+ ...post,
+ replyCount: detailResponse.data.data.post.replyCount || 0,
+ isLiked: false
+ };
+ }
+ return post;
+ } catch (err) {
+ console.error(`获取帖子${post.id}详情失败:`, err);
+ return post;
+ }
+ })
+ );
+ setHelpPosts(postsWithCounts);
+ setTotalPages(Math.ceil(response.data.data.total / 5));
+ } else {
+ setHelpError(response.data.message || '搜索失败');
+ }
+ } catch (err) {
+ setHelpError(err.message || '搜索失败');
+ } finally {
+ setHelpLoading(false);
+ }
+ };
+
+ // 添加重置搜索函数
+ const handleResetHelpSearch = async () => {
+ setHelpSearch('');
+ await fetchHelpPosts(1); // 重置到第一页
+ };
+
+
const handleAnnouncementClick = (announcement, e) => {
if (!e.target.closest('.exclude-click')) {
@@ -205,20 +296,41 @@
}
};
- // 获取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);
+
+const fetchTorrentPosts = async (page = 1, isReset = false) => {
+ setTorrentLoading(true);
+ try {
+ const params = {
+ page,
+ size: 5
+ };
+
+ // 如果有筛选条件且不是重置操作
+ if (!isReset && Object.values(selectedFilters).some(v => v !== 'all')) {
+ if (selectedFilters.type !== 'all') params.category = selectedFilters.type;
+ if (selectedFilters.subtitle !== 'all') params.subtitle = selectedFilters.subtitle;
+ if (selectedFilters.region !== 'all') params.region = selectedFilters.region;
+ if (selectedFilters.resolution !== 'all') params.resolution = selectedFilters.resolution;
}
- };
+
+ const response = (shareSearch && !isReset)
+ ? await searchTorrents(shareSearch, page)
+ : await api.get('http://localhost:8088/torrent', { params });
+
+ if (response.data.code === 200) {
+ setTorrentPosts(response.data.data.records);
+ const total = response.data.data.total;
+ setTotalPages(Math.ceil(total / 5));
+ setCurrentPage(page);
+ } else {
+ setTorrentError(response.data.message);
+ }
+ } catch (err) {
+ setTorrentError(err.message);
+ } finally {
+ setTorrentLoading(false);
+ }
+};
// 在useEffect中调用
useEffect(() => {
@@ -276,7 +388,7 @@
}, [activeTab, currentPage]); // 添加 currentPage 作为依赖
- // 分类维度配置
+ // 分类维度配置
const filterCategories = {
type: {
label: '类型',
@@ -285,28 +397,50 @@
'电影': '电影',
'电视剧': '电视剧',
'动漫': '动漫',
- '综艺': '综艺'
+ '综艺': '综艺',
+ '音乐': '音乐',
+ '其他': '其他'
}
},
subtitle: {
label: '字幕',
options: {
'all': '全部',
- 'yes': '有字幕',
- 'no': '无字幕'
+ '无需字幕': '无需字幕',
+ '暂无字幕': '暂无字幕',
+ '自带中文字幕': '自带中文字幕',
+ '自带双语字幕(含中文)': '自带双语字幕(含中文)',
+ '附件中文字幕': '附件中文字幕',
+ '附件双语字幕': '附件双语字幕'
}
},
region: {
label: '地区',
options: {
'all': '全部',
- 'cn': '大陆',
- 'us': '欧美',
- 'jp': '日本'
+ '中国': '中国',
+ '英国': '英国',
+ '美国': '美国',
+ '日本': '日本',
+ '韩国': '韩国',
+ '其他': '其他'
+ }
+ },
+ resolution: {
+ label: '分辨率',
+ options: {
+ 'all': '全部',
+ '4K': '4K',
+ '2K': '2K',
+ '1080P': '1080P',
+ '720P': '720P',
+ 'SD': 'SD',
+ '无损音源': '无损音源',
+ '杜比全景声': '杜比全景声',
+ '其他': '其他'
}
}
};
-
const [selectedFilters, setSelectedFilters] = useState(
location.state?.savedFilters ||
Object.keys(filterCategories).reduce((acc, category) => {
@@ -315,27 +449,54 @@
}, {})
);
-
-// 处理筛选条件变更
+ // 处理筛选条件变更
const handleFilterSelect = (category, value) => {
setSelectedFilters(prev => ({
...prev,
- [category]: prev[category] === value ? null : value // 点击已选中的则取消
+ [category]: prev[category] === value ? 'all' : 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);
+ // 应用筛选条件
+ const applyFilters = async () => {
+ try {
+ setTorrentLoading(true);
+
+ // 构建查询参数
+ const params = {
+ page: 1, // 从第一页开始
+ size: 5
+ };
+
+ // 添加筛选条件
+ if (selectedFilters.type !== 'all') {
+ params.category = selectedFilters.type;
+ }
+ if (selectedFilters.subtitle !== 'all') {
+ params.subtitle = selectedFilters.subtitle;
+ }
+ if (selectedFilters.region !== 'all') {
+ params.region = selectedFilters.region;
+ }
+ if (selectedFilters.resolution !== 'all') {
+ params.resolution = selectedFilters.resolution;
+ }
+
+ // 调用API获取筛选结果
+ const response = await api.get('http://localhost:8088/torrent', { params });
+
+ if (response.data.code === 200) {
+ setTorrentPosts(response.data.data.records);
+ setTotalPages(Math.ceil(response.data.data.total / 5));
+ setCurrentPage(1);
+ } else {
+ setTorrentError(response.data.message || '筛选失败');
+ }
+ } catch (err) {
+ setTorrentError(err.message || '筛选失败');
+ } finally {
+ setTorrentLoading(false);
+ }
};
@@ -346,43 +507,37 @@
}
}, [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') || '演示用户', // 确保这里读取的是最新值
+ // 在Dashboard.jsx中修改useEffect
+useEffect(() => {
+ const token = localStorage.getItem('token');
+ if (!token) {
+ navigate('/login');
+ return;
+ }
+
+ const fetchUserInfo = async () => {
+ try {
+ setLoading(true);
+ const backendData = await getUserInfo(); // 调用修改后的方法
+ console.log('后端返回的用户数据:', backendData); // 调试用
+
+ setUserInfo({
+ name: backendData.username || '演示用户',
avatar: 'https://via.placeholder.com/40',
- isAdmin: true
- });
- setLoading(false);
- }, [navigate]);
+ isAdmin: backendData.authority === 'ADMIN' // 检查 authority 是否为 "ADMIN"
+ });
+ } catch (error) {
+ console.error('获取用户信息失败:', error);
+ setError('获取用户信息失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchUserInfo();
+}, [navigate]);
- // 轮播图自动切换效果
+
useEffect(() => {
if (activeTab === 'announcement') {
const timer = setInterval(() => {
@@ -403,6 +558,22 @@
case 'announcement':
return (
<div className="content-area" data-testid="announcement-section">
+ <div className="section-search-container">
+ <input
+ type="text"
+ placeholder="搜索公告..."
+ value={announcementSearch}
+ onChange={(e) => setAnnouncementSearch(e.target.value)}
+ className="section-search-input"
+ onKeyPress={(e) => e.key === 'Enter' && handleSearchAnnouncement()}
+ />
+ <button
+ className="search-button"
+ onClick={handleSearchAnnouncement}
+ >
+ 搜索
+ </button>
+ </div>
{/* 轮播图区域 */}
<div className="carousel-container">
<div className={`carousel-slide ${currentSlide === 0 ? 'active' : ''}`}>
@@ -443,6 +614,31 @@
case 'share':
return (
<div className="content-area" data-testid="share-section">
+ {/* 分享区搜索框 */}
+ <div className="section-search-container">
+ <input
+ type="text"
+ placeholder="搜索资源..."
+ value={shareSearch}
+ onChange={(e) => setShareSearch(e.target.value)}
+ className="section-search-input"
+ onKeyPress={(e) => e.key === 'Enter' && handleSearchShare()}
+ />
+ <button
+ className="search-button"
+ onClick={handleSearchShare}
+ >
+ 搜索
+ </button>
+ <button
+ className="reset-button"
+ onClick={handleResetShareSearch} // 使用新的重置函数
+ style={{marginLeft: '10px'}}
+ >
+ 重置
+ </button>
+ </div>
+
{/* 上传按钮 - 添加在筛选区上方 */}
<div className="upload-header">
<button
@@ -517,19 +713,26 @@
<option value="动漫">动漫</option>
<option value="综艺">综艺</option>
<option value="音乐">音乐</option>
+ <option value="其他">其他</option>
</select>
</div>
- {/* 新增地区输入框 */}
+ {/* 修改后的地区下拉框 */}
<div className="form-group">
<label>地区</label>
- <input
- type="text"
+ <select
value={uploadData.region || ''}
onChange={(e) => setUploadData({...uploadData, region: e.target.value})}
- placeholder="例如: 美国, 中国, 日本等"
required
- />
+ >
+ <option value="">请选择</option>
+ <option value="中国">中国</option>
+ <option value="英国">英国</option>
+ <option value="美国">美国</option>
+ <option value="日本">日本</option>
+ <option value="韩国">韩国</option>
+ <option value="其他">其他</option>
+ </select>
</div>
{/* 添加分辨率下拉框 */}
@@ -637,7 +840,7 @@
<p className="resource-subtitle">字幕: {torrent.subtitle}</p>
</div>
<div className="resource-stats">
- <span className="stat">{torrent.size}</span>
+ <span className="stat">{torrent.size}</span>
<span className="stat">发布者: {torrent.username}</span>
</div>
<button
@@ -653,7 +856,7 @@
))}
</div>
- {/* 分页控件 */}
+ {totalPages > 1 && (
<div className="pagination">
<button
onClick={() => fetchTorrentPosts(currentPage - 1)}
@@ -679,12 +882,30 @@
下一页
</button>
</div>
+ )}
</div>
);
// 在Dashboard.jsx的renderContent函数中修改case 'request'部分
case 'request':
return (
<div className="content-area" data-testid="request-section">
+ {/* 求种区搜索框 */}
+ <div className="section-search-container">
+ <input
+ type="text"
+ placeholder="搜索求种..."
+ value={requestSearch}
+ onChange={(e) => setRequestSearch(e.target.value)}
+ className="section-search-input"
+ onKeyPress={(e) => e.key === 'Enter' && handleSearchRequest()}
+ />
+ <button
+ className="search-button"
+ onClick={handleSearchRequest}
+ >
+ 搜索
+ </button>
+ </div>
{/* 求种区帖子列表 */}
<div className="request-list">
{[
@@ -734,6 +955,31 @@
case 'help':
return (
<div className="content-area" data-testid="help-section">
+ {/* 求助区搜索框 */}
+ <div className="section-search-container">
+ <input
+ type="text"
+ placeholder="搜索求助..."
+ value={helpSearch}
+ onChange={(e) => setHelpSearch(e.target.value)}
+ className="section-search-input"
+ onKeyPress={(e) => e.key === 'Enter' && handleSearchHelp()}
+ />
+ <button
+ className="search-button"
+ onClick={handleSearchHelp}
+ >
+ 搜索
+ </button>
+ <button
+ className="reset-button"
+ onClick={handleResetHelpSearch}
+ style={{marginLeft: '10px'}}
+ >
+ 重置
+ </button>
+ </div>
+
{/* 新增发帖按钮 */}
<div className="post-header">
<button
@@ -891,14 +1137,9 @@
<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 className="platform-name">
+ <h2>PT资源站</h2>
</div>
<div className="user-actions">
diff --git a/src/components/Personal/Exchange.jsx b/src/components/Personal/Exchange.jsx
new file mode 100644
index 0000000..1e5b2e8
--- /dev/null
+++ b/src/components/Personal/Exchange.jsx
@@ -0,0 +1,175 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { generateInviteCode, getUserInviteCodes, exchangeUpload, getUserInfo } from '../../api/personal';
+import './personalSubpage.css';
+
+const Exchange = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [inviteCodes, setInviteCodes] = useState([]);
+ const [userInfo, setUserInfo] = useState(null);
+ const [magicPoints, setMagicPoints] = useState('0');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 获取用户信息和邀请码列表
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ const [userData, codes] = await Promise.all([
+ getUserInfo(),
+ getUserInviteCodes()
+ ]);
+ setUserInfo(userData);
+ setInviteCodes(codes);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchData();
+ }, []);
+
+ // 生成邀请码
+ const handleGenerateInviteCode = async () => {
+ try {
+ setLoading(true);
+ const newCode = await generateInviteCode();
+ setInviteCodes([...inviteCodes, newCode]);
+ // 刷新用户信息
+ const updatedUser = await getUserInfo();
+ setUserInfo(updatedUser);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleExchangeUpload = async () => {
+ const points = Number(magicPoints);
+ if (!points || points <= 0) {
+ setError('请输入有效的魔力值');
+ return;
+ }
+ try {
+ setLoading(true);
+ await exchangeUpload(points);
+ // 刷新用户信息
+ const updatedUser = await getUserInfo();
+ setUserInfo(updatedUser);
+ setMagicPoints('0');
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleBack = () => {
+ // 返回个人中心,并携带来源标记
+ navigate('/personal', {
+ state: {
+ fromSubpage: true, // 标记来自子页面
+ dashboardTab: location.state?.dashboardTab // 保留Dashboard的标签页状态
+ },
+ replace: true // 替换当前历史记录
+ });
+ };
+
+ if (loading) {
+ return <div className="subpage-container">加载中...</div>;
+ }
+
+ if (error) {
+ return (
+ <div className="subpage-container">
+ <button className="back-button" onClick={handleBack}>
+ ← 返回个人中心
+ </button>
+ <div className="error">错误: {error}</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="subpage-container">
+ <button className="back-button" onClick={handleBack}>
+ ← 返回个人中心
+ </button>
+
+ <h2 className="page-title">兑换区</h2>
+
+ <div className="exchange-section">
+ <h3>当前魔力值: {userInfo?.magicPoints || 0}</h3>
+
+ <div className="exchange-card">
+ <h4>兑换邀请码</h4>
+ <p>消耗10魔力值兑换一个邀请码</p>
+ <button
+ className="exchange-btn"
+ onClick={handleGenerateInviteCode}
+ disabled={!userInfo || userInfo.magicPoints < 10}
+ >
+ 兑换邀请码
+ </button>
+ </div>
+
+ <div className="exchange-card">
+ <h4>兑换上传量</h4>
+ <p>1魔力值 = 1GB上传量</p>
+ <div className="exchange-input-group">
+ <input
+ type="number"
+ value={magicPoints}
+ onChange={(e) => {
+ // 允许空字符串或有效数字
+ const value = e.target.value;
+ if (value === '' || !isNaN(value)) {
+ setMagicPoints(value);
+ }
+ }}
+ min="1"
+ max={userInfo?.magicPoints || 0}
+ placeholder="输入要兑换的魔力值"
+ />
+
+ <button
+ className="exchange-btn"
+ onClick={handleExchangeUpload}
+ disabled={
+ !magicPoints ||
+ Number(magicPoints) <= 0 ||
+ !userInfo ||
+ Number(magicPoints) > userInfo.magicPoints
+ }
+ >
+ 兑换上传量
+ </button>
+ </div>
+ </div>
+
+ {inviteCodes.length > 0 && (
+ <div className="invite-code-list">
+ <h4>我的邀请码</h4>
+ <ul>
+ {inviteCodes.map((code, index) => (
+ <li key={index}>
+ <span className="code">{code.code}</span>
+ <span className={`status ${code.isUsed ? 'used' : 'available'}`}>
+ {code.isUsed ? '已使用' : '可用'}
+ </span>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default Exchange;
\ No newline at end of file
diff --git a/src/components/Personal/Exchange.test.jsx b/src/components/Personal/Exchange.test.jsx
new file mode 100644
index 0000000..20fe641
--- /dev/null
+++ b/src/components/Personal/Exchange.test.jsx
@@ -0,0 +1,196 @@
+import React from 'react';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { MemoryRouter, useNavigate, useLocation } from 'react-router-dom';
+import Exchange from './Exchange';
+import {
+ generateInviteCode,
+ getUserInviteCodes,
+ exchangeUpload,
+ getUserInfo
+} from '../../api/personal';
+
+// Mock API 调用
+jest.mock('../../api/personal', () => ({
+ generateInviteCode: jest.fn(),
+ getUserInviteCodes: jest.fn(),
+ exchangeUpload: jest.fn(),
+ getUserInfo: jest.fn()
+}));
+
+// Mock react-router-dom hooks
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: jest.fn(),
+ useLocation: jest.fn()
+}));
+
+describe('Exchange Component', () => {
+ const mockNavigate = jest.fn();
+ const mockLocation = {
+ pathname: '/personal/exchange',
+ state: { dashboardTab: 'exchange' }
+ };
+
+ const mockUserInfo = {
+ magicPoints: 100,
+ username: 'testuser'
+ };
+
+ const mockInviteCodes = [
+ { code: 'ABCD-1234', isUsed: false },
+ { code: 'EFGH-5678', isUsed: true }
+ ];
+
+ beforeEach(() => {
+ useNavigate.mockReturnValue(mockNavigate);
+ useLocation.mockReturnValue(mockLocation);
+ jest.clearAllMocks();
+
+ // 设置默认 mock 返回值
+ getUserInfo.mockResolvedValue(mockUserInfo);
+ getUserInviteCodes.mockResolvedValue(mockInviteCodes);
+ generateInviteCode.mockResolvedValue({ code: 'NEW-CODE', isUsed: false });
+ exchangeUpload.mockResolvedValue({ success: true });
+ });
+
+ it('应该正确加载并显示用户信息和邀请码', async () => {
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ // 初始加载状态
+ expect(screen.getByText('加载中...')).toBeInTheDocument();
+
+ // 等待数据加载完成
+ await waitFor(() => {
+ expect(screen.getByText('兑换区')).toBeInTheDocument();
+ expect(screen.getByText('当前魔力值: 100')).toBeInTheDocument();
+ expect(screen.getByText('ABCD-1234')).toBeInTheDocument();
+ expect(screen.getByText('EFGH-5678')).toBeInTheDocument();
+ expect(screen.getByText('可用')).toBeInTheDocument();
+ expect(screen.getByText('已使用')).toBeInTheDocument();
+ });
+ });
+
+ it('应该处理生成邀请码操作', async () => {
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ // 使用更精确的选择器定位按钮
+ const generateButtons = screen.getAllByRole('button', { name: '兑换邀请码' });
+ // 选择第一个按钮(或根据实际情况选择正确的按钮)
+ fireEvent.click(generateButtons[0]);
+ });
+
+ expect(generateInviteCode).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(getUserInfo).toHaveBeenCalledTimes(2); // 初始加载 + 生成后刷新
+ });
+ });
+
+ it('应该处理兑换上传量操作', async () => {
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const input = screen.getByPlaceholderText('输入要兑换的魔力值');
+ const exchangeButton = screen.getByRole('button', { name: '兑换上传量' });
+
+ // 输入有效值
+ fireEvent.change(input, { target: { value: '50' } });
+ fireEvent.click(exchangeButton);
+ });
+
+ expect(exchangeUpload).toHaveBeenCalledWith(50);
+ await waitFor(() => {
+ expect(getUserInfo).toHaveBeenCalledTimes(2); // 初始加载 + 兑换后刷新
+ });
+ });
+
+
+ it('应该处理返回按钮点击', async () => {
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const backButton = screen.getByText(/← 返回个人中心/);
+ fireEvent.click(backButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/personal', {
+ state: {
+ fromSubpage: true,
+ dashboardTab: 'exchange'
+ },
+ replace: true
+ });
+ });
+ });
+
+ it('应该显示错误信息当API调用失败', async () => {
+ getUserInfo.mockRejectedValueOnce(new Error('获取用户信息失败'));
+
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('错误: 获取用户信息失败')).toBeInTheDocument();
+ });
+ });
+
+ it('应该禁用兑换按钮当魔力值不足', async () => {
+ getUserInfo.mockResolvedValueOnce({ magicPoints: 5 }); // 设置魔力值不足
+
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const inviteButtons = screen.getAllByRole('button', { name: '兑换邀请码' });
+ expect(inviteButtons[0]).toBeDisabled();
+ });
+ });
+
+ it('应该正确处理空邀请码列表', async () => {
+ getUserInviteCodes.mockResolvedValueOnce([]);
+
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText('我的邀请码')).not.toBeInTheDocument();
+ });
+ });
+
+ it('应该显示加载状态', async () => {
+ // 延迟API响应以测试加载状态
+ getUserInfo.mockImplementation(() => new Promise(() => {}));
+
+ render(
+ <MemoryRouter>
+ <Exchange />
+ </MemoryRouter>
+ );
+
+ expect(screen.getByText('加载中...')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/components/Personal/Favorite.jsx b/src/components/Personal/Favorite.jsx
deleted file mode 100644
index 97aa1e6..0000000
--- a/src/components/Personal/Favorite.jsx
+++ /dev/null
@@ -1,47 +0,0 @@
-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/Personal.css b/src/components/Personal/Personal.css
index c087ac6..28a1adb 100644
--- a/src/components/Personal/Personal.css
+++ b/src/components/Personal/Personal.css
@@ -159,4 +159,63 @@
margin-top: 20px;
border-top: 1px solid #f0f0f0;
padding-top: 20px;
- }
\ No newline at end of file
+ }
+
+
+ /* Personal.css */
+/* ... 其他已有样式 ... */
+
+/* 下载进度卡片样式 */
+.progress-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.download-task {
+ margin-bottom: 15px;
+}
+
+.task-info {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 5px;
+}
+
+.task-id {
+ font-size: 14px;
+ color: #666;
+}
+
+.task-progress {
+ font-size: 14px;
+ font-weight: bold;
+ color: #1890ff;
+}
+
+.progress-bar {
+ height: 8px;
+ background: #f0f0f0;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+ background: #1890ff;
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+
+.user-meta span {
+ margin-right: 15px;
+ color: #666;
+}
+
+.user-meta span:last-child {
+ color: #ff9800;
+ font-weight: bold;
+}
diff --git a/src/components/Personal/Personal.jsx b/src/components/Personal/Personal.jsx
index 033952d..8c60baf 100644
--- a/src/components/Personal/Personal.jsx
+++ b/src/components/Personal/Personal.jsx
@@ -1,34 +1,91 @@
-import React from 'react';
-import { useNavigate,useLocation, Outlet } from 'react-router-dom';
+// Personal.jsx
+import React, { useState, useEffect } from 'react';
+import { useNavigate, useLocation, Outlet } from 'react-router-dom';
+import { getUserInfo, getDownloadQuota, getDownloadProgress } from '../../api/personal';
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 location = useLocation();
+ const [userData, setUserData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [downloadProgress, setDownloadProgress] = useState({});
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ // 并行获取用户信息和下载额度
+ const [userInfo, downloadQuota] = await Promise.all([
+ getUserInfo(),
+ getDownloadQuota()
+ ]);
+
+ setUserData({
+ username: userInfo.username,
+ avatar: 'https://via.placeholder.com/150',
+ joinDate: userInfo.registTime,
+ level: userInfo.level, // 可以根据userInfo.level设置不同等级
+ points: userInfo.magicPoints,
+ upload: userInfo.upload,
+ download: userInfo.download,
+ ratio: userInfo.shareRate,
+ downloadQuota: {
+ total: downloadQuota.total,
+ used: downloadQuota.used,
+ remaining: downloadQuota.remaining
+ }
+ });
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ // 获取下载进度数据
+ const fetchDownloadProgress = async () => {
+ try {
+ const progressData = await getDownloadProgress();
+ setDownloadProgress(progressData);
+ } catch (err) {
+ console.error('获取下载进度失败:', err);
}
};
+ useEffect(() => {
+ // 初始获取下载进度
+ fetchDownloadProgress();
+
+ // 设置定时器,每10秒获取一次下载进度
+ const intervalId = setInterval(fetchDownloadProgress, 10000);
+
+ // 组件卸载时清除定时器
+ return () => clearInterval(intervalId);
+ }, []);
+
+
+ if (loading) {
+ return <div className="loading">加载中...</div>;
+ }
+
+ if (error) {
+ return <div className="error">错误: {error}</div>;
+ }
+
+ if (!userData) {
+ return <div className="error">未获取到用户数据</div>;
+ }
+
const features = [
{
- title: '我的收藏',
- description: '查看收藏的资源',
- path: '/personal/Favorite' // 相对路径
+ title: '兑换区',
+ description: '下载量/邀请码',
+ path: '/personal/Exchange' // 相对路径
},
{
title: '上传记录',
@@ -63,6 +120,23 @@
}
};
+ const formatSize = (bytes) => {
+ if (bytes < 1024) return `${bytes} B`;
+ const kb = bytes / 1024;
+ if (kb < 1024) return `${kb.toFixed(2)} KB`;
+ const mb = kb / 1024;
+ if (mb < 1024) return `${mb.toFixed(2)} MB`;
+ const gb = mb / 1024;
+ return `${gb.toFixed(2)} GB`;
+ };
+
+ // 添加进度条颜色函数
+ const getProgressColor = (percentage) => {
+ if (percentage < 0.3) return '#4CAF50'; // 绿色
+ if (percentage < 0.7) return '#FFC107'; // 黄色
+ return '#F44336'; // 红色
+ };
+
return (
<div className="personal-container">
{/* 返回按钮 */}
@@ -82,7 +156,7 @@
<h2 className="username">{userData.username}</h2>
<div className="user-meta">
<span>加入时间: {userData.joinDate}</span>
- <span>等级: {userData.level}</span>
+ <span>会员等级: Lv.{userData.level}</span>
</div>
</div>
</div>
@@ -90,16 +164,16 @@
{/* 用户数据统计 */}
<div className="stats-grid">
<div className="stat-item">
- <div className="stat-label">积分</div>
+ <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 className="stat-value">{formatSize(userData.upload)}</div>
</div>
<div className="stat-item">
<div className="stat-label">下载量</div>
- <div className="stat-value">{userData.download}</div>
+ <div className="stat-value">{formatSize(userData.download)}</div>
</div>
<div className="stat-item">
<div className="stat-label">分享率</div>
@@ -112,17 +186,46 @@
<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>
+ <span className="quota-used">
+ {formatSize(userData.downloadQuota.used)} 已使用
+ </span>
+ <span className="quota-remaining">
+ {formatSize(userData.downloadQuota.remaining)} 剩余
+ </span>
</div>
<div className="progress-bar">
<div
className="progress-fill"
- style={{ width: `${(userData.downloadQuota.used / userData.downloadQuota.total) * 100}%` }}
+ style={{
+ width: `${(userData.downloadQuota.used / userData.downloadQuota.total) * 100}%`,
+ backgroundColor: getProgressColor(userData.downloadQuota.used / userData.downloadQuota.total)
+ }}
></div>
</div>
- <div className="quota-total">总额度: {userData.downloadQuota.total}GB</div>
+ <div className="quota-total">
+ 总额度: {formatSize(userData.downloadQuota.total)}
+ </div>
</div>
+
+ {Object.keys(downloadProgress).length > 0 && (
+ <div className="progress-card">
+ <h3>当前下载进度</h3>
+ {Object.entries(downloadProgress).map(([taskId, progress]) => (
+ <div key={taskId} className="download-task">
+ <div className="task-info">
+ <span className="task-id">任务: {taskId.substring(0, 8)}...</span>
+ <span className="task-progress">{Math.round(progress * 100)}%</span>
+ </div>
+ <div className="progress-bar">
+ <div
+ className="progress-fill"
+ style={{ width: `${progress * 100}%` }}
+ ></div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
{/* 功能卡片区 */}
<div className="action-cards">
diff --git a/src/components/Personal/Personal.test.jsx b/src/components/Personal/Personal.test.jsx
new file mode 100644
index 0000000..ef6bad8
--- /dev/null
+++ b/src/components/Personal/Personal.test.jsx
@@ -0,0 +1,209 @@
+// Personal.test.jsx
+import React from 'react';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
+import Personal from './Personal';
+import { getUserInfo, getDownloadQuota, getDownloadProgress } from '../../api/personal';
+
+// Mock API 调用
+jest.mock('../../api/personal', () => ({
+ getUserInfo: jest.fn(),
+ getDownloadQuota: jest.fn(),
+ getDownloadProgress: jest.fn()
+}));
+
+// Mock react-router-dom hooks
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: jest.fn(),
+ useLocation: jest.fn()
+}));
+
+describe('Personal Component', () => {
+ const mockNavigate = jest.fn();
+ const mockLocation = {
+ pathname: '/personal',
+ state: null
+ };
+
+ beforeEach(() => {
+ useNavigate.mockReturnValue(mockNavigate);
+ useLocation.mockReturnValue(mockLocation);
+
+ // 重置所有 mock
+ jest.clearAllMocks();
+
+ // 设置默认 mock 返回值
+ getUserInfo.mockResolvedValue({
+ username: 'testuser',
+ registTime: '2023-01-01',
+ level: 2,
+ magicPoints: 1000,
+ upload: 1024 * 1024 * 5, // 5MB
+ download: 1024 * 1024 * 2, // 2MB
+ shareRate: 2.5
+ });
+
+ getDownloadQuota.mockResolvedValue({
+ total: 1024 * 1024 * 10, // 10MB
+ used: 1024 * 1024 * 3, // 3MB
+ remaining: 1024 * 1024 * 7 // 7MB
+ });
+
+ getDownloadProgress.mockResolvedValue({
+ 'task1': 0.25,
+ 'task2': 0.75
+ });
+ });
+
+ it('应该正确加载并显示用户数据', async () => {
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ // 初始加载状态
+ expect(screen.getByText('加载中...')).toBeInTheDocument();
+
+ // 等待数据加载完成
+ await waitFor(() => {
+ expect(screen.getByText('testuser')).toBeInTheDocument();
+ expect(screen.getByText(/加入时间: 2023-01-01/)).toBeInTheDocument();
+ expect(screen.getByText(/会员等级: Lv.2/)).toBeInTheDocument();
+ expect(screen.getByText('1000')).toBeInTheDocument(); // 保种积分
+ expect(screen.getByText('5.00 MB')).toBeInTheDocument(); // 上传量
+ expect(screen.getByText('2.00 MB')).toBeInTheDocument(); // 下载量
+ expect(screen.getByText('2.5')).toBeInTheDocument(); // 分享率
+ });
+ });
+
+ it('应该显示下载额度信息', async () => {
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/3.00 MB 已使用/)).toBeInTheDocument();
+ expect(screen.getByText(/7.00 MB 剩余/)).toBeInTheDocument();
+ expect(screen.getByText(/总额度: 10.00 MB/)).toBeInTheDocument();
+ });
+ });
+
+ it('应该显示下载进度', async () => {
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('当前下载进度')).toBeInTheDocument();
+ expect(screen.getByText(/任务: task1/)).toBeInTheDocument();
+ expect(screen.getByText('25%')).toBeInTheDocument();
+ expect(screen.getByText(/任务: task2/)).toBeInTheDocument();
+ expect(screen.getByText('75%')).toBeInTheDocument();
+ });
+ });
+
+ it('应该显示功能卡片并处理点击', async () => {
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const exchangeCard = screen.getByText('兑换区');
+ expect(exchangeCard).toBeInTheDocument();
+
+ // 模拟点击功能卡片
+ act(() => {
+ exchangeCard.closest('.action-card').click();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith('/personal/Exchange');
+ });
+ });
+
+ it('应该处理返回按钮点击', async () => {
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const backButton = screen.getByText(/返回/);
+ act(() => {
+ backButton.click();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(-1);
+ });
+ });
+
+ it('应该处理从子页面返回的情况', async () => {
+ useLocation.mockReturnValue({
+ pathname: '/personal',
+ state: { fromSubpage: true, dashboardTab: 'uploads' }
+ });
+
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const backButton = screen.getByText(/返回/);
+ act(() => {
+ backButton.click();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith('/dashboard/uploads', { replace: true });
+ });
+ });
+
+ it('应该显示错误信息当API调用失败', async () => {
+ getUserInfo.mockRejectedValue(new Error('获取用户信息失败'));
+
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/错误: 获取用户信息失败/)).toBeInTheDocument();
+ });
+ });
+
+
+ it('应该定期更新下载进度', async () => {
+ jest.useFakeTimers();
+
+ render(
+ <MemoryRouter>
+ <Personal />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(getDownloadProgress).toHaveBeenCalledTimes(1);
+ });
+
+ // 快进时间
+ act(() => {
+ jest.advanceTimersByTime(10000);
+ });
+
+ await waitFor(() => {
+ expect(getDownloadProgress).toHaveBeenCalledTimes(2);
+ });
+
+ jest.useRealTimers();
+ });
+});
\ No newline at end of file
diff --git a/src/components/Personal/Setting.jsx b/src/components/Personal/Setting.jsx
index 967be6c..9ba07e8 100644
--- a/src/components/Personal/Setting.jsx
+++ b/src/components/Personal/Setting.jsx
@@ -1,97 +1,161 @@
-import React, { useState } from 'react';
-import { useNavigate,useLocation } from 'react-router-dom';
+import React, { useState,useEffect } from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { getUserInfo, updatePassword } from '../../api/personal';
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 [userInfo, setUserInfo] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [passwordForm, setPasswordForm] = useState({
+ oldPassword: '',
+ newPassword: '',
+ confirmPassword: ''
});
- const handleChange = (e) => {
- const { name, value, type, checked } = e.target;
- setFormData(prev => ({
+ // 获取用户信息
+ useEffect(() => {
+ const fetchUserInfo = async () => {
+ try {
+ const info = await getUserInfo();
+ setUserInfo(info);
+ } catch (err) {
+ console.error('获取用户信息失败:', err);
+ }
+ };
+ fetchUserInfo();
+ }, []);
+
+ const handleBack = () => {
+ navigate('/personal', {
+ state: {
+ fromSubpage: true,
+ dashboardTab: location.state?.dashboardTab
+ },
+ replace: true
+ });
+ };
+
+ const handlePasswordChange = (e) => {
+ const { name, value } = e.target;
+ setPasswordForm(prev => ({
...prev,
- [name]: type === 'checkbox' ? checked : value
+ [name]: value
}));
};
- const handleSubmit = (e) => {
+ const handlePasswordSubmit = async (e) => {
e.preventDefault();
- alert('设置已保存');
- };
+ setError(null);
+ setSuccess(null);
- const handleBack = () => {
- // 返回个人中心,并携带来源标记
- navigate('/personal', {
- state: {
- fromSubpage: true, // 标记来自子页面
- dashboardTab: location.state?.dashboardTab // 保留Dashboard的标签页状态
- },
- replace: true // 替换当前历史记录
- });
+ // 验证表单
+ if (!passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
+ setError('请填写所有密码字段');
+ return;
+ }
+
+ if (passwordForm.newPassword !== passwordForm.confirmPassword) {
+ setError('新密码与确认密码不一致');
+ return;
+ }
+
+ if (passwordForm.newPassword.length < 6) {
+ setError('新密码长度至少为6位');
+ return;
+ }
+
+ try {
+ setLoading(true);
+ await updatePassword(passwordForm.oldPassword, passwordForm.newPassword);
+ setSuccess('密码修改成功');
+ setPasswordForm({
+ oldPassword: '',
+ newPassword: '',
+ confirmPassword: ''
+ });
+ } catch (err) {
+ setError(err.message || '修改密码失败');
+ } finally {
+ setLoading(false);
+ }
};
return (
<div className="subpage-container">
- <button className="back-button" onClick={(handleBack)}>
+ <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"
- />
+ <h2 className="page-title">个人设置</h2>
+
+ <div className="setting-section">
+ <div className="user-info-card">
+ <h3>账户信息</h3>
+ <div className="info-item">
+ <label>用户名:</label>
+ <span>{userInfo?.username || '加载中...'}</span>
+ </div>
+ <p className="info-note">用户名不可更改</p>
</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="password-form-card">
+ <h3>修改密码</h3>
+ <form onSubmit={handlePasswordSubmit}>
+ <div className="form-group">
+ <label htmlFor="oldPassword">原密码:</label>
+ <input
+ type="password"
+ id="oldPassword"
+ name="oldPassword"
+ value={passwordForm.oldPassword}
+ onChange={handlePasswordChange}
+ required
+ />
+ </div>
- <div className="form-group">
- <label className="form-label">
- <input
- type="checkbox"
- name="notification"
- checked={formData.notification}
- onChange={handleChange}
- />
- 接收邮件通知
- </label>
- </div>
+ <div className="form-group">
+ <label htmlFor="newPassword">新密码:</label>
+ <input
+ type="password"
+ id="newPassword"
+ name="newPassword"
+ value={passwordForm.newPassword}
+ onChange={handlePasswordChange}
+ required
+ minLength="6"
+ />
+ </div>
- <div className="form-actions">
- <button type="submit" className="action-btn">
- 保存设置
- </button>
- <button
- type="button"
- className="action-btn danger-btn"
- onClick={onLogout}
- >
- 退出登录
- </button>
+ <div className="form-group">
+ <label htmlFor="confirmPassword">确认新密码:</label>
+ <input
+ type="password"
+ id="confirmPassword"
+ name="confirmPassword"
+ value={passwordForm.confirmPassword}
+ onChange={handlePasswordChange}
+ required
+ minLength="6"
+ />
+ </div>
+
+ {error && <div className="error-message">{error}</div>}
+ {success && <div className="success-message">{success}</div>}
+
+ <button
+ type="submit"
+ className="submit-button"
+ disabled={loading}
+ >
+ {loading ? '处理中...' : '修改密码'}
+ </button>
+ </form>
</div>
- </form>
+ </div>
</div>
);
};
diff --git a/src/components/Personal/Setting.test.jsx b/src/components/Personal/Setting.test.jsx
new file mode 100644
index 0000000..e8b1cc0
--- /dev/null
+++ b/src/components/Personal/Setting.test.jsx
@@ -0,0 +1,195 @@
+// Setting.test.jsx
+import React from 'react';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { MemoryRouter, useNavigate, useLocation } from 'react-router-dom';
+import Setting from './Setting';
+import { getUserInfo, updatePassword } from '../../api/personal';
+
+// Mock API 调用
+jest.mock('../../api/personal', () => ({
+ getUserInfo: jest.fn(),
+ updatePassword: jest.fn()
+}));
+
+// Mock react-router-dom hooks
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: jest.fn(),
+ useLocation: jest.fn()
+}));
+
+describe('Setting Component', () => {
+ const mockNavigate = jest.fn();
+ const mockLocation = {
+ pathname: '/personal/setting',
+ state: { dashboardTab: 'settings' }
+ };
+
+ beforeEach(() => {
+ useNavigate.mockReturnValue(mockNavigate);
+ useLocation.mockReturnValue(mockLocation);
+
+ // 重置所有 mock
+ jest.clearAllMocks();
+
+ // 设置默认 mock 返回值
+ getUserInfo.mockResolvedValue({
+ username: 'testuser',
+ email: 'test@example.com'
+ });
+
+ updatePassword.mockResolvedValue({ success: true });
+ });
+
+ it('应该正确加载并显示用户信息', async () => {
+ render(
+ <MemoryRouter>
+ <Setting />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('个人设置')).toBeInTheDocument();
+ expect(screen.getByText('账户信息')).toBeInTheDocument();
+ expect(screen.getByText('用户名:')).toBeInTheDocument();
+ expect(screen.getByText('testuser')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: '修改密码' })).toBeInTheDocument();
+ });
+ });
+
+ it('应该处理密码修改表单提交', async () => {
+ render(
+ <MemoryRouter>
+ <Setting />
+ </MemoryRouter>
+ );
+
+ // 填写表单
+ fireEvent.change(screen.getByLabelText('原密码:'), {
+ target: { value: 'oldpassword123' }
+ });
+ fireEvent.change(screen.getByLabelText('新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+ fireEvent.change(screen.getByLabelText('确认新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+
+ // 提交表单
+ fireEvent.click(screen.getByRole('button', { name: '修改密码' }));
+
+ await waitFor(() => {
+ expect(updatePassword).toHaveBeenCalledWith('oldpassword123', 'newpassword123');
+ expect(screen.getByText('密码修改成功')).toBeInTheDocument();
+ });
+ });
+
+
+ it('应该处理API错误', async () => {
+ updatePassword.mockRejectedValue(new Error('原密码不正确'));
+
+ render(
+ <MemoryRouter>
+ <Setting />
+ </MemoryRouter>
+ );
+
+ // 填写表单
+ fireEvent.change(screen.getByLabelText('原密码:'), {
+ target: { value: 'wrongpassword' }
+ });
+ fireEvent.change(screen.getByLabelText('新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+ fireEvent.change(screen.getByLabelText('确认新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+
+ // 提交表单
+ fireEvent.click(screen.getByRole('button', { name: '修改密码' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('原密码不正确')).toBeInTheDocument();
+ });
+ });
+
+ it('应该显示加载状态', async () => {
+ // 延迟API响应以测试加载状态
+ updatePassword.mockImplementation(() => new Promise(() => {}));
+
+ render(
+ <MemoryRouter>
+ <Setting />
+ </MemoryRouter>
+ );
+
+ // 填写表单
+ fireEvent.change(screen.getByLabelText('原密码:'), {
+ target: { value: 'oldpassword123' }
+ });
+ fireEvent.change(screen.getByLabelText('新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+ fireEvent.change(screen.getByLabelText('确认新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+
+ // 提交表单
+ fireEvent.click(screen.getByRole('button', { name: '修改密码' }));
+
+ // 检查加载状态
+ expect(screen.getByText('处理中...')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '处理中...' })).toBeDisabled();
+ });
+
+ it('应该处理返回按钮点击', async () => {
+ render(
+ <MemoryRouter>
+ <Setting />
+ </MemoryRouter>
+ );
+
+ const backButton = screen.getByText(/← 返回个人中心/);
+ fireEvent.click(backButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/personal', {
+ state: {
+ fromSubpage: true,
+ dashboardTab: 'settings'
+ },
+ replace: true
+ });
+ });
+
+ it('应该清空表单并显示成功消息', async () => {
+ render(
+ <MemoryRouter>
+ <Setting />
+ </MemoryRouter>
+ );
+
+ // 填写表单
+ fireEvent.change(screen.getByLabelText('原密码:'), {
+ target: { value: 'oldpassword123' }
+ });
+ fireEvent.change(screen.getByLabelText('新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+ fireEvent.change(screen.getByLabelText('确认新密码:'), {
+ target: { value: 'newpassword123' }
+ });
+
+ // 提交表单
+ fireEvent.click(screen.getByRole('button', { name: '修改密码' }));
+
+ await waitFor(() => {
+ // 检查表单是否清空
+ expect(screen.getByLabelText('原密码:')).toHaveValue('');
+ expect(screen.getByLabelText('新密码:')).toHaveValue('');
+ expect(screen.getByLabelText('确认新密码:')).toHaveValue('');
+
+ // 检查成功消息
+ expect(screen.getByText('密码修改成功')).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/Personal/Upload.jsx b/src/components/Personal/Upload.jsx
index 4d6e934..2d91b30 100644
--- a/src/components/Personal/Upload.jsx
+++ b/src/components/Personal/Upload.jsx
@@ -1,28 +1,117 @@
-import React from 'react';
-import { useNavigate,useLocation } from 'react-router-dom';
+import React, { useState, useEffect } from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { getUserTorrents, deleteTorrent } from '../../api/personal';
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 [torrents, setTorrents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [pagination, setPagination] = useState({
+ page: 1,
+ size: 5,
+ total: 0
+ });
+
+
+
+ // 格式化日期
+ const formatDate = (dateString) => {
+ const date = new Date(dateString);
+ return date.toLocaleString();
+ };
+
+ // 获取上传记录
+ const fetchTorrents = async () => {
+ try {
+ setLoading(true);
+ const { records, total } = await getUserTorrents(pagination.page, pagination.size);
+ setTorrents(records);
+ setPagination(prev => ({ ...prev, total }));
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+
+
+ // 删除种子
+ const handleDelete = async (id) => {
+ if (window.confirm('确定要删除这个种子吗?此操作不可撤销!')) {
+ try {
+ await deleteTorrent(id);
+ // 删除成功后刷新列表
+ fetchTorrents();
+ } catch (err) {
+ alert('删除失败: ' + err.message);
+ }
+ }
+ };
+
+ // 计算总页数
+ const totalPages = Math.ceil(pagination.total / pagination.size);
+
+ // 生成页码数组
+ const getPageNumbers = () => {
+ const pages = [];
+ const maxVisiblePages = 5; // 最多显示5个页码
+
+ let startPage = Math.max(1, pagination.page - Math.floor(maxVisiblePages / 2));
+ let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
+
+ // 调整起始页码,确保始终显示 maxVisiblePages 个页码(如果总页数足够)
+ if (endPage - startPage + 1 < maxVisiblePages) {
+ startPage = Math.max(1, endPage - maxVisiblePages + 1);
+ }
+
+ for (let i = startPage; i <= endPage; i++) {
+ pages.push(i);
+ }
+
+ return pages;
+ };
+
+ // 直接跳转到指定页码
+ const jumpToPage = (page) => {
+ if (page >= 1 && page <= totalPages && page !== pagination.page) {
+ setPagination(prev => ({ ...prev, page }));
+ }
+ };
+
+ // 分页改变
+ const handlePageChange = (newPage) => {
+ setPagination(prev => ({ ...prev, page: newPage }));
+ };
+
+ useEffect(() => {
+ fetchTorrents();
+ }, [pagination.page]);
const handleBack = () => {
- // 返回个人中心,并携带来源标记
navigate('/personal', {
state: {
- fromSubpage: true, // 标记来自子页面
- dashboardTab: location.state?.dashboardTab // 保留Dashboard的标签页状态
+ fromSubpage: true,
+ dashboardTab: location.state?.dashboardTab
},
- replace: true // 替换当前历史记录
+ replace: true
});
};
+
+ if (loading) {
+ return <div className="subpage-container">加载中...</div>;
+ }
+
+ if (error) {
+ return <div className="subpage-container">错误: {error}</div>;
+ }
+
return (
<div className="subpage-container">
- <button className="back-button" onClick={(handleBack)}>
+ <button className="back-button" onClick={handleBack}>
← 返回个人中心
</button>
@@ -33,31 +122,99 @@
<tr>
<th>资源名称</th>
<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>
+ {torrents.map(torrent => (
+ <tr key={torrent.id} className="list-item">
+ <td>{torrent.torrentName}</td>
+ <td>{torrent.formattedSize}</td>
+ <td>{formatDate(torrent.createTime)}</td>
+ <td>{torrent.downloadCount || 0}</td>
<td>
- <span className={`status-badge ${
- item.status === '已发布' ? 'published' : 'pending'
- }`}>
- {item.status}
- </span>
- </td>
- <td>{item.date}</td>
- <td>
- <button className="action-btn">详情</button>
+ <button
+ className="action-btn delete-btn"
+ onClick={() => handleDelete(torrent.id)}
+ >
+ 删除
+ </button>
</td>
</tr>
))}
</tbody>
</table>
+
+ {/* 修改后的分页控件 */}
+ <div className="pagination">
+ <button
+ disabled={pagination.page <= 1}
+ onClick={() => jumpToPage(1)}
+ className="page-nav"
+ >
+ 首页
+ </button>
+ <button
+ disabled={pagination.page <= 1}
+ onClick={() => jumpToPage(pagination.page - 1)}
+ className="page-nav"
+ >
+ 上一页
+ </button>
+
+ {/* 显示页码 */}
+ {getPageNumbers().map(number => (
+ <button
+ key={number}
+ onClick={() => jumpToPage(number)}
+ className={`page-number ${pagination.page === number ? 'active' : ''}`}
+ >
+ {number}
+ </button>
+ ))}
+
+ {/* 显示省略号(如果有更多页) */}
+ {totalPages > getPageNumbers()[getPageNumbers().length - 1] && (
+ <span className="ellipsis">...</span>
+ )}
+
+ <button
+ disabled={pagination.page >= totalPages}
+ onClick={() => jumpToPage(pagination.page + 1)}
+ className="page-nav"
+ >
+ 下一页
+ </button>
+ <button
+ disabled={pagination.page >= totalPages}
+ onClick={() => jumpToPage(totalPages)}
+ className="page-nav"
+ >
+ 末页
+ </button>
+
+ <div className="page-info">
+ <span>共 {totalPages} 页</span>
+ <span>跳至</span>
+ <input
+ type="number"
+ min="1"
+ max={totalPages}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ const page = parseInt(e.target.value);
+ if (!isNaN(page)) {
+ jumpToPage(Math.max(1, Math.min(page, totalPages)));
+ e.target.value = '';
+ }
+ }
+ }}
+ />
+ <span>页</span>
+ </div>
+ </div>
</div>
);
};
diff --git a/src/components/Personal/Upload.test.jsx b/src/components/Personal/Upload.test.jsx
new file mode 100644
index 0000000..9c72182
--- /dev/null
+++ b/src/components/Personal/Upload.test.jsx
@@ -0,0 +1,206 @@
+// Upload.test.jsx
+import React from 'react';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { MemoryRouter, useNavigate, useLocation } from 'react-router-dom';
+import Upload from './Upload';
+import { getUserTorrents, deleteTorrent } from '../../api/personal';
+
+// Mock API 调用
+jest.mock('../../api/personal', () => ({
+ getUserTorrents: jest.fn(),
+ deleteTorrent: jest.fn()
+}));
+
+// Mock react-router-dom hooks
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: jest.fn(),
+ useLocation: jest.fn()
+}));
+
+// Mock window.confirm
+global.confirm = jest.fn(() => true);
+
+describe('Upload Component', () => {
+ const mockNavigate = jest.fn();
+ const mockLocation = {
+ pathname: '/personal/upload',
+ state: { dashboardTab: 'uploads' }
+ };
+
+ const mockTorrents = [
+ {
+ id: 1,
+ torrentName: 'Test Torrent 1',
+ formattedSize: '1.2 GB',
+ createTime: '2023-01-01T12:00:00Z',
+ downloadCount: 10
+ },
+ {
+ id: 2,
+ torrentName: 'Test Torrent 2',
+ formattedSize: '2.5 GB',
+ createTime: '2023-01-02T12:00:00Z',
+ downloadCount: 5
+ }
+ ];
+
+ beforeEach(() => {
+ useNavigate.mockReturnValue(mockNavigate);
+ useLocation.mockReturnValue(mockLocation);
+ jest.clearAllMocks();
+ getUserTorrents.mockResolvedValue({
+ records: mockTorrents,
+ total: 10
+ });
+ deleteTorrent.mockResolvedValue({ success: true });
+ });
+
+ it('应该正确加载并显示上传记录', async () => {
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ // 初始加载状态
+ expect(screen.getByText('加载中...')).toBeInTheDocument();
+
+ // 等待数据加载完成
+ await waitFor(() => {
+ expect(screen.getByText('上传记录')).toBeInTheDocument();
+ expect(screen.getByText('Test Torrent 1')).toBeInTheDocument();
+ expect(screen.getByText('Test Torrent 2')).toBeInTheDocument();
+ expect(screen.getByText('1.2 GB')).toBeInTheDocument();
+ expect(screen.getByText('2.5 GB')).toBeInTheDocument();
+ expect(screen.getAllByText('删除')).toHaveLength(2);
+ });
+ });
+
+ it('应该处理删除操作', async () => {
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const deleteButtons = screen.getAllByText('删除');
+ fireEvent.click(deleteButtons[0]);
+ });
+
+ expect(global.confirm).toHaveBeenCalledWith('确定要删除这个种子吗?此操作不可撤销!');
+ await waitFor(() => {
+ expect(deleteTorrent).toHaveBeenCalledWith(1);
+ expect(getUserTorrents).toHaveBeenCalledTimes(2); // 初始加载 + 删除后刷新
+ });
+ });
+
+ it('应该处理分页变化', async () => {
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const nextPageButton = screen.getByText('下一页');
+ fireEvent.click(nextPageButton);
+ });
+
+ await waitFor(() => {
+ expect(getUserTorrents).toHaveBeenLastCalledWith(2, 5);
+ });
+ });
+
+ it('应该处理直接跳转页码', async () => {
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const pageInput = screen.getByRole('spinbutton');
+ fireEvent.change(pageInput, { target: { value: '2' } });
+ fireEvent.keyDown(pageInput, { key: 'Enter' });
+ });
+
+ await waitFor(() => {
+ expect(getUserTorrents).toHaveBeenLastCalledWith(2, 5);
+ }, { timeout: 1000 });
+ });
+
+ it('应该处理返回按钮点击', async () => {
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const backButton = screen.getByText((content) =>
+ content.includes('返回个人中心')
+ );
+ fireEvent.click(backButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/personal', {
+ state: {
+ fromSubpage: true,
+ dashboardTab: 'uploads'
+ },
+ replace: true
+ });
+ });
+ });
+
+ it('应该显示错误信息当API调用失败', async () => {
+ getUserTorrents.mockRejectedValue(new Error('获取上传记录失败'));
+
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('错误: 获取上传记录失败')).toBeInTheDocument();
+ });
+ });
+
+
+ it('应该禁用分页按钮当在第一页或最后一页', async () => {
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ const prevButton = screen.getByText('上一页');
+ const firstPageButton = screen.getByText('首页');
+
+ expect(prevButton).toBeDisabled();
+ expect(firstPageButton).toBeDisabled();
+ });
+ });
+
+ it('应该显示正确的页码导航', async () => {
+ // 模拟有更多页的情况
+ getUserTorrents.mockResolvedValue({
+ records: mockTorrents,
+ total: 50
+ });
+
+ render(
+ <MemoryRouter>
+ <Upload />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('...')).toBeInTheDocument();
+ expect(screen.getByText('共 10 页')).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/Personal/personalSubpage.css b/src/components/Personal/personalSubpage.css
index a8e5638..2ba8687 100644
--- a/src/components/Personal/personalSubpage.css
+++ b/src/components/Personal/personalSubpage.css
@@ -1,161 +1,336 @@
-/* 基础布局 */
-.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;
- }
+/* 上传记录表格样式 */
+.uploads-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 20px;
+}
- /* 收藏列表 */
-.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
+.uploads-table th, .uploads-table td {
+ padding: 12px 15px;
+ text-align: left;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.uploads-table th {
+ background-color: #f5f5f5;
+ font-weight: 600;
+}
+
+.list-item:hover {
+ background-color: #f9f9f9;
+}
+
+/* 操作按钮样式 */
+.action-btn {
+ padding: 6px 12px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.delete-btn {
+ background-color: #ff4d4f;
+ color: white;
+}
+
+.delete-btn:hover {
+ background-color: #ff7875;
+}
+
+/* 分页控件样式 */
+.pagination {
+ margin-top: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 15px;
+}
+
+.pagination button {
+ padding: 6px 12px;
+ border: 1px solid #d9d9d9;
+ background-color: #fff;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.pagination button:disabled {
+ color: #d9d9d9;
+ cursor: not-allowed;
+}
+
+.pagination button:not(:disabled):hover {
+ border-color: #1890ff;
+ color: #1890ff;
+}
+
+/* 分页控件样式 */
+.pagination {
+ margin-top: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.page-nav, .page-number {
+ padding: 6px 12px;
+ border: 1px solid #d9d9d9;
+ background-color: #fff;
+ border-radius: 4px;
+ cursor: pointer;
+ min-width: 32px;
+ text-align: center;
+}
+
+.page-nav:disabled, .page-number:disabled {
+ color: #d9d9d9;
+ cursor: not-allowed;
+}
+
+.page-nav:not(:disabled):hover,
+.page-number:not(:disabled):hover {
+ border-color: #1890ff;
+ color: #1890ff;
+}
+
+.page-number.active {
+ background-color: #1890ff;
+ color: white;
+ border-color: #1890ff;
+}
+
+.ellipsis {
+ padding: 0 8px;
+}
+
+.page-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: 15px;
+}
+
+.page-info input {
+ width: 50px;
+ padding: 4px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ text-align: center;
+}
+
+.page-info input:focus {
+ outline: none;
+ border-color: #1890ff;
+}
+
+/* 调整表格列宽 */
+.uploads-table th:nth-child(1),
+.uploads-table td:nth-child(1) {
+ width: 30%;
+}
+
+.uploads-table th:nth-child(2),
+.uploads-table td:nth-child(2) {
+ width: 15%;
+}
+
+.uploads-table th:nth-child(3),
+.uploads-table td:nth-child(3) {
+ width: 20%;
+}
+
+.uploads-table th:nth-child(4),
+.uploads-table td:nth-child(4) {
+ width: 15%;
+ text-align: center;
+}
+
+.uploads-table th:nth-child(5),
+.uploads-table td:nth-child(5) {
+ width: 20%;
+ text-align: center;
+}
+
+/* 兑换区样式 */
+.exchange-section {
+ margin-top: 20px;
+ padding: 20px;
+ background-color: #f9f9f9;
+ border-radius: 8px;
+}
+
+.exchange-card {
+ margin-bottom: 20px;
+ padding: 15px;
+ background-color: white;
+ border-radius: 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.exchange-card h4 {
+ margin-top: 0;
+ color: #333;
+}
+
+.exchange-card p {
+ color: #666;
+ margin-bottom: 15px;
+}
+
+.exchange-btn {
+ padding: 8px 16px;
+ background-color: #1890ff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.exchange-btn:hover {
+ background-color: #40a9ff;
+}
+
+.exchange-btn:disabled {
+ background-color: #d9d9d9;
+ cursor: not-allowed;
+}
+
+.exchange-input-group {
+ display: flex;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.exchange-input-group input {
+ flex: 1;
+ padding: 8px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+}
+
+.invite-code-list {
+ margin-top: 20px;
+}
+
+.invite-code-list ul {
+ list-style: none;
+ padding: 0;
+}
+
+.invite-code-list li {
+ display: flex;
+ justify-content: space-between;
+ padding: 10px;
+ border-bottom: 1px solid #eee;
+}
+
+.invite-code-list .code {
+ font-family: monospace;
+}
+
+.invite-code-list .status {
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 12px;
+}
+
+.invite-code-list .status.available {
+ background-color: #f6ffed;
+ color: #52c41a;
+ border: 1px solid #b7eb8f;
+}
+
+.invite-code-list .status.used {
+ background-color: #fff2f0;
+ color: #ff4d4f;
+ border: 1px solid #ffccc7;
+}
+
+
+/* personalSubpage.css 中添加以下样式 */
+
+.setting-section {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.user-info-card, .password-form-card {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.info-item {
+ display: flex;
+ margin-bottom: 10px;
+}
+
+.info-item label {
+ font-weight: bold;
+ width: 100px;
+}
+
+.info-item span {
+ flex: 1;
+}
+
+.info-note {
+ color: #666;
+ font-size: 0.9em;
+ margin-top: 10px;
+}
+
+.form-group {
+ margin-bottom: 15px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 5px;
+ font-weight: bold;
+}
+
+.form-group input {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ box-sizing: border-box;
+}
+
+.submit-button {
+ background-color: #4CAF50;
+ color: white;
+ padding: 10px 15px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 16px;
+}
+
+.submit-button:hover {
+ background-color: #45a049;
+}
+
+.submit-button:disabled {
+ background-color: #cccccc;
+ cursor: not-allowed;
+}
+
+.error-message {
+ color: #f44336;
+ margin-bottom: 15px;
+}
+
+.success-message {
+ color: #4CAF50;
+ margin-bottom: 15px;
+}
\ No newline at end of file