feat(auth): 实现登录注册功能并重构 App 组件
- 新增登录和注册页面组件
- 实现用户认证和权限管理逻辑
- 重构 App 组件,使用 Router 和 AuthProvider
- 添加管理员面板和论坛页面组件
Change-Id: Iaa4502616970e75e3268537f73c75dac8f60e24d
diff --git a/src/features/admin/pages/AdminPanel.jsx b/src/features/admin/pages/AdminPanel.jsx
new file mode 100644
index 0000000..93d8d87
--- /dev/null
+++ b/src/features/admin/pages/AdminPanel.jsx
@@ -0,0 +1,118 @@
+import React, { useState, useEffect } from 'react';
+import { Card, Table, Button, Space, Typography, message, Popconfirm, Spin } from 'antd';
+import { UserOutlined, UploadOutlined, SettingOutlined } from '@ant-design/icons';
+import { getUserList, deleteUser } from '../../auth/services/authApi';
+
+const { Title } = Typography;
+
+const AdminPanel = () => {
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // 获取管理员信息
+ const adminUser = JSON.parse(localStorage.getItem('user') || '{}');
+
+ // 加载用户数据
+ useEffect(() => {
+ fetchUsers();
+ }, []);
+
+ // 获取用户列表
+ const fetchUsers = async () => {
+ try {
+ setLoading(true);
+ const response = await getUserList(adminUser.username);
+ if (response.success) {
+ const userList = response.data.users || [];
+ // 添加key属性
+ const formattedUsers = userList.map(user => ({
+ ...user,
+ key: user.id,
+ role: user.userType === 1 ? '管理员' : '普通用户',
+ status: '正常'
+ }));
+ setUsers(formattedUsers);
+ }
+ } catch (error) {
+ message.error(error.message || '获取用户列表失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 删除用户
+ const handleDelete = async (username) => {
+ try {
+ const response = await deleteUser(adminUser.username, username);
+ if (response.success) {
+ message.success('用户删除成功');
+ fetchUsers(); // 重新加载用户列表
+ }
+ } catch (error) {
+ message.error(error.message || '删除用户失败');
+ }
+ };
+
+ const columns = [
+ { title: '用户名', dataIndex: 'username', key: 'username' },
+ { title: '角色', dataIndex: 'role', key: 'role' },
+ { title: '状态', dataIndex: 'status', key: 'status' },
+ { title: '注册日期', dataIndex: 'createTime', key: 'createTime' },
+ {
+ title: '操作',
+ key: 'action',
+ render: (_, record) => (
+ <Space size="middle">
+ <Button type="link">编辑</Button>
+ {record.userType !== 1 && (
+ <Popconfirm
+ title="确定要删除该用户吗?"
+ onConfirm={() => handleDelete(record.username)}
+ okText="确定"
+ cancelText="取消"
+ >
+ <Button type="link" danger>删除</Button>
+ </Popconfirm>
+ )}
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div className="p-6">
+ <Title level={2}>管理员控制面板</Title>
+ <div className="flex gap-4 mb-6">
+ <Card title="用户统计" className="w-1/3">
+ <div className="flex items-center">
+ <UserOutlined className="text-2xl mr-2" />
+ <span className="text-xl">{users.length} 名用户</span>
+ </div>
+ </Card>
+ <Card title="资源统计" className="w-1/3">
+ <div className="flex items-center">
+ <UploadOutlined className="text-2xl mr-2" />
+ <span className="text-xl">25 个资源</span>
+ </div>
+ </Card>
+ <Card title="系统状态" className="w-1/3">
+ <div className="flex items-center">
+ <SettingOutlined className="text-2xl mr-2" />
+ <span className="text-xl">运行正常</span>
+ </div>
+ </Card>
+ </div>
+ <Card title="用户管理">
+ {loading ? (
+ <div className="flex justify-center py-8">
+ <Spin size="large" tip="加载中..." />
+ </div>
+ ) : (
+ <Table columns={columns} dataSource={users} />
+ )}
+ </Card>
+ </div>
+ );
+};
+
+export default AdminPanel;
\ No newline at end of file
diff --git a/src/features/auth/contexts/AuthContext.jsx b/src/features/auth/contexts/AuthContext.jsx
new file mode 100644
index 0000000..5f9b37e
--- /dev/null
+++ b/src/features/auth/contexts/AuthContext.jsx
@@ -0,0 +1,133 @@
+import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import { loginUser, registerUser, getUserInfo, logoutUser } from '../services/authApi';
+import { message } from 'antd';
+import { useNavigate } from 'react-router-dom'; // 导入 useNavigate
+
+const AuthContext = createContext(null);
+
+export const AuthProvider = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true); // 初始加载状态
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const navigate = useNavigate();
+
+ const loadAuthData = useCallback(() => {
+ setLoading(true);
+ try {
+ const storedToken = localStorage.getItem('authToken'); // 假设您使用token
+ const storedUser = localStorage.getItem('user');
+
+ if (storedToken && storedUser) {
+ setUser(JSON.parse(storedUser));
+ setIsAuthenticated(true);
+ // 调用API获取最新的用户信息
+ getUserInfo().then(response => {
+ if (response.data && response.data.user) {
+ setUser(response.data.user);
+ localStorage.setItem('user', JSON.stringify(response.data.user));
+ }
+ }).catch(error => {
+ console.error("获取用户信息失败", error);
+ });
+ } else {
+ setIsAuthenticated(false);
+ setUser(null);
+ }
+ } catch (error) {
+ console.error("Failed to load auth data from storage", error);
+ setIsAuthenticated(false);
+ setUser(null);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadAuthData();
+ }, [loadAuthData]);
+
+ const login = async (credentials) => {
+ setLoading(true);
+ try {
+ const response = await loginUser(credentials);
+ const { token, user: userData } = response.data;
+
+ localStorage.setItem('authToken', token);
+ localStorage.setItem('user', JSON.stringify(userData));
+ setUser(userData);
+ setIsAuthenticated(true);
+ message.success('登录成功');
+ return userData;
+ } catch (error) {
+ console.error("Login failed", error);
+ setIsAuthenticated(false);
+ setUser(null);
+ message.error(error.message || '登录失败,请检查用户名和密码');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const register = async (userData) => {
+ setLoading(true);
+ try {
+ const response = await registerUser(userData);
+ const { token, user: newUser } = response.data;
+
+ localStorage.setItem('authToken', token);
+ localStorage.setItem('user', JSON.stringify(newUser));
+ setUser(newUser);
+ setIsAuthenticated(true);
+ message.success('注册成功');
+ return newUser;
+ } catch (error) {
+ console.error("Registration failed", error);
+ message.error(error.message || '注册失败,请稍后再试');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const logout = async () => {
+ try {
+ await logoutUser();
+ localStorage.removeItem('authToken');
+ localStorage.removeItem('user');
+ localStorage.removeItem('permissions'); // 移除旧的权限存储
+ setUser(null);
+ setIsAuthenticated(false);
+ message.success('已成功退出登录');
+ navigate('/login');
+ return true;
+ } catch (error) {
+ console.error("登出失败", error);
+ message.error('登出失败');
+ return false;
+ }
+ };
+
+ const hasRole = useCallback((roleName) => {
+ return user?.role === roleName;
+ }, [user]);
+
+ const value = {
+ user,
+ isAuthenticated,
+ loading,
+ login,
+ register,
+ logout,
+ hasRole,
+ reloadAuthData: loadAuthData // 暴露一个重新加载数据的方法
+ };
+
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+};
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (context === undefined || context === null) { // 增加了对 null 的检查
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/src/features/auth/pages/LoginPage.jsx b/src/features/auth/pages/LoginPage.jsx
new file mode 100644
index 0000000..19434a4
--- /dev/null
+++ b/src/features/auth/pages/LoginPage.jsx
@@ -0,0 +1,155 @@
+// src/features/auth/pages/LoginPage.jsx
+import React, { useState } from "react";
+import { useNavigate, Link } from "react-router-dom";
+import {
+ Form,
+ Input,
+ Button,
+ Checkbox,
+ Card,
+ Typography,
+ Space,
+ Divider,
+ message,
+} from "antd";
+import { UserOutlined, LockOutlined } from "@ant-design/icons";
+import { useAuth } from "../contexts/AuthContext"; // 使用新的 AuthContext
+// import { loginUser } from '../services/authApi'; // 如果不直接在 context 中调用 API
+
+const { Title, Text } = Typography;
+
+const LoginPage = () => {
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const { login, isAuthenticated, user } = useAuth(); // 从 Context 获取 login 方法等
+
+ React.useEffect(() => {
+ // 如果已经登录,并且有用户信息,则重定向到首页
+ if (isAuthenticated && user) {
+ navigate("/");
+ }
+ }, [isAuthenticated, user, navigate]);
+
+ const onFinish = async (values) => {
+ setLoading(true);
+ try {
+ await login({ username: values.username, password: values.password });
+ // 登录成功后的导航由 AuthContext 内部或 ProtectedRoute 处理
+ // AuthContext 已经包含成功提示,这里不再重复提示
+ navigate("/"); // 或者根据用户角色导航到不同页面
+ } catch (error) {
+ // 错误消息由 AuthContext 中的 login 方法或 request 拦截器处理
+ console.error("Login page error:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <div className="flex justify-center items-center min-h-screen bg-slate-100 p-4">
+ {" "}
+ {/* Tailwind: bg-gray-100 -> bg-slate-100 */}
+ <Card className="w-full max-w-md shadow-lg rounded-lg">
+ {" "}
+ {/* Tailwind: rounded-lg */}
+ <div className="text-center mb-8">
+ {" "}
+ {/* Tailwind: mb-6 -> mb-8 */}
+ <Title level={2} className="!mb-2 text-slate-700">
+ PT站登录
+ </Title>{" "}
+ {/* Tailwind: text-slate-700 */}
+ <Text type="secondary">欢迎回来,请登录您的账号</Text>
+ </div>
+ <Form
+ name="login_form" // 最好给表单一个唯一的名字
+ initialValues={{ remember: true }}
+ onFinish={onFinish}
+ size="large"
+ layout="vertical"
+ className="space-y-6" // Tailwind: 间距控制
+ >
+ <Form.Item
+ name="username"
+ rules={[{ required: true, message: "请输入您的用户名!" }]}
+ >
+ <Input
+ prefix={<UserOutlined className="site-form-item-icon" />}
+ placeholder="用户名"
+ />
+ </Form.Item>
+ <Form.Item
+ name="password"
+ rules={[{ required: true, message: "请输入您的密码!" }]}
+ >
+ <Input.Password
+ prefix={<LockOutlined className="site-form-item-icon" />}
+ placeholder="密码"
+ />
+ </Form.Item>
+ <Form.Item className="!mb-0">
+ {" "}
+ {/* Tailwind: !mb-0 覆盖antd默认margin */}
+ <div className="flex justify-between items-center">
+ <Form.Item name="remember" valuePropName="checked" noStyle>
+ <Checkbox>记住我</Checkbox>
+ </Form.Item>
+ <Link
+ to="/forgot-password"
+ className="text-blue-600 hover:text-blue-700 hover:underline"
+ >
+ {" "}
+ {/* Tailwind: hover:underline */}
+ 忘记密码?
+ </Link>
+ </div>
+ </Form.Item>
+ <Form.Item>
+ <Button
+ type="primary"
+ htmlType="submit"
+ className="w-full !text-base"
+ loading={loading}
+ >
+ {" "}
+ {/* Tailwind: !text-base (示例) */}登 录
+ </Button>
+ </Form.Item>
+ <Divider plain>
+ <span className="text-slate-500">或</span>
+ </Divider>{" "}
+ {/* Tailwind: text-slate-500 */}
+ <div className="text-center">
+ <Text type="secondary" className="mr-1">
+ 还没有账号?
+ </Text>
+ <Link
+ to="/register"
+ className="font-medium text-blue-600 hover:text-blue-700 hover:underline"
+ >
+ 立即注册
+ </Link>
+ </div>
+ </Form>
+ {/* 提示信息部分可以保留或移除 */}
+ <div className="mt-8 p-4 bg-slate-50 rounded-md border border-slate-200">
+ {" "}
+ {/* Tailwind: border, border-slate-200 */}
+ <Text
+ type="secondary"
+ className="block mb-2 font-semibold text-slate-600"
+ >
+ 测试账号提示
+ </Text>
+ <ul className="space-y-1 text-sm text-slate-500 list-disc list-inside">
+ <li>管理员: admin / admin123</li>
+ <li>普通用户: user / user123</li>
+ {/* ...其他测试账号 */}
+ </ul>
+ </div>
+ </Card>
+ </div>
+ );
+};
+
+export default LoginPage;
diff --git a/src/features/auth/pages/RegisterPage.jsx b/src/features/auth/pages/RegisterPage.jsx
new file mode 100644
index 0000000..c4fb06d
--- /dev/null
+++ b/src/features/auth/pages/RegisterPage.jsx
@@ -0,0 +1,130 @@
+// src/features/auth/pages/RegisterPage.jsx
+import React, { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { Form, Input, Button, Card, Typography, Divider, message } from 'antd';
+import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons';
+import { useAuth } from '../contexts/AuthContext'; // 使用新的 AuthContext
+
+const { Title, Text } = Typography;
+
+const RegisterPage = () => {
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const { register, isAuthenticated, user } = useAuth(); // 从 Context 获取 register 方法
+
+ React.useEffect(() => {
+ if (isAuthenticated && user) {
+ navigate('/'); // 如果已登录,跳转到首页
+ }
+ }, [isAuthenticated, user, navigate]);
+
+ const onFinish = async (values) => {
+ setLoading(true);
+ try {
+ // 从表单值中移除 'confirm' 字段,因为它不需要发送到后端
+ const { confirm, ...registrationData } = values;
+ await register(registrationData); // 使用 context 中的 register 方法
+ message.success('注册成功!将跳转到登录页...');
+ setTimeout(() => {
+ navigate('/login');
+ }, 1500); // 延迟跳转,让用户看到成功消息
+ } catch (error) {
+ // 错误消息由 AuthContext 中的 register 方法处理或 request 拦截器处理
+ console.error('Registration page error:', error);
+ // message.error(error.message || '注册失败,请重试'); // 如果 Context 未处理错误提示
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <div className="flex justify-center items-center min-h-screen bg-slate-100 p-4">
+ <Card className="w-full max-w-md shadow-lg rounded-lg">
+ <div className="text-center mb-8">
+ <Title level={2} className="!mb-2 text-slate-700">创建您的账户</Title>
+ <Text type="secondary">加入我们的PT社区</Text>
+ </div>
+
+ <Form
+ name="register_form"
+ onFinish={onFinish}
+ size="large"
+ layout="vertical"
+ className="space-y-4" // 调整表单项间距
+ >
+ <Form.Item
+ name="username"
+ rules={[
+ { required: true, message: '请输入您的用户名!' },
+ { min: 3, message: '用户名至少需要3个字符' },
+ { max: 20, message: '用户名不能超过20个字符' },
+ { pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' }
+ ]}
+ hasFeedback // 显示校验状态图标
+ >
+ <Input prefix={<UserOutlined />} placeholder="用户名" />
+ </Form.Item>
+
+ <Form.Item
+ name="email"
+ rules={[
+ { required: true, message: '请输入您的邮箱地址!' },
+ { type: 'email', message: '请输入一个有效的邮箱地址!' }
+ ]}
+ hasFeedback
+ >
+ <Input prefix={<MailOutlined />} placeholder="邮箱" />
+ </Form.Item>
+
+ <Form.Item
+ name="password"
+ rules={[
+ { required: true, message: '请输入您的密码!' },
+ { min: 6, message: '密码至少需要6个字符' }
+ // 可以添加更复杂的密码强度校验规则
+ ]}
+ hasFeedback
+ >
+ <Input.Password prefix={<LockOutlined />} placeholder="密码" />
+ </Form.Item>
+
+ <Form.Item
+ name="confirm"
+ dependencies={['password']}
+ hasFeedback
+ rules={[
+ { required: true, message: '请再次输入您的密码!' },
+ ({ getFieldValue }) => ({
+ validator(_, value) {
+ if (!value || getFieldValue('password') === value) {
+ return Promise.resolve();
+ }
+ return Promise.reject(new Error('两次输入的密码不一致!'));
+ },
+ }),
+ ]}
+ >
+ <Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
+ </Form.Item>
+
+ <Form.Item className="!mt-6"> {/* 增加注册按钮的上边距 */}
+ <Button type="primary" htmlType="submit" className="w-full !text-base" loading={loading}>
+ 注 册
+ </Button>
+ </Form.Item>
+
+ <Divider plain><span className="text-slate-500">或</span></Divider>
+
+ <div className="text-center">
+ <Text type="secondary" className="mr-1">已经有账户了?</Text>
+ <Link to="/login" className="font-medium text-blue-600 hover:text-blue-700 hover:underline">
+ 前往登录
+ </Link>
+ </div>
+ </Form>
+ </Card>
+ </div>
+ );
+};
+
+export default RegisterPage;
\ No newline at end of file
diff --git a/src/features/auth/services/authApi.js b/src/features/auth/services/authApi.js
new file mode 100644
index 0000000..dd40a80
--- /dev/null
+++ b/src/features/auth/services/authApi.js
@@ -0,0 +1,165 @@
+// src/features/auth/services/authApi.js
+import request from "../../../services/request";
+import { message } from "antd";
+
+// 使用API前缀
+const API_PREFIX = "/user";
+const ADMIN_PREFIX = "/admin";
+
+// 导出API函数
+export const loginUser = (credentials) => {
+ return request.post(`${API_PREFIX}/login`, credentials).then((response) => {
+ if (response.data && response.data.success) {
+ // 保存token和用户信息到localStorage
+ localStorage.setItem("token", response.data.data.token);
+ localStorage.setItem("user", JSON.stringify(response.data.data.user));
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "登录失败"));
+ }
+ });
+};
+
+export const adminLogin = (credentials) => {
+ return request.post(`${ADMIN_PREFIX}/login`, credentials).then((response) => {
+ if (response.data && response.data.success) {
+ // 保存token和用户信息到localStorage
+ localStorage.setItem("token", response.data.data.token);
+ localStorage.setItem("user", JSON.stringify(response.data.data.user));
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "管理员登录失败"));
+ }
+ });
+};
+
+export const registerUser = (userData) => {
+ return request.post(`${API_PREFIX}/register`, userData).then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "注册失败"));
+ }
+ });
+};
+
+export const updateUsername = (username, newUsername) => {
+ const token = localStorage.getItem("token");
+ return request
+ .post(`${API_PREFIX}/update/username`,
+ { username, newUsername },
+ { headers: { token } })
+ .then((response) => {
+ if (response.data && response.data.success) {
+ // 更新本地存储的用户信息
+ const user = JSON.parse(localStorage.getItem("user") || "{}");
+ user.username = newUsername;
+ localStorage.setItem("user", JSON.stringify(user));
+ return response.data;
+ } else {
+ return Promise.reject(
+ new Error(response.data.message || "修改用户名失败")
+ );
+ }
+ });
+};
+
+export const updatePassword = (username, newPassword) => {
+ const token = localStorage.getItem("token");
+ return request
+ .post(`${API_PREFIX}/update/password`,
+ { username, newPassword },
+ { headers: { token } })
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(
+ new Error(response.data.message || "修改密码失败")
+ );
+ }
+ });
+};
+
+export const updateEmail = (username, newEmail) => {
+ const token = localStorage.getItem("token");
+ return request
+ .post(`${API_PREFIX}/update/email`,
+ { username, newEmail },
+ { headers: { token } })
+ .then((response) => {
+ if (response.data && response.data.success) {
+ // 更新本地存储的用户信息
+ const user = JSON.parse(localStorage.getItem("user") || "{}");
+ user.email = newEmail;
+ localStorage.setItem("user", JSON.stringify(user));
+ return response.data;
+ } else {
+ return Promise.reject(
+ new Error(response.data.message || "修改邮箱失败")
+ );
+ }
+ });
+};
+
+export const getUserInfo = (username) => {
+ const token = localStorage.getItem("token");
+ return request
+ .get(`${API_PREFIX}/get/info?username=${username}`,
+ { headers: { token } })
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(
+ new Error(response.data.message || "获取用户信息失败")
+ );
+ }
+ });
+};
+
+export const getUserList = (username) => {
+ const token = localStorage.getItem("token");
+ return request
+ .get(`/user/list?username=${username}`,
+ { headers: { token } })
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(
+ new Error(response.data.message || "获取用户列表失败")
+ );
+ }
+ });
+};
+
+export const deleteUser = (username, targetUsername) => {
+ const token = localStorage.getItem("token");
+ return request
+ .delete(`/user/delete`,
+ {
+ headers: { token },
+ data: { username, targetUsername }
+ })
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(
+ new Error(response.data.message || "删除用户失败")
+ );
+ }
+ });
+};
+
+export const logoutUser = () => {
+ // 清除本地存储
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+
+ return Promise.resolve({
+ success: true,
+ message: "注销成功"
+ });
+};
diff --git a/src/features/forum/pages/ForumPage.jsx b/src/features/forum/pages/ForumPage.jsx
new file mode 100644
index 0000000..7e6e78f
--- /dev/null
+++ b/src/features/forum/pages/ForumPage.jsx
@@ -0,0 +1,143 @@
+import React, { useState, useEffect } from 'react';
+import { List, Avatar, Space, Tag, Typography, Button, message, Modal, Form, Input, Spin } from 'antd';
+import { getPosts, createPost } from '../services/forumApi';
+
+const { Title, Paragraph, Text } = Typography;
+const { TextArea } = Input;
+
+const ForumPage = () => {
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [form] = Form.useForm();
+
+ // 获取用户信息
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
+
+ // 加载帖子数据
+ useEffect(() => {
+ fetchPosts();
+ }, []);
+
+ // 获取帖子列表
+ const fetchPosts = async () => {
+ try {
+ setLoading(true);
+ const response = await getPosts({ username: user.username });
+ if (response.success) {
+ setPosts(response.data.posts || []);
+ }
+ } catch (error) {
+ message.error(error.message || '获取帖子列表失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 显示新建帖子对话框
+ const showModal = () => {
+ setIsModalOpen(true);
+ };
+
+ // 关闭对话框
+ const handleCancel = () => {
+ setIsModalOpen(false);
+ form.resetFields();
+ };
+
+ // 提交新帖子
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+
+ // 添加作者信息
+ values.author = user.username;
+
+ const response = await createPost(values);
+ if (response.success) {
+ message.success('帖子发布成功');
+ setIsModalOpen(false);
+ form.resetFields();
+ fetchPosts(); // 重新加载帖子列表
+ }
+ } catch (error) {
+ message.error(error.message || '发布帖子失败');
+ }
+ };
+
+ return (
+ <div className="space-y-6">
+ <Title level={2}>社区论坛</Title>
+ <Paragraph className="text-slate-500">
+ 欢迎来到我们的社区论坛,这里是会员交流分享的地方。
+ </Paragraph>
+
+ {loading ? (
+ <div className="flex justify-center py-8">
+ <Spin size="large" tip="加载中..." />
+ </div>
+ ) : (
+ <List
+ itemLayout="vertical"
+ size="large"
+ dataSource={posts}
+ renderItem={(item) => (
+ <List.Item
+ key={item.id}
+ extra={
+ <Space>
+ <Tag color="green">浏览: {item.views || 0}</Tag>
+ <Tag color="blue">点赞: {item.likes || 0}</Tag>
+ <Text type="secondary">{item.createTime}</Text>
+ </Space>
+ }
+ >
+ <List.Item.Meta
+ avatar={<Avatar src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${item.author}`} />}
+ title={<a href={`/post/${item.id}`}>{item.title}</a>}
+ description={<Text type="secondary">作者: {item.author}</Text>}
+ />
+ <Paragraph ellipsis={{ rows: 2 }}>{item.content}</Paragraph>
+ </List.Item>
+ )}
+ />
+ )}
+
+ <div className="text-center mt-4">
+ <Button type="primary" onClick={showModal}>发布新主题</Button>
+ </div>
+
+ {/* 新建帖子对话框 */}
+ <Modal
+ title="发布新主题"
+ open={isModalOpen}
+ onOk={handleSubmit}
+ onCancel={handleCancel}
+ okText="发布"
+ cancelText="取消"
+ >
+ <Form
+ form={form}
+ layout="vertical"
+ >
+ <Form.Item
+ name="title"
+ label="标题"
+ rules={[{ required: true, message: '请输入标题' }]}
+ >
+ <Input placeholder="请输入标题" />
+ </Form.Item>
+ <Form.Item
+ name="content"
+ label="内容"
+ rules={[{ required: true, message: '请输入帖子内容' }]}
+ >
+ <TextArea rows={6} placeholder="请输入帖子内容" />
+ </Form.Item>
+ </Form>
+ </Modal>
+ </div>
+ );
+};
+
+export default ForumPage;
\ No newline at end of file
diff --git a/src/features/forum/services/forumApi.js b/src/features/forum/services/forumApi.js
new file mode 100644
index 0000000..68feca1
--- /dev/null
+++ b/src/features/forum/services/forumApi.js
@@ -0,0 +1,117 @@
+import request from "../../../services/request";
+
+// 帖子相关API
+export const createPost = (postData) => {
+ const token = localStorage.getItem("token");
+ return request
+ .post(
+ "/posts/create",
+ postData,
+ { headers: { token } }
+ )
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "创建帖子失败"));
+ }
+ });
+};
+
+export const getPosts = (filters) => {
+ const token = localStorage.getItem("token");
+
+ // 构建查询参数
+ let queryParams = new URLSearchParams();
+ if (filters) {
+ if (filters.username) queryParams.append("username", filters.username);
+ if (filters.title) queryParams.append("title", filters.title);
+ if (filters.author) queryParams.append("author", filters.author);
+ if (filters.date) queryParams.append("date", filters.date);
+ }
+
+ return request
+ .get(
+ `/posts/list?${queryParams.toString()}`,
+ { headers: { token } }
+ )
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "获取帖子列表失败"));
+ }
+ });
+};
+
+export const deletePost = (username, pid) => {
+ const token = localStorage.getItem("token");
+ return request
+ .delete(
+ "/posts/delete",
+ {
+ headers: { token },
+ data: { username, pid }
+ }
+ )
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "删除帖子失败"));
+ }
+ });
+};
+
+// 评论相关API
+export const addComment = (commentData) => {
+ const token = localStorage.getItem("token");
+ return request
+ .post(
+ "/comment/add",
+ commentData,
+ { headers: { token } }
+ )
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "添加评论失败"));
+ }
+ });
+};
+
+export const getComments = (postId, username) => {
+ const token = localStorage.getItem("token");
+ return request
+ .get(
+ `/comment/get?postId=${postId}&username=${username}`,
+ { headers: { token } }
+ )
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "获取评论失败"));
+ }
+ });
+};
+
+export const deleteComment = (username, commentId) => {
+ const token = localStorage.getItem("token");
+ return request
+ .delete(
+ "/comment/delete",
+ {
+ headers: { token },
+ data: { username, commentId }
+ }
+ )
+ .then((response) => {
+ if (response.data && response.data.success) {
+ return response.data;
+ } else {
+ return Promise.reject(new Error(response.data.message || "删除评论失败"));
+ }
+ });
+};
\ No newline at end of file
diff --git a/src/features/home/pages/HomePage.jsx b/src/features/home/pages/HomePage.jsx
new file mode 100644
index 0000000..27e1628
--- /dev/null
+++ b/src/features/home/pages/HomePage.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Button, Divider, List, Typography, Space, Tag } from 'antd';
+import { CloudDownloadOutlined } from '@ant-design/icons';
+
+const { Title, Paragraph, Text } = Typography;
+
+const HomePage = () => (
+ <div className="space-y-6">
+ <div className="text-center py-8">
+ <Title level={1}>欢迎来到 PT 网站</Title>
+ <Paragraph className="text-lg text-slate-500">
+ 高清资源分享,互助共赢的PT资源社区
+ </Paragraph>
+ <Button type="primary" size="large" icon={<CloudDownloadOutlined />}>
+ 浏览资源
+ </Button>
+ </div>
+
+ <Divider>最新公告</Divider>
+
+ <List
+ itemLayout="horizontal"
+ dataSource={[
+ {
+ title: '网站升级通知',
+ date: '2023-06-15',
+ content: '网站将于本周六进行系统升级,届时将暂停服务4小时。'
+ },
+ {
+ title: '新规则发布',
+ date: '2023-06-10',
+ content: '关于发布资源的新规则已经生效,请会员查看详情。'
+ },
+ {
+ title: '新用户注册开放',
+ date: '2023-06-01',
+ content: '本站现已开放新用户注册,每天限额100名。'
+ }
+ ]}
+ renderItem={(item) => (
+ <List.Item>
+ <List.Item.Meta
+ title={<Space><Text strong>{item.title}</Text><Tag color="blue">{item.date}</Tag></Space>}
+ description={item.content}
+ />
+ </List.Item>
+ )}
+ />
+ </div>
+);
+
+export default HomePage;
\ No newline at end of file
diff --git a/src/features/profile/pages/ProfilePage.jsx b/src/features/profile/pages/ProfilePage.jsx
new file mode 100644
index 0000000..79fbfe4
--- /dev/null
+++ b/src/features/profile/pages/ProfilePage.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { Typography, Button, Empty } from 'antd';
+
+const { Title } = Typography;
+
+const ProfilePage = () => (
+ <div className="space-y-6">
+ <Title level={2}>个人资料</Title>
+
+ <div className="text-center py-12">
+ <Empty description="用户资料页面正在开发中" />
+ <Button type="primary" className="mt-4">
+ 编辑资料
+ </Button>
+ </div>
+ </div>
+);
+
+export default ProfilePage;
\ No newline at end of file
diff --git a/src/features/pt/pages/PTPage.jsx b/src/features/pt/pages/PTPage.jsx
new file mode 100644
index 0000000..a1e2b34
--- /dev/null
+++ b/src/features/pt/pages/PTPage.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Card, Typography, Space } from 'antd';
+
+const { Title, Paragraph } = Typography;
+
+const PTPage = () => (
+ <div className="space-y-6">
+ <Title level={2}>PT 系统说明</Title>
+ <Card className="shadow-sm">
+ <Space direction="vertical" size="large" style={{ width: '100%' }}>
+ <div>
+ <Title level={4}>什么是PT?</Title>
+ <Paragraph>
+ Private Tracker (PT) 是一种私人的BitTorrent追踪器,只允许注册用户访问和使用。PT站点通常会记录用户的上传和下载量,以维护良好的分享率。
+ </Paragraph>
+ </div>
+
+ <div>
+ <Title level={4}>分享率规则</Title>
+ <Paragraph>
+ 分享率 = 总上传量 ÷ 总下载量。新用户需要保持不低于0.4的分享率。长期保持优秀分享率的用户将获得额外权益。
+ </Paragraph>
+ </div>
+
+ <div>
+ <Title level={4}>邀请制度</Title>
+ <Paragraph>
+ 达到以下条件的用户可以获得邀请资格:
+ </Paragraph>
+ <ul>
+ <li>注册时间超过3个月</li>
+ <li>分享率大于1.0</li>
+ <li>上传量超过50GB</li>
+ </ul>
+ </div>
+ </Space>
+ </Card>
+ </div>
+);
+
+export default PTPage;
\ No newline at end of file
diff --git a/src/features/tools/pages/ToolsPage.jsx b/src/features/tools/pages/ToolsPage.jsx
new file mode 100644
index 0000000..90572bc
--- /dev/null
+++ b/src/features/tools/pages/ToolsPage.jsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { Card, Typography, Button } from 'antd';
+import { ToolOutlined, CloudDownloadOutlined, AppstoreOutlined } from '@ant-design/icons';
+
+const { Title, Paragraph } = Typography;
+
+const ToolsPage = () => (
+ <div className="space-y-6">
+ <Title level={2}>工具箱</Title>
+ <Paragraph className="text-slate-500">
+ 这里提供了一些有用的PT相关工具和资源。
+ </Paragraph>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ <Card
+ title="分享率计算器"
+ extra={<ToolOutlined />}
+ className="shadow-sm hover:shadow-md transition-shadow"
+ >
+ <p>计算所需上传量以达到目标分享率。</p>
+ <Button type="link" className="mt-2">使用工具</Button>
+ </Card>
+
+ <Card
+ title="批量下载工具"
+ extra={<CloudDownloadOutlined />}
+ className="shadow-sm hover:shadow-md transition-shadow"
+ >
+ <p>一键下载多个种子文件。</p>
+ <Button type="link" className="mt-2">使用工具</Button>
+ </Card>
+
+ <Card
+ title="MediaInfo 解析器"
+ extra={<AppstoreOutlined />}
+ className="shadow-sm hover:shadow-md transition-shadow"
+ >
+ <p>解析并美化展示MediaInfo信息。</p>
+ <Button type="link" className="mt-2">使用工具</Button>
+ </Card>
+ </div>
+ </div>
+);
+
+export default ToolsPage;
\ No newline at end of file
diff --git a/src/features/torrents/pages/TorrentListPage.jsx b/src/features/torrents/pages/TorrentListPage.jsx
new file mode 100644
index 0000000..9e1fd49
--- /dev/null
+++ b/src/features/torrents/pages/TorrentListPage.jsx
@@ -0,0 +1,671 @@
+import React, { useState, useEffect } from "react";
+import {
+ Typography,
+ Button,
+ Tabs,
+ Radio,
+ Tag,
+ Divider,
+ Checkbox,
+ Input,
+ Table,
+ Space,
+ Tooltip,
+ Badge,
+ Spin,
+ Skeleton,
+ Result,
+} from "antd";
+import {
+ UploadOutlined,
+ SearchOutlined,
+ DownloadOutlined,
+ EyeOutlined,
+ LinkOutlined,
+ FileTextOutlined,
+ CheckCircleOutlined,
+ ClockCircleOutlined,
+ LoadingOutlined,
+ ReloadOutlined,
+} from "@ant-design/icons";
+import { Link } from "react-router-dom";
+
+const { Title, Text } = Typography;
+const { TabPane } = Tabs;
+const { Search } = Input;
+
+// 模拟种子数据
+const torrentData = Array.from({ length: 100 }, (_, index) => ({
+ key: index + 1,
+ id: index + 1,
+ category: [
+ "电影",
+ "剧集",
+ "音乐",
+ "动漫",
+ "游戏",
+ "综艺",
+ "体育",
+ "软件",
+ "学习",
+ "纪录片",
+ "其他",
+ ][index % 11],
+ subcategory:
+ index % 11 === 0
+ ? "动作"
+ : index % 11 === 1
+ ? "美剧"
+ : index % 11 === 2
+ ? "古典"
+ : index % 11 === 3
+ ? "日漫"
+ : index % 11 === 4
+ ? "RPG"
+ : index % 11 === 5
+ ? "脱口秀"
+ : index % 11 === 6
+ ? "足球"
+ : index % 11 === 7
+ ? "工具"
+ : index % 11 === 8
+ ? "课程"
+ : index % 11 === 9
+ ? "自然"
+ : "其他",
+ title: `种子标题 ${index + 1} ${index % 5 === 0 ? "[中字]" : ""} ${
+ index % 7 === 0 ? "合集" : ""
+ }`,
+ size: `${(Math.random() * 50).toFixed(1)} GB`,
+ fileCount: Math.floor(Math.random() * 100) + 1,
+ views: Math.floor(Math.random() * 1000) + 100,
+ publishTime: `2023-${Math.floor(Math.random() * 12) + 1}-${
+ Math.floor(Math.random() * 28) + 1
+ }`,
+ seeders: Math.floor(Math.random() * 100),
+ leechers: Math.floor(Math.random() * 50),
+ completed: Math.floor(Math.random() * 200),
+ uploader: `user${Math.floor(Math.random() * 10) + 1}`,
+ isOwnTorrent: index % 15 === 0,
+ hasSubtitle: index % 5 === 0,
+ isCollection: index % 7 === 0,
+ isActive: Math.random() > 0.2, // 80%的概率是活种
+}));
+
+// 定义类别及其子类别
+const categories = [
+ {
+ key: "all",
+ name: "全部",
+ count: torrentData.length,
+ subcategories: [],
+ },
+ {
+ key: "movie",
+ name: "电影",
+ count: torrentData.filter((t) => t.category === "电影").length,
+ subcategories: [
+ "动作",
+ "喜剧",
+ "爱情",
+ "科幻",
+ "恐怖",
+ "剧情",
+ "战争",
+ "纪录",
+ "动画",
+ "其他",
+ ],
+ },
+ {
+ key: "tv",
+ name: "剧集",
+ count: torrentData.filter((t) => t.category === "剧集").length,
+ subcategories: ["美剧", "英剧", "韩剧", "日剧", "国产剧", "港台剧", "其他"],
+ },
+ {
+ key: "music",
+ name: "音乐",
+ count: torrentData.filter((t) => t.category === "音乐").length,
+ subcategories: [
+ "流行",
+ "摇滚",
+ "电子",
+ "民谣",
+ "嘻哈",
+ "古典",
+ "爵士",
+ "原声带",
+ "其他",
+ ],
+ },
+ {
+ key: "anime",
+ name: "动漫",
+ count: torrentData.filter((t) => t.category === "动漫").length,
+ subcategories: ["日漫", "国漫", "美漫", "剧场版", "OVA", "其他"],
+ },
+ {
+ key: "game",
+ name: "游戏",
+ count: torrentData.filter((t) => t.category === "游戏").length,
+ subcategories: [
+ "角色扮演",
+ "动作",
+ "射击",
+ "策略",
+ "模拟",
+ "冒险",
+ "体育",
+ "格斗",
+ "其他",
+ ],
+ },
+ {
+ key: "variety",
+ name: "综艺",
+ count: torrentData.filter((t) => t.category === "综艺").length,
+ subcategories: [
+ "真人秀",
+ "脱口秀",
+ "访谈",
+ "选秀",
+ "纪实",
+ "搞笑",
+ "情感",
+ "其他",
+ ],
+ },
+ {
+ key: "sports",
+ name: "体育",
+ count: torrentData.filter((t) => t.category === "体育").length,
+ subcategories: [
+ "足球",
+ "篮球",
+ "网球",
+ "赛车",
+ "拳击",
+ "格斗",
+ "奥运",
+ "其他",
+ ],
+ },
+ {
+ key: "software",
+ name: "软件",
+ count: torrentData.filter((t) => t.category === "软件").length,
+ subcategories: [
+ "操作系统",
+ "应用软件",
+ "图形设计",
+ "音频编辑",
+ "视频制作",
+ "编程开发",
+ "其他",
+ ],
+ },
+ {
+ key: "learning",
+ name: "学习",
+ count: torrentData.filter((t) => t.category === "学习").length,
+ subcategories: [
+ "语言",
+ "编程",
+ "设计",
+ "经济",
+ "管理",
+ "考试",
+ "技能",
+ "其他",
+ ],
+ },
+ {
+ key: "documentary",
+ name: "纪录片",
+ count: torrentData.filter((t) => t.category === "纪录片").length,
+ subcategories: [
+ "自然",
+ "历史",
+ "科学",
+ "人文",
+ "社会",
+ "军事",
+ "传记",
+ "其他",
+ ],
+ },
+ {
+ key: "other",
+ name: "其他",
+ count: torrentData.filter((t) => t.category === "其他").length,
+ subcategories: ["其他"],
+ },
+];
+
+const TorrentListPage = () => {
+ // 状态管理
+ const [activeCategory, setActiveCategory] = useState("all");
+ const [activeSubcategories, setActiveSubcategories] = useState([]);
+ const [statusFilter, setStatusFilter] = useState("all"); // 'all', 'active', 'dead'
+ const [ownTorrentsOnly, setOwnTorrentsOnly] = useState(false);
+ const [subtitledOnly, setSubtitledOnly] = useState(false);
+ const [collectionsOnly, setCollectionsOnly] = useState(false);
+ const [searchText, setSearchText] = useState("");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize] = useState(50);
+
+ // 添加加载状态
+ const [loading, setLoading] = useState(true);
+ const [categoryLoading, setCategoryLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [torrents, setTorrents] = useState([]);
+
+ // 模拟数据加载
+ useEffect(() => {
+ const loadData = async () => {
+ try {
+ setLoading(true);
+ // 模拟网络延迟
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ setTorrents(torrentData);
+ setError(null);
+ } catch (err) {
+ setError("加载种子数据失败,请稍后再试");
+ setTorrents([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadData();
+ }, []);
+
+ // 模拟分类切换加载
+ const handleCategoryChange = (key) => {
+ setCategoryLoading(true);
+ setActiveCategory(key);
+ setActiveSubcategories([]);
+ setCurrentPage(1);
+
+ // 模拟切换延迟
+ setTimeout(() => {
+ setCategoryLoading(false);
+ }, 500);
+ };
+
+ // 处理二级分类变化
+ const handleSubcategoryChange = (subcategory) => {
+ const newSubcategories = [...activeSubcategories];
+ const index = newSubcategories.indexOf(subcategory);
+
+ if (index > -1) {
+ newSubcategories.splice(index, 1);
+ } else {
+ newSubcategories.push(subcategory);
+ }
+
+ setActiveSubcategories(newSubcategories);
+ setCurrentPage(1);
+ };
+
+ // 过滤种子数据
+ const filteredTorrents = torrents.filter((torrent) => {
+ // 一级分类筛选
+ if (activeCategory !== "all") {
+ const categoryObj = categories.find((cat) => cat.key === activeCategory);
+ if (categoryObj && torrent.category !== categoryObj.name) {
+ return false;
+ }
+ }
+
+ // 二级分类筛选
+ if (
+ activeSubcategories.length > 0 &&
+ !activeSubcategories.includes(torrent.subcategory)
+ ) {
+ return false;
+ }
+
+ // 状态筛选
+ if (statusFilter === "active" && !torrent.isActive) return false;
+ if (statusFilter === "dead" && torrent.isActive) return false;
+
+ // 我的种子筛选
+ if (ownTorrentsOnly && !torrent.isOwnTorrent) return false;
+
+ // 有字幕筛选
+ if (subtitledOnly && !torrent.hasSubtitle) return false;
+
+ // 合集筛选
+ if (collectionsOnly && !torrent.isCollection) return false;
+
+ // 搜索文本筛选
+ if (
+ searchText &&
+ !torrent.title.toLowerCase().includes(searchText.toLowerCase())
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // 处理搜索,并添加加载状态
+ const handleSearch = (value) => {
+ setLoading(true);
+ setTimeout(() => {
+ setSearchText(value);
+ setLoading(false);
+ }, 500);
+ };
+
+ // 表格列定义
+ const columns = [
+ {
+ title: "类别",
+ dataIndex: "category",
+ key: "category",
+ width: 100,
+ render: (text, record) => (
+ <Space direction="vertical" size={0}>
+ <Tag color="blue">{text}</Tag>
+ <Text type="secondary" style={{ fontSize: "12px" }}>
+ {record.subcategory}
+ </Text>
+ </Space>
+ ),
+ },
+ {
+ title: "名称",
+ dataIndex: "title",
+ key: "title",
+ render: (text, record) => (
+ <Space>
+ <Link to={`/torrent/${record.id}`}>{text}</Link>
+ {record.hasSubtitle && <Tag color="green">中字</Tag>}
+ {record.isCollection && <Tag color="purple">合集</Tag>}
+ </Space>
+ ),
+ },
+ {
+ title: "操作",
+ key: "action",
+ width: 120,
+ render: (_, record) => (
+ <Space size="small">
+ <Tooltip title="下载种子">
+ <Button type="primary" size="small" icon={<DownloadOutlined />} />
+ </Tooltip>
+ <Tooltip title="查看详情">
+ <Button size="small" icon={<EyeOutlined />} />
+ </Tooltip>
+ <Tooltip title="复制链接">
+ <Button size="small" icon={<LinkOutlined />} />
+ </Tooltip>
+ </Space>
+ ),
+ },
+ {
+ title: "大小",
+ dataIndex: "size",
+ key: "size",
+ width: 90,
+ sorter: (a, b) => parseFloat(a.size) - parseFloat(b.size),
+ },
+ {
+ title: "文件",
+ dataIndex: "fileCount",
+ key: "fileCount",
+ width: 70,
+ render: (count) => (
+ <Tooltip title={`${count} 个文件`}>
+ <span>
+ <FileTextOutlined /> {count}
+ </span>
+ </Tooltip>
+ ),
+ },
+ {
+ title: "点击",
+ dataIndex: "views",
+ key: "views",
+ width: 70,
+ sorter: (a, b) => a.views - b.views,
+ },
+ {
+ title: "发布时间",
+ dataIndex: "publishTime",
+ key: "publishTime",
+ width: 100,
+ sorter: (a, b) => new Date(a.publishTime) - new Date(b.publishTime),
+ },
+ {
+ title: "种子数",
+ dataIndex: "seeders",
+ key: "seeders",
+ width: 80,
+ sorter: (a, b) => a.seeders - b.seeders,
+ render: (seeders, record) => (
+ <Badge
+ count={seeders}
+ style={{
+ backgroundColor: seeders > 0 ? "#52c41a" : "#f5222d",
+ fontSize: "12px",
+ }}
+ />
+ ),
+ },
+ {
+ title: "下载量",
+ dataIndex: "leechers",
+ key: "leechers",
+ width: 80,
+ sorter: (a, b) => a.leechers - b.leechers,
+ render: (leechers) => (
+ <Badge
+ count={leechers}
+ style={{
+ backgroundColor: "#faad14",
+ fontSize: "12px",
+ }}
+ />
+ ),
+ },
+ {
+ title: "完成",
+ dataIndex: "completed",
+ key: "completed",
+ width: 70,
+ sorter: (a, b) => a.completed - b.completed,
+ },
+ {
+ title: "发布者",
+ dataIndex: "uploader",
+ key: "uploader",
+ width: 100,
+ render: (text, record) => (
+ <Space>
+ <Link to={`/user/${text}`}>{text}</Link>
+ {record.isOwnTorrent && (
+ <Tooltip title="我的种子">
+ <Tag color="green">我</Tag>
+ </Tooltip>
+ )}
+ </Space>
+ ),
+ },
+ ];
+
+ // 加载错误时显示错误信息
+ if (error) {
+ return (
+ <Result
+ status="error"
+ title="加载失败"
+ subTitle={error}
+ extra={[
+ <Button
+ type="primary"
+ key="reload"
+ icon={<ReloadOutlined />}
+ onClick={() => window.location.reload()}
+ >
+ 重新加载
+ </Button>
+ ]}
+ />
+ );
+ }
+
+ // 自定义加载图标
+ const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
+
+ return (
+ <div className="space-y-4">
+ <div className="flex justify-between items-center">
+ <Title level={2}>种子列表</Title>
+ <Button type="primary" icon={<UploadOutlined />}>
+ <Link to="/upload">发布种子</Link>
+ </Button>
+ </div>
+
+ {/* 一级分类筛选 */}
+ <Spin spinning={categoryLoading} indicator={antIcon}>
+ <Tabs
+ activeKey={activeCategory}
+ onChange={handleCategoryChange}
+ type="card"
+ >
+ {categories.map((category) => (
+ <TabPane
+ tab={
+ <span>
+ {category.name} <Tag>{category.count}</Tag>
+ </span>
+ }
+ key={category.key}
+ >
+ {/* 二级分类筛选 */}
+ {category.subcategories.length > 0 && (
+ <div className="flex flex-wrap gap-2 p-3 bg-gray-50 rounded">
+ {category.subcategories.map((subcategory) => (
+ <Checkbox
+ key={subcategory}
+ checked={activeSubcategories.includes(subcategory)}
+ onChange={() => handleSubcategoryChange(subcategory)}
+ >
+ {subcategory}
+ </Checkbox>
+ ))}
+ </div>
+ )}
+ </TabPane>
+ ))}
+ </Tabs>
+ </Spin>
+
+ <Divider style={{ margin: "12px 0" }} />
+
+ {/* 额外筛选条件 */}
+ <div className="flex justify-between items-center flex-wrap gap-4">
+ <div className="flex items-center gap-4 flex-wrap">
+ <Skeleton loading={loading} active paragraph={false} title={{ width: '100%' }} className="inline-block">
+ <Checkbox
+ checked={ownTorrentsOnly}
+ onChange={(e) => setOwnTorrentsOnly(e.target.checked)}
+ >
+ 我的种子
+ </Checkbox>
+
+ <Checkbox
+ checked={subtitledOnly}
+ onChange={(e) => setSubtitledOnly(e.target.checked)}
+ >
+ 有字幕
+ </Checkbox>
+
+ <Checkbox
+ checked={collectionsOnly}
+ onChange={(e) => setCollectionsOnly(e.target.checked)}
+ >
+ 合集
+ </Checkbox>
+
+ <Radio.Group
+ value={statusFilter}
+ onChange={(e) => setStatusFilter(e.target.value)}
+ optionType="button"
+ buttonStyle="solid"
+ >
+ <Radio.Button value="all">全部</Radio.Button>
+ <Radio.Button value="active">
+ <Tooltip title="有做种的资源">
+ <Space>
+ <Badge status="success" />
+ 仅活种
+ </Space>
+ </Tooltip>
+ </Radio.Button>
+ <Radio.Button value="dead">
+ <Tooltip title="无人做种的资源">
+ <Space>
+ <Badge status="error" />
+ 仅死种
+ </Space>
+ </Tooltip>
+ </Radio.Button>
+ </Radio.Group>
+ </Skeleton>
+ </div>
+
+ <div>
+ <Search
+ placeholder="搜索种子"
+ allowClear
+ enterButton={<SearchOutlined />}
+ size="middle"
+ onSearch={handleSearch}
+ style={{ width: 300 }}
+ loading={loading}
+ />
+ </div>
+ </div>
+
+ {/* 种子列表 */}
+ <div className="relative">
+ <Table
+ columns={columns}
+ dataSource={filteredTorrents}
+ pagination={{
+ current: currentPage,
+ pageSize: pageSize,
+ total: filteredTorrents.length,
+ onChange: (page) => {
+ setLoading(true);
+ setTimeout(() => {
+ setCurrentPage(page);
+ setLoading(false);
+ }, 500);
+ },
+ showSizeChanger: false,
+ showTotal: (total) => `共 ${total} 个种子`,
+ }}
+ size="middle"
+ bordered
+ scroll={{ x: 1100 }}
+ loading={{
+ spinning: loading,
+ indicator: <Spin indicator={antIcon} />,
+ tip: "正在加载种子数据...",
+ }}
+ locale={{
+ emptyText: "没有找到符合条件的种子",
+ }}
+ />
+ </div>
+ </div>
+ );
+};
+
+export default TorrentListPage;
diff --git a/src/features/torrents/pages/UploadTorrentPage.jsx b/src/features/torrents/pages/UploadTorrentPage.jsx
new file mode 100644
index 0000000..8e17a9e
--- /dev/null
+++ b/src/features/torrents/pages/UploadTorrentPage.jsx
@@ -0,0 +1,857 @@
+import React, { useState } from "react";
+import {
+ Typography,
+ Tabs,
+ Form,
+ Input,
+ Upload,
+ Button,
+ Select,
+ DatePicker,
+ InputNumber,
+ Radio,
+ Checkbox,
+ Collapse,
+ Alert,
+ Divider,
+ Space,
+ message,
+} from "antd";
+import {
+ UploadOutlined,
+ InboxOutlined,
+ InfoCircleOutlined,
+} from "@ant-design/icons";
+
+const { Title, Paragraph, Text } = Typography;
+const { TabPane } = Tabs;
+const { Option } = Select;
+const { TextArea } = Input;
+const { Panel } = Collapse;
+const { Dragger } = Upload;
+
+// 通用的上传组件配置
+const uploadProps = {
+ name: "torrent",
+ multiple: false,
+ action: "/api/upload-torrent",
+ accept: ".torrent",
+ maxCount: 1,
+ onChange(info) {
+ const { status } = info.file;
+ if (status === "done") {
+ message.success(`${info.file.name} 文件上传成功`);
+ } else if (status === "error") {
+ message.error(`${info.file.name} 文件上传失败`);
+ }
+ },
+ beforeUpload(file) {
+ const isTorrent = file.type === "application/x-bittorrent" || file.name.endsWith(".torrent");
+ if (!isTorrent) {
+ message.error("您只能上传 .torrent 文件!");
+ }
+ return isTorrent || Upload.LIST_IGNORE;
+ },
+};
+
+// 注意事项内容
+const NoticeContent = () => (
+ <div className="space-y-4">
+ <Alert
+ message="请仔细阅读以下注意事项"
+ description="上传种子前,请确保您了解并同意以下规则,否则您的种子可能会被删除或您的账号可能会被处罚。"
+ type="info"
+ showIcon
+ />
+
+ <Collapse defaultActiveKey={["1"]}>
+ <Panel header="一般规则" key="1">
+ <ul className="list-disc pl-8 space-y-2">
+ <li>您上传的资源必须是合法的,不得上传侵犯版权的内容。</li>
+ <li>禁止上传含有恶意代码的文件,所有上传内容会经过安全检查。</li>
+ <li>资源标题必须清晰描述内容,不得使用误导性标题。</li>
+ <li>请确保您提供的信息准确完整,包括分类、标签、简介等。</li>
+ <li>如发现重复资源,管理员有权合并或删除。</li>
+ <li>新用户需要达到一定等级后才能上传资源,具体请参考用户等级规则。</li>
+ </ul>
+ </Panel>
+ <Panel header="做种要求" key="2">
+ <ul className="list-disc pl-8 space-y-2">
+ <li>上传者必须保证做种至少 7 天,或直到至少有 3 个其他用户完成下载。</li>
+ <li>对于大型资源({'>'}20GB),请确保您有足够的上传带宽。</li>
+ <li>对于稀有资源,请尽可能长期做种,这将获得额外的积分奖励。</li>
+ <li>故意停止做种可能导致警告或账号处罚。</li>
+ </ul>
+ </Panel>
+ <Panel header="奖励政策" key="3">
+ <ul className="list-disc pl-8 space-y-2">
+ <li>首发资源将获得额外的积分奖励。</li>
+ <li>高质量的资源(高分辨率、完整音轨等)将获得质量加成。</li>
+ <li>稀有、珍贵的资源会获得特殊奖励。</li>
+ <li>长期保持良好的做种习惯将提高您的用户级别。</li>
+ </ul>
+ </Panel>
+ </Collapse>
+ </div>
+);
+
+// 通用版规组件
+const CategoryRules = ({ category }) => {
+ const rules = {
+ movie: "电影资源上传规则...",
+ tv: "剧集资源上传规则...",
+ music: "音乐资源上传规则...",
+ anime: "动漫资源上传规则...",
+ game: "游戏资源上传规则...",
+ variety: "综艺资源上传规则...",
+ sports: "体育资源上传规则...",
+ software: "软件资源上传规则...",
+ learning: "学习资源上传规则...",
+ documentary: "纪录片资源上传规则...",
+ other: "其他资源上传规则...",
+ };
+
+ return (
+ <Collapse className="mb-6">
+ <Panel header={`${category}资源版规`} key="1">
+ <Paragraph>{rules[category] || "暂无特定规则"}</Paragraph>
+ </Panel>
+ </Collapse>
+ );
+};
+
+// 基础表单组件
+const BaseFormItems = ({ category }) => (
+ <>
+ <Form.Item
+ name="torrentFile"
+ label="种子文件"
+ rules={[{ required: true, message: "请上传种子文件" }]}
+ >
+ <Dragger {...uploadProps}>
+ <p className="ant-upload-drag-icon">
+ <InboxOutlined />
+ </p>
+ <p className="ant-upload-text">点击或拖拽种子文件到此区域上传</p>
+ <p className="ant-upload-hint">
+ 支持单个.torrent文件上传,请确保种子文件有效
+ </p>
+ </Dragger>
+ </Form.Item>
+
+ <Form.Item
+ name="title"
+ label="标题"
+ rules={[{ required: true, message: "请输入资源标题" }]}
+ >
+ <Input placeholder="请输入完整、准确的资源标题" />
+ </Form.Item>
+
+ <Form.Item
+ name="chineseName"
+ label="中文名"
+ rules={[{ required: true, message: "请输入资源中文名" }]}
+ >
+ <Input placeholder="请输入资源中文名称" />
+ </Form.Item>
+
+ <Form.Item
+ name="englishName"
+ label="英文名/原名"
+ >
+ <Input placeholder="请输入资源英文名称或原名" />
+ </Form.Item>
+
+ <Form.Item
+ name="year"
+ label="年份"
+ rules={[{ required: true, message: "请选择年份" }]}
+ >
+ <DatePicker picker="year" placeholder="选择年份" />
+ </Form.Item>
+
+ <Form.Item
+ name="region"
+ label="地区"
+ rules={[{ required: true, message: "请选择地区" }]}
+ >
+ <Select placeholder="请选择地区">
+ <Option value="china">中国大陆</Option>
+ <Option value="hongkong">中国香港</Option>
+ <Option value="taiwan">中国台湾</Option>
+ <Option value="japan">日本</Option>
+ <Option value="korea">韩国</Option>
+ <Option value="usa">美国</Option>
+ <Option value="uk">英国</Option>
+ <Option value="france">法国</Option>
+ <Option value="germany">德国</Option>
+ <Option value="italy">意大利</Option>
+ <Option value="spain">西班牙</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="language"
+ label="语言"
+ rules={[{ required: true, message: "请选择语言" }]}
+ >
+ <Select placeholder="请选择语言" mode="multiple">
+ <Option value="chinese">中文</Option>
+ <Option value="english">英语</Option>
+ <Option value="japanese">日语</Option>
+ <Option value="korean">韩语</Option>
+ <Option value="french">法语</Option>
+ <Option value="german">德语</Option>
+ <Option value="italian">意大利语</Option>
+ <Option value="spanish">西班牙语</Option>
+ <Option value="russian">俄语</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="hasSubtitle"
+ valuePropName="checked"
+ >
+ <Checkbox>包含中文字幕</Checkbox>
+ </Form.Item>
+
+ <Form.Item
+ name="description"
+ label="资源简介"
+ rules={[{ required: true, message: "请输入资源简介" }]}
+ >
+ <TextArea rows={6} placeholder="请详细描述资源内容、特点等信息" />
+ </Form.Item>
+
+ <Form.Item
+ name="mediaInfo"
+ label="MediaInfo"
+ >
+ <TextArea rows={4} placeholder="请粘贴MediaInfo信息(如有)" />
+ </Form.Item>
+
+ <Form.Item
+ name="screenshots"
+ label="截图"
+ >
+ <Upload
+ listType="picture-card"
+ action="/api/upload-screenshot"
+ accept=".jpg,.jpeg,.png,.webp"
+ multiple
+ >
+ <div>
+ <UploadOutlined />
+ <div style={{ marginTop: 8 }}>上传截图</div>
+ </div>
+ </Upload>
+ </Form.Item>
+ </>
+);
+
+// 电影特定表单项
+const MovieFormItems = () => (
+ <>
+ <Form.Item
+ name="director"
+ label="导演"
+ >
+ <Input placeholder="请输入导演名称,多个导演请用逗号分隔" />
+ </Form.Item>
+
+ <Form.Item
+ name="actors"
+ label="主演"
+ >
+ <Input placeholder="请输入主演名称,多个主演请用逗号分隔" />
+ </Form.Item>
+
+ <Form.Item
+ name="movieType"
+ label="电影类型"
+ rules={[{ required: true, message: "请选择电影类型" }]}
+ >
+ <Select placeholder="请选择电影类型" mode="multiple">
+ <Option value="action">动作</Option>
+ <Option value="comedy">喜剧</Option>
+ <Option value="romance">爱情</Option>
+ <Option value="sci-fi">科幻</Option>
+ <Option value="horror">恐怖</Option>
+ <Option value="drama">剧情</Option>
+ <Option value="war">战争</Option>
+ <Option value="documentary">纪录</Option>
+ <Option value="animation">动画</Option>
+ <Option value="thriller">惊悚</Option>
+ <Option value="crime">犯罪</Option>
+ <Option value="fantasy">奇幻</Option>
+ <Option value="adventure">冒险</Option>
+ <Option value="history">历史</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="resolution"
+ label="分辨率"
+ rules={[{ required: true, message: "请选择分辨率" }]}
+ >
+ <Select placeholder="请选择分辨率">
+ <Option value="4K">4K</Option>
+ <Option value="2K">2K</Option>
+ <Option value="1080p">1080p</Option>
+ <Option value="720p">720p</Option>
+ <Option value="480p">480p</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="source"
+ label="片源"
+ rules={[{ required: true, message: "请选择片源" }]}
+ >
+ <Select placeholder="请选择片源">
+ <Option value="bluray">蓝光原盘</Option>
+ <Option value="remux">蓝光重灌</Option>
+ <Option value="encode">压制</Option>
+ <Option value="web-dl">WEB-DL</Option>
+ <Option value="dvd">DVD</Option>
+ <Option value="hdtv">HDTV</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="duration"
+ label="片长(分钟)"
+ >
+ <InputNumber min={1} placeholder="请输入片长" />
+ </Form.Item>
+
+ <Form.Item
+ name="imdbId"
+ label="IMDb ID"
+ >
+ <Input placeholder="例如:tt0111161" />
+ </Form.Item>
+
+ <Form.Item
+ name="doubanId"
+ label="豆瓣ID"
+ >
+ <Input placeholder="例如:1292052" />
+ </Form.Item>
+ </>
+);
+
+// 剧集特定表单项
+const TVFormItems = () => (
+ <>
+ <Form.Item
+ name="season"
+ label="季数"
+ >
+ <InputNumber min={1} placeholder="第几季" />
+ </Form.Item>
+
+ <Form.Item
+ name="episode"
+ label="集数"
+ >
+ <Input placeholder="例如:1-12、全集、SP" />
+ </Form.Item>
+
+ <Form.Item
+ name="isComplete"
+ valuePropName="checked"
+ >
+ <Checkbox>已完结</Checkbox>
+ </Form.Item>
+
+ <Form.Item
+ name="director"
+ label="导演"
+ >
+ <Input placeholder="请输入导演名称,多个导演请用逗号分隔" />
+ </Form.Item>
+
+ <Form.Item
+ name="actors"
+ label="主演"
+ >
+ <Input placeholder="请输入主演名称,多个主演请用逗号分隔" />
+ </Form.Item>
+
+ <Form.Item
+ name="tvType"
+ label="剧集类型"
+ rules={[{ required: true, message: "请选择剧集类型" }]}
+ >
+ <Select placeholder="请选择剧集类型" mode="multiple">
+ <Option value="action">动作</Option>
+ <Option value="comedy">喜剧</Option>
+ <Option value="romance">爱情</Option>
+ <Option value="sci-fi">科幻</Option>
+ <Option value="horror">恐怖</Option>
+ <Option value="drama">剧情</Option>
+ <Option value="crime">犯罪</Option>
+ <Option value="fantasy">奇幻</Option>
+ <Option value="adventure">冒险</Option>
+ <Option value="mystery">悬疑</Option>
+ <Option value="thriller">惊悚</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="resolution"
+ label="分辨率"
+ rules={[{ required: true, message: "请选择分辨率" }]}
+ >
+ <Select placeholder="请选择分辨率">
+ <Option value="4K">4K</Option>
+ <Option value="2K">2K</Option>
+ <Option value="1080p">1080p</Option>
+ <Option value="720p">720p</Option>
+ <Option value="480p">480p</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="source"
+ label="片源"
+ rules={[{ required: true, message: "请选择片源" }]}
+ >
+ <Select placeholder="请选择片源">
+ <Option value="bluray">蓝光原盘</Option>
+ <Option value="remux">蓝光重灌</Option>
+ <Option value="encode">压制</Option>
+ <Option value="web-dl">WEB-DL</Option>
+ <Option value="dvd">DVD</Option>
+ <Option value="hdtv">HDTV</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="imdbId"
+ label="IMDb ID"
+ >
+ <Input placeholder="例如:tt0111161" />
+ </Form.Item>
+
+ <Form.Item
+ name="doubanId"
+ label="豆瓣ID"
+ >
+ <Input placeholder="例如:1292052" />
+ </Form.Item>
+ </>
+);
+
+// 音乐特定表单项
+const MusicFormItems = () => (
+ <>
+ <Form.Item
+ name="artist"
+ label="艺术家"
+ rules={[{ required: true, message: "请输入艺术家名称" }]}
+ >
+ <Input placeholder="请输入艺术家名称" />
+ </Form.Item>
+
+ <Form.Item
+ name="album"
+ label="专辑名称"
+ >
+ <Input placeholder="请输入专辑名称(如果是专辑)" />
+ </Form.Item>
+
+ <Form.Item
+ name="musicType"
+ label="音乐类型"
+ rules={[{ required: true, message: "请选择音乐类型" }]}
+ >
+ <Select placeholder="请选择音乐类型" mode="multiple">
+ <Option value="pop">流行</Option>
+ <Option value="rock">摇滚</Option>
+ <Option value="electronic">电子</Option>
+ <Option value="folk">民谣</Option>
+ <Option value="hip-hop">嘻哈</Option>
+ <Option value="classical">古典</Option>
+ <Option value="jazz">爵士</Option>
+ <Option value="soundtrack">原声带</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="format"
+ label="音频格式"
+ rules={[{ required: true, message: "请选择音频格式" }]}
+ >
+ <Select placeholder="请选择音频格式">
+ <Option value="flac">FLAC</Option>
+ <Option value="ape">APE</Option>
+ <Option value="wav">WAV</Option>
+ <Option value="mp3">MP3</Option>
+ <Option value="aac">AAC</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="bitrate"
+ label="比特率"
+ >
+ <Select placeholder="请选择比特率">
+ <Option value="lossless">无损</Option>
+ <Option value="320">320Kbps</Option>
+ <Option value="256">256Kbps</Option>
+ <Option value="192">192Kbps</Option>
+ <Option value="128">128Kbps</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="tracks"
+ label="曲目数"
+ >
+ <InputNumber min={1} placeholder="请输入曲目数量" />
+ </Form.Item>
+ </>
+);
+
+// 动漫特定表单项
+const AnimeFormItems = () => (
+ <>
+ <Form.Item
+ name="season"
+ label="季数"
+ >
+ <InputNumber min={1} placeholder="第几季" />
+ </Form.Item>
+
+ <Form.Item
+ name="episode"
+ label="集数"
+ >
+ <Input placeholder="例如:1-12、全集、SP、剧场版" />
+ </Form.Item>
+
+ <Form.Item
+ name="isComplete"
+ valuePropName="checked"
+ >
+ <Checkbox>已完结</Checkbox>
+ </Form.Item>
+
+ <Form.Item
+ name="animeType"
+ label="动漫类型"
+ rules={[{ required: true, message: "请选择动漫类型" }]}
+ >
+ <Select placeholder="请选择动漫类型" mode="multiple">
+ <Option value="tv">TV版</Option>
+ <Option value="movie">剧场版</Option>
+ <Option value="ova">OVA</Option>
+ <Option value="special">特别篇</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="resolution"
+ label="分辨率"
+ rules={[{ required: true, message: "请选择分辨率" }]}
+ >
+ <Select placeholder="请选择分辨率">
+ <Option value="4K">4K</Option>
+ <Option value="2K">2K</Option>
+ <Option value="1080p">1080p</Option>
+ <Option value="720p">720p</Option>
+ <Option value="480p">480p</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="source"
+ label="片源"
+ rules={[{ required: true, message: "请选择片源" }]}
+ >
+ <Select placeholder="请选择片源">
+ <Option value="bluray">蓝光原盘</Option>
+ <Option value="remux">蓝光重灌</Option>
+ <Option value="encode">压制</Option>
+ <Option value="web-dl">WEB-DL</Option>
+ <Option value="dvd">DVD</Option>
+ <Option value="hdtv">HDTV</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="subTeam"
+ label="字幕组"
+ >
+ <Input placeholder="请输入字幕组名称" />
+ </Form.Item>
+ </>
+);
+
+// 游戏特定表单项
+const GameFormItems = () => (
+ <>
+ <Form.Item
+ name="platform"
+ label="平台"
+ rules={[{ required: true, message: "请选择游戏平台" }]}
+ >
+ <Select placeholder="请选择游戏平台" mode="multiple">
+ <Option value="pc">PC</Option>
+ <Option value="ps5">PS5</Option>
+ <Option value="ps4">PS4</Option>
+ <Option value="ps3">PS3</Option>
+ <Option value="xbox-series">Xbox Series X/S</Option>
+ <Option value="xbox-one">Xbox One</Option>
+ <Option value="switch">Nintendo Switch</Option>
+ <Option value="ns">Nintendo Switch</Option>
+ <Option value="3ds">Nintendo 3DS</Option>
+ <Option value="mobile">手机</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="gameType"
+ label="游戏类型"
+ rules={[{ required: true, message: "请选择游戏类型" }]}
+ >
+ <Select placeholder="请选择游戏类型" mode="multiple">
+ <Option value="rpg">角色扮演</Option>
+ <Option value="action">动作</Option>
+ <Option value="fps">第一人称射击</Option>
+ <Option value="tps">第三人称射击</Option>
+ <Option value="strategy">策略</Option>
+ <Option value="simulation">模拟</Option>
+ <Option value="adventure">冒险</Option>
+ <Option value="sports">体育</Option>
+ <Option value="racing">竞速</Option>
+ <Option value="fighting">格斗</Option>
+ <Option value="other">其他</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="version"
+ label="版本"
+ >
+ <Input placeholder="请输入游戏版本,例如:V1.02.3" />
+ </Form.Item>
+
+ <Form.Item
+ name="developer"
+ label="开发商"
+ >
+ <Input placeholder="请输入游戏开发商" />
+ </Form.Item>
+
+ <Form.Item
+ name="publisher"
+ label="发行商"
+ >
+ <Input placeholder="请输入游戏发行商" />
+ </Form.Item>
+
+ <Form.Item
+ name="language"
+ label="语言"
+ rules={[{ required: true, message: "请选择游戏语言" }]}
+ >
+ <Select placeholder="请选择游戏语言" mode="multiple">
+ <Option value="chinese">中文</Option>
+ <Option value="english">英文</Option>
+ <Option value="japanese">日文</Option>
+ <Option value="korean">韩文</Option>
+ <Option value="french">法文</Option>
+ <Option value="german">德文</Option>
+ <Option value="russian">俄文</Option>
+ <Option value="multi">多语言</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="hasCrack"
+ valuePropName="checked"
+ >
+ <Checkbox>已破解/免安装</Checkbox>
+ </Form.Item>
+ </>
+);
+
+// 其他类型特定的表单项(简化)
+const OtherFormItems = ({ category }) => {
+ if (category === "variety") {
+ return (
+ <>
+ <Form.Item name="host" label="主持人">
+ <Input placeholder="请输入主持人名称" />
+ </Form.Item>
+ <Form.Item name="episode" label="期数">
+ <Input placeholder="例如:第1-12期、20200101" />
+ </Form.Item>
+ </>
+ );
+ }
+
+ if (category === "sports") {
+ return (
+ <>
+ <Form.Item name="league" label="联赛/赛事名称">
+ <Input placeholder="请输入联赛或赛事名称" />
+ </Form.Item>
+ <Form.Item name="match" label="比赛">
+ <Input placeholder="例如:队伍A vs 队伍B" />
+ </Form.Item>
+ </>
+ );
+ }
+
+ if (category === "software") {
+ return (
+ <>
+ <Form.Item name="version" label="版本">
+ <Input placeholder="请输入软件版本" />
+ </Form.Item>
+ <Form.Item name="os" label="操作系统">
+ <Select placeholder="请选择操作系统" mode="multiple">
+ <Option value="windows">Windows</Option>
+ <Option value="macos">macOS</Option>
+ <Option value="linux">Linux</Option>
+ <Option value="android">Android</Option>
+ <Option value="ios">iOS</Option>
+ </Select>
+ </Form.Item>
+ </>
+ );
+ }
+
+ if (category === "learning") {
+ return (
+ <>
+ <Form.Item name="subject" label="学科/主题">
+ <Input placeholder="请输入学科或主题" />
+ </Form.Item>
+ <Form.Item name="level" label="难度级别">
+ <Select placeholder="请选择难度级别">
+ <Option value="beginner">入门</Option>
+ <Option value="intermediate">中级</Option>
+ <Option value="advanced">高级</Option>
+ </Select>
+ </Form.Item>
+ </>
+ );
+ }
+
+ if (category === "documentary") {
+ return (
+ <>
+ <Form.Item name="docType" label="纪录片类型">
+ <Select placeholder="请选择纪录片类型" mode="multiple">
+ <Option value="nature">自然</Option>
+ <Option value="history">历史</Option>
+ <Option value="science">科学</Option>
+ <Option value="culture">人文</Option>
+ <Option value="society">社会</Option>
+ <Option value="biography">传记</Option>
+ </Select>
+ </Form.Item>
+ <Form.Item name="episodes" label="集数">
+ <Input placeholder="例如:共8集" />
+ </Form.Item>
+ </>
+ );
+ }
+
+ return null;
+};
+
+// 主组件
+const UploadTorrentPage = () => {
+ const [form] = Form.useForm();
+ const [activeCategory, setActiveCategory] = useState("notice");
+
+ const onFinish = (values) => {
+ console.log("提交的表单数据:", values);
+ message.success("种子上传成功!");
+ };
+
+ const renderFormByCategory = (category) => {
+ if (category === "notice") {
+ return <NoticeContent />;
+ }
+
+ return (
+ <Form
+ form={form}
+ layout="vertical"
+ onFinish={onFinish}
+ scrollToFirstError
+ >
+ <CategoryRules category={category} />
+
+ <BaseFormItems category={category} />
+
+ {/* 根据类别渲染特定的表单项 */}
+ {category === "movie" && <MovieFormItems />}
+ {category === "tv" && <TVFormItems />}
+ {category === "music" && <MusicFormItems />}
+ {category === "anime" && <AnimeFormItems />}
+ {category === "game" && <GameFormItems />}
+ {["variety", "sports", "software", "learning", "documentary", "other"].includes(category) && (
+ <OtherFormItems category={category} />
+ )}
+
+ <Divider />
+
+ <Form.Item>
+ <Space>
+ <Button type="primary" htmlType="submit">
+ 提交
+ </Button>
+ <Button onClick={() => form.resetFields()}>重置</Button>
+ </Space>
+ </Form.Item>
+ </Form>
+ );
+ };
+
+ return (
+ <div className="space-y-6">
+ <Title level={2}>发布种子</Title>
+
+ <Tabs activeKey={activeCategory} onChange={setActiveCategory} type="card">
+ <TabPane tab="注意事项" key="notice" />
+ <TabPane tab="电影" key="movie" />
+ <TabPane tab="剧集" key="tv" />
+ <TabPane tab="音乐" key="music" />
+ <TabPane tab="动漫" key="anime" />
+ <TabPane tab="游戏" key="game" />
+ <TabPane tab="综艺" key="variety" />
+ <TabPane tab="体育" key="sports" />
+ <TabPane tab="软件" key="software" />
+ <TabPane tab="学习" key="learning" />
+ <TabPane tab="纪录片" key="documentary" />
+ <TabPane tab="其他" key="other" />
+ </Tabs>
+
+ <div>{renderFormByCategory(activeCategory)}</div>
+ </div>
+ );
+};
+
+export default UploadTorrentPage;
\ No newline at end of file