保存本地对routes.ts的修改
Change-Id: I4f4dbd8069893d7363e251130791dc0594be44e1
diff --git a/src/api/categoryApi.ts b/src/api/categoryApi.ts
new file mode 100644
index 0000000..816ac71
--- /dev/null
+++ b/src/api/categoryApi.ts
@@ -0,0 +1,90 @@
+import axios from 'axios';
+import type { CategoryDTO, Category, WorkResponse } from './otherType';
+
+const API_BASE_URL = '/api';
+
+class CategoryAPI {
+ /**
+ * 获取全部分区(树形结构)
+ * @returns 返回分区的树形结构数据
+ */
+ static getCategoryTree(): Promise<CategoryDTO[]> {
+ return axios.get<CategoryDTO[]>(`${API_BASE_URL}/categories`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 获取单个分区详情
+ * @param id 分区ID
+ * @returns 返回单个分区的详细信息
+ */
+ static getCategory(id: number): Promise<Category> {
+ return axios.get<Category>(`${API_BASE_URL}/categories/${id}`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 创建新分区
+ * @param category 分区数据(不包含ID)
+ * @returns 返回创建成功的消息
+ */
+ static createCategory(category: Omit<Category, 'id'>): Promise<string> {
+ return axios.post<string>(`${API_BASE_URL}/categories`, category)
+ .then(response => response.data);
+ }
+
+ /**
+ * 更新分区信息
+ * @param id 分区ID
+ * @param category 更新后的分区数据
+ * @returns 返回更新成功的消息
+ */
+ static updateCategory(id: number, category: Partial<Category>): Promise<string> {
+ return axios.put<string>(`${API_BASE_URL}/categories/${id}`, category)
+ .then(response => response.data);
+ }
+
+ /**
+ * 删除分区
+ * @param id 分区ID
+ * @returns 返回删除成功的消息
+ */
+ static deleteCategory(id: number): Promise<string> {
+ return axios.delete<string>(`${API_BASE_URL}/categories/${id}`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 获取分区下的作品列表(带分页)
+ * @param categoryId 分区ID
+ * @param params 分页和排序参数
+ * @returns 返回作品列表和分页信息
+ */
+ static getWorksByCategory(
+ categoryId: number,
+ params?: {
+ page?: number;
+ size?: number;
+ sort?: string;
+ }
+ ): Promise<WorkResponse> {
+ return axios.get<WorkResponse>(`${API_BASE_URL}/works`, {
+ params: {
+ categoryId,
+ ...params
+ }
+ }).then(response => response.data);
+ }
+
+ /**
+ * 获取子分区列表
+ * @param parentId 父分区ID
+ * @returns 返回子分区列表
+ */
+ static getSubCategories(parentId: number): Promise<CategoryDTO[]> {
+ return axios.get<CategoryDTO[]>(`${API_BASE_URL}/categories/${parentId}/children`)
+ .then(response => response.data);
+ }
+}
+
+export default CategoryAPI;
\ No newline at end of file
diff --git a/src/api/categoryTypes.ts b/src/api/categoryTypes.ts
new file mode 100644
index 0000000..4a46034
--- /dev/null
+++ b/src/api/categoryTypes.ts
@@ -0,0 +1,67 @@
+import type { ReactNode } from "react";
+
+// 基础分类接口
+export interface CategoryDTO {
+ id: number;
+ name: string;
+ description?: string;
+ parentId?: number; // 移除了 null 类型,只保留 number | undefined
+ children?: CategoryDTO[];
+ workCount?: number;
+}
+
+
+// 基础资源接口
+interface BaseResource {
+ id: number;
+ title: string;
+ categoryId: number;
+ categoryName: string;
+ uploadDate: string;
+ uploader: string;
+ size: string;
+ seeders: number;
+ leechers: number;
+ downloadCount: number;
+ hash: string;
+}
+
+// 音乐资源
+export interface MusicResource extends BaseResource {
+ category: ReactNode;
+ artist: string;
+ quality: 'MP3 128k' | 'MP3 320k' | 'FLAC' | 'APE' | 'Hi-Res' | 'DSD';
+ genre: string[];
+}
+
+// 影视资源
+export interface MovieResource extends BaseResource {
+ director: string;
+ actors: string[];
+ resolution: '1080p' | '720p' | '4K' | '8K';
+ duration: string;
+}
+
+// 游戏资源
+export interface GameResource extends BaseResource {
+ developer: string;
+ publisher: string;
+ platform: 'PC' | 'PS' | 'Xbox' | 'Switch';
+ releaseDate: string;
+}
+
+// 其他资源
+export interface OtherResource extends BaseResource {
+ description: string;
+ fileType: string;
+}
+
+// 分类DTO
+export interface CategoryDTO {
+ id: number;
+ name: string;
+ description?: string;
+ parentId?: number;
+ children?: CategoryDTO[];
+ workCount?: number;
+}
\ No newline at end of file
diff --git a/src/api/commentApi.ts b/src/api/commentApi.ts
new file mode 100644
index 0000000..360e659
--- /dev/null
+++ b/src/api/commentApi.ts
@@ -0,0 +1,77 @@
+import axios from 'axios';
+import type { Comment, CommentFormData, CommentReply, ReplyFormData } from './otherType';
+
+const API_BASE_URL = '/api';
+
+class CommentAPI {
+ /**
+ * 添加评论
+ */
+ static addComment(data: CommentFormData): Promise<Comment> {
+ return axios.post<Comment>(`${API_BASE_URL}/comments`, data)
+ .then(response => response.data);
+ }
+
+ /**
+ * 删除评论
+ */
+ static deleteComment(commentId: number): Promise<void> {
+ return axios.delete(`${API_BASE_URL}/comments/${commentId}`);
+ }
+
+ /**
+ * 更新评论
+ */
+ static updateComment(commentId: number, data: CommentFormData): Promise<Comment> {
+ return axios.put<Comment>(`${API_BASE_URL}/comments/${commentId}`, data)
+ .then(response => response.data);
+ }
+
+ /**
+ * 获取单个评论
+ */
+ static getCommentById(commentId: number): Promise<Comment> {
+ return axios.get<Comment>(`${API_BASE_URL}/comments/${commentId}`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 获取帖子下的所有评论
+ */
+ static getCommentsByPostId(postId: number): Promise<Comment[]> {
+ return axios.get<Comment[]>(`${API_BASE_URL}/comments/post/${postId}`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 添加回复
+ */
+ static addReply(commentId: number, data: ReplyFormData): Promise<CommentReply> {
+ return axios.post<CommentReply>(`${API_BASE_URL}/comments/${commentId}/replies`, data)
+ .then(response => response.data);
+ }
+
+ /**
+ * 获取评论的所有回复
+ */
+ static getReplies(commentId: number): Promise<CommentReply[]> {
+ return axios.get<CommentReply[]>(`${API_BASE_URL}/comments/${commentId}/replies`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 点赞评论
+ */
+ static likeComment(commentId: number): Promise<void> {
+ return axios.post(`${API_BASE_URL}/comments/${commentId}/like`);
+ }
+
+ /**
+ * 举报评论
+ */
+ static reportComment(commentId: number, reason: string): Promise<void> {
+ return axios.post(`${API_BASE_URL}/comments/${commentId}/report`, { reason });
+ }
+}
+
+export default CommentAPI;
\ No newline at end of file
diff --git a/src/api/otherType.ts b/src/api/otherType.ts
new file mode 100644
index 0000000..60523d0
--- /dev/null
+++ b/src/api/otherType.ts
@@ -0,0 +1,164 @@
+// 认证相关类型
+export interface LoginRequest {
+ email: string;
+ password: string;
+}
+
+export interface LoginResponse {
+ user: string;
+ token: string;
+ refreshToken: string;
+}
+
+export interface CommonResponse<T= null> {
+ code: number;
+ message: string;
+ data: T;
+}
+
+// 作品相关类型
+export interface Work {
+ size: string;
+ data(data: any): unknown;
+ artist: string;
+ quality: string;
+ genre: string[];
+ id: number;
+ title: string;
+ author: string;
+ authorId: number;
+ views: number;
+ likes: number;
+ categoryId: number;
+ categoryName: string;
+ description: string;
+ content: string;
+ coverUrl: string;
+ createTime: string;
+ attachments: Attachment[];
+ bugCount: number;
+ discussionCount: number;
+}
+
+export interface WorkResponse {
+ content: Work[];
+ totalElements: number;
+ totalPages: number;
+}
+
+export interface WorkFormData {
+ title: string;
+ categoryId: number;
+ description: string;
+ content: string;
+ coverFile?: File;
+ attachments?: File[];
+}
+
+// Bug反馈相关类型
+export interface BugReport {
+ id: number;
+ title: string;
+ description: string;
+ severity: 'low' | 'medium' | 'high';
+ status: 'open' | 'resolved' | 'wontfix';
+ reporter: string;
+ reporterId: number;
+ workId: number;
+ createTime: string;
+}
+
+// 讨论区相关类型
+export interface Discussion {
+ id: number;
+ title: string;
+ content: string;
+ author: string;
+ authorId: number;
+ workId: number;
+ isPinned: boolean;
+ commentCount: number;
+ createTime: string;
+ lastUpdateTime: string;
+}
+
+export interface DiscussionFormData {
+ workId: number;
+ title: string;
+ content: string;
+}
+
+// 评论相关类型
+export interface Comment {
+ id: number;
+ author: string;
+ avatar: string;
+ content: string;
+ likes: number;
+ replyCount: number;
+ createTime: string;
+ replies?: CommentReply[];
+}
+
+export interface CommentFormData {
+ postId: number; // 可以是作品ID或讨论区ID
+ content: string;
+ parentId?: number; // 用于回复评论
+}
+
+export interface CommentReply {
+ id: number;
+ author: string;
+ avatar: string;
+ content: string;
+ likes: number;
+ createTime: string;
+}
+
+export interface ReplyFormData {
+ content: string;
+}
+
+// 分区相关类型
+export interface Category {
+ id: number;
+ name: string;
+ description?: string;
+ parentId?: number | null;
+ coverUrl?: string;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface CategoryDTO extends Category {
+ children?: CategoryDTO[];
+ workCount?: number;
+}
+
+// 用户相关类型
+export interface User {
+ id: number;
+ username: string;
+ email: string;
+ avatar?: string;
+ role: 'user' | 'admin';
+ createdAt: string;
+ worksCount: number;
+ completedWorks: number;
+}
+
+// 通用类型
+export interface Attachment {
+ id: number;
+ name: string;
+ url: string;
+ size: string;
+}
+
+// API响应通用类型
+export interface ApiResponse<T> {
+ success: boolean;
+ data?: T;
+ message?: string;
+ error?: string;
+}
\ No newline at end of file
diff --git a/src/api/workApi.ts b/src/api/workApi.ts
new file mode 100644
index 0000000..d624821
--- /dev/null
+++ b/src/api/workApi.ts
@@ -0,0 +1,105 @@
+import axios from 'axios';
+import type { Work, WorkResponse, WorkFormData, BugReport, Discussion } from './otherType';
+
+const API_BASE_URL = '/api';
+
+class WorkAPI {
+ /**
+ * 获取作品列表(支持分页、分类筛选和搜索)
+ */
+ static getWorks(params?: {
+ categoryId?: number;
+ page?: number;
+ size?: number;
+ search?: string;
+ }): Promise<WorkResponse> {
+ return axios.get<WorkResponse>(`${API_BASE_URL}/works`, { params })
+ .then(response => response.data);
+ }
+
+ /**
+ * 获取单个作品详情
+ */
+ static getWorkById(id: number): Promise<Work> {
+ return axios.get<Work>(`${API_BASE_URL}/works/${id}`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 创建新作品
+ */
+ static createWork(data: WorkFormData): Promise<Work> {
+ return axios.post<Work>(`${API_BASE_URL}/works`, data)
+ .then(response => response.data);
+ }
+
+ /**
+ * 更新作品信息
+ */
+ static updateWork(id: number, data: WorkFormData): Promise<Work> {
+ return axios.put<Work>(`${API_BASE_URL}/works/${id}`, data)
+ .then(response => response.data);
+ }
+
+ /**
+ * 删除作品
+ */
+ static deleteWork(id: number): Promise<void> {
+ return axios.delete(`${API_BASE_URL}/works/${id}`);
+ }
+
+ /**
+ * 点赞作品
+ */
+ static likeWork(id: number): Promise<void> {
+ return axios.post(`${API_BASE_URL}/works/${id}/like`);
+ }
+
+ /**
+ * 根据作者查询作品
+ */
+ static getWorksByAuthor(author: string): Promise<Work[]> {
+ return axios.get<Work[]>(`${API_BASE_URL}/works/byAuthor`, { params: { author } })
+ .then(response => response.data);
+ }
+
+ /**
+ * 获取作品Bug反馈列表
+ */
+ static getBugReports(workId: number): Promise<BugReport[]> {
+ return axios.get<BugReport[]>(`${API_BASE_URL}/works/${workId}/bugs`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 提交Bug反馈
+ */
+ static reportBug(workId: number, data: {
+ title: string;
+ description: string;
+ severity: 'low' | 'medium' | 'high';
+ }): Promise<void> {
+ return axios.post(`${API_BASE_URL}/works/${workId}/bugs`, data);
+ }
+
+ /**
+ * 获取作品讨论区列表
+ */
+ static getDiscussions(workId: number): Promise<Discussion[]> {
+ return axios.get<Discussion[]>(`${API_BASE_URL}/works/${workId}/discussions`)
+ .then(response => response.data);
+ }
+
+ /**
+ * 创建新讨论
+ */
+ static createDiscussion(data: {
+ workId: number;
+ title: string;
+ content: string;
+ }): Promise<void> {
+ return axios.post(`${API_BASE_URL}/discussions`, data);
+ }
+}
+
+export default WorkAPI;
\ No newline at end of file
diff --git a/src/assets/categories/film.jpg b/src/assets/categories/film.jpg
new file mode 100644
index 0000000..3d05b4d
--- /dev/null
+++ b/src/assets/categories/film.jpg
Binary files differ
diff --git a/src/assets/categories/game.jpg b/src/assets/categories/game.jpg
new file mode 100644
index 0000000..15571fe
--- /dev/null
+++ b/src/assets/categories/game.jpg
Binary files differ
diff --git a/src/assets/categories/music.jpg b/src/assets/categories/music.jpg
new file mode 100644
index 0000000..47dda2c
--- /dev/null
+++ b/src/assets/categories/music.jpg
Binary files differ
diff --git a/src/assets/categories/other.jpg b/src/assets/categories/other.jpg
new file mode 100644
index 0000000..b7385c0
--- /dev/null
+++ b/src/assets/categories/other.jpg
Binary files differ
diff --git a/src/components/BugReportSection.tsx b/src/components/BugReportSection.tsx
new file mode 100644
index 0000000..a512fc6
--- /dev/null
+++ b/src/components/BugReportSection.tsx
@@ -0,0 +1,159 @@
+import React, { useState, useEffect } from 'react';
+import { List, Button, Form, Input, Select, message, Typography, Tag, Divider, Pagination } from 'antd';
+import { BugOutlined } from '@ant-design/icons';
+import WorkAPI from '../api/workApi';
+import type { BugReport } from '../api/otherType';
+
+const { Text } = Typography;
+const { Option } = Select;
+const { TextArea } = Input;
+
+interface BugReportForm {
+ title: string;
+ description: string;
+ severity: 'low' | 'medium' | 'high';
+}
+
+const BugReportSection: React.FC<{ workId: number }> = ({ workId }) => {
+ const [bugs, setBugs] = useState<BugReport[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [form] = Form.useForm();
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(5); // 每页显示5条
+
+ // 加载Bug反馈列表
+ useEffect(() => {
+ const loadBugs = async () => {
+ try {
+ setLoading(true);
+ const bugList = await WorkAPI.getBugReports(workId);
+ setBugs(bugList);
+ } catch (error) {
+ message.error('加载Bug反馈失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadBugs();
+ }, [workId]);
+
+ // 获取当前页的Bug数据
+ const getCurrentPageBugs = () => {
+ const startIndex = (currentPage - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ return bugs.slice(startIndex, endIndex);
+ };
+
+ // 提交Bug反馈
+ const handleSubmit = async (values: BugReportForm) => {
+ try {
+ await WorkAPI.reportBug(workId, values);
+ message.success('Bug反馈提交成功');
+ form.resetFields();
+ // 重新加载Bug列表
+ const bugList = await WorkAPI.getBugReports(workId);
+ setBugs(bugList);
+ // 重置到第一页
+ setCurrentPage(1);
+ } catch (error) {
+ message.error('Bug反馈提交失败');
+ }
+ };
+
+ const getSeverityColor = (severity: string) => {
+ switch (severity) {
+ case 'high': return 'red';
+ case 'medium': return 'orange';
+ default: return 'green';
+ }
+ };
+
+ return (
+ <div className="bug-report-section">
+ <Divider orientation="left">
+ <BugOutlined /> Bug反馈
+ </Divider>
+
+ <Form form={form} onFinish={handleSubmit} layout="vertical">
+ <Form.Item
+ name="title"
+ label="Bug标题"
+ rules={[{ required: true, message: '请输入Bug标题' }]}
+ >
+ <Input placeholder="简要描述Bug" />
+ </Form.Item>
+
+ <Form.Item
+ name="severity"
+ label="严重程度"
+ initialValue="medium"
+ rules={[{ required: true }]}
+ >
+ <Select>
+ <Option value="low">低</Option>
+ <Option value="medium">中</Option>
+ <Option value="high">高</Option>
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="description"
+ label="详细描述"
+ rules={[{ required: true, message: '请详细描述Bug' }]}
+ >
+ <TextArea rows={4} placeholder="请详细描述Bug出现的步骤、现象和预期结果" />
+ </Form.Item>
+
+ <Form.Item>
+ <Button type="primary" htmlType="submit">
+ 提交Bug
+ </Button>
+ </Form.Item>
+ </Form>
+
+ <Divider orientation="left">Bug列表</Divider>
+
+ <List
+ dataSource={getCurrentPageBugs()}
+ loading={loading}
+ renderItem={bug => (
+ <List.Item>
+ <div className="bug-item">
+ <div className="bug-header">
+ <Text strong>{bug.title}</Text>
+ <Tag color={getSeverityColor(bug.severity)}>
+ {bug.severity === 'high' ? '严重' : bug.severity === 'medium' ? '中等' : '轻微'}
+ </Tag>
+ </div>
+ <Text type="secondary">报告人: {bug.reporter} | 时间: {new Date(bug.createTime).toLocaleString()}</Text>
+ <div className="bug-content">
+ <Text>{bug.description}</Text>
+ </div>
+ <Tag color={bug.status === 'open' ? 'red' : bug.status === 'resolved' ? 'green' : 'gray'}>
+ {bug.status === 'open' ? '未解决' : bug.status === 'resolved' ? '已解决' : '不予解决'}
+ </Tag>
+ </div>
+ </List.Item>
+ )}
+ />
+
+ <div style={{ textAlign: 'right', marginTop: 16 }}>
+ <Pagination
+ current={currentPage}
+ pageSize={pageSize}
+ total={bugs.length}
+ onChange={(page, size) => {
+ setCurrentPage(page);
+ setPageSize(size);
+ }}
+ showSizeChanger
+ showQuickJumper
+ showTotal={total => `共 ${total} 条`}
+ />
+ </div>
+ </div>
+ );
+};
+
+export default BugReportSection;
\ No newline at end of file
diff --git a/src/components/CustomComment.css b/src/components/CustomComment.css
new file mode 100644
index 0000000..8f9a012
--- /dev/null
+++ b/src/components/CustomComment.css
@@ -0,0 +1,54 @@
+/* src/components/CustomComment.css */
+.custom-comment {
+ margin-bottom: 16px;
+ padding: 16px 0;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.custom-comment:last-child {
+ border-bottom: none;
+}
+
+.custom-comment-inner {
+ display: flex;
+}
+
+.custom-comment-avatar {
+ margin-right: 12px;
+}
+
+.custom-comment-content {
+ flex: 1;
+}
+
+.custom-comment-header {
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+}
+
+.custom-comment-author {
+ font-weight: 500;
+ color: rgba(0, 0, 0, 0.85);
+}
+
+.custom-comment-datetime {
+ margin-left: 12px;
+ font-size: 12px;
+ color: rgba(0, 0, 0, 0.45);
+}
+
+.custom-comment-text {
+ margin-bottom: 8px;
+ color: rgba(0, 0, 0, 0.85);
+ white-space: pre-line;
+}
+
+.custom-comment-actions {
+ margin-top: 8px;
+}
+
+.custom-comment-action {
+ padding: 0 4px;
+ font-size: 12px;
+}
\ No newline at end of file
diff --git a/src/components/DiscussionSection.tsx b/src/components/DiscussionSection.tsx
new file mode 100644
index 0000000..7fd39c4
--- /dev/null
+++ b/src/components/DiscussionSection.tsx
@@ -0,0 +1,190 @@
+import React, { useState, useEffect } from 'react';
+import { List, Button, Form, Input, message, Avatar, Typography, Modal, Pagination } from 'antd';
+import { LikeOutlined, MessageOutlined } from '@ant-design/icons';
+import CommentAPI from '../api/commentApi';
+import type { Comment } from '../api/otherType';
+
+const { Text } = Typography;
+const { TextArea } = Input;
+
+interface DiscussionSectionProps {
+ workId: number;
+}
+
+const DiscussionSection: React.FC<DiscussionSectionProps> = ({ workId }) => {
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [modalVisible, setModalVisible] = useState(false);
+ const [form] = Form.useForm();
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(5); // 每页显示5条
+
+ // 加载评论
+ useEffect(() => {
+ const loadComments = async () => {
+ try {
+ setLoading(true);
+ const commentList = await CommentAPI.getCommentsByPostId(workId);
+ setComments(commentList);
+ } catch (error) {
+ message.error('加载评论失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadComments();
+ }, [workId]);
+
+ // 获取当前页的评论数据
+ const getCurrentPageComments = () => {
+ const startIndex = (currentPage - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ return comments.slice(startIndex, endIndex);
+ };
+
+ // 提交新评论
+ const handleSubmit = async (values: { title: string; content: string }) => {
+ try {
+ await CommentAPI.addComment({
+ postId: workId,
+ content: values.content
+ });
+ message.success('评论添加成功');
+ form.resetFields();
+ setModalVisible(false);
+ // 重新加载评论
+ const commentList = await CommentAPI.getCommentsByPostId(workId);
+ setComments(commentList);
+ // 重置到第一页
+ setCurrentPage(1);
+ } catch (error) {
+ message.error('评论添加失败');
+ }
+ };
+
+ // 点赞评论
+ const handleLikeComment = async (commentId: number) => {
+ try {
+ await CommentAPI.likeComment(commentId);
+ setComments(prev => prev.map(comment =>
+ comment.id === commentId
+ ? { ...comment, likes: (comment.likes || 0) + 1 }
+ : comment
+ ));
+ } catch (error) {
+ message.error('点赞失败');
+ }
+ };
+
+ return (
+ <div className="discussion-section">
+ <Button
+ type="primary"
+ icon={<MessageOutlined />}
+ onClick={() => setModalVisible(true)}
+ style={{ marginBottom: 16 }}
+ >
+ 新建讨论
+ </Button>
+
+ <List
+ dataSource={getCurrentPageComments()}
+ loading={loading}
+ renderItem={comment => (
+ <List.Item className="comment-item">
+ <div className="comment-content">
+ <div className="comment-header">
+ <Avatar size="small">
+ {comment.author?.charAt(0) || '?'}
+ </Avatar>
+ <Text strong>{comment.author || '匿名用户'}</Text>
+ <Text type="secondary">
+ {comment.createTime ? new Date(comment.createTime).toLocaleString() : '未知时间'}
+ </Text>
+ </div>
+ <div className="comment-body">
+ <Text>{comment.content}</Text>
+ </div>
+ <div className="comment-actions">
+ <Button
+ type="text"
+ icon={<LikeOutlined />}
+ onClick={() => handleLikeComment(comment.id)}
+ >
+ {comment.likes || 0}
+ </Button>
+ </div>
+ </div>
+ </List.Item>
+ )}
+ />
+
+ <div style={{ textAlign: 'right', marginTop: 16 }}>
+ <Pagination
+ current={currentPage}
+ pageSize={pageSize}
+ total={comments.length}
+ onChange={(page, size) => {
+ setCurrentPage(page);
+ setPageSize(size);
+ }}
+ showSizeChanger
+ showQuickJumper
+ showTotal={total => `共 ${total} 条`}
+ />
+ </div>
+
+ <Modal
+ title={
+ <span>
+ <MessageOutlined style={{ marginRight: 8 }} />
+ 新建讨论
+ </span>
+ }
+ visible={modalVisible}
+ onCancel={() => setModalVisible(false)}
+ footer={[
+ <Button key="cancel" onClick={() => setModalVisible(false)}>
+ 取消
+ </Button>,
+ <Button
+ key="submit"
+ type="primary"
+ onClick={() => {
+ form.validateFields()
+ .then(values => handleSubmit(values))
+ .catch(() => message.error('请填写完整信息'));
+ }}
+ >
+ 创建讨论
+ </Button>,
+ ]}
+ >
+ <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 DiscussionSection;
\ No newline at end of file
diff --git a/src/feature/categories/GameCategory.tsx b/src/feature/categories/GameCategory.tsx
new file mode 100644
index 0000000..3f5c78d
--- /dev/null
+++ b/src/feature/categories/GameCategory.tsx
@@ -0,0 +1,173 @@
+import React, { useState, useEffect } from 'react';
+import { Card, List, message, Spin, Input, Tag, Typography} from 'antd';
+import { SearchOutlined, PlayCircleOutlined } from '@ant-design/icons';
+import CategoryAPI from '../../api/categoryApi';
+import type { GameResource } from '../../api/categoryTypes';
+import { useNavigate } from 'react-router';
+
+const { Text } = Typography;
+const { Search } = Input;
+
+const GameCategory: React.FC = () => {
+ const [games, setGames] = useState<GameResource[]>([]);
+ const [filteredGames, setFilteredGames] = useState<GameResource[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ // 加载数据
+useEffect(() => {
+ const loadGames = async () => {
+ try {
+ const response = await CategoryAPI.getWorksByCategory(3);
+
+ // 类型转换或断言
+ const gameResources = response.content.map((work: any) => ({
+ ...work,
+ developer: (work as any).developer || '未知开发商',
+ publisher: (work as any).publisher || '未知发行商',
+ platform: (work as any).platform || 'PC',
+ releaseDate: (work as any).releaseDate || '未知日期'
+ })) as unknown as GameResource[];
+
+ setGames(gameResources);
+ setFilteredGames(gameResources);
+ } catch (error) {
+ message.error('加载游戏资源失败');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadGames();
+}, []);
+
+ // 筛选逻辑
+ useEffect(() => {
+ if (!searchQuery) {
+ setFilteredGames(games);
+ return;
+ }
+
+ const filtered = games.filter(game =>
+ game.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ game.developer.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ setFilteredGames(filtered);
+ }, [searchQuery, games]);
+
+
+ // // 在加载数据部分添加虚拟数据
+ // useEffect(() => {
+ // const loadGames = async () => {
+ // try {
+ // // 模拟API请求
+ // const mockGames: GameResource[] = [
+ // {
+ // id: 5,
+ // title: '赛博朋克2077',
+ // developer: 'CD Projekt Red',
+ // publisher: 'CD Projekt',
+ // categoryId: 3,
+ // categoryName: '角色扮演',
+ // platform: 'PC',
+ // releaseDate: '2020-12-10',
+ // seeders: 156,
+ // leechers: 34,
+ // uploadDate: '2023-06-01',
+ // uploader: 'gameLover',
+ // downloadCount: 2876,
+ // hash: 'm3n4o5p6q7r8',
+ // size: '123Mb'
+ // },
+ // {
+ // id: 6,
+ // title: '艾尔登法环',
+ // developer: 'FromSoftware',
+ // publisher: '万代南梦宫',
+ // categoryId: 3,
+ // categoryName: '动作冒险',
+ // releaseDate: '2022-02-25',
+ // seeders: 201,
+ // leechers: 45,
+ // uploadDate: '2023-05-20',
+ // uploader: 'hardcoreGamer',
+ // downloadCount: 3542,
+ // hash: 's9t0u1v2w3x4',
+ // platform: 'PC',
+ // size: '234Mb'
+ // }
+ // ];
+
+ // setGames(mockGames);
+ // setFilteredGames(mockGames);
+ // } catch (error) {
+ // message.error('加载游戏资源失败');
+ // } finally {
+ // setIsLoading(false);
+ // }
+ // };
+
+ // loadGames();
+ // }, []);
+
+ return (
+ <div className="game-category-container">
+ <div className="category-header">
+ <h1><PlayCircleOutlined /> 游戏资源分区</h1>
+ <p>PC、主机游戏资源分享</p>
+ </div>
+
+ <Search
+ placeholder="搜索游戏名称或开发商"
+ allowClear
+ enterButton={<SearchOutlined />}
+ value={searchQuery}
+ onChange={e => setSearchQuery(e.target.value)}
+ className="search-bar"
+ />
+
+ {isLoading ? (
+ <Spin tip="加载游戏资源..." size="large" />
+ ) : (
+ <List
+ grid={{ gutter: 16, column: 1 }}
+ dataSource={filteredGames}
+ renderItem={game => (
+ <List.Item>
+ <GameCard game={game} />
+ </List.Item>
+ )}
+ />
+ )}
+ </div>
+ );
+};
+
+const GameCard: React.FC<{ game: GameResource }> = ({ game }) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/works/${game.id}`);
+ };
+
+ return (
+ <Card className="game-card" onClick={handleClick} hoverable>
+ <div className="card-content">
+ <div className="card-header">
+ <Text strong className="resource-title">{game.title}</Text>
+ <Text type="secondary" className="resource-developer">开发商: {game.developer}</Text>
+ </div>
+
+ <div className="card-meta">
+ <Tag color="blue">{game.platform}</Tag>
+ <Tag color="geekblue">{game.categoryName}</Tag>
+ <Tag color="green">{game.seeders} 做种</Tag>
+ <Tag color="orange">{game.leechers} 下载</Tag>
+ </div>
+ </div>
+ </Card>
+ );
+};
+
+export default GameCategory;
\ No newline at end of file
diff --git a/src/feature/categories/MovieCategory.tsx b/src/feature/categories/MovieCategory.tsx
new file mode 100644
index 0000000..262ebd4
--- /dev/null
+++ b/src/feature/categories/MovieCategory.tsx
@@ -0,0 +1,172 @@
+import React, { useState, useEffect } from 'react';
+import { Card, List, message, Spin, Input, Tag, Typography} from 'antd';
+import { SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
+import CategoryAPI from '../../api/categoryApi';
+import type { MovieResource } from '../../api/categoryTypes';
+import { useNavigate } from 'react-router';
+
+const { Text } = Typography;
+const { Search } = Input;
+
+const MovieCategory: React.FC = () => {
+ const [movies, setMovies] = useState<MovieResource[]>([]);
+ const [filteredMovies, setFilteredMovies] = useState<MovieResource[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ // 加载数据
+useEffect(() => {
+ const loadMovies = async () => {
+ try {
+ const response = await CategoryAPI.getWorksByCategory(2);
+ // 添加类型断言或转换
+ const movies = response.content.map((work: any) => ({
+ ...work,
+ director: (work as any).director || '未知导演',
+ actors: (work as any).actors || [],
+ resolution: (work as any).resolution || '1080p',
+ duration: (work as any).duration || '0分钟'
+ })) as unknown as MovieResource[];
+
+ setMovies(movies);
+ setFilteredMovies(movies);
+ } catch (error) {
+ message.error('加载影视资源失败');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadMovies();
+}, []);
+
+ // 筛选逻辑
+ useEffect(() => {
+ if (!searchQuery) {
+ setFilteredMovies(movies);
+ return;
+ }
+
+ const filtered = movies.filter(movie =>
+ movie.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ movie.director.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ setFilteredMovies(filtered);
+ }, [searchQuery, movies]);
+
+
+// // 在加载数据部分添加虚拟数据
+// useEffect(() => {
+// const loadMovies = async () => {
+// try {
+// // 模拟API请求
+// const mockMovies: MovieResource[] = [
+// {
+// id: 3,
+// title: '盗梦空间',
+// director: '克里斯托弗·诺兰',
+// categoryId: 2,
+// categoryName: '科幻电影',
+// resolution: '1080p',
+// duration: '148分钟',
+// actors: ['莱昂纳多·迪卡普里奥', '约瑟夫·高登-莱维特'],
+// seeders: 215,
+// leechers: 42,
+// uploadDate: '2023-06-10',
+// uploader: 'movieFan',
+// downloadCount: 3256,
+// hash: 'a1b2c3d4e5f6',
+// size: '234Mb'
+// },
+// {
+// id: 4,
+// title: '肖申克的救赎',
+// director: '弗兰克·德拉邦特',
+// categoryId: 2,
+// categoryName: '剧情片',
+// resolution: '4K',
+// duration: '142分钟',
+// actors: ['蒂姆·罗宾斯', '摩根·弗里曼'],
+// seeders: 189,
+// leechers: 28,
+// uploadDate: '2023-05-15',
+// uploader: 'classicMovie',
+// downloadCount: 4123,
+// hash: 'g7h8i9j0k1l2',
+// size: '123Mb'
+// }
+// ];
+
+// setMovies(mockMovies);
+// setFilteredMovies(mockMovies);
+// } catch (error) {
+// message.error('加载影视资源失败');
+// } finally {
+// setIsLoading(false);
+// }
+// };
+
+// loadMovies();
+// }, []);
+
+ return (
+ <div className="movie-category-container">
+ <div className="category-header">
+ <h1><VideoCameraOutlined /> 影视资源分区</h1>
+ <p>高清电影、电视剧、纪录片资源分享</p>
+ </div>
+
+ <Search
+ placeholder="搜索电影或导演"
+ allowClear
+ enterButton={<SearchOutlined />}
+ value={searchQuery}
+ onChange={e => setSearchQuery(e.target.value)}
+ className="search-bar"
+ />
+
+ {isLoading ? (
+ <Spin tip="加载影视资源..." size="large" />
+ ) : (
+ <List
+ grid={{ gutter: 16, column: 1 }}
+ dataSource={filteredMovies}
+ renderItem={movie => (
+ <List.Item>
+ <MovieCard movie={movie} />
+ </List.Item>
+ )}
+ />
+ )}
+ </div>
+ );
+};
+
+const MovieCard: React.FC<{ movie: MovieResource }> = ({ movie }) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/works/${movie.id}`);
+ };
+
+ return (
+ <Card className="movie-card" onClick={handleClick} hoverable>
+ <div className="card-content">
+ <div className="card-header">
+ <Text strong className="resource-title">{movie.title}</Text>
+ <Text type="secondary" className="resource-director">导演: {movie.director}</Text>
+ </div>
+
+ <div className="card-meta">
+ <Tag color="blue">{movie.resolution}</Tag>
+ <Tag color="geekblue">{movie.categoryName}</Tag>
+ <Tag color="green">{movie.seeders} 做种</Tag>
+ <Tag color="orange">{movie.leechers} 下载</Tag>
+ </div>
+ </div>
+ </Card>
+ );
+};
+
+export default MovieCategory;
\ No newline at end of file
diff --git a/src/feature/categories/MusicCategory.tsx b/src/feature/categories/MusicCategory.tsx
new file mode 100644
index 0000000..f9057ee
--- /dev/null
+++ b/src/feature/categories/MusicCategory.tsx
@@ -0,0 +1,191 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Card, Button, List, Tag, Typography,
+ message, Spin,
+ Input} from 'antd';
+import {
+ SearchOutlined
+} from '@ant-design/icons';
+import CategoryAPI from '../../api/categoryApi';
+import type { CategoryDTO, MusicResource } from '../../api/categoryTypes';
+import { useNavigate } from 'react-router';
+
+const { Search } = Input;
+const { Text } = Typography;
+
+const MusicCategory: React.FC = () => {
+ const [categories, setCategories] = useState<CategoryDTO[]>([]);
+ const [resources, setResources] = useState<MusicResource[]>([]);
+ const [filteredResources, setFilteredResources] = useState<MusicResource[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
+
+ // 筛选状态
+ const [searchQuery, setSearchQuery] = useState('');
+ const [qualityFilter, setQualityFilter] = useState<string>('all');
+
+ // 加载分类数据
+ useEffect(() => {
+ const loadData = async () => {
+ try {
+ const [categoryData, resourceData] = await Promise.all([
+ CategoryAPI.getCategoryTree(),
+ CategoryAPI.getWorksByCategory(1)
+ ]);
+
+ // 确保 parentId 不包含 null
+ const normalizedCategories = categoryData.map(cat => ({
+ ...cat,
+ parentId: cat.parentId === null ? undefined : cat.parentId
+ })) as CategoryDTO[];
+
+ // 音乐资源类型转换
+ const musicResources = resourceData.content.map(work => ({
+ ...work,
+ artist: work.artist || '未知艺术家',
+ quality: work.quality || 'MP3 320k',
+ genre: work.genre || ['未知流派'],
+ categoryName: work.categoryName || '未知分类',
+ size: work.size || '0MB'
+ })) as unknown as MusicResource[];
+
+ setCategories(normalizedCategories);
+ setResources(musicResources);
+ setFilteredResources(musicResources);
+ } catch (error) {
+ message.error('加载数据失败');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadData();
+ }, []);
+
+ // 筛选逻辑
+ useEffect(() => {
+ let filtered = resources;
+
+ // 搜索筛选
+ if (searchQuery) {
+ filtered = filtered.filter(resource =>
+ resource.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ resource.artist.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ }
+
+ // 音质筛选
+ if (qualityFilter !== 'all') {
+ filtered = filtered.filter(resource => resource.quality === qualityFilter);
+ }
+
+ // 分类筛选
+ if (selectedCategory) {
+ filtered = filtered.filter(resource => resource.categoryId === selectedCategory);
+ }
+
+ setFilteredResources(filtered);
+ }, [searchQuery, qualityFilter, selectedCategory, resources]);
+
+ // 分类选择处理
+ const handleCategorySelect = (categoryId: number) => {
+ setSelectedCategory(prev => prev === categoryId ? null : categoryId);
+ };
+
+ return (
+ <div className="music-category-container">
+ {/* 头部区域 */}
+ <div className="category-header">
+ <h1>音乐资源分区</h1>
+ <p>高质量音乐资源共享,保持分享率,共建良好PT环境</p>
+ </div>
+
+ {/* 搜索和筛选 */}
+ <div className="filter-section">
+ <Search
+ placeholder="搜索音乐名称或艺术家"
+ allowClear
+ enterButton={<SearchOutlined />}
+ value={searchQuery}
+ onChange={e => setSearchQuery(e.target.value)}
+ className="search-bar"
+ />
+
+ <div className="quality-filter">
+ <span>音质筛选:</span>
+ <Button.Group>
+ {['all', 'FLAC', 'Hi-Res', 'MP3 320k'].map(quality => (
+ <Button
+ key={quality}
+ type={qualityFilter === quality ? 'primary' : 'default'}
+ onClick={() => setQualityFilter(quality)}
+ >
+ {quality === 'all' ? '全部' : quality}
+ </Button>
+ ))}
+ </Button.Group>
+ </div>
+ </div>
+
+ {/* 分类导航 */}
+ <div className="category-navigation">
+ {categories.map(category => (
+ <Tag
+ key={category.id}
+ color={selectedCategory === category.id ? 'blue' : undefined}
+ onClick={() => handleCategorySelect(category.id)}
+ className="category-tag"
+ >
+ {category.name} ({category.workCount || 0})
+ </Tag>
+ ))}
+ </div>
+
+ {/* 内容展示 */}
+ {isLoading ? (
+ <Spin tip="加载中..." size="large" />
+ ) : (
+ <div className="resource-list">
+ <List
+ grid={{ gutter: 16, column: 1 }}
+ dataSource={filteredResources}
+ renderItem={resource => (
+ <List.Item>
+ <ResourceCard resource={resource} />
+ </List.Item>
+ )}
+ />
+ </div>
+ )}
+ </div>
+ );
+};
+
+// 资源卡片组件
+const ResourceCard: React.FC<{ resource: MusicResource }> = ({ resource }) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/works/${resource.id}`);
+ };
+
+ return (
+ <Card className="resource-card" onClick={handleClick} hoverable>
+ <div className="card-content">
+ <div className="card-header">
+ <Text strong className="resource-title">{resource.title}</Text>
+ <Text type="secondary" className="resource-artist">{resource.artist}</Text>
+ </div>
+
+ <div className="card-meta">
+ <Tag color="blue">{resource.quality}</Tag>
+ <Tag color="geekblue">{resource.categoryName}</Tag>
+ <Tag color="green">{resource.seeders} 做种</Tag>
+ <Tag color="orange">{resource.leechers} 下载</Tag>
+ </div>
+ </div>
+ </Card>
+ );
+};
+
+export default MusicCategory;
\ No newline at end of file
diff --git a/src/feature/categories/OtherCategory.tsx b/src/feature/categories/OtherCategory.tsx
new file mode 100644
index 0000000..6c3318a
--- /dev/null
+++ b/src/feature/categories/OtherCategory.tsx
@@ -0,0 +1,178 @@
+import React, { useState, useEffect } from 'react';
+import { Card, List, message, Spin, Input, Tag, Typography} from 'antd';
+import { SearchOutlined, AppstoreOutlined } from '@ant-design/icons';
+import CategoryAPI from '../../api/categoryApi';
+import type { MovieResource, OtherResource } from '../../api/categoryTypes';
+import { useNavigate } from 'react-router';
+
+const { Text } = Typography;
+const { Search } = Input;
+
+const OtherCategory: React.FC = () => {
+ const [resources, setResources] = useState<OtherResource[]>([]);
+ const [filteredResources, setFilteredResources] = useState<OtherResource[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ // 加载数据
+useEffect(() => {
+ const loadResources = async () => {
+ try {
+ const response = await CategoryAPI.getWorksByCategory(4);
+
+ // 其他资源类型转换
+ const otherResources = response.content.map(work => ({
+ ...work,
+ description: (work as any).description || '暂无描述',
+ fileType: (work as any).fileType || '未知类型'
+ })) as unknown as OtherResource[];
+
+ setResources(otherResources);
+ setFilteredResources(otherResources);
+ } catch (error) {
+ message.error('加载资源失败');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadResources();
+}, []);
+
+ // 筛选逻辑
+ useEffect(() => {
+ if (!searchQuery) {
+ setFilteredResources(resources);
+ return;
+ }
+
+ const filtered = resources.filter(resource =>
+ resource.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ resource.description.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ setFilteredResources(filtered);
+ }, [searchQuery, resources]);
+
+ // 在加载数据部分添加虚拟数据
+ useEffect(() => {
+ const loadMovies = async () => {
+ try {
+ // 模拟API请求
+ const mockMovies: MovieResource[] = [
+ {
+ id: 3,
+ title: '盗梦空间',
+ director: '克里斯托弗·诺兰',
+ categoryId: 2,
+ categoryName: '科幻电影',
+ resolution: '1080p',
+ duration: '148分钟',
+ actors: ['莱昂纳多·迪卡普里奥', '约瑟夫·高登-莱维特'],
+ seeders: 215,
+ leechers: 42,
+ uploadDate: '2023-06-10',
+ uploader: 'movieFan',
+ downloadCount: 3256,
+ hash: 'a1b2c3d4e5f6',
+ size: '123Mb'
+ },
+ {
+ id: 4,
+ title: '肖申克的救赎',
+ director: '弗兰克·德拉邦特',
+ categoryId: 2,
+ categoryName: '剧情片',
+ resolution: '4K',
+ duration: '142分钟',
+ actors: ['蒂姆·罗宾斯', '摩根·弗里曼'],
+ seeders: 189,
+ leechers: 28,
+ uploadDate: '2023-05-15',
+ uploader: 'classicMovie',
+ downloadCount: 4123,
+ hash: 'g7h8i9j0k1l2',
+ size: '123Mb'
+ }
+ ];
+
+ setMovies(mockMovies);
+ setFilteredMovies(mockMovies);
+ } catch (error) {
+ message.error('加载影视资源失败');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadMovies();
+ }, []);
+
+ return (
+ <div className="other-category-container">
+ <div className="category-header">
+ <h1><AppstoreOutlined /> 其他资源分区</h1>
+ <p>软件、电子书等其他类型资源</p>
+ </div>
+
+ <Search
+ placeholder="搜索资源名称或描述"
+ allowClear
+ enterButton={<SearchOutlined />}
+ value={searchQuery}
+ onChange={e => setSearchQuery(e.target.value)}
+ className="search-bar"
+ />
+
+ {isLoading ? (
+ <Spin tip="加载资源..." size="large" />
+ ) : (
+ <List
+ grid={{ gutter: 16, column: 1 }}
+ dataSource={filteredResources}
+ renderItem={resource => (
+ <List.Item>
+ <OtherResourceCard resource={resource} />
+ </List.Item>
+ )}
+ />
+ )}
+ </div>
+ );
+};
+
+const OtherResourceCard: React.FC<{ resource: OtherResource }> = ({ resource }) => {
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ navigate(`/works/${resource.id}`);
+ };
+
+ return (
+ <Card className="other-resource-card" onClick={handleClick} hoverable>
+ <div className="card-content">
+ <div className="card-header">
+ <Text strong className="resource-title">{resource.title}</Text>
+ <Text type="secondary" className="resource-type">类型: {resource.fileType}</Text>
+ </div>
+
+ <div className="card-meta">
+ <Tag color="geekblue">{resource.categoryName}</Tag>
+ <Tag color="green">{resource.seeders} 做种</Tag>
+ <Tag color="orange">{resource.leechers} 下载</Tag>
+ </div>
+ </div>
+ </Card>
+ );
+};
+
+export default OtherCategory;
+
+function setMovies(_mockMovies: MovieResource[]) {
+ throw new Error('Function not implemented.');
+}
+
+
+function setFilteredMovies(_mockMovies: MovieResource[]) {
+ throw new Error('Function not implemented.');
+}
diff --git a/src/feature/home/Home.tsx b/src/feature/home/Home.tsx
index fc2c982..80e44eb 100644
--- a/src/feature/home/Home.tsx
+++ b/src/feature/home/Home.tsx
@@ -1,12 +1,116 @@
+import { NavLink } from 'react-router';
+import filmImg from '../../assets/categories/film.jpg';
+import musicImg from '../../assets/categories/music.jpg';
+import gameImg from '../../assets/categories/game.jpg';
+import otherImg from '../../assets/categories/other.jpg';
+// 假设你有排行榜数据,这里模拟一下,实际根据接口等替换
+const filmRankList = [
+ { id: 1, name: '影视作品1', view: '1.2万' },
+ { id: 2, name: '影视作品2', view: '1.1万' },
+ { id: 3, name: '影视作品3', view: '1.0万' },
+];
+const musicRankList = [
+ { id: 1, name: '音乐作品1', view: '1.5万' },
+ { id: 2, name: '音乐作品2', view: '1.3万' },
+ { id: 3, name: '音乐作品3', view: '1.2万' },
+];
+const gameRankList = [
+ { id: 1, name: '游戏作品1', view: '2.0万' },
+ { id: 2, name: '游戏作品2', view: '1.8万' },
+ { id: 3, name: '游戏作品3', view: '1.6万' },
+];
+const otherRankList = [
+ { id: 1, name: '其他作品1', view: '0.8万' },
+ { id: 2, name: '其他作品2', view: '0.7万' },
+ { id: 3, name: '其他作品3', view: '0.6万' },
+];
+
function Home() {
+ const categories = [
+ { id: 'film', name: '影视', image: filmImg, rankList: filmRankList },
+ { id: 'music', name: '音乐', image: musicImg, rankList: musicRankList },
+ { id: 'game', name: '游戏', image: gameImg, rankList: gameRankList },
+ { id: 'other', name: '其他', image: otherImg, rankList: otherRankList },
+ ];
return (
- <>
- 主页
- </>
- )
+ <div className="max-w-7xl mx-auto px-4 py-16 flex flex-col items-center">
+ {/* 页面标题 */}
+ <h1 className="text-[clamp(2rem,5vw,3.5rem)] font-bold text-center mb-12 text-gray-800 tracking-tight">
+ 欢迎访问创意协作平台
+ </h1>
+
+ {/* 分区 + 排行榜 容器,改为 flex 布局 */}
+ <div className="w-full flex max-w-6xl mx-auto">
+ {/* 分区卡片容器 - 响应式网格布局 */}
+ <div className="grid grid-cols-4 gap-6 w-3/4">
+ {categories.map((category) => (
+ <div
+ key={category.id}
+ className="group relative overflow-hidden rounded-xl shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-xl"
+ >
+ <NavLink to={`/categories/${category.id}`} className="block">
+ {/* 图片容器 */}
+ <div className="aspect-[4/3] relative overflow-hidden">
+ {/* 背景图片 - 带模糊和亮度调整 */}
+ <img
+ src={category.image}
+ alt={category.name}
+ className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
+ style={{ filter: 'blur(1px) brightness(0.85)' }}
+ />
+
+ {/* 渐变遮罩层 - 增强文字可读性 */}
+ <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent z-10"></div>
+
+ {/* 文字内容 */}
+ <div className="absolute inset-0 flex flex-col items-center justify-center p-4 z-20">
+ {/* 分类标题 - 艺术字体 */}
+ <h2 className="text-[clamp(1.8rem,4vw,2.5rem)] font-bold text-white text-center mb-2" style={{
+ fontFamily: '"Playfair Display", serif',
+ textShadow: '0 4px 12px rgba(0,0,0,0.8)',
+ transform: 'translateY(10px)',
+ transition: 'transform 0.3s ease-out',
+ letterSpacing: '2px',
+ borderBottom: '2px solid rgba(255,255,255,0.6)',
+ paddingBottom: '4px'
+ }}>
+ {category.name}
+ </h2>
+
+ {/* 描述文本 - 悬停显示 */}
+ <p className="text-indigo-100 text-center opacity-0 transform translate-y-4 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0 mt-2" style={{
+ fontFamily: '"Merriweather", serif',
+ textShadow: '0 2px 6px rgba(0,0,0,0.6)'
+ }}>
+ 探索{category.name}创意项目
+ </p>
+ </div>
+ </div>
+ </NavLink>
+ </div>
+ ))}
+ </div>
+
+ {/* 排行榜区域 - 右侧 */}
+ <div className="w-1/4 pl-6">
+ {categories.map((category) => (
+ <div key={category.id} className="mb-8">
+ <h3 className="text-xl font-bold text-gray-800 mb-4">{category.name}排行榜</h3>
+ <ul>
+ {category.rankList.map((item) => (
+ <li key={item.id} className="flex justify-between items-center mb-2">
+ <span>{item.name}</span>
+ <span className="text-gray-600">{item.view}</span>
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
}
-
-
-export default Home
+export default Home;
\ No newline at end of file
diff --git a/src/feature/work/WorkPage.css b/src/feature/work/WorkPage.css
new file mode 100644
index 0000000..14b815a
--- /dev/null
+++ b/src/feature/work/WorkPage.css
@@ -0,0 +1,155 @@
+/* 作品页面容器 */
+.work-page-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+/* 返回按钮 */
+.back-button {
+ margin-bottom: 20px;
+}
+
+/* 作品头部 */
+.work-header {
+ margin-bottom: 20px;
+}
+
+.work-header .ant-typography {
+ margin-bottom: 8px;
+}
+
+.work-meta {
+ margin: 16px 0;
+}
+
+.like-button {
+ margin-top: 10px;
+}
+
+/* 内容区域 */
+.work-content {
+ padding: 20px;
+ background: #f9f9f9;
+ border-radius: 4px;
+}
+
+/* Bug反馈区域 */
+.bug-report-section {
+ margin-top: 20px;
+}
+
+.bug-item {
+ width: 100%;
+ padding: 15px;
+ border: 1px solid #f0f0f0;
+ border-radius: 4px;
+ margin-bottom: 10px;
+}
+
+.bug-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.bug-content {
+ margin: 10px 0;
+}
+
+/* 讨论区 */
+.discussion-section {
+ margin-top: 20px;
+}
+
+.discussion-container {
+ display: flex;
+ gap: 20px;
+}
+
+.discussion-list {
+ width: 300px;
+ border-right: 1px solid #f0f0f0;
+ padding-right: 20px;
+}
+
+.discussion-detail {
+ flex: 1;
+}
+
+.discussion-item {
+ padding: 15px;
+ cursor: pointer;
+ border-radius: 4px;
+ margin-bottom: 10px;
+ transition: all 0.3s;
+}
+
+.discussion-item:hover {
+ background: #f5f5f5;
+}
+
+.discussion-item.active {
+ background: #e6f7ff;
+ border-left: 3px solid #1890ff;
+}
+
+.discussion-detail-content {
+ padding: 20px;
+ background: #f9f9f9;
+ border-radius: 4px;
+ margin-bottom: 20px;
+}
+
+.discussion-title {
+ font-size: 18px;
+ display: block;
+ margin-bottom: 10px;
+}
+
+.discussion-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 15px;
+}
+
+.discussion-body {
+ margin-bottom: 20px;
+}
+
+.comment-item {
+ padding: 15px 0;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.comment-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.comment-actions {
+ margin-top: 10px;
+}
+
+.new-discussion-btn {
+ margin-bottom: 20px;
+}
+
+/* 响应式调整 */
+@media (max-width: 768px) {
+ .discussion-container {
+ flex-direction: column;
+ }
+
+ .discussion-list {
+ width: 100%;
+ border-right: none;
+ padding-right: 0;
+ border-bottom: 1px solid #f0f0f0;
+ padding-bottom: 20px;
+ margin-bottom: 20px;
+ }
+}
\ No newline at end of file
diff --git a/src/feature/work/WorkPage.tsx b/src/feature/work/WorkPage.tsx
new file mode 100644
index 0000000..3a108a7
--- /dev/null
+++ b/src/feature/work/WorkPage.tsx
@@ -0,0 +1,131 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router';
+import { Button, Tabs, message, Spin, Tag, Typography, Space, Divider } from 'antd';
+import { ArrowLeftOutlined, LikeOutlined, BugOutlined, CommentOutlined } from '@ant-design/icons';
+import WorkAPI from '../../api/workApi';
+import BugReportSection from '../../components/BugReportSection';
+import DiscussionSection from '../../components/DiscussionSection';
+import type { Work } from '../../api/otherType';
+
+const { Title, Text, Paragraph } = Typography;
+const { TabPane } = Tabs;
+
+const WorkPage: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const [work, setWork] = useState<Work | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState('details');
+
+ // 加载作品数据
+ useEffect(() => {
+ const loadWork = async () => {
+ try {
+ const workData = await WorkAPI.getWorkById(Number(id));
+ setWork(workData);
+ } catch (error) {
+ message.error('加载作品失败');
+ navigate('/');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadWork();
+ }, [id, navigate]);
+
+ // 点赞处理
+ const handleLike = async () => {
+ try {
+ await WorkAPI.likeWork(Number(id));
+ setWork(prev => prev ? { ...prev, likes: prev.likes + 1 } : null);
+ message.success('点赞成功');
+ } catch (error) {
+ message.error('点赞失败');
+ }
+ };
+
+ if (loading) {
+ return <Spin size="large" className="center-spinner" />;
+ }
+
+ if (!work) {
+ return <div>作品不存在</div>;
+ }
+
+ return (
+ <div className="work-page-container">
+ <Button
+ type="text"
+ icon={<ArrowLeftOutlined />}
+ onClick={() => navigate(-1)}
+ className="back-button"
+ >
+ 返回
+ </Button>
+
+ <div className="work-header">
+ <Title level={2}>{work.title}</Title>
+ <Text type="secondary">作者: {work.author}</Text>
+
+ <div className="work-meta">
+ <Space size="middle">
+ <Tag color="blue">{work.categoryName}</Tag>
+ <Text>浏览: {work.views}</Text>
+ <Text>点赞: {work.likes}</Text>
+ <Text>上传时间: {new Date(work.createTime).toLocaleDateString()}</Text>
+ </Space>
+ </div>
+
+ <Button
+ type="primary"
+ icon={<LikeOutlined />}
+ onClick={handleLike}
+ className="like-button"
+ >
+ 点赞
+ </Button>
+ </div>
+
+ <Divider />
+
+ <Tabs activeKey={activeTab} onChange={setActiveTab}>
+ <TabPane tab="作品详情" key="details">
+ <div className="work-content">
+ <Title level={4}>作品描述</Title>
+ <Paragraph>{work.description}</Paragraph>
+
+ <Title level={4}>作品内容</Title>
+ <div className="content-container">
+ {work.content}
+ </div>
+ </div>
+ </TabPane>
+
+ <TabPane
+ tab={
+ <span>
+ <BugOutlined /> Bug反馈
+ </span>
+ }
+ key="bugs"
+ >
+ <BugReportSection workId={work.id} />
+ </TabPane>
+
+ <TabPane
+ tab={
+ <span>
+ <CommentOutlined /> 交流区
+ </span>
+ }
+ key="discussions"
+ >
+ <DiscussionSection workId={work.id} />
+ </TabPane>
+ </Tabs>
+ </div>
+ );
+};
+
+export default WorkPage;
\ No newline at end of file
diff --git a/src/routes/routes.ts b/src/routes/routes.ts
index 3bb3135..401b6bb 100644
--- a/src/routes/routes.ts
+++ b/src/routes/routes.ts
@@ -15,6 +15,12 @@
const ProtectedHome = withProtect(Home);
const ProtectedWork = withProtect(Work);
const ProtectedCreatWork = withProtect(CreateWork)
+import FilmCategory from '../feature/categories/MovieCategory';
+import MusicCategory from '../feature/categories/MusicCategory';
+import GameCategory from '../feature/categories/GameCategory';
+import OtherCategory from '../feature/categories/OtherCategory';
+import WorkPage from '../feature/work/WorkPage';
+
export default createBrowserRouter([
{
path: "/",
@@ -24,6 +30,11 @@
path: "/",
Component: ProtectedHome,
},
+ {path: 'categories/film', Component: FilmCategory,},
+ {path: 'categories/music', Component: MusicCategory,},
+ {path: 'categories/game', Component: GameCategory,},
+ {path: 'categories/other', Component: OtherCategory,},
+ {path: '/works/:id', Component: WorkPage, },
{
Component: AuthLayout,
children: [
diff --git a/src/test/category/GameCategory.test.tsx b/src/test/category/GameCategory.test.tsx
new file mode 100644
index 0000000..ad42353
--- /dev/null
+++ b/src/test/category/GameCategory.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react';
+import GameCategory from '../../feature/categories/GameCategory';
+import { Provider } from 'react-redux';
+import { store } from '../../store/store';
+import { MemoryRouter } from 'react-router';
+
+describe('GameCategory Component', () => {
+ it('renders game category page with search and game cards', () => {
+ render(
+ <MemoryRouter>
+ <Provider store={store}>
+ <GameCategory />
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 验证标题和描述
+ expect(screen.getByText('游戏资源分区')).toBeInTheDocument();
+ expect(screen.getByText('PC、主机游戏资源分享')).toBeInTheDocument();
+
+ // 验证搜索框
+ const searchInput = screen.getByPlaceholderText('搜索游戏名称或开发商');
+ expect(searchInput).toBeInTheDocument();
+
+ // 验证游戏卡片
+ expect(screen.getByText('赛博朋克2077')).toBeInTheDocument();
+ expect(screen.getByText('艾尔登法环')).toBeInTheDocument();
+
+ // 验证开发商信息
+ expect(screen.getByText('开发商: CD Projekt Red')).toBeInTheDocument();
+ expect(screen.getByText('开发商: FromSoftware')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/test/category/MovieCategory.test.tsx b/src/test/category/MovieCategory.test.tsx
new file mode 100644
index 0000000..be1ed01
--- /dev/null
+++ b/src/test/category/MovieCategory.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react';
+import MovieCategory from '../../feature/categories/MovieCategory';
+import { Provider } from 'react-redux';
+import { store } from '../../store/store';
+import { MemoryRouter } from 'react-router';
+
+describe('MovieCategory Component', () => {
+ it('renders movie category page with search and movie cards', () => {
+ render(
+ <MemoryRouter>
+ <Provider store={store}>
+ <MovieCategory />
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 验证标题和描述
+ expect(screen.getByText('影视资源分区')).toBeInTheDocument();
+ expect(screen.getByText('高清电影、电视剧、纪录片资源分享')).toBeInTheDocument();
+
+ // 验证搜索框
+ const searchInput = screen.getByPlaceholderText('搜索电影或导演');
+ expect(searchInput).toBeInTheDocument();
+
+ // 验证电影卡片
+ expect(screen.getByText('盗梦空间')).toBeInTheDocument();
+ expect(screen.getByText('肖申克的救赎')).toBeInTheDocument();
+
+ // 验证导演信息
+ expect(screen.getByText('导演: 克里斯托弗·诺兰')).toBeInTheDocument();
+ expect(screen.getByText('导演: 弗兰克·德拉邦特')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/test/category/MusicCategory.test.tsx b/src/test/category/MusicCategory.test.tsx
new file mode 100644
index 0000000..4de1bdf
--- /dev/null
+++ b/src/test/category/MusicCategory.test.tsx
@@ -0,0 +1,38 @@
+import { render, screen } from '@testing-library/react';
+import MusicCategory from '../../feature/categories/MusicCategory';
+import { Provider } from 'react-redux';
+import { store } from '../../store/store';
+import { MemoryRouter } from 'react-router';
+
+describe('MusicCategory Component', () => {
+ it('renders music category page with search, filters and music cards', () => {
+ render(
+ <MemoryRouter>
+ <Provider store={store}>
+ <MusicCategory />
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 验证标题和描述
+ expect(screen.getByText('音乐资源分区')).toBeInTheDocument();
+ expect(screen.getByText('高质量音乐资源共享,保持分享率,共建良好PT环境')).toBeInTheDocument();
+
+ // 验证搜索框
+ const searchInput = screen.getByPlaceholderText('搜索音乐名称或艺术家');
+ expect(searchInput).toBeInTheDocument();
+
+ // 验证音质筛选
+ expect(screen.getByText('音质筛选:')).toBeInTheDocument();
+ expect(screen.getByText('全部')).toBeInTheDocument();
+ expect(screen.getByText('FLAC')).toBeInTheDocument();
+
+ // 验证分类标签
+ expect(screen.getByText('欧美流行 (15)')).toBeInTheDocument();
+ expect(screen.getByText('华语流行 (23)')).toBeInTheDocument();
+
+ // 验证音乐卡片
+ expect(screen.getByText('Blinding Lights')).toBeInTheDocument();
+ expect(screen.getByText('Bohemian Rhapsody')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/test/category/OtherCategory.test.tsx b/src/test/category/OtherCategory.test.tsx
new file mode 100644
index 0000000..dd1e793
--- /dev/null
+++ b/src/test/category/OtherCategory.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@testing-library/react';
+import OtherCategory from '../../feature/categories/OtherCategory';
+import { Provider } from 'react-redux';
+import { store } from '../../store/store';
+import { MemoryRouter } from 'react-router';
+
+describe('OtherCategory Component', () => {
+ it('renders other resources category page with search and resource cards', () => {
+ render(
+ <MemoryRouter>
+ <Provider store={store}>
+ <OtherCategory />
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 验证标题和描述
+ expect(screen.getByText('其他资源分区')).toBeInTheDocument();
+ expect(screen.getByText('软件、电子书等其他类型资源')).toBeInTheDocument();
+
+ // 验证搜索框
+ const searchInput = screen.getByPlaceholderText('搜索资源名称或描述');
+ expect(searchInput).toBeInTheDocument();
+
+ // 验证资源卡片
+ expect(screen.getByText('盗梦空间')).toBeInTheDocument();
+ expect(screen.getByText('肖申克的救赎')).toBeInTheDocument();
+
+ // 验证类型信息
+ expect(screen.getByText('类型: 未知类型')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/test/work/WorkPage.test.tsx b/src/test/work/WorkPage.test.tsx
new file mode 100644
index 0000000..4dc5036
--- /dev/null
+++ b/src/test/work/WorkPage.test.tsx
@@ -0,0 +1,268 @@
+import { render, screen } from '@testing-library/react';
+import WorkPage from '../../feature/work/WorkPage';
+import { Provider } from 'react-redux';
+import { store } from '../../store/store';
+import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
+import { act } from 'react-dom/test-utils';
+import WorkAPI from '../../api/workApi';
+import type { Work } from '../../api/otherType';
+import '@testing-library/jest-dom';
+
+// 模拟整个WorkAPI类
+jest.mock('../../api/workApi', () => {
+ return {
+ __esModule: true,
+ default: {
+ getWorkById: jest.fn(),
+ likeWork: jest.fn(),
+ // 添加其他可能用到的方法
+ getBugReports: jest.fn(),
+ getDiscussions: jest.fn()
+ }
+ }
+});
+
+// 模拟子组件
+jest.mock('../../components/BugReportSection', () => () => (
+ <div data-testid="bug-report-mock">BugReportSection Mock</div>
+));
+
+jest.mock('../../components/DiscussionSection', () => () => (
+ <div data-testid="discussion-mock">DiscussionSection Mock</div>
+));
+
+// 模拟react-router-dom的useNavigate
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => jest.fn()
+}));
+
+describe('WorkPage Component', () => {
+ const mockWork: Work = {
+ id: 1,
+ title: '测试作品',
+ author: '测试作者',
+ categoryName: '测试分类',
+ views: 100,
+ likes: 50,
+ createTime: '2023-01-01T00:00:00Z',
+ description: '测试描述',
+ content: '测试内容',
+ size: '',
+ data: function (): unknown {
+ throw new Error('Function not implemented.');
+ },
+ artist: '',
+ quality: '',
+ genre: [],
+ authorId: 0,
+ categoryId: 0,
+ coverUrl: '',
+ attachments: [],
+ bugCount: 0,
+ discussionCount: 0
+ };
+
+ beforeEach(() => {
+ // 重置所有模拟
+ jest.clearAllMocks();
+
+ // 设置默认模拟实现
+ (WorkAPI.getWorkById as jest.Mock).mockResolvedValue(mockWork);
+ (WorkAPI.likeWork as jest.Mock).mockResolvedValue({});
+ });
+
+ it('renders loading spinner initially', async () => {
+ // 延迟API响应以测试加载状态
+ let resolvePromise: (value: Work) => void;
+ (WorkAPI.getWorkById as jest.Mock).mockImplementation(
+ () => new Promise<Work>((resolve) => {
+ resolvePromise = resolve;
+ })
+ );
+
+ render(
+ <MemoryRouter initialEntries={['/works/1']}>
+ <Provider store={store}>
+ <Routes>
+ <Route path="/works/:id" element={<WorkPage />} />
+ </Routes>
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 验证加载状态
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ expect(screen.getByText(/加载/)).toBeInTheDocument();
+
+ // 完成API请求
+ await act(async () => {
+ resolvePromise(mockWork);
+ });
+
+ // 验证加载完成后内容
+ expect(await screen.findByText('测试作品')).toBeInTheDocument();
+ });
+
+ it('renders work details after loading', async () => {
+ render(
+ <MemoryRouter initialEntries={['/works/1']}>
+ <Provider store={store}>
+ <Routes>
+ <Route path="/works/:id" element={<WorkPage />} />
+ </Routes>
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 等待数据加载完成
+ expect(await screen.findByText('测试作品')).toBeInTheDocument();
+
+ // 验证头部信息
+ expect(screen.getByText('作者: 测试作者')).toBeInTheDocument();
+ expect(screen.getByText('测试分类')).toHaveClass('ant-tag');
+ expect(screen.getByText('浏览: 100')).toBeInTheDocument();
+ expect(screen.getByText('点赞: 50')).toBeInTheDocument();
+ expect(screen.getByText(/2023/)).toBeInTheDocument();
+
+ // 验证作品内容
+ expect(screen.getByRole('heading', { level: 4, name: '作品描述' })).toBeInTheDocument();
+ expect(screen.getByText('测试描述')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 4, name: '作品内容' })).toBeInTheDocument();
+ expect(screen.getByText('测试内容')).toBeInTheDocument();
+
+ // 验证标签页
+ expect(screen.getByRole('tab', { name: '作品详情' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Bug反馈/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /交流区/ })).toBeInTheDocument();
+ });
+
+ it('handles back button click', async () => {
+ const mockNavigate = jest.fn();
+ (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
+
+ render(
+ <MemoryRouter initialEntries={['/works/1']}>
+ <Provider store={store}>
+ <Routes>
+ <Route path="/works/:id" element={<WorkPage />} />
+ </Routes>
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 等待数据加载完成
+ await screen.findByText('测试作品');
+
+ // 点击返回按钮
+ const backButton = screen.getByRole('button', { name: '返回' });
+ await act(async () => {
+ backButton.click();
+ });
+
+ // 验证导航被调用
+ expect(mockNavigate).toHaveBeenCalledWith(-1);
+ });
+
+ it('handles like button click', async () => {
+ render(
+ <MemoryRouter initialEntries={['/works/1']}>
+ <Provider store={store}>
+ <Routes>
+ <Route path="/works/:id" element={<WorkPage />} />
+ </Routes>
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 等待数据加载完成
+ await screen.findByText('测试作品');
+
+ // 点击点赞按钮
+ const likeButton = screen.getByRole('button', { name: '点赞' });
+ await act(async () => {
+ likeButton.click();
+ });
+
+ // 验证API调用
+ expect(WorkAPI.likeWork).toHaveBeenCalledTimes(1);
+ expect(WorkAPI.likeWork).toHaveBeenCalledWith(1);
+
+ // 验证UI反馈
+ expect(await screen.findByText('点赞成功')).toBeInTheDocument();
+ });
+
+ it('shows error message when work loading fails', async () => {
+ const errorMessage = '加载失败';
+ (WorkAPI.getWorkById as jest.Mock).mockRejectedValue(new Error(errorMessage));
+
+ const mockNavigate = jest.fn();
+ (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
+
+ render(
+ <MemoryRouter initialEntries={['/works/1']}>
+ <Provider store={store}>
+ <Routes>
+ <Route path="/works/:id" element={<WorkPage />} />
+ </Routes>
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 验证错误消息
+ expect(await screen.findByText('作品不存在')).toBeInTheDocument();
+
+ // 验证导航到首页
+ expect(mockNavigate).toHaveBeenCalledWith('/');
+ });
+
+ it('switches between tabs correctly', async () => {
+ render(
+ <MemoryRouter initialEntries={['/works/1']}>
+ <Provider store={store}>
+ <Routes>
+ <Route path="/works/:id" element={<WorkPage />} />
+ </Routes>
+ </Provider>
+ </MemoryRouter>
+ );
+
+ // 等待数据加载完成
+ await screen.findByText('测试作品');
+
+ // 初始显示作品详情
+ expect(screen.getByText('测试描述')).toBeInTheDocument();
+ expect(screen.queryByTestId('bug-report-mock')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('discussion-mock')).not.toBeInTheDocument();
+
+ // 点击Bug反馈标签
+ const bugTab = screen.getByRole('tab', { name: /Bug反馈/ });
+ await act(async () => {
+ bugTab.click();
+ });
+
+ // 验证Bug反馈内容显示
+ expect(screen.queryByText('测试描述')).not.toBeInTheDocument();
+ expect(screen.getByTestId('bug-report-mock')).toBeInTheDocument();
+
+ // 点击交流区标签
+ const discussionTab = screen.getByRole('tab', { name: /交流区/ });
+ await act(async () => {
+ discussionTab.click();
+ });
+
+ // 验证交流区内容显示
+ expect(screen.queryByTestId('bug-report-mock')).not.toBeInTheDocument();
+ expect(screen.getByTestId('discussion-mock')).toBeInTheDocument();
+
+ // 切换回作品详情
+ const detailsTab = screen.getByRole('tab', { name: '作品详情' });
+ await act(async () => {
+ detailsTab.click();
+ });
+
+ // 验证作品详情再次显示
+ expect(screen.getByText('测试描述')).toBeInTheDocument();
+ expect(screen.queryByTestId('discussion-mock')).not.toBeInTheDocument();
+ });
+});
\ No newline at end of file