创作中心模块包含首页展示、个人中心、帖子审核。
“首页展示”支持广告轮播展示、推广帖子优先展示、分页显示所有帖子、导航栏便捷标签筛选帖子、全局标题模糊搜索帖子、点击帖子“查看更多”进入帖子详情页。帖子详情页展示帖子封面图片、作者时间、详细内容(可以插入种子链接对种子进行介绍与推广)等基本信息、对帖子点赞收藏举报评论回复、查看相关推荐帖子。相关推荐会推荐当前帖子作者的其他帖子(最多推荐5篇),还会推荐具有相似标签的其他帖子,两者总共最多推荐9篇帖子。
“个人中心”包含“我的中心”和“我的收藏”。
“我的中心”中可以管理已经成功发布的帖子(编辑、删除帖子),还可以发布新帖子。发布新帖子时除了填写帖子基本信息以外,帖子标签支持下拉多项选择,用户还可以选择帖子推广项目并进行支付。设置了多种推广项目,包含广告轮播推广、帖子置顶展示、限时优先展示、分类页首条展示。系统后台执行自动定时任务,每小时对帖子的推广时效性进行检查,如超出推广时限,则取消帖子的推广显示特权。用户点击发布帖子后帖子处于待审核状态,需要管理员审核通过才能正常发布在首页展示页面。编辑帖子时用户可以追加帖子推广,但如果帖子处于推广状态,则禁止修改推广项目。
“我的收藏”中可以便捷查看所有已收藏的帖子。
“帖子审核”包含“帖子发布管理”和“帖子举报管理”。“帖子审核”板块具有权限管理,只有管理员界面能够进入。
“帖子发布管理”对所有待审核帖子进行处理,支持预览待审核帖子详细内容,批准通过和拒绝通过选项。
“帖子举报管理”对所有用户的举报请求进行人工审核,如果举报内容属实,则将帖子下架处理,如果举报内容不属实,驳回举报请求。所有举报请求的处理结果均留存显示,方便后续再次审查。
Change-Id: If822351183e9d55a5a56ff5cf1e13b313fdbe231
diff --git a/config/routes.ts b/config/routes.ts
index ffad02f..f9e4fcb 100644
--- a/config/routes.ts
+++ b/config/routes.ts
@@ -89,4 +89,28 @@
},
]
},
+ {
+ name: '帖子中心',
+ icon: 'read',
+ path: '/post/center',
+ component: './PostCenter/index.tsx',
+ },
+ {
+ name: '帖子详情',
+ path: '/post-detail/:id',
+ component: './PostCenter/PostDetail.tsx',
+ hideInMenu: true,
+ },
+ {
+ name: '个人中心',
+ path: '/user-center',
+ component: './UserCenter/index.tsx',
+ hideInMenu: true,
+ },
+ {
+ name: '帖子审核',
+ path: '/post-review',
+ component: './PostReview/index.tsx',
+ hideInMenu: true,
+ }
];
diff --git a/jest.config.ts b/jest.config.ts
index 1de2a1a..098ae99 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -1,23 +1,40 @@
-import { configUmiAlias, createConfig } from '@umijs/max/test';
+import { Config } from 'jest';
-export default async () => {
- const config = await configUmiAlias({
- ...createConfig({
- target: 'browser',
- }),
- });
-
- console.log();
- return {
- ...config,
- testEnvironmentOptions: {
- ...(config?.testEnvironmentOptions || {}),
- url: 'http://localhost:8000',
+const config: Config = {
+ preset: 'ts-jest',
+ testEnvironment: 'jsdom',
+ setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
+ moduleNameMapper: {
+ '^@/(.*)$': '<rootDir>/src/$1',
+ },
+ testMatch: [
+ '<rootDir>/tests/**/*.test.ts',
+ '<rootDir>/tests/**/*.test.tsx',
+ ],
+ collectCoverageFrom: [
+ 'src/**/*.{ts,tsx}',
+ '!src/**/*.d.ts',
+ '!src/**/*.test.{ts,tsx}',
+ ],
+ coverageDirectory: 'coverage',
+ coverageReporters: ['text', 'lcov', 'html'],
+ coverageThreshold: {
+ global: {
+ branches: 80,
+ functions: 80,
+ lines: 80,
+ statements: 80,
},
- setupFiles: [...(config.setupFiles || []), './tests/setupTests.jsx'],
- globals: {
- ...config.globals,
- localStorage: null,
+ },
+ transform: {
+ '^.+\\.(ts|tsx)$': 'ts-jest',
+ },
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
+ globals: {
+ 'ts-jest': {
+ tsconfig: 'tsconfig.json',
},
- };
+ },
};
+
+export default config;
diff --git a/mock/post.ts b/mock/post.ts
new file mode 100644
index 0000000..fdbb9f3
--- /dev/null
+++ b/mock/post.ts
@@ -0,0 +1,290 @@
+import { Request, Response } from 'express';
+
+// Mock数据
+const mockPosts = [
+ {
+ postId: 1,
+ title: '【日剧推荐】献给刚刚接触日剧的你',
+ content: `
+ <div class="post-content">
+ <p>在日本,并没有四大台柱的称谓,这一说法的由来是贴吧的一个投票。</p>
+ <h3>校园剧</h3>
+ <p>龙樱 - 校园青春励志剧,如果你还没参加高考,推荐你提前看看,相信你会有所收获。</p>
+ <p>野猪大改造 - 两个J家美少年和两个台柱,除第一集节奏稍慢外,无硬伤,有友情有效笑有泪水。</p>
+ <h3>职场剧</h3>
+ <p>麻辣教师 - 小栗旬还是个弱小男生。</p>
+ <p>极道鲜师 - 学生上课搞乱不听讲怎么办,小栗来教你。</p>
+ </div>
+ `,
+ summary: '这是一篇关于日剧推荐的帖子,包含了校园剧和职场剧的推荐。',
+ coverImage: '/images/flower.jpg',
+ authorId: 1,
+ author: 'Ryo',
+ views: 87951,
+ comments: 15,
+ favorites: 256,
+ likes: 1024,
+ status: '1',
+ publishTime: '2017年8月26日',
+ tags: '日剧,嘉宾专栏,作品合集',
+ promotionPlanId: 1,
+ createTime: '2017-08-26 10:30:00',
+ updateTime: '2017-08-26 10:30:00',
+ },
+ {
+ postId: 2,
+ title: '【2025年4月档】对岸的家务事 高清1080P 新番双语+中文字幕 百度网盘 更新07',
+ content: '<p>对岸的家务事是一部关于家庭生活的日剧。</p>',
+ summary: '对岸的家务事是一部关于家庭生活的日剧。',
+ coverImage: '/images/flower.jpg',
+ authorId: 2,
+ author: '日剧翻译组',
+ views: 3245,
+ comments: 156,
+ favorites: 89,
+ likes: 345,
+ status: '1',
+ publishTime: '2025-5-14',
+ tags: '日剧,2025,家务',
+ promotionPlanId: null,
+ createTime: '2025-05-14 10:30:00',
+ updateTime: '2025-05-14 10:30:00',
+ },
+ {
+ postId: 3,
+ title: '【2025年4月档】天久鹰央的推理历表 高清1080P 中文字幕 百度网盘 更新04',
+ content: '<p>天久鹰央的推理历表是一部推理题材的日剧。</p>',
+ summary: '天久鹰央的推理历表是一部推理题材的日剧。',
+ coverImage: '/images/flower.jpg',
+ authorId: 2,
+ author: '日剧翻译组',
+ views: 4589,
+ comments: 267,
+ favorites: 123,
+ likes: 567,
+ status: '1',
+ publishTime: '2025-5-14',
+ tags: '日剧,2025,推理',
+ promotionPlanId: null,
+ createTime: '2025-05-14 10:30:00',
+ updateTime: '2025-05-14 10:30:00',
+ }
+];
+
+const mockComments = [
+ {
+ commentId: 1,
+ postId: 1,
+ content: '早期的堀北真希真是太美了',
+ userId: 10,
+ userName: 'Inchou',
+ userAvatar: 'https://via.placeholder.com/40',
+ parentId: 0,
+ status: '1',
+ likes: 5,
+ createTime: '2025-05-15 05:19:00',
+ },
+ {
+ commentId: 2,
+ postId: 1,
+ content: '谢谢分享 😚',
+ userId: 11,
+ userName: '紫毛球',
+ userAvatar: 'https://via.placeholder.com/40',
+ parentId: 0,
+ status: '1',
+ likes: 3,
+ createTime: '2025-05-02 10:35:00',
+ },
+ {
+ commentId: 3,
+ postId: 1,
+ content: '感谢分享',
+ userId: 12,
+ userName: 'JJxMM666',
+ userAvatar: 'https://via.placeholder.com/40',
+ parentId: 0,
+ status: '1',
+ likes: 2,
+ createTime: '2025-05-01 18:54:00',
+ }
+];
+
+export default {
+ // 获取帖子列表
+ 'GET /api/post/list': (req: Request, res: Response) => {
+ const { pageNum = 1, pageSize = 10, title, status = '1' } = req.query;
+
+ let filteredPosts = mockPosts.filter(post => post.status === status);
+
+ if (title) {
+ filteredPosts = filteredPosts.filter(post =>
+ post.title.toLowerCase().includes((title as string).toLowerCase())
+ );
+ }
+
+ const startIndex = (Number(pageNum) - 1) * Number(pageSize);
+ const endIndex = startIndex + Number(pageSize);
+ const paginatedPosts = filteredPosts.slice(startIndex, endIndex);
+
+ res.json({
+ code: 200,
+ rows: paginatedPosts,
+ total: filteredPosts.length,
+ msg: '查询成功',
+ });
+ },
+
+ // 获取帖子详情
+ 'GET /api/post/:id': (req: Request, res: Response) => {
+ const { id } = req.params;
+ const post = mockPosts.find(p => p.postId === Number(id));
+
+ if (!post) {
+ return res.json({
+ code: 404,
+ msg: '帖子不存在',
+ });
+ }
+
+ const postComments = mockComments
+ .filter(c => c.postId === Number(id) && c.parentId === 0)
+ .map(comment => ({
+ comment,
+ replies: mockComments.filter(c => c.parentId === comment.commentId)
+ }));
+
+ const authorPosts = mockPosts
+ .filter(p => p.authorId === post.authorId && p.postId !== post.postId)
+ .slice(0, 3);
+
+ const similarPosts = mockPosts
+ .filter(p => {
+ if (p.postId === post.postId) return false;
+ const postTags = post.tags.split(',');
+ const pTags = p.tags.split(',');
+ return postTags.some(tag => pTags.includes(tag));
+ })
+ .slice(0, 3);
+
+ res.json({
+ code: 200,
+ data: {
+ post,
+ tags: post.tags.split(',').map((tag, index) => ({
+ tagId: index + 1,
+ tagName: tag,
+ tagColor: 'blue',
+ postCount: Math.floor(Math.random() * 100) + 1,
+ status: '0'
+ })),
+ comments: postComments,
+ authorPosts,
+ similarPosts,
+ favorited: false
+ },
+ msg: '查询成功',
+ });
+ },
+
+ // 添加评论
+ 'POST /api/post/comment': (req: Request, res: Response) => {
+ const { postId, content, parentId = 0 } = req.body;
+
+ if (!postId || !content) {
+ return res.json({
+ code: 400,
+ msg: '参数不完整',
+ });
+ }
+
+ const newComment = {
+ commentId: Date.now(),
+ postId: Number(postId),
+ content,
+ userId: 100,
+ userName: '当前用户',
+ userAvatar: 'https://via.placeholder.com/40',
+ parentId: Number(parentId),
+ status: '1',
+ likes: 0,
+ createTime: new Date().toLocaleString('zh-CN'),
+ };
+
+ mockComments.push(newComment);
+
+ // 更新帖子评论数
+ const post = mockPosts.find(p => p.postId === Number(postId));
+ if (post) {
+ post.comments += 1;
+ }
+
+ res.json({
+ code: 200,
+ data: newComment,
+ msg: '评论成功',
+ });
+ },
+
+ // 收藏/取消收藏帖子
+ 'POST /api/post/favorite/:id': (req: Request, res: Response) => {
+ const { id } = req.params;
+ const { favorite } = req.query;
+
+ const post = mockPosts.find(p => p.postId === Number(id));
+ if (!post) {
+ return res.json({
+ code: 404,
+ msg: '帖子不存在',
+ });
+ }
+
+ if (favorite === 'true') {
+ post.favorites += 1;
+ res.json({
+ code: 200,
+ msg: '收藏成功',
+ });
+ } else {
+ post.favorites = Math.max(0, post.favorites - 1);
+ res.json({
+ code: 200,
+ msg: '取消收藏成功',
+ });
+ }
+ },
+
+ // 获取热门标签
+ 'GET /api/post/tags/hot': (req: Request, res: Response) => {
+ const mockTags = [
+ { tagId: 1, tagName: '日剧', tagColor: 'blue', postCount: 150, status: '0' },
+ { tagId: 2, tagName: '电影', tagColor: 'green', postCount: 120, status: '0' },
+ { tagId: 3, tagName: '音乐', tagColor: 'orange', postCount: 80, status: '0' },
+ { tagId: 4, tagName: '推理', tagColor: 'purple', postCount: 65, status: '0' },
+ { tagId: 5, tagName: '爱情', tagColor: 'pink', postCount: 45, status: '0' },
+ ];
+
+ res.json({
+ code: 200,
+ data: mockTags,
+ msg: '查询成功',
+ });
+ },
+
+ // 根据标签获取帖子
+ 'GET /api/post/bytag/:tagId': (req: Request, res: Response) => {
+ const { tagId } = req.params;
+
+ // 简单的mock实现,实际应该根据tagId查询相关帖子
+ const filteredPosts = mockPosts.filter(post =>
+ post.tags.includes('日剧') // 假设查询日剧标签
+ );
+
+ res.json({
+ code: 200,
+ rows: filteredPosts,
+ total: filteredPosts.length,
+ msg: '查询成功',
+ });
+ },
+};
\ No newline at end of file
diff --git a/package.json b/package.json
index ce917d9..044644a 100644
--- a/package.json
+++ b/package.json
@@ -4,19 +4,20 @@
"private": true,
"description": "An out-of-box UI solution for enterprise applications",
"scripts": {
- "dev": "npm run start:dev",
+ "dev": "max dev",
"build": "max build",
"deploy": "npm run build && npm run gh-pages",
"preview": "npm run build && max preview --port 8000",
"serve": "umi-serve",
- "start": "cross-env UMI_ENV=dev max dev",
+ "start": "npm run dev",
"start:dev": "cross-env REACT_APP_ENV=dev MOCK=none UMI_ENV=dev max dev",
"start:no-mock": "cross-env MOCK=none UMI_ENV=dev max dev",
"start:pre": "cross-env REACT_APP_ENV=pre UMI_ENV=dev max dev",
"start:test": "cross-env REACT_APP_ENV=test MOCK=none UMI_ENV=dev max dev",
"test": "jest",
- "test:coverage": "npm run jest -- --coverage",
- "test:update": "npm run jest -- -u",
+ "test:watch": "jest --watch",
+ "test:coverage": "jest --coverage",
+ "test:ci": "jest --ci --coverage --watchAll=false",
"docker-hub:build": "docker build -f Dockerfile.hub -t ant-design-pro ./",
"docker-prod:build": "docker-compose -f ./docker/docker-compose.yml build",
"docker-prod:dev": "docker-compose -f ./docker/docker-compose.yml up",
@@ -39,7 +40,9 @@
"prepare": "cd .. && husky install",
"prettier": "prettier -c --write \"**/**.{js,jsx,tsx,ts,less,md,json}\"",
"tsc": "tsc --noEmit",
- "record": "cross-env NODE_ENV=development REACT_APP_ENV=test max record --scene=login"
+ "record": "cross-env NODE_ENV=development REACT_APP_ENV=test max record --scene=login",
+ "format": "prettier --cache --write .",
+ "setup": "max setup"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": "npm run lint-staged:js",
@@ -53,6 +56,7 @@
"not ie <= 10"
],
"dependencies": {
+ "@ant-design/compatible": "^5.1.4",
"@ant-design/icons": "^5.5.0",
"@ant-design/plots": "^2.3.2",
"@ant-design/pro-components": "^2.7.19",
@@ -81,15 +85,17 @@
},
"devDependencies": {
"@ant-design/pro-cli": "^3.3.0",
- "@testing-library/react": "^16.0.1",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/classnames": "^2.3.1",
"@types/express": "^4.17.21",
"@types/history": "^4.7.11",
- "@types/jest": "^29.5.12",
+ "@types/jest": "^29.5.14",
"@types/lodash": "^4.17.4",
"@types/node": "^22.13.5",
- "@types/react": "^18.3.0",
- "@types/react-dom": "^18.3.0",
+ "@types/react": "^18.3.23",
+ "@types/react-dom": "^18.3.7",
"@types/react-helmet": "^6.1.11",
"@umijs/fabric": "^2.14.1",
"@umijs/lint": "^4.2.9",
@@ -104,8 +110,9 @@
"mockjs": "^1.1.0",
"prettier": "^3.3.0",
"swagger-ui-dist": "^5.17.14",
+ "ts-jest": "^29.3.4",
"ts-node": "^10.9.1",
- "typescript": "^5.6.2",
+ "typescript": "^5.8.3",
"umi-presets-pro": "^2.0.0"
},
"engines": {
diff --git a/public/images/.gitkeep b/public/images/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/images/.gitkeep
diff --git a/src/pages/PostCenter/PostCard.module.css b/src/pages/PostCenter/PostCard.module.css
new file mode 100644
index 0000000..3f0623f
--- /dev/null
+++ b/src/pages/PostCenter/PostCard.module.css
@@ -0,0 +1,192 @@
+.postCardWrapper {
+ width: 100%;
+ height: 100%;
+}
+
+.postCard {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ transition: all 0.3s ease;
+ background: white;
+}
+
+.postCard:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+}
+
+.coverContainer {
+ width: 100%;
+ height: 200px;
+ overflow: hidden;
+ position: relative;
+ background: #f5f5f5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.coverImage {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ object-position: center;
+ transition: transform 0.3s ease;
+ background: #f5f5f5;
+}
+
+.postCard:hover .coverImage {
+ transform: scale(1.02);
+}
+
+.promotionBadge {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: linear-gradient(45deg, #ff6b6b, #ffa500);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ z-index: 10;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.cardContent {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.postTitle {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 12px 0;
+ line-height: 1.4;
+ color: #262626;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ height: 44px;
+}
+
+.postMeta {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ color: #8c8c8c;
+ margin-bottom: 12px;
+ height: 20px;
+}
+
+.authorName {
+ font-weight: 500;
+ color: #595959;
+}
+
+.publishTime {
+ color: #8c8c8c;
+}
+
+.tagsContainer {
+ margin-bottom: 12px;
+ height: 24px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ overflow: hidden;
+}
+
+.tag {
+ font-size: 11px;
+ padding: 2px 6px;
+ margin: 0;
+ border-radius: 4px;
+}
+
+.postSummary {
+ flex: 1;
+ font-size: 13px;
+ color: #595959;
+ line-height: 1.5;
+ margin-bottom: 16px;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ height: 60px;
+}
+
+.postFooter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: auto;
+ padding-top: 12px;
+ border-top: 1px solid #f0f0f0;
+ height: 32px;
+}
+
+.stats {
+ display: flex;
+ gap: 16px;
+}
+
+.statItem {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: #8c8c8c;
+}
+
+.statItem .anticon {
+ font-size: 12px;
+}
+
+.readMoreBtn {
+ padding: 0;
+ font-size: 12px;
+ color: #1890ff;
+ font-weight: 500;
+}
+
+.readMoreBtn:hover {
+ color: #40a9ff;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .coverContainer {
+ height: 160px;
+ }
+
+ .postTitle {
+ font-size: 14px;
+ height: 40px;
+ }
+
+ .postSummary {
+ font-size: 12px;
+ -webkit-line-clamp: 2;
+ height: 36px;
+ }
+
+ .stats {
+ gap: 12px;
+ }
+
+ .statItem {
+ font-size: 11px;
+ }
+}
\ No newline at end of file
diff --git a/src/pages/PostCenter/PostCard.tsx b/src/pages/PostCenter/PostCard.tsx
new file mode 100644
index 0000000..947ecc3
--- /dev/null
+++ b/src/pages/PostCenter/PostCard.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Card, Tag, Button, Avatar, Badge } from 'antd';
+import { EyeOutlined, CommentOutlined, UserOutlined, ClockCircleOutlined, HeartOutlined, CrownOutlined } from '@ant-design/icons';
+import { history } from 'umi';
+import styles from './PostCard.module.css';
+import { Post } from '../PostCenter/types';
+
+interface PostCardProps {
+ post: Post;
+}
+
+const PostCard: React.FC<PostCardProps> = ({ post }) => {
+ const {
+ id,
+ title,
+ author,
+ publishTime,
+ tags,
+ views,
+ comments,
+ favorites,
+ likes,
+ coverImage,
+ summary,
+ promotionPlanId,
+ isPromoted
+ } = post;
+
+ const goToDetail = () => {
+ history.push(`/post-detail/${id}`);
+ };
+
+ return (
+ <div className={styles.postCardWrapper}>
+ <Card
+ hoverable
+ cover={
+ <div className={styles.coverContainer}>
+ {isPromoted && (
+ <div className={styles.promotionBadge}>
+ <CrownOutlined />
+ <span>推广</span>
+ </div>
+ )}
+ <img
+ alt={title}
+ src={coverImage}
+ className={styles.coverImage}
+ onError={(e) => {
+ e.currentTarget.src = '/images/404.png';
+ }}
+ />
+ </div>
+ }
+ className={styles.postCard}
+ bodyStyle={{ padding: '16px', height: '240px', display: 'flex', flexDirection: 'column' }}
+ >
+ <div className={styles.cardContent}>
+ <h3 className={styles.postTitle} title={title}>{title}</h3>
+
+ <div className={styles.postMeta}>
+ <Avatar size="small" style={{ marginRight: 6 }} icon={<UserOutlined />}>
+ {author && author[0]}
+ </Avatar>
+ <span className={styles.authorName}>{author}</span>
+ <ClockCircleOutlined style={{ marginLeft: 12, marginRight: 4 }} />
+ <span className={styles.publishTime}>{publishTime}</span>
+ </div>
+
+ <div className={styles.tagsContainer}>
+ {(Array.isArray(tags) ? tags : []).slice(0, 3).map(tag => (
+ <Tag color="blue" key={tag} className={styles.tag}>{tag}</Tag>
+ ))}
+ {tags && tags.length > 3 && (
+ <Tag color="default" className={styles.tag}>+{tags.length - 3}</Tag>
+ )}
+ </div>
+
+ <div className={styles.postSummary} title={summary}>{summary}</div>
+
+ <div className={styles.postFooter}>
+ <div className={styles.stats}>
+ <span className={styles.statItem}>
+ <EyeOutlined /> {views || 0}
+ </span>
+ <span className={styles.statItem}>
+ <CommentOutlined /> {comments || 0}
+ </span>
+ <span className={styles.statItem}>
+ <HeartOutlined /> {favorites || 0}
+ </span>
+ </div>
+ <Button
+ type="link"
+ className={styles.readMoreBtn}
+ onClick={goToDetail}
+ >
+ 查看更多 »
+ </Button>
+ </div>
+ </div>
+ </Card>
+ </div>
+ );
+};
+
+export default PostCard;
\ No newline at end of file
diff --git a/src/pages/PostCenter/PostDetail.module.css b/src/pages/PostCenter/PostDetail.module.css
new file mode 100644
index 0000000..da072d1
--- /dev/null
+++ b/src/pages/PostCenter/PostDetail.module.css
@@ -0,0 +1,233 @@
+.postDetailContainer {
+ max-width: 1200px;
+ margin: 0 auto;
+ background: #f5f5f5;
+ min-height: 100vh;
+}
+
+.postCoverSection {
+ margin-bottom: 24px;
+ border-radius: 0 0 16px 16px;
+ overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.coverImageContainer {
+ position: relative;
+ width: 100%;
+ height: 400px;
+ overflow: hidden;
+ background: #f0f0f0;
+}
+
+.coverImage {
+ width: 100% !important;
+ height: 100% !important;
+ object-fit: cover !important;
+ transition: transform 0.3s ease;
+}
+
+.coverImage:hover {
+ transform: scale(1.02);
+}
+
+.coverOverlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+}
+
+.coverGradient {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 100px;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.3));
+}
+
+.previewMask {
+ background: rgba(0, 0, 0, 0.5);
+ color: white;
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+.postDetailHeader {
+ margin: 0 24px 24px;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.titleContainer {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.titleContainer h2 {
+ margin: 0;
+ flex: 1;
+}
+
+.promotionBadge {
+ background: linear-gradient(45deg, #ff6b6b, #ffa500);
+ color: white;
+ padding: 6px 12px;
+ border-radius: 16px;
+ font-size: 12px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ white-space: nowrap;
+}
+
+.postMeta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 24px;
+ margin: 16px 0;
+ padding: 16px 0;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.postAuthor,
+.postTime,
+.postViews,
+.postTags {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.postActions {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+}
+
+.postContent {
+ margin: 0 24px 24px;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.postContent img {
+ max-width: 100%;
+ height: auto;
+}
+
+.commentSection {
+ margin: 0 24px 24px;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.commentInput {
+ margin-bottom: 24px;
+}
+
+.commentActions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.commentList {
+ margin-top: 24px;
+}
+
+.replyList {
+ margin-top: 16px;
+ padding-left: 24px;
+ border-left: 2px solid #f0f0f0;
+}
+
+.relatedPosts {
+ margin: 0 24px 24px;
+ padding: 24px;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.recommendHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+}
+
+.recommendHeader h4 {
+ margin: 0;
+}
+
+.postDetailLoading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ color: #666;
+}
+
+.postDetailError {
+ text-align: center;
+ padding: 60px 24px;
+ color: #999;
+ font-size: 16px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .postDetailContainer {
+ margin: 0;
+ }
+
+ .coverImageContainer {
+ height: 250px;
+ }
+
+ .postDetailHeader,
+ .postContent,
+ .commentSection,
+ .relatedPosts {
+ margin: 0 12px 16px;
+ border-radius: 8px;
+ }
+
+ .postMeta {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .postActions {
+ flex-direction: column;
+ }
+
+ .postActions button {
+ width: 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .coverImageContainer {
+ height: 200px;
+ }
+
+ .postDetailHeader,
+ .postContent,
+ .commentSection,
+ .relatedPosts {
+ margin: 0 8px 12px;
+ }
+}
\ No newline at end of file
diff --git a/src/pages/PostCenter/PostDetail.tsx b/src/pages/PostCenter/PostDetail.tsx
new file mode 100644
index 0000000..c6aedcc
--- /dev/null
+++ b/src/pages/PostCenter/PostDetail.tsx
@@ -0,0 +1,547 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, Link } from 'umi';
+import { Card, Avatar, Tag, Row, Col, Divider, List, Input, Button, Typography, message, Spin, Image, Modal, Badge, Pagination } from 'antd';
+import { Comment } from '@ant-design/compatible';
+import {
+ UserOutlined,
+ ClockCircleOutlined,
+ EyeOutlined,
+ TagOutlined,
+ LikeOutlined,
+ DislikeOutlined,
+ ShareAltOutlined,
+ HeartOutlined,
+ HeartFilled,
+ ExclamationCircleOutlined,
+ CrownOutlined
+} from '@ant-design/icons';
+import { getPostDetail, addComment, toggleFavorite, toggleLike, toggleCommentLike, reportPost } from '@/services/post';
+import PostCard from '../PostCenter/PostCard';
+import styles from './PostDetail.module.css';
+import { Post, CommentType } from '../PostCenter/types';
+
+const { TextArea } = Input;
+const { Title, Paragraph, Text } = Typography;
+
+const PostDetail: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const [post, setPost] = useState<Post | null>(null);
+ const [recommendedPosts, setRecommendedPosts] = useState<Post[]>([]);
+ const [currentRecommendPage, setCurrentRecommendPage] = useState<number>(1);
+ const [comments, setComments] = useState<CommentType[]>([]);
+ const [replyTo, setReplyTo] = useState<number | null>(null);
+ const [commentText, setCommentText] = useState<string>('');
+ const [loading, setLoading] = useState<boolean>(true);
+ const [favorited, setFavorited] = useState<boolean>(false);
+ const [liked, setLiked] = useState<boolean>(false);
+ const [submittingComment, setSubmittingComment] = useState<boolean>(false);
+ const [commentLikes, setCommentLikes] = useState<Record<number, boolean>>({});
+ const [reportModalVisible, setReportModalVisible] = useState<boolean>(false);
+ const [reportReason, setReportReason] = useState<string>('');
+ const [submittingReport, setSubmittingReport] = useState<boolean>(false);
+
+ const recommendPageSize = 3; // 每页显示3个推荐帖子
+
+ useEffect(() => {
+ if (id) {
+ fetchPostDetail(Number(id));
+ }
+ }, [id]);
+
+ const fetchPostDetail = async (postId: number) => {
+ try {
+ setLoading(true);
+ const response = await getPostDetail(postId);
+
+ if (response.code === 200 && response.data) {
+ const { post: postData, tags, comments: commentsData, recommendedPosts, favorited: isFavorited } = response.data;
+
+ // 转换帖子数据格式
+ const formattedPost: Post = {
+ ...postData,
+ id: postData.postId,
+ title: postData.title || '无标题',
+ author: postData.author || '未知作者',
+ publishTime: postData.publishTime || postData.createTime || '',
+ tags: postData.tags ? postData.tags.split(',') : [],
+ views: postData.views || 0,
+ comments: postData.comments || 0,
+ favorites: postData.favorites || 0,
+ likes: postData.likes || 0,
+ coverImage: postData.coverImage || '/images/404.png',
+ summary: postData.summary || '暂无摘要',
+ isPromoted: postData.promotionPlanId != null && postData.promotionPlanId > 0,
+ };
+ setPost(formattedPost);
+ setFavorited(isFavorited);
+
+ // 转换评论数据格式
+ const formattedComments = commentsData.map((commentItem: any) => {
+ const comment = commentItem.comment;
+ const replies = commentItem.replies || [];
+
+ return {
+ ...comment,
+ id: comment.commentId,
+ author: comment.userName || '匿名用户',
+ avatar: comment.userAvatar || '/images/404.png',
+ datetime: comment.createTime,
+ likes: comment.likes || 0,
+ replies: replies.map((reply: any) => ({
+ ...reply,
+ id: reply.commentId,
+ author: reply.userName || '匿名用户',
+ avatar: reply.userAvatar || '/images/404.png',
+ datetime: reply.createTime,
+ likes: reply.likes || 0,
+ replies: []
+ }))
+ };
+ });
+ setComments(formattedComments);
+
+ // 转换推荐帖子数据格式
+ const formatPosts = (posts: any[]) => posts.map((p: any) => ({
+ ...p,
+ id: p.postId,
+ title: p.title || '无标题',
+ author: p.author || '未知作者',
+ publishTime: p.publishTime || p.createTime || '',
+ tags: p.tags ? p.tags.split(',') : [],
+ views: p.views || 0,
+ comments: p.comments || 0,
+ favorites: p.favorites || 0,
+ likes: p.likes || 0,
+ coverImage: p.coverImage || '/images/404.png',
+ summary: p.summary || '暂无摘要',
+ isPromoted: p.promotionPlanId != null && p.promotionPlanId > 0, // 添加推广标识
+ }));
+
+ // 设置推荐帖子
+ const formattedRecommendedPosts = formatPosts(recommendedPosts || []);
+ setRecommendedPosts(formattedRecommendedPosts);
+ } else {
+ message.error(response.msg || '获取帖子详情失败');
+ }
+ } catch (error) {
+ console.error('获取帖子详情失败:', error);
+ message.error('获取帖子详情失败,请稍后重试');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCommentSubmit = async () => {
+ if (!commentText.trim()) {
+ message.warning('评论内容不能为空');
+ return;
+ }
+
+ if (!post) return;
+
+ try {
+ setSubmittingComment(true);
+ const response = await addComment({
+ postId: Number(post.id),
+ content: commentText,
+ parentId: replyTo || 0,
+ });
+
+ if (response.code === 200) {
+ message.success('评论发表成功');
+ setCommentText('');
+ setReplyTo(null);
+ // 重新获取帖子详情以更新评论列表
+ fetchPostDetail(Number(post.id));
+ } else {
+ message.error(response.msg || '评论发表失败');
+ }
+ } catch (error) {
+ console.error('评论发表失败:', error);
+ message.error('评论发表失败,请稍后重试');
+ } finally {
+ setSubmittingComment(false);
+ }
+ };
+
+ const handleFavoriteToggle = async () => {
+ if (!post) return;
+
+ try {
+ const response = await toggleFavorite(Number(post.id), !favorited);
+
+ if (response.code === 200) {
+ setFavorited(!favorited);
+ message.success(favorited ? '取消收藏成功' : '收藏成功');
+ // 更新帖子收藏数
+ setPost(prev => prev ? {
+ ...prev,
+ favorites: (prev.favorites || 0) + (favorited ? -1 : 1)
+ } : null);
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ console.error('收藏操作失败:', error);
+ message.error('操作失败,请稍后重试');
+ }
+ };
+
+ const handleLikeToggle = async () => {
+ if (!post) return;
+
+ try {
+ const response = await toggleLike(Number(post.id), !liked);
+
+ if (response.code === 200) {
+ setLiked(!liked);
+ message.success(liked ? '取消点赞成功' : '点赞成功');
+ // 更新帖子点赞数
+ setPost(prev => prev ? {
+ ...prev,
+ likes: (prev.likes || 0) + (liked ? -1 : 1)
+ } : null);
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ console.error('点赞操作失败:', error);
+ message.error('操作失败,请稍后重试');
+ }
+ };
+
+ const handleReply = (commentId: number) => {
+ setReplyTo(commentId);
+ };
+
+ const cancelReply = () => {
+ setReplyTo(null);
+ };
+
+ const handleCommentLike = async (commentId: number) => {
+ try {
+ const isLiked = commentLikes[commentId] || false;
+ const response = await toggleCommentLike(commentId, !isLiked);
+
+ if (response.code === 200) {
+ setCommentLikes(prev => ({
+ ...prev,
+ [commentId]: !isLiked
+ }));
+
+ // 更新评论点赞数
+ setComments(prev => prev.map(comment => {
+ if (comment.id === commentId) {
+ return {
+ ...comment,
+ likes: (comment.likes || 0) + (isLiked ? -1 : 1)
+ };
+ }
+ // 检查回复
+ if (comment.replies) {
+ const updatedReplies = comment.replies.map(reply => {
+ if (reply.id === commentId) {
+ return {
+ ...reply,
+ likes: (reply.likes || 0) + (isLiked ? -1 : 1)
+ };
+ }
+ return reply;
+ });
+ return { ...comment, replies: updatedReplies };
+ }
+ return comment;
+ }));
+
+ message.success(isLiked ? '取消点赞成功' : '点赞成功');
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ console.error('评论点赞操作失败:', error);
+ message.error('操作失败,请稍后重试');
+ }
+ };
+
+ const handleReport = async () => {
+ if (!post) return;
+
+ if (!reportReason.trim()) {
+ message.warning('请填写举报理由');
+ return;
+ }
+
+ try {
+ setSubmittingReport(true);
+ const response = await reportPost(Number(post.id), reportReason);
+
+ if (response.code === 200) {
+ message.success('举报提交成功,我们会尽快处理');
+ setReportModalVisible(false);
+ setReportReason('');
+ } else {
+ message.error(response.msg || '举报提交失败');
+ }
+ } catch (error) {
+ console.error('举报提交失败:', error);
+ message.error('举报提交失败,请稍后重试');
+ } finally {
+ setSubmittingReport(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ <div className={styles.postDetailLoading}>
+ <Spin size="large" />
+ <div style={{ marginTop: 16 }}>加载中...</div>
+ </div>
+ );
+ }
+
+ if (!post) {
+ return <div className={styles.postDetailError}>帖子不存在或已被删除</div>;
+ }
+
+ return (
+ <div className={styles.postDetailContainer}>
+ {/* 帖子封面图片 */}
+ {post.coverImage && (
+ <div className={styles.postCoverSection}>
+ <div className={styles.coverImageContainer}>
+ <Image
+ src={post.coverImage}
+ alt={post.title}
+ className={styles.coverImage}
+ preview={{
+ mask: <div className={styles.previewMask}>点击预览</div>
+ }}
+ onError={(e) => {
+ e.currentTarget.src = '/images/404.png';
+ }}
+ />
+ <div className={styles.coverOverlay}>
+ <div className={styles.coverGradient}></div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 帖子头部信息 */}
+ <Card className={styles.postDetailHeader}>
+ <div className={styles.titleContainer}>
+ <Title level={2}>{post.title}</Title>
+ {post.isPromoted && (
+ <div className={styles.promotionBadge}>
+ <CrownOutlined />
+ <span>推广</span>
+ </div>
+ )}
+ </div>
+
+ <div className={styles.postMeta}>
+ <div className={styles.postAuthor}>
+ <Avatar size="small" icon={<UserOutlined />} />
+ <Text strong style={{ marginLeft: 8 }}>{post.author}</Text>
+ </div>
+
+ <div className={styles.postTime}>
+ <ClockCircleOutlined />
+ <Text type="secondary" style={{ marginLeft: 8 }}>{post.publishTime}</Text>
+ </div>
+
+ <div className={styles.postViews}>
+ <EyeOutlined />
+ <Text type="secondary" style={{ marginLeft: 8 }}>{post.views} 查看</Text>
+ </div>
+
+ <div className={styles.postTags}>
+ <TagOutlined />
+ <span style={{ marginLeft: 8 }}>
+ {post.tags.map(tag => (
+ <Tag key={tag} color="blue">{tag}</Tag>
+ ))}
+ </span>
+ </div>
+ </div>
+
+ {/* 操作按钮 */}
+ <div className={styles.postActions}>
+ <Button
+ type={favorited ? "primary" : "default"}
+ icon={favorited ? <HeartFilled /> : <HeartOutlined />}
+ onClick={handleFavoriteToggle}
+ >
+ {favorited ? '已收藏' : '收藏'} ({post.favorites || 0})
+ </Button>
+ <Button
+ type={liked ? "primary" : "default"}
+ icon={<LikeOutlined />}
+ onClick={handleLikeToggle}
+ >
+ {liked ? '已点赞' : '点赞'} ({post.likes || 0})
+ </Button>
+ {/* <Button icon={<ShareAltOutlined />}>
+ 分享
+ </Button> */}
+ <Button
+ icon={<ExclamationCircleOutlined />}
+ onClick={() => setReportModalVisible(true)}
+ >
+ 举报
+ </Button>
+ </div>
+ </Card>
+
+ {/* 帖子内容 */}
+ <Card className={styles.postContent}>
+ <div dangerouslySetInnerHTML={{ __html: post.content || post.summary || '' }} />
+ </Card>
+
+ {/* 评论区 */}
+ <Card className={styles.commentSection}>
+ <Title level={4}>评论 ({comments.length})</Title>
+
+ <div className={styles.commentInput}>
+ <TextArea
+ value={commentText}
+ onChange={e => setCommentText(e.target.value)}
+ placeholder={replyTo ? '回复评论...' : '写下你的评论...'}
+ rows={4}
+ />
+ <div className={styles.commentActions}>
+ {replyTo && (
+ <Button onClick={cancelReply}>取消回复</Button>
+ )}
+ <Button
+ type="primary"
+ onClick={handleCommentSubmit}
+ loading={submittingComment}
+ >
+ 发表{replyTo ? '回复' : '评论'}
+ </Button>
+ </div>
+ </div>
+
+ <List
+ className={styles.commentList}
+ itemLayout="horizontal"
+ dataSource={comments}
+ renderItem={comment => (
+ <li>
+ <Comment
+ author={comment.author}
+ avatar={comment.avatar}
+ content={comment.content}
+ datetime={comment.datetime}
+ actions={[
+ <span key="like">
+ <Button
+ type={commentLikes[comment.id] ? "primary" : "text"}
+ size="small"
+ icon={<LikeOutlined />}
+ onClick={() => handleCommentLike(comment.id)}
+ >
+ {comment.likes || 0}
+ </Button>
+ </span>,
+ <span key="reply" onClick={() => handleReply(comment.id)}>回复</span>
+ ]}
+ >
+ {comment.replies && comment.replies.length > 0 && (
+ <List
+ className={styles.replyList}
+ itemLayout="horizontal"
+ dataSource={comment.replies}
+ renderItem={reply => (
+ <li>
+ <Comment
+ author={reply.author}
+ avatar={reply.avatar}
+ content={reply.content}
+ datetime={reply.datetime}
+ actions={[
+ <span key="like">
+ <Button
+ type={commentLikes[reply.id] ? "primary" : "text"}
+ size="small"
+ icon={<LikeOutlined />}
+ onClick={() => handleCommentLike(reply.id)}
+ >
+ {reply.likes || 0}
+ </Button>
+ </span>
+ ]}
+ />
+ </li>
+ )}
+ />
+ )}
+ </Comment>
+ </li>
+ )}
+ />
+ </Card>
+
+ {/* 举报弹窗 */}
+ <Modal
+ title="举报帖子"
+ open={reportModalVisible}
+ onOk={handleReport}
+ onCancel={() => {
+ setReportModalVisible(false);
+ setReportReason('');
+ }}
+ confirmLoading={submittingReport}
+ okText="提交举报"
+ cancelText="取消"
+ >
+ <div style={{ marginBottom: 16 }}>
+ <strong>帖子:</strong>{post?.title}
+ </div>
+ <div>
+ <strong>举报理由:</strong>
+ <TextArea
+ value={reportReason}
+ onChange={(e) => setReportReason(e.target.value)}
+ placeholder="请详细描述举报理由..."
+ rows={4}
+ style={{ marginTop: 8 }}
+ />
+ </div>
+ </Modal>
+
+ {/* 相关推荐 */}
+ {recommendedPosts.length > 0 && (
+ <div className={styles.relatedPosts}>
+ <div className={styles.recommendHeader}>
+ <Title level={4}>相关推荐</Title>
+ {recommendedPosts.length > recommendPageSize && (
+ <Pagination
+ current={currentRecommendPage}
+ total={recommendedPosts.length}
+ pageSize={recommendPageSize}
+ onChange={(page) => setCurrentRecommendPage(page)}
+ showSizeChanger={false}
+ showQuickJumper={false}
+ showTotal={(total, range) => `${range[0]}-${range[1]} / ${total}`}
+ size="small"
+ />
+ )}
+ </div>
+ <Row gutter={[24, 24]}>
+ {recommendedPosts
+ .slice((currentRecommendPage - 1) * recommendPageSize, currentRecommendPage * recommendPageSize)
+ .map(post => (
+ <Col xs={24} sm={12} md={8} key={post.id}>
+ <PostCard post={post} />
+ </Col>
+ ))}
+ </Row>
+ </div>
+ )}
+ </div>
+ );
+};
+
+export default PostDetail;
\ No newline at end of file
diff --git a/src/pages/PostCenter/index.module.css b/src/pages/PostCenter/index.module.css
new file mode 100644
index 0000000..2b262e2
--- /dev/null
+++ b/src/pages/PostCenter/index.module.css
@@ -0,0 +1,189 @@
+.postCenterContainer {
+ min-height: 100vh;
+ background-color: #f5f5f5;
+}
+
+.headerNav {
+ background: white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ padding: 0 24px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 64px;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.categoryMenu {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.categoryButton {
+ border: none;
+ box-shadow: none;
+ font-weight: 500;
+ padding: 8px 16px;
+ border-radius: 6px;
+ transition: all 0.3s ease;
+}
+
+.categoryButton:hover {
+ background-color: #f0f0f0;
+}
+
+.searchContainer {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ max-width: 400px;
+ margin: 0 24px;
+}
+
+.userCenter {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.carouselContainer {
+ margin: 24px;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.carouselSlide {
+ position: relative;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+}
+
+.carouselOverlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
+ color: white;
+ padding: 40px 40px 24px;
+ text-align: left;
+}
+
+.carouselTitle {
+ font-size: 28px;
+ font-weight: bold;
+ margin: 0 0 12px 0;
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
+}
+
+.carouselSummary {
+ font-size: 16px;
+ margin: 0 0 16px 0;
+ opacity: 0.9;
+ line-height: 1.5;
+}
+
+.carouselMeta {
+ display: flex;
+ gap: 24px;
+ font-size: 14px;
+ opacity: 0.8;
+}
+
+.postsSection {
+ padding: 24px;
+}
+
+.categoryTitle {
+ margin-bottom: 24px;
+ text-align: center;
+}
+
+.categoryTitle h2 {
+ font-size: 24px;
+ margin: 0 0 8px 0;
+ color: #1890ff;
+}
+
+.categoryTitle p {
+ margin: 0;
+ color: #666;
+ font-size: 14px;
+}
+
+.postsRow {
+ margin-bottom: 32px;
+}
+
+.postCol {
+ display: flex;
+ height: 480px; /* 固定卡片高度 */
+}
+
+.postCol > * {
+ width: 100%;
+}
+
+.emptyState {
+ text-align: center;
+ padding: 60px 0;
+ color: #999;
+ font-size: 16px;
+}
+
+.paginationContainer {
+ display: flex;
+ justify-content: center;
+ margin-top: 32px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .headerNav {
+ flex-direction: column;
+ height: auto;
+ padding: 12px;
+ gap: 12px;
+ }
+
+ .categoryMenu {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .searchContainer {
+ margin: 0;
+ max-width: 100%;
+ }
+
+ .carouselContainer {
+ margin: 12px;
+ }
+
+ .carouselOverlay {
+ padding: 20px;
+ }
+
+ .carouselTitle {
+ font-size: 20px;
+ }
+
+ .carouselSummary {
+ font-size: 14px;
+ }
+
+ .carouselMeta {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .postsSection {
+ padding: 12px;
+ }
+}
\ No newline at end of file
diff --git a/src/pages/PostCenter/index.tsx b/src/pages/PostCenter/index.tsx
new file mode 100644
index 0000000..b81efb0
--- /dev/null
+++ b/src/pages/PostCenter/index.tsx
@@ -0,0 +1,301 @@
+import React, { useEffect, useState } from 'react';
+import { Row, Col, Pagination, Input, Carousel, Menu, Card, message, Button } from 'antd';
+import { SearchOutlined, AuditOutlined } from '@ant-design/icons';
+import { getPostList, getPromotionPosts } from '@/services/post';
+import PostCard from '../PostCenter/PostCard';
+import styles from './index.module.css';
+import { Post } from '../PostCenter/types';
+import { useNavigate } from 'react-router-dom';
+import { useModel } from 'umi';
+
+const { Search } = Input;
+
+const PostCenter: React.FC = () => {
+ const [posts, setPosts] = useState<Post[]>([]);
+ const [promotionPosts, setPromotionPosts] = useState<Post[]>([]);
+ const [total, setTotal] = useState<number>(0);
+ const [page, setPage] = useState<number>(1);
+ const [loading, setLoading] = useState<boolean>(false);
+ const [selectedCategory, setSelectedCategory] = useState<string>('all');
+ const [searchKeyword, setSearchKeyword] = useState<string>('');
+ const pageSize = 12;
+ const navigate = useNavigate();
+ const { initialState } = useModel('@@initialState');
+
+ // 检查是否为管理员 - 用户名包含admin
+ const isAdmin = initialState?.currentUser?.userName?.toLowerCase().includes('admin') || false;
+
+ const fetchPosts = async (current: number = 1, category?: string, searchTitle?: string) => {
+ try {
+ setLoading(true);
+ const params: any = {
+ pageNum: current,
+ pageSize: pageSize,
+ status: '1', // 只查询正常状态的帖子
+ };
+
+ // 根据分类筛选
+ if (category && category !== 'all') {
+ params.tags = category;
+ }
+
+ // 搜索关键词
+ if (searchTitle) {
+ params.title = searchTitle;
+ }
+
+ const response = await getPostList(params);
+
+ if (response.code === 200) {
+ // 确保返回的数据符合 Post 类型
+ const formattedPosts = (response.rows || []).map((post: API.Post.PostInfo) => ({
+ ...post,
+ id: post.postId, // 确保id字段映射正确
+ tags: post.tags ? post.tags.split(',') : [],
+ views: post.views || 0,
+ comments: post.comments || 0,
+ favorites: post.favorites || 0,
+ likes: post.likes || 0,
+ coverImage: post.coverImage || '/images/404.png', // 使用本地默认图片
+ isPromoted: post.promotionPlanId != null && post.promotionPlanId > 0, // 添加推广标识
+ }));
+ setPosts(formattedPosts);
+ setTotal(response.total || 0);
+ } else {
+ message.error(response.msg || '获取帖子列表失败');
+ setPosts([]);
+ setTotal(0);
+ }
+ } catch (error) {
+ console.error('获取帖子失败:', error);
+ message.error('获取帖子列表失败,请稍后重试');
+ setPosts([]);
+ setTotal(0);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchPromotionPosts = async () => {
+ try {
+ const response = await getPromotionPosts();
+ if (response.code === 200) {
+ const formattedPosts = (response.data || []).map((post: API.Post.PostInfo) => ({
+ ...post,
+ id: post.postId,
+ tags: post.tags ? post.tags.split(',') : [],
+ views: post.views || 0,
+ comments: post.comments || 0,
+ favorites: post.favorites || 0,
+ likes: post.likes || 0,
+ coverImage: post.coverImage || '/images/404.png',
+ isPromoted: post.promotionPlanId != null && post.promotionPlanId > 0, // 添加推广标识
+ }));
+ setPromotionPosts(formattedPosts);
+ }
+ } catch (error) {
+ console.error('获取推广帖子失败:', error);
+ }
+ };
+
+ useEffect(() => {
+ fetchPosts(page, selectedCategory, searchKeyword);
+ fetchPromotionPosts();
+ }, [page, selectedCategory]);
+
+ const handleSearch = (value: string) => {
+ console.log('搜索:', value);
+ setSearchKeyword(value);
+ setPage(1); // 重置页码
+ fetchPosts(1, selectedCategory, value);
+ };
+
+ const handlePageChange = (newPage: number) => {
+ setPage(newPage);
+ fetchPosts(newPage, selectedCategory, searchKeyword);
+ };
+
+ const handleCategoryChange = (category: string) => {
+ setSelectedCategory(category);
+ setPage(1); // 重置页码
+ setSearchKeyword(''); // 清空搜索
+ fetchPosts(1, category, '');
+ };
+
+ return (
+ <div className={styles.postCenterContainer}>
+ {/* 顶部导航 */}
+ <div className={styles.headerNav}>
+ <div className={styles.categoryMenu}>
+ <Button
+ type={selectedCategory === 'all' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('all')}
+ className={styles.categoryButton}
+ >
+ 首页
+ </Button>
+ <Button
+ type={selectedCategory === '日剧' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('日剧')}
+ className={styles.categoryButton}
+ >
+ 日剧
+ </Button>
+ <Button
+ type={selectedCategory === '电影' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('电影')}
+ className={styles.categoryButton}
+ >
+ 电影
+ </Button>
+ <Button
+ type={selectedCategory === '音乐' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('音乐')}
+ className={styles.categoryButton}
+ >
+ 音乐
+ </Button>
+ <Button
+ type={selectedCategory === '合集' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('合集')}
+ className={styles.categoryButton}
+ >
+ 合集
+ </Button>
+ <Button
+ type={selectedCategory === '动漫' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('动漫')}
+ className={styles.categoryButton}
+ >
+ 动漫
+ </Button>
+ <Button
+ type={selectedCategory === '游戏' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('游戏')}
+ className={styles.categoryButton}
+ >
+ 游戏
+ </Button>
+ </div>
+
+ <div className={styles.searchContainer}>
+ <Search
+ placeholder="搜索帖子..."
+ onSearch={handleSearch}
+ style={{ width: 300 }}
+ enterButton={<SearchOutlined />}
+ value={searchKeyword}
+ onChange={(e) => setSearchKeyword(e.target.value)}
+ />
+ </div>
+
+ <div className={styles.userCenter}>
+ {isAdmin && (
+ <Button
+ icon={<AuditOutlined />}
+ onClick={() => navigate('/post-review')}
+ style={{ marginRight: 16 }}
+ >
+ 帖子审核
+ </Button>
+ )}
+ <Button
+ type="primary"
+ onClick={() => navigate('/user-center')}
+ >
+ 个人中心
+ </Button>
+ </div>
+ </div>
+
+ {/* 轮播推荐图 */}
+ <div className={styles.carouselContainer}>
+ <Carousel autoplay>
+ {promotionPosts.length > 0 ? (
+ promotionPosts.map((post) => (
+ <div key={post.id} onClick={() => navigate(`/post-detail/${post.id}`)}>
+ <div
+ className={styles.carouselSlide}
+ style={{
+ backgroundImage: `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), url(${post.coverImage})`,
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
+ height: '300px',
+ position: 'relative',
+ cursor: 'pointer'
+ }}
+ >
+ <div className={styles.carouselOverlay}>
+ <h2 className={styles.carouselTitle}>{post.title}</h2>
+ <p className={styles.carouselSummary}>{post.summary}</p>
+ <div className={styles.carouselMeta}>
+ <span>作者: {post.author}</span>
+ <span>浏览: {post.views}</span>
+ <span>点赞: {post.likes}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ ))
+ ) : (
+ // 默认轮播图
+ <>
+ <div>
+ <div
+ className={styles.carouselSlide}
+ style={{
+ backgroundImage: `url(/images/flower.jpg)`,
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
+ height: '300px',
+ }}
+ >
+ <div className={styles.carouselOverlay}>
+ <h2 className={styles.carouselTitle}>欢迎来到ThunderHub</h2>
+ <p className={styles.carouselSummary}>发现精彩内容,分享美好时光</p>
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+ </Carousel>
+ </div>
+
+ {/* 卡片帖子区 */}
+ <div className={styles.postsSection}>
+ {selectedCategory !== 'all' && (
+ <div className={styles.categoryTitle}>
+ <h2>{selectedCategory} 分类</h2>
+ <p>共找到 {total} 篇相关帖子</p>
+ </div>
+ )}
+
+ <Row gutter={[24, 24]} className={styles.postsRow}>
+ {posts.map((post) => (
+ <Col xs={24} sm={12} lg={8} xl={6} key={post.id} className={styles.postCol}>
+ <PostCard post={post} />
+ </Col>
+ ))}
+ </Row>
+
+ {posts.length === 0 && !loading && (
+ <div className={styles.emptyState}>
+ <p>暂无相关帖子</p>
+ </div>
+ )}
+
+ <div className={styles.paginationContainer}>
+ <Pagination
+ current={page}
+ pageSize={pageSize}
+ total={total}
+ onChange={handlePageChange}
+ showTotal={(total) => `共 ${total} 条帖子`}
+ />
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default PostCenter;
\ No newline at end of file
diff --git a/src/pages/PostCenter/types.ts b/src/pages/PostCenter/types.ts
new file mode 100644
index 0000000..4cfe237
--- /dev/null
+++ b/src/pages/PostCenter/types.ts
@@ -0,0 +1,55 @@
+export interface Post {
+ postId?: number;
+ id: string | number;
+ title: string;
+ author: string;
+ publishTime: string;
+ tags: string[];
+ views: number;
+ comments?: number;
+ favorites?: number;
+ likes?: number;
+ coverImage?: string;
+ summary?: string;
+ content?: string;
+ status?: string;
+ authorId?: number;
+ createTime?: string;
+ updateTime?: string;
+ promotionPlanId?: number;
+ isPromoted?: boolean;
+}
+
+export interface CommentType {
+ commentId?: number;
+ id: number;
+ author: string;
+ avatar: string;
+ content: string;
+ datetime: string;
+ replies: CommentType[];
+ likes?: number;
+ parentId?: number;
+ postId?: number;
+ userId?: number;
+ userName?: string;
+ userAvatar?: string;
+ status?: string;
+}
+
+export interface PostTag {
+ tagId: number;
+ tagName: string;
+ tagColor?: string;
+ postCount: number;
+ status?: string;
+}
+
+export interface PostFavorite {
+ favoriteId: number;
+ postId: number;
+ userId: number;
+ postTitle?: string;
+ postCover?: string;
+ status: string;
+}
\ No newline at end of file
diff --git a/src/pages/PostReview/ReportManagement.tsx b/src/pages/PostReview/ReportManagement.tsx
new file mode 100644
index 0000000..f5c0e99
--- /dev/null
+++ b/src/pages/PostReview/ReportManagement.tsx
@@ -0,0 +1,325 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Card,
+ Table,
+ Button,
+ Modal,
+ Tag,
+ Space,
+ message,
+ Typography,
+ Input
+} from 'antd';
+import {
+ EyeOutlined,
+ CheckOutlined,
+ CloseOutlined
+} from '@ant-design/icons';
+import { getReportList, handleReport } from '@/services/post';
+import styles from './index.module.css';
+
+const { Title, Paragraph } = Typography;
+const { TextArea } = Input;
+
+interface ReportInfo {
+ reportId: number;
+ postId: number;
+ postTitle: string;
+ reportUserId: number;
+ reportUserName: string;
+ reportReason: string;
+ status: string;
+ handleResult?: string;
+ handleTime?: string;
+ handleBy?: string;
+ createTime: string;
+}
+
+const ReportManagement: React.FC = () => {
+ const [reports, setReports] = useState<ReportInfo[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [detailModalVisible, setDetailModalVisible] = useState(false);
+ const [handleModalVisible, setHandleModalVisible] = useState(false);
+ const [currentReport, setCurrentReport] = useState<ReportInfo | null>(null);
+ const [currentAction, setCurrentAction] = useState<'approve' | 'reject' | null>(null);
+ const [handleReason, setHandleReason] = useState('');
+
+ useEffect(() => {
+ fetchReports();
+ }, []);
+
+ const fetchReports = async () => {
+ setLoading(true);
+ try {
+ const response = await getReportList({
+ pageNum: 1,
+ pageSize: 100
+ });
+
+ if (response.code === 200) {
+ setReports(response.rows || []);
+ } else {
+ message.error(response.msg || '获取举报列表失败');
+ }
+ } catch (error) {
+ message.error('获取举报列表失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleReportAction = async (report: ReportInfo, action: 'approve' | 'reject') => {
+ setCurrentReport(report);
+ setCurrentAction(action);
+ setHandleReason('');
+ setHandleModalVisible(true);
+ };
+
+ const submitHandle = async () => {
+ if (!currentReport || !currentAction) return;
+
+ try {
+ const response = await handleReport(
+ currentReport.reportId,
+ currentAction,
+ currentReport.postId,
+ handleReason
+ );
+
+ if (response.code === 200) {
+ message.success(currentAction === 'approve' ? '举报处理成功,帖子已下架' : '举报已驳回');
+ fetchReports();
+ } else {
+ message.error(response.msg || '处理失败');
+ }
+ } catch (error) {
+ message.error('处理失败');
+ }
+
+ setHandleModalVisible(false);
+ setCurrentReport(null);
+ setCurrentAction(null);
+ setHandleReason('');
+ };
+
+ const showDetail = (report: ReportInfo) => {
+ setCurrentReport(report);
+ setDetailModalVisible(true);
+ };
+
+ const columns = [
+ {
+ title: '举报帖子',
+ dataIndex: 'postTitle',
+ key: 'postTitle',
+ width: 180,
+ render: (text: string, record: ReportInfo) => (
+ <a onClick={() => showDetail(record)} style={{ color: '#1890ff' }}>
+ {text}
+ </a>
+ ),
+ },
+ {
+ title: '举报人',
+ dataIndex: 'reportUserName',
+ key: 'reportUserName',
+ width: 100,
+ },
+ {
+ title: '举报时间',
+ dataIndex: 'createTime',
+ key: 'createTime',
+ width: 140,
+ },
+ {
+ title: '举报理由',
+ dataIndex: 'reportReason',
+ key: 'reportReason',
+ width: 150,
+ ellipsis: true,
+ render: (text: string) => (
+ <span title={text}>{text}</span>
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 80,
+ render: (status: string) => {
+ const statusMap: Record<string, { color: string; text: string }> = {
+ '0': { color: 'orange', text: '待处理' },
+ '1': { color: 'green', text: '已处理' },
+ '2': { color: 'red', text: '已驳回' }
+ };
+ const statusInfo = statusMap[status] || { color: 'gray', text: '未知' };
+ return <Tag color={statusInfo.color}>{statusInfo.text}</Tag>;
+ },
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 300,
+ fixed: 'right' as const,
+ render: (text: any, record: ReportInfo) => (
+ <Space size="small">
+ <Button
+ type="link"
+ icon={<EyeOutlined />}
+ onClick={() => showDetail(record)}
+ size="small"
+ >
+ 查看
+ </Button>
+ {record.status === '0' && (
+ <>
+ <Button
+ type="link"
+ icon={<CheckOutlined />}
+ style={{ color: 'green' }}
+ onClick={() => handleReportAction(record, 'approve')}
+ size="small"
+ >
+ 确认下架
+ </Button>
+ <Button
+ type="link"
+ danger
+ icon={<CloseOutlined />}
+ onClick={() => handleReportAction(record, 'reject')}
+ size="small"
+ >
+ 驳回举报
+ </Button>
+ </>
+ )}
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div className={styles.postReviewContainer}>
+ <Card title="帖子举报管理">
+ <Table
+ columns={columns}
+ dataSource={reports}
+ loading={loading}
+ rowKey="reportId"
+ scroll={{ x: 880 }}
+ pagination={{
+ pageSize: 10,
+ showTotal: (total) => `共 ${total} 条记录`,
+ showSizeChanger: true,
+ showQuickJumper: true,
+ }}
+ />
+ </Card>
+
+ {/* 举报详情弹窗 */}
+ <Modal
+ title="举报详情"
+ open={detailModalVisible}
+ onCancel={() => {
+ setDetailModalVisible(false);
+ setCurrentReport(null);
+ }}
+ footer={null}
+ width={600}
+ >
+ {currentReport && (
+ <div>
+ <div style={{ marginBottom: 16 }}>
+ <strong>举报帖子:</strong>{currentReport.postTitle}
+ </div>
+ <div style={{ marginBottom: 16 }}>
+ <strong>举报人:</strong>{currentReport.reportUserName}
+ </div>
+ <div style={{ marginBottom: 16 }}>
+ <strong>举报时间:</strong>{currentReport.createTime}
+ </div>
+ <div style={{ marginBottom: 16 }}>
+ <strong>举报理由:</strong>
+ <Paragraph>{currentReport.reportReason}</Paragraph>
+ </div>
+ {currentReport.status !== '0' && (
+ <>
+ <div style={{ marginBottom: 16 }}>
+ <strong>处理结果:</strong>{currentReport.handleResult}
+ </div>
+ <div style={{ marginBottom: 16 }}>
+ <strong>处理时间:</strong>{currentReport.handleTime}
+ </div>
+ <div style={{ marginBottom: 16 }}>
+ <strong>处理人:</strong>{currentReport.handleBy}
+ </div>
+ </>
+ )}
+ {currentReport.status === '0' && (
+ <div style={{ marginTop: 16 }}>
+ <Space>
+ <Button
+ type="primary"
+ icon={<CheckOutlined />}
+ onClick={() => {
+ handleReportAction(currentReport, 'approve');
+ setDetailModalVisible(false);
+ }}
+ >
+ 确认下架
+ </Button>
+ <Button
+ danger
+ icon={<CloseOutlined />}
+ onClick={() => {
+ handleReportAction(currentReport, 'reject');
+ setDetailModalVisible(false);
+ }}
+ >
+ 驳回举报
+ </Button>
+ </Space>
+ </div>
+ )}
+ </div>
+ )}
+ </Modal>
+
+ {/* 处理举报弹窗 */}
+ <Modal
+ title={currentAction === 'approve' ? '确认下架帖子' : '驳回举报'}
+ open={handleModalVisible}
+ onOk={submitHandle}
+ onCancel={() => {
+ setHandleModalVisible(false);
+ setCurrentReport(null);
+ setCurrentAction(null);
+ setHandleReason('');
+ }}
+ okText="确定"
+ cancelText="取消"
+ >
+ <div style={{ marginBottom: 16 }}>
+ <strong>帖子:</strong>{currentReport?.postTitle}
+ </div>
+ <div>
+ <strong>
+ {currentAction === 'approve' ? '下架理由' : '驳回理由'}
+ (可选):
+ </strong>
+ <TextArea
+ value={handleReason}
+ onChange={(e) => setHandleReason(e.target.value)}
+ placeholder={
+ currentAction === 'approve' ? '请输入下架理由...' : '请输入驳回理由...'
+ }
+ rows={4}
+ style={{ marginTop: 8 }}
+ />
+ </div>
+ </Modal>
+ </div>
+ );
+};
+
+export default ReportManagement;
\ No newline at end of file
diff --git a/src/pages/PostReview/index.module.css b/src/pages/PostReview/index.module.css
new file mode 100644
index 0000000..887b367
--- /dev/null
+++ b/src/pages/PostReview/index.module.css
@@ -0,0 +1,50 @@
+.postReviewContainer {
+ padding: 24px;
+}
+
+.postDetail {
+ padding: 16px 0;
+}
+
+.postMeta {
+ margin: 16px 0;
+ padding: 16px;
+ background: #f5f5f5;
+ border-radius: 8px;
+}
+
+.postMeta p {
+ margin: 8px 0;
+}
+
+.postCover {
+ margin: 16px 0;
+ text-align: center;
+}
+
+.postTags {
+ margin: 16px 0;
+}
+
+.postSummary {
+ margin: 16px 0;
+ padding: 16px;
+ background: #f9f9f9;
+ border-radius: 8px;
+}
+
+.postContent {
+ margin: 16px 0;
+ padding: 16px;
+ border: 1px solid #e8e8e8;
+ border-radius: 8px;
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.postActions {
+ margin-top: 24px;
+ text-align: center;
+ padding-top: 16px;
+ border-top: 1px solid #e8e8e8;
+}
\ No newline at end of file
diff --git a/src/pages/PostReview/index.tsx b/src/pages/PostReview/index.tsx
new file mode 100644
index 0000000..d3d6036
--- /dev/null
+++ b/src/pages/PostReview/index.tsx
@@ -0,0 +1,394 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Card,
+ Table,
+ Button,
+ Modal,
+ Tag,
+ Space,
+ message,
+ Popconfirm,
+ Typography,
+ Image,
+ Row,
+ Col,
+ Input,
+ Tabs
+} from 'antd';
+import {
+ EyeOutlined,
+ CheckOutlined,
+ CloseOutlined,
+ DeleteOutlined
+} from '@ant-design/icons';
+import { getReviewPosts, reviewPost, takeDownPost } from '@/services/post';
+import ReportManagement from './ReportManagement';
+import styles from './index.module.css';
+
+const { Title, Paragraph } = Typography;
+const { TextArea } = Input;
+const { TabPane } = Tabs;
+
+interface PostReviewProps {}
+
+const PostReview: React.FC<PostReviewProps> = () => {
+ const [activeTab, setActiveTab] = useState('review');
+ const [posts, setPosts] = useState<API.Post.PostInfo[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [detailModalVisible, setDetailModalVisible] = useState(false);
+ const [reasonModalVisible, setReasonModalVisible] = useState(false);
+ const [currentPost, setCurrentPost] = useState<API.Post.PostInfo | null>(null);
+ const [currentAction, setCurrentAction] = useState<'approve' | 'reject' | 'takedown' | null>(null);
+ const [reason, setReason] = useState('');
+
+ useEffect(() => {
+ if (activeTab === 'review') {
+ fetchPendingPosts();
+ }
+ }, [activeTab]);
+
+ const fetchPendingPosts = async () => {
+ setLoading(true);
+ try {
+ const response = await getReviewPosts({
+ pageNum: 1,
+ pageSize: 100,
+ status: '0' // 只查询待审核的帖子
+ });
+
+ if (response.code === 200) {
+ setPosts(response.rows || []);
+ } else {
+ message.error(response.msg || '获取待审核帖子失败');
+ }
+ } catch (error) {
+ message.error('获取待审核帖子失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAction = async (postId: number, action: 'approve' | 'reject', reason?: string) => {
+ try {
+ const response = await reviewPost(postId, action, reason);
+
+ if (response.code === 200) {
+ message.success(action === 'approve' ? '帖子审核通过' : '帖子已拒绝');
+ fetchPendingPosts();
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ message.error('操作失败');
+ }
+ };
+
+ const handleTakeDown = async (postId: number, reason?: string) => {
+ try {
+ const response = await takeDownPost(postId, reason);
+
+ if (response.code === 200) {
+ message.success('帖子已下架');
+ fetchPendingPosts();
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ message.error('操作失败');
+ }
+ };
+
+ const showReasonModal = (post: API.Post.PostInfo, action: 'approve' | 'reject' | 'takedown') => {
+ setCurrentPost(post);
+ setCurrentAction(action);
+ setReason('');
+ setReasonModalVisible(true);
+ };
+
+ const handleReasonSubmit = () => {
+ if (!currentPost || !currentAction) return;
+
+ if (currentAction === 'takedown') {
+ handleTakeDown(currentPost.postId || 0, reason);
+ } else {
+ handleAction(currentPost.postId || 0, currentAction, reason);
+ }
+
+ setReasonModalVisible(false);
+ setCurrentPost(null);
+ setCurrentAction(null);
+ setReason('');
+ };
+
+ const handleViewDetail = (post: API.Post.PostInfo) => {
+ setCurrentPost(post);
+ setDetailModalVisible(true);
+ };
+
+ const columns = [
+ {
+ title: '帖子标题',
+ dataIndex: 'title',
+ key: 'title',
+ width: 200,
+ render: (text: string, record: API.Post.PostInfo) => (
+ <a onClick={() => handleViewDetail(record)}>{text}</a>
+ ),
+ },
+ {
+ title: '作者',
+ dataIndex: 'author',
+ key: 'author',
+ width: 100,
+ },
+ {
+ title: '发布时间',
+ dataIndex: 'publishTime',
+ key: 'publishTime',
+ width: 150,
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ render: (status: string) => {
+ const statusMap: Record<string, { color: string; text: string }> = {
+ '0': { color: 'orange', text: '待审核' },
+ '1': { color: 'green', text: '已发布' },
+ '2': { color: 'red', text: '已拒绝' },
+ '3': { color: 'gray', text: '已下架' }
+ };
+ const statusInfo = statusMap[status] || { color: 'gray', text: '未知' };
+ return <Tag color={statusInfo.color}>{statusInfo.text}</Tag>;
+ },
+ },
+ {
+ title: '标签',
+ dataIndex: 'tags',
+ key: 'tags',
+ width: 150,
+ render: (tags: string) => {
+ if (!tags) return '-';
+ return tags.split(',').map(tag => (
+ <Tag key={tag} color="blue">{tag}</Tag>
+ ));
+ },
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 250,
+ render: (text: any, record: API.Post.PostInfo) => (
+ <Space size="small">
+ <Button
+ type="link"
+ icon={<EyeOutlined />}
+ onClick={() => handleViewDetail(record)}
+ >
+ 查看
+ </Button>
+ {record.status === '0' && (
+ <>
+ <Button
+ type="link"
+ icon={<CheckOutlined />}
+ style={{ color: 'green' }}
+ onClick={() => showReasonModal(record, 'approve')}
+ >
+ 通过
+ </Button>
+ <Button
+ type="link"
+ danger
+ icon={<CloseOutlined />}
+ onClick={() => showReasonModal(record, 'reject')}
+ >
+ 拒绝
+ </Button>
+ </>
+ )}
+ {record.status === '1' && (
+ <Button
+ type="link"
+ danger
+ icon={<DeleteOutlined />}
+ onClick={() => showReasonModal(record, 'takedown')}
+ >
+ 下架
+ </Button>
+ )}
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div className={styles.postReviewContainer}>
+ <Card title="帖子审核管理">
+ <Tabs activeKey={activeTab} onChange={setActiveTab}>
+ <TabPane tab="帖子发布管理" key="review">
+ <Table
+ columns={columns}
+ dataSource={posts}
+ loading={loading}
+ rowKey="postId"
+ pagination={{
+ pageSize: 10,
+ showTotal: (total) => `共 ${total} 条记录`,
+ }}
+ />
+ </TabPane>
+
+ <TabPane tab="帖子举报管理" key="report">
+ <ReportManagement />
+ </TabPane>
+ </Tabs>
+ </Card>
+
+ {/* 帖子详情弹窗 */}
+ <Modal
+ title="帖子详情"
+ open={detailModalVisible}
+ onCancel={() => {
+ setDetailModalVisible(false);
+ setCurrentPost(null);
+ }}
+ footer={null}
+ width={800}
+ >
+ {currentPost && (
+ <div className={styles.postDetail}>
+ <Title level={3}>{currentPost.title}</Title>
+
+ <div className={styles.postMeta}>
+ <Row gutter={16}>
+ <Col span={12}>
+ <p><strong>作者:</strong>{currentPost.author}</p>
+ <p><strong>发布时间:</strong>{currentPost.publishTime}</p>
+ </Col>
+ <Col span={12}>
+ <p><strong>浏览量:</strong>{currentPost.views || 0}</p>
+ <p><strong>点赞数:</strong>{currentPost.likes || 0}</p>
+ </Col>
+ </Row>
+ </div>
+
+ {currentPost.coverImage && (
+ <div className={styles.postCover}>
+ <Image
+ src={currentPost.coverImage}
+ alt="封面图片"
+ style={{ maxWidth: '100%', maxHeight: '200px' }}
+ />
+ </div>
+ )}
+
+ <div className={styles.postTags}>
+ <strong>标签:</strong>
+ {currentPost.tags ? (
+ currentPost.tags.split(',').map(tag => (
+ <Tag key={tag} color="blue">{tag}</Tag>
+ ))
+ ) : (
+ <span>无标签</span>
+ )}
+ </div>
+
+ <div className={styles.postSummary}>
+ <strong>摘要:</strong>
+ <Paragraph>{currentPost.summary}</Paragraph>
+ </div>
+
+ <div className={styles.postContent}>
+ <strong>内容:</strong>
+ <div dangerouslySetInnerHTML={{ __html: currentPost.content || '' }} />
+ </div>
+
+ <div className={styles.postActions}>
+ <Space>
+ {currentPost.status === '0' && (
+ <>
+ <Button
+ type="primary"
+ icon={<CheckOutlined />}
+ onClick={() => {
+ showReasonModal(currentPost, 'approve');
+ setDetailModalVisible(false);
+ }}
+ >
+ 通过审核
+ </Button>
+ <Button
+ danger
+ icon={<CloseOutlined />}
+ onClick={() => {
+ showReasonModal(currentPost, 'reject');
+ setDetailModalVisible(false);
+ }}
+ >
+ 拒绝审核
+ </Button>
+ </>
+ )}
+ {currentPost.status === '1' && (
+ <Button
+ danger
+ icon={<DeleteOutlined />}
+ onClick={() => {
+ showReasonModal(currentPost, 'takedown');
+ setDetailModalVisible(false);
+ }}
+ >
+ 强制下架
+ </Button>
+ )}
+ </Space>
+ </div>
+ </div>
+ )}
+ </Modal>
+
+ {/* 审核理由弹窗 */}
+ <Modal
+ title={
+ currentAction === 'approve' ? '审核通过' :
+ currentAction === 'reject' ? '审核拒绝' : '强制下架'
+ }
+ open={reasonModalVisible}
+ onOk={handleReasonSubmit}
+ onCancel={() => {
+ setReasonModalVisible(false);
+ setCurrentPost(null);
+ setCurrentAction(null);
+ setReason('');
+ }}
+ okText="确定"
+ cancelText="取消"
+ >
+ <div style={{ marginBottom: 16 }}>
+ <strong>帖子:</strong>{currentPost?.title}
+ </div>
+ <div>
+ <strong>
+ {currentAction === 'approve' ? '通过理由' :
+ currentAction === 'reject' ? '拒绝理由' : '下架理由'}
+ (可选):
+ </strong>
+ <TextArea
+ value={reason}
+ onChange={(e) => setReason(e.target.value)}
+ placeholder={
+ currentAction === 'approve' ? '请输入审核通过的理由...' :
+ currentAction === 'reject' ? '请输入审核拒绝的理由...' : '请输入强制下架的理由...'
+ }
+ rows={4}
+ style={{ marginTop: 8 }}
+ />
+ </div>
+ </Modal>
+ </div>
+ );
+};
+
+export default PostReview;
\ No newline at end of file
diff --git a/src/pages/UserCenter/index.module.css b/src/pages/UserCenter/index.module.css
new file mode 100644
index 0000000..3728909
--- /dev/null
+++ b/src/pages/UserCenter/index.module.css
@@ -0,0 +1,90 @@
+.userCenterContainer {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 24px;
+ min-height: 100vh;
+ background: #f5f5f5;
+}
+
+.userCenterCard {
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.tabContent {
+ padding: 16px 0;
+}
+
+.tabHeader {
+ margin-bottom: 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.emptyState {
+ text-align: center;
+ padding: 60px 0;
+ color: #999;
+}
+
+.emptyState p {
+ margin-top: 16px;
+ font-size: 16px;
+}
+
+.paymentModal {
+ text-align: center;
+}
+
+.paymentInfo {
+ margin-bottom: 24px;
+ padding: 16px;
+ background: #f9f9f9;
+ border-radius: 8px;
+}
+
+.paymentInfo h3 {
+ margin: 0 0 8px 0;
+ color: #1890ff;
+}
+
+.paymentInfo p {
+ margin: 4px 0;
+ color: #666;
+}
+
+.qrCode {
+ margin: 24px 0;
+}
+
+.qrCodePlaceholder {
+ padding: 24px;
+ border: 2px dashed #d9d9d9;
+ border-radius: 8px;
+ background: #fafafa;
+}
+
+.mockQrCode {
+ width: 120px;
+ height: 120px;
+ margin: 16px auto;
+ border: 1px solid #d9d9d9;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ background: #fff;
+}
+
+.mockQrCode p {
+ margin: 4px 0;
+ font-size: 12px;
+ color: #666;
+}
+
+.paymentActions {
+ margin-top: 24px;
+}
\ No newline at end of file
diff --git a/src/pages/UserCenter/index.tsx b/src/pages/UserCenter/index.tsx
new file mode 100644
index 0000000..13de6ad
--- /dev/null
+++ b/src/pages/UserCenter/index.tsx
@@ -0,0 +1,968 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Card,
+ Tabs,
+ Button,
+ Table,
+ Modal,
+ Form,
+ Input,
+ Select,
+ Upload,
+ message,
+ Tag,
+ Space,
+ Popconfirm,
+ Row,
+ Col,
+ Radio,
+ InputNumber,
+ Image
+} from 'antd';
+import {
+ PlusOutlined,
+ EditOutlined,
+ DeleteOutlined,
+ EyeOutlined,
+ UploadOutlined,
+ HeartOutlined,
+ LoadingOutlined
+} from '@ant-design/icons';
+import { useNavigate } from 'react-router-dom';
+import {
+ getMyPosts,
+ getMyFavorites,
+ publishPost,
+ updatePost,
+ deletePost,
+ getAvailableTags,
+ uploadImage,
+ deleteImage,
+ getPromotionPlans,
+ createPayment,
+ getPromotionStatus,
+ confirmPayment,
+ cancelPayment
+} from '@/services/post';
+import PostCard from '../PostCenter/PostCard';
+import styles from './index.module.css';
+
+const { TabPane } = Tabs;
+const { TextArea } = Input;
+const { Option } = Select;
+
+interface PostFormData {
+ title: string;
+ content: string;
+ summary: string;
+ tags: string[] | string;
+ promotionPlan?: number;
+ coverImage?: string;
+}
+
+interface PromotionPlan {
+ id: number;
+ name: string;
+ description: string;
+ price: number;
+ duration: number;
+}
+
+interface PaymentRecord {
+ paymentId: number;
+ postId: number;
+ planId: number;
+ userId: number;
+ amount: number;
+ paymentStatus: string;
+ paymentTime: string;
+}
+
+const UserCenter: React.FC = () => {
+ const navigate = useNavigate();
+ const [activeTab, setActiveTab] = useState('myPosts');
+ const [publishModalVisible, setPublishModalVisible] = useState(false);
+ const [editModalVisible, setEditModalVisible] = useState(false);
+ const [paymentModalVisible, setPaymentModalVisible] = useState(false);
+ const [myPosts, setMyPosts] = useState<API.Post.PostInfo[]>([]);
+ const [favorites, setFavorites] = useState<API.Post.PostInfo[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [form] = Form.useForm();
+ const [editForm] = Form.useForm();
+ const [selectedPromotion, setSelectedPromotion] = useState<PromotionPlan | null>(null);
+ const [currentEditPost, setCurrentEditPost] = useState<API.Post.PostInfo | null>(null);
+ const [availableTags, setAvailableTags] = useState<API.Post.PostTag[]>([]);
+ const [promotionPlans, setPromotionPlans] = useState<PromotionPlan[]>([]);
+ const [uploadLoading, setUploadLoading] = useState(false);
+ const [editUploadLoading, setEditUploadLoading] = useState(false);
+ const [coverImageUrl, setCoverImageUrl] = useState<string>('');
+ const [editCoverImageUrl, setEditCoverImageUrl] = useState<string>('');
+ const [currentPayment, setCurrentPayment] = useState<PaymentRecord | null>(null);
+ const [isEditingPromotion, setIsEditingPromotion] = useState(false);
+
+ useEffect(() => {
+ if (activeTab === 'myPosts') {
+ fetchMyPosts();
+ } else if (activeTab === 'favorites') {
+ fetchFavorites();
+ }
+ }, [activeTab]);
+
+ useEffect(() => {
+ fetchAvailableTags();
+ fetchPromotionPlans();
+ }, []);
+
+ const fetchMyPosts = async () => {
+ setLoading(true);
+ try {
+ const response = await getMyPosts({ pageNum: 1, pageSize: 100 });
+ if (response.code === 200) {
+ setMyPosts(response.rows || []);
+ } else {
+ message.error(response.msg || '获取我的帖子失败');
+ }
+ } catch (error) {
+ message.error('获取我的帖子失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchFavorites = async () => {
+ setLoading(true);
+ try {
+ const response = await getMyFavorites({ pageNum: 1, pageSize: 100 });
+ if (response.code === 200) {
+ setFavorites(response.rows || []);
+ } else {
+ message.error(response.msg || '获取收藏列表失败');
+ }
+ } catch (error) {
+ message.error('获取收藏列表失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchAvailableTags = async () => {
+ try {
+ const response = await getAvailableTags();
+ if (response.code === 200) {
+ setAvailableTags(response.data || []);
+ } else {
+ message.error(response.msg || '获取可用标签失败');
+ }
+ } catch (error) {
+ message.error('获取可用标签失败');
+ }
+ };
+
+ const fetchPromotionPlans = async () => {
+ try {
+ const response = await getPromotionPlans();
+ if (response.code === 200) {
+ setPromotionPlans(response.data || []);
+ }
+ } catch (error) {
+ console.error('获取推广计划失败:', error);
+ }
+ };
+
+ const handlePublishPost = async (values: PostFormData) => {
+ try {
+ if (values.promotionPlan && selectedPromotion) {
+ // 如果选择了推广,创建支付记录
+ const paymentResponse = await createPayment({
+ postId: 0, // 新帖子,暂时设为0,后端会处理
+ planId: selectedPromotion.id,
+ amount: selectedPromotion.price
+ });
+
+ if (paymentResponse.code === 200) {
+ setCurrentPayment(paymentResponse.data);
+ setPaymentModalVisible(true);
+ return;
+ } else {
+ message.error(paymentResponse.msg || '创建支付记录失败');
+ return;
+ }
+ }
+
+ // 直接发布帖子
+ await submitPost(values);
+ } catch (error) {
+ message.error('发布帖子失败');
+ }
+ };
+
+ const submitPost = async (values: PostFormData) => {
+ try {
+ // 处理标签格式
+ const tagsString = Array.isArray(values.tags) ? values.tags.join(',') : values.tags;
+
+ const postData = {
+ title: values.title,
+ content: values.content,
+ summary: values.summary,
+ tags: tagsString,
+ promotionPlan: values.promotionPlan,
+ coverImage: coverImageUrl || undefined
+ };
+
+ const response = await publishPost(postData);
+ if (response.code === 200) {
+ message.success('帖子发布成功');
+ setPublishModalVisible(false);
+ form.resetFields();
+ setSelectedPromotion(null);
+ setCoverImageUrl('');
+ fetchMyPosts();
+ } else {
+ message.error(response.msg || '发布帖子失败');
+ }
+ } catch (error) {
+ message.error('发布帖子失败');
+ }
+ };
+
+ const handleEditPost = async (post: API.Post.PostInfo) => {
+ setCurrentEditPost(post);
+ const tagsArray = post.tags ? (typeof post.tags === 'string' ? post.tags.split(',') : post.tags) : [];
+
+ // 检查推广状态
+ try {
+ const promotionResponse = await getPromotionStatus(post.postId || post.id || 0);
+ if (promotionResponse.code === 200) {
+ const { hasPromotion, promotionPlanId } = promotionResponse.data;
+ setIsEditingPromotion(hasPromotion);
+
+ editForm.setFieldsValue({
+ title: post.title,
+ content: post.content,
+ summary: post.summary,
+ tags: tagsArray,
+ promotionPlan: hasPromotion ? promotionPlanId : undefined
+ });
+ }
+ } catch (error) {
+ console.error('获取推广状态失败:', error);
+ editForm.setFieldsValue({
+ title: post.title,
+ content: post.content,
+ summary: post.summary,
+ tags: tagsArray,
+ promotionPlan: post.promotionPlanId
+ });
+ }
+
+ setEditCoverImageUrl(post.coverImage || '');
+ setEditModalVisible(true);
+ };
+
+ const handleUpdatePost = async (values: any) => {
+ if (!currentEditPost) return;
+
+ try {
+ // 处理标签格式
+ const tagsString = Array.isArray(values.tags) ? values.tags.join(',') : values.tags;
+
+ // 检查是否选择了新的推广计划
+ const hasNewPromotion = values.promotionPlan && !isEditingPromotion;
+
+ if (hasNewPromotion) {
+ // 如果选择了新的推广计划,需要先创建支付记录
+ const selectedPlan = promotionPlans.find(p => p.id === values.promotionPlan);
+ if (selectedPlan) {
+ setSelectedPromotion(selectedPlan);
+
+ // 创建支付记录
+ const paymentResponse = await createPayment({
+ postId: currentEditPost.postId || currentEditPost.id || 0,
+ planId: selectedPlan.id,
+ amount: selectedPlan.price
+ });
+
+ if (paymentResponse.code === 200) {
+ setCurrentPayment(paymentResponse.data);
+ setPaymentModalVisible(true);
+ return; // 等待支付完成后再更新帖子
+ } else {
+ message.error(paymentResponse.msg || '创建支付记录失败');
+ return;
+ }
+ }
+ }
+
+ // 直接更新帖子(没有新推广或已有推广)
+ await updatePostDirectly(values, tagsString);
+ } catch (error) {
+ message.error('更新帖子失败');
+ }
+ };
+
+ const updatePostDirectly = async (values: any, tagsString: string) => {
+ if (!currentEditPost) return;
+
+ const updateData = {
+ ...currentEditPost,
+ title: values.title,
+ content: values.content,
+ summary: values.summary,
+ tags: tagsString,
+ coverImage: editCoverImageUrl || currentEditPost.coverImage,
+ promotionPlanId: values.promotionPlan
+ };
+
+ const response = await updatePost(updateData);
+ if (response.code === 200) {
+ message.success('帖子更新成功');
+ setEditModalVisible(false);
+ editForm.resetFields();
+ setCurrentEditPost(null);
+ setEditCoverImageUrl('');
+ setIsEditingPromotion(false);
+ fetchMyPosts();
+ } else {
+ message.error(response.msg || '更新帖子失败');
+ }
+ };
+
+ const handleDeletePost = async (postId: number) => {
+ try {
+ const response = await deletePost(postId);
+ if (response.code === 200) {
+ message.success('帖子删除成功');
+ fetchMyPosts();
+ } else {
+ message.error(response.msg || '删除帖子失败');
+ }
+ } catch (error) {
+ message.error('删除帖子失败');
+ }
+ };
+
+ const handleViewPost = (postId: number) => {
+ navigate(`/post-detail/${postId}`);
+ };
+
+ const handlePaymentConfirm = async () => {
+ if (!currentPayment) return;
+
+ try {
+ const response = await confirmPayment(currentPayment.paymentId);
+ if (response.code === 200) {
+ message.success('支付成功,推广已生效');
+ setPaymentModalVisible(false);
+ setCurrentPayment(null);
+
+ // 如果是编辑模式,完成帖子更新
+ if (editModalVisible && currentEditPost) {
+ const values = editForm.getFieldsValue();
+ const tagsString = Array.isArray(values.tags) ? values.tags.join(',') : values.tags;
+ await updatePostDirectly(values, tagsString);
+ } else {
+ // 如果是发布模式
+ setPublishModalVisible(false);
+ form.resetFields();
+ setSelectedPromotion(null);
+ setCoverImageUrl('');
+ fetchMyPosts();
+ }
+ } else {
+ message.error(response.msg || '支付确认失败');
+ }
+ } catch (error) {
+ message.error('支付确认失败');
+ }
+ };
+
+ const handlePaymentCancel = async () => {
+ if (!currentPayment) return;
+
+ try {
+ await cancelPayment(currentPayment.paymentId);
+ message.info('支付已取消');
+ setPaymentModalVisible(false);
+ setCurrentPayment(null);
+ setSelectedPromotion(null);
+ } catch (error) {
+ console.error('取消支付失败:', error);
+ setPaymentModalVisible(false);
+ setCurrentPayment(null);
+ setSelectedPromotion(null);
+ }
+ };
+
+ const handleImageUpload = async (file: any) => {
+ setUploadLoading(true);
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await uploadImage(formData);
+ if (response.code === 200 && response.data) {
+ setCoverImageUrl(response.data.url);
+ message.success('图片上传成功');
+ return false; // 阻止自动上传
+ } else {
+ message.error(response.msg || '图片上传失败');
+ }
+ } catch (error) {
+ message.error('图片上传失败');
+ } finally {
+ setUploadLoading(false);
+ }
+ return false;
+ };
+
+ const handleDeleteImage = async () => {
+ if (coverImageUrl) {
+ try {
+ const filename = coverImageUrl.split('/').pop();
+ if (filename) {
+ await deleteImage(filename);
+ }
+ setCoverImageUrl('');
+ message.success('图片删除成功');
+ } catch (error) {
+ message.error('图片删除失败');
+ }
+ }
+ };
+
+ const handleCancelPublish = async () => {
+ // 如果有上传的图片但没有发布帖子,删除图片
+ if (coverImageUrl) {
+ try {
+ const filename = coverImageUrl.split('/').pop();
+ if (filename) {
+ await deleteImage(filename);
+ }
+ } catch (error) {
+ console.error('删除图片失败:', error);
+ }
+ }
+
+ setPublishModalVisible(false);
+ form.resetFields();
+ setSelectedPromotion(null);
+ setCoverImageUrl('');
+ };
+
+ const uploadButton = (
+ <div>
+ {uploadLoading ? <LoadingOutlined /> : <PlusOutlined />}
+ <div style={{ marginTop: 8 }}>上传封面</div>
+ </div>
+ );
+
+ const handleEditImageUpload = async (file: any) => {
+ setEditUploadLoading(true);
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await uploadImage(formData);
+ if (response.code === 200 && response.data) {
+ // 如果有旧图片,删除它
+ if (editCoverImageUrl) {
+ const oldFilename = editCoverImageUrl.split('/').pop();
+ if (oldFilename) {
+ await deleteImage(oldFilename);
+ }
+ }
+
+ setEditCoverImageUrl(response.data.url);
+ message.success('图片上传成功');
+ return false;
+ } else {
+ message.error(response.msg || '图片上传失败');
+ }
+ } catch (error) {
+ message.error('图片上传失败');
+ } finally {
+ setEditUploadLoading(false);
+ }
+ return false;
+ };
+
+ const handleDeleteEditImage = async () => {
+ if (editCoverImageUrl) {
+ try {
+ const filename = editCoverImageUrl.split('/').pop();
+ if (filename) {
+ await deleteImage(filename);
+ }
+ setEditCoverImageUrl('');
+ message.success('图片删除成功');
+ } catch (error) {
+ message.error('图片删除失败');
+ }
+ }
+ };
+
+ const editUploadButton = (
+ <div>
+ {editUploadLoading ? <LoadingOutlined /> : <PlusOutlined />}
+ <div style={{ marginTop: 8 }}>上传封面</div>
+ </div>
+ );
+
+ const myPostsColumns = [
+ {
+ title: '标题',
+ dataIndex: 'title',
+ key: 'title',
+ render: (text: string, record: API.Post.PostInfo) => (
+ <a onClick={() => handleViewPost(record.postId || record.id || 0)}>{text}</a>
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ render: (status: string) => {
+ const statusMap: Record<string, { color: string; text: string }> = {
+ '0': { color: 'orange', text: '待审核' },
+ '1': { color: 'green', text: '已发布' },
+ '2': { color: 'red', text: '已拒绝' },
+ '3': { color: 'gray', text: '已下架' }
+ };
+ const statusInfo = statusMap[status] || { color: 'gray', text: '未知' };
+ return <Tag color={statusInfo.color}>{statusInfo.text}</Tag>;
+ },
+ },
+ {
+ title: '浏览量',
+ dataIndex: 'views',
+ key: 'views',
+ },
+ {
+ title: '评论数',
+ dataIndex: 'comments',
+ key: 'comments',
+ },
+ {
+ title: '收藏数',
+ dataIndex: 'favorites',
+ key: 'favorites',
+ },
+ {
+ title: '点赞数',
+ dataIndex: 'likes',
+ key: 'likes',
+ },
+ {
+ title: '发布时间',
+ dataIndex: 'publishTime',
+ key: 'publishTime',
+ },
+ {
+ title: '操作',
+ key: 'action',
+ render: (text: any, record: API.Post.PostInfo) => (
+ <Space size="middle">
+ <Button
+ type="link"
+ icon={<EyeOutlined />}
+ onClick={() => handleViewPost(record.postId || record.id || 0)}
+ >
+ 查看
+ </Button>
+ <Button
+ type="link"
+ icon={<EditOutlined />}
+ onClick={() => handleEditPost(record)}
+ >
+ 编辑
+ </Button>
+ <Popconfirm
+ title="确定要删除这篇帖子吗?"
+ onConfirm={() => handleDeletePost(record.postId || record.id || 0)}
+ okText="确定"
+ cancelText="取消"
+ >
+ <Button type="link" danger icon={<DeleteOutlined />}>
+ 删除
+ </Button>
+ </Popconfirm>
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div className={styles.userCenterContainer}>
+ <Card title="个人中心" className={styles.userCenterCard}>
+ <Tabs activeKey={activeTab} onChange={setActiveTab}>
+ <TabPane tab="我的帖子" key="myPosts">
+ <div className={styles.tabContent}>
+ <div className={styles.tabHeader}>
+ <Button
+ type="primary"
+ icon={<PlusOutlined />}
+ onClick={() => setPublishModalVisible(true)}
+ >
+ 发布新帖子
+ </Button>
+ </div>
+ <Table
+ columns={myPostsColumns}
+ dataSource={myPosts}
+ loading={loading}
+ rowKey="id"
+ pagination={{
+ pageSize: 10,
+ showTotal: (total) => `共 ${total} 条记录`,
+ }}
+ />
+ </div>
+ </TabPane>
+
+ <TabPane tab="我的收藏" key="favorites">
+ <div className={styles.tabContent}>
+ <Row gutter={[24, 24]}>
+ {favorites.map((post: any) => {
+ // 确保post对象有正确的id字段
+ const formattedPost = {
+ ...post,
+ id: post.postId || post.id,
+ tags: post.tags ? (Array.isArray(post.tags) ? post.tags : post.tags.split(',')) : []
+ };
+ return (
+ <Col xs={24} sm={12} md={8} key={formattedPost.id}>
+ <PostCard post={formattedPost} />
+ </Col>
+ );
+ })}
+ </Row>
+ {favorites.length === 0 && !loading && (
+ <div className={styles.emptyState}>
+ <HeartOutlined style={{ fontSize: 48, color: '#ccc' }} />
+ <p>暂无收藏的帖子</p>
+ </div>
+ )}
+ </div>
+ </TabPane>
+ </Tabs>
+ </Card>
+
+ {/* 发布帖子弹窗 */}
+ <Modal
+ title="发布新帖子"
+ open={publishModalVisible}
+ onCancel={handleCancelPublish}
+ footer={null}
+ width={800}
+ >
+ <Form
+ form={form}
+ layout="vertical"
+ onFinish={handlePublishPost}
+ >
+ <Form.Item
+ name="title"
+ label="帖子标题"
+ rules={[{ required: true, message: '请输入帖子标题' }]}
+ >
+ <Input placeholder="请输入帖子标题" />
+ </Form.Item>
+
+ <Form.Item
+ name="summary"
+ label="帖子摘要"
+ rules={[{ required: true, message: '请输入帖子摘要' }]}
+ >
+ <TextArea rows={3} placeholder="请输入帖子摘要" />
+ </Form.Item>
+
+ <Form.Item
+ name="content"
+ label="帖子内容"
+ rules={[{ required: true, message: '请输入帖子内容' }]}
+ >
+ <TextArea rows={8} placeholder="请输入帖子内容" />
+ </Form.Item>
+
+ <Form.Item
+ name="coverImage"
+ label="封面图片(可选)"
+ >
+ <Upload
+ listType="picture-card"
+ showUploadList={false}
+ beforeUpload={handleImageUpload}
+ >
+ {coverImageUrl ? (
+ <Image
+ src={coverImageUrl}
+ alt="封面"
+ width="100%"
+ height="100%"
+ style={{ objectFit: 'cover' }}
+ />
+ ) : (
+ uploadButton
+ )}
+ </Upload>
+ {coverImageUrl && (
+ <Button
+ type="link"
+ onClick={handleDeleteImage}
+ style={{ padding: 0, marginTop: 8 }}
+ >
+ 删除图片
+ </Button>
+ )}
+ </Form.Item>
+
+ <Form.Item
+ name="tags"
+ label="标签"
+ rules={[{ required: true, message: '请选择标签' }]}
+ >
+ <Select
+ mode="multiple"
+ placeholder="请选择标签"
+ allowClear
+ style={{ width: '100%' }}
+ >
+ {availableTags.map(tag => (
+ <Select.Option key={tag.tagId} value={tag.tagName}>
+ <Tag color={tag.tagColor}>{tag.tagName}</Tag>
+ </Select.Option>
+ ))}
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="promotionPlan"
+ label="推广选项(可选)"
+ >
+ <Radio.Group>
+ <Space direction="vertical">
+ <Radio value={undefined}>不选择推广</Radio>
+ {promotionPlans.map(plan => (
+ <Radio key={plan.id} value={plan.id}>
+ <div>
+ <strong>{plan.name}</strong> - ¥{plan.price} ({plan.duration}天)
+ <br />
+ <span style={{ color: '#666', fontSize: '12px' }}>
+ {plan.description}
+ </span>
+ </div>
+ </Radio>
+ ))}
+ </Space>
+ </Radio.Group>
+ </Form.Item>
+
+ <Form.Item>
+ <Space>
+ <Button type="primary" htmlType="submit">
+ {selectedPromotion ? '选择支付方式' : '发布帖子'}
+ </Button>
+ <Button onClick={handleCancelPublish}>
+ 取消
+ </Button>
+ </Space>
+ </Form.Item>
+ </Form>
+ </Modal>
+
+ {/* 编辑帖子弹窗 */}
+ <Modal
+ title="编辑帖子"
+ open={editModalVisible}
+ onCancel={() => {
+ setEditModalVisible(false);
+ editForm.resetFields();
+ setCurrentEditPost(null);
+ setEditCoverImageUrl('');
+ setIsEditingPromotion(false);
+ }}
+ footer={null}
+ width={800}
+ >
+ <Form
+ form={editForm}
+ layout="vertical"
+ onFinish={handleUpdatePost}
+ >
+ <Form.Item
+ name="title"
+ label="帖子标题"
+ rules={[{ required: true, message: '请输入帖子标题' }]}
+ >
+ <Input placeholder="请输入帖子标题" />
+ </Form.Item>
+
+ <Form.Item
+ name="summary"
+ label="帖子摘要"
+ rules={[{ required: true, message: '请输入帖子摘要' }]}
+ >
+ <TextArea rows={3} placeholder="请输入帖子摘要" />
+ </Form.Item>
+
+ <Form.Item
+ name="content"
+ label="帖子内容"
+ rules={[{ required: true, message: '请输入帖子内容' }]}
+ >
+ <TextArea rows={8} placeholder="请输入帖子内容" />
+ </Form.Item>
+
+ <Form.Item
+ name="coverImage"
+ label="封面图片"
+ >
+ <Upload
+ listType="picture-card"
+ showUploadList={false}
+ beforeUpload={handleEditImageUpload}
+ >
+ {editCoverImageUrl ? (
+ <Image
+ src={editCoverImageUrl}
+ alt="封面"
+ width="100%"
+ height="100%"
+ style={{ objectFit: 'cover' }}
+ />
+ ) : (
+ editUploadButton
+ )}
+ </Upload>
+ {editCoverImageUrl && (
+ <Button
+ type="link"
+ onClick={handleDeleteEditImage}
+ style={{ padding: 0, marginTop: 8 }}
+ >
+ 删除图片
+ </Button>
+ )}
+ </Form.Item>
+
+ <Form.Item
+ name="tags"
+ label="标签"
+ rules={[{ required: true, message: '请选择标签' }]}
+ >
+ <Select
+ mode="multiple"
+ placeholder="请选择标签"
+ allowClear
+ style={{ width: '100%' }}
+ >
+ {availableTags.map(tag => (
+ <Select.Option key={tag.tagId} value={tag.tagName}>
+ <Tag color={tag.tagColor}>{tag.tagName}</Tag>
+ </Select.Option>
+ ))}
+ </Select>
+ </Form.Item>
+
+ <Form.Item
+ name="promotionPlan"
+ label="推广选项(可选)"
+ >
+ <Radio.Group disabled={isEditingPromotion}>
+ <Space direction="vertical">
+ <Radio value={undefined}>不选择推广</Radio>
+ {isEditingPromotion && (
+ <div style={{ color: '#ff4d4f', fontSize: '12px', marginBottom: 8 }}>
+ 该帖子已购买推广,无法更改推广选项
+ </div>
+ )}
+ {promotionPlans.map(plan => (
+ <Radio key={plan.id} value={plan.id} disabled={isEditingPromotion}>
+ <div>
+ <strong>{plan.name}</strong> - ¥{plan.price} ({plan.duration}天)
+ <br />
+ <span style={{ color: '#666', fontSize: '12px' }}>
+ {plan.description}
+ </span>
+ </div>
+ </Radio>
+ ))}
+ </Space>
+ </Radio.Group>
+ </Form.Item>
+
+ <Form.Item>
+ <Space>
+ <Button type="primary" htmlType="submit">
+ 更新帖子
+ </Button>
+ <Button onClick={() => {
+ setEditModalVisible(false);
+ editForm.resetFields();
+ setCurrentEditPost(null);
+ setEditCoverImageUrl('');
+ setIsEditingPromotion(false);
+ }}>
+ 取消
+ </Button>
+ </Space>
+ </Form.Item>
+ </Form>
+ </Modal>
+
+ {/* 支付弹窗 */}
+ <Modal
+ title="支付推广费用"
+ open={paymentModalVisible}
+ onCancel={handlePaymentCancel}
+ footer={null}
+ width={400}
+ >
+ <div className={styles.paymentModal}>
+ {selectedPromotion && (
+ <>
+ <div className={styles.paymentInfo}>
+ <h3>{selectedPromotion.name}</h3>
+ <p>{selectedPromotion.description}</p>
+ <p>费用: <strong>¥{selectedPromotion.price}</strong></p>
+ <p>时长: {selectedPromotion.duration}天</p>
+ </div>
+
+ <div className={styles.qrCode}>
+ <div className={styles.qrCodePlaceholder}>
+ <p>支付二维码</p>
+ <p style={{ fontSize: '12px', color: '#666' }}>
+ 请使用支付宝扫描二维码支付
+ </p>
+ <div className={styles.mockQrCode}>
+ <p>模拟二维码</p>
+ <p>¥{selectedPromotion.price}</p>
+ </div>
+ </div>
+ </div>
+
+ <div className={styles.paymentActions}>
+ <Button
+ type="primary"
+ onClick={handlePaymentConfirm}
+ style={{ width: '100%', marginBottom: 8 }}
+ >
+ 我已完成支付
+ </Button>
+ <Button
+ onClick={handlePaymentCancel}
+ style={{ width: '100%' }}
+ >
+ 取消支付
+ </Button>
+ </div>
+ </>
+ )}
+ </div>
+ </Modal>
+ </div>
+ );
+};
+
+export default UserCenter;
\ No newline at end of file
diff --git a/src/services/post/index.ts b/src/services/post/index.ts
new file mode 100644
index 0000000..3452095
--- /dev/null
+++ b/src/services/post/index.ts
@@ -0,0 +1,262 @@
+import { request } from '@umijs/max';
+
+export interface PostListParams {
+ pageNum?: number;
+ pageSize?: number;
+ title?: string;
+ status?: string;
+ tags?: string;
+}
+
+export interface PostDetailResponse {
+ post: API.Post.PostInfo;
+ tags: API.Post.PostTag[];
+ comments: any[];
+ authorPosts: API.Post.PostInfo[];
+ similarPosts: API.Post.PostInfo[];
+ recommendedPosts: API.Post.PostInfo[];
+ favorited: boolean;
+}
+
+export interface CommentAddParams {
+ postId: number;
+ content: string;
+ parentId?: number;
+}
+
+// 获取帖子列表
+export async function getPostList(params: PostListParams) {
+ console.log('getPostList', params);
+ return request<API.TableDataInfo>('/api/post-center/list', {
+ method: 'GET',
+ params,
+ });
+}
+
+// 获取帖子详情
+export async function getPostDetail(postId: number) {
+ console.log('getPostDetail', postId);
+ return request<API.AjaxResult<PostDetailResponse>>(`/api/post-center/${postId}`, {
+ method: 'GET',
+ });
+}
+
+// 添加评论
+export async function addComment(data: CommentAddParams) {
+ console.log('addComment', data);
+ return request<API.AjaxResult>('/api/post-center/comment', {
+ method: 'POST',
+ data,
+ });
+}
+
+// 收藏/取消收藏帖子
+export async function toggleFavorite(postId: number, favorite: boolean) {
+ console.log('toggleFavorite', postId, favorite);
+ return request<API.AjaxResult>(`/api/post-center/favorite/${postId}`, {
+ method: 'POST',
+ params: { favorite },
+ });
+}
+
+// 获取热门标签
+export async function getHotTags(): Promise<API.AjaxResult<API.Post.PostTag[]>> {
+ return request('/api/post-center/tags/hot', {
+ method: 'GET',
+ });
+}
+
+// 根据标签获取帖子
+export async function getPostsByTag(tagId: number) {
+ console.log('getPostsByTag', tagId);
+ return request<API.TableDataInfo>(`/api/post-center/bytag/${tagId}`, {
+ method: 'GET',
+ });
+}
+
+// 发布帖子
+export async function publishPost(data: {
+ title: string;
+ content: string;
+ summary: string;
+ tags: string;
+ promotionPlan?: number;
+}): Promise<API.AjaxResult> {
+ return request('/api/post-center/publish', {
+ method: 'POST',
+ data,
+ });
+}
+
+// 获取我的帖子列表
+export async function getMyPosts(params: {
+ pageNum?: number;
+ pageSize?: number;
+}): Promise<API.TableDataInfo<API.Post.PostInfo>> {
+ return request('/api/post-center/my-posts', {
+ method: 'GET',
+ params,
+ });
+}
+
+// 获取我的收藏列表
+export async function getMyFavorites(params: {
+ pageNum?: number;
+ pageSize?: number;
+}): Promise<API.TableDataInfo<API.Post.PostInfo>> {
+ return request('/api/post-center/my-favorites', {
+ method: 'GET',
+ params,
+ });
+}
+
+// 更新帖子
+export async function updatePost(data: API.Post.PostInfo): Promise<API.AjaxResult> {
+ return request('/api/post-center/update', {
+ method: 'PUT',
+ data,
+ });
+}
+
+// 删除帖子
+export async function deletePost(postId: number): Promise<API.AjaxResult> {
+ return request(`/api/post-center/delete/${postId}`, {
+ method: 'DELETE',
+ });
+}
+
+// 获取可用标签列表(用于下拉选择)
+export async function getAvailableTags(): Promise<API.AjaxResult<API.Post.PostTag[]>> {
+ return request('/api/post-center/tags/available', {
+ method: 'GET',
+ });
+}
+
+// 上传图片
+export async function uploadImage(file: FormData): Promise<API.AjaxResult<{url: string}>> {
+ return request('/api/post-center/upload', {
+ method: 'POST',
+ data: file,
+ });
+}
+
+// 删除图片
+export async function deleteImage(filename: string): Promise<API.AjaxResult> {
+ return request('/api/post-center/upload', {
+ method: 'DELETE',
+ params: { filename },
+ });
+}
+
+// 获取推广计划列表
+export async function getPromotionPlans(): Promise<API.AjaxResult<any[]>> {
+ return request('/api/post-center/promotion-plans', {
+ method: 'GET',
+ });
+}
+
+// 创建支付记录
+export async function createPayment(data: {
+ postId: number;
+ planId: number;
+ amount: number;
+}): Promise<API.AjaxResult> {
+ return request('/api/post-center/payment', {
+ method: 'POST',
+ data,
+ });
+}
+
+// 点赞/取消点赞帖子
+export async function toggleLike(postId: number, like: boolean) {
+ console.log('toggleLike', postId, like);
+ return request<API.AjaxResult>(`/api/post-center/like/${postId}`, {
+ method: 'POST',
+ params: { like },
+ });
+}
+
+// 获取推广帖子列表(用于轮播展示)
+export async function getPromotionPosts(): Promise<API.AjaxResult<API.Post.PostInfo[]>> {
+ return request('/api/post-center/promotion', {
+ method: 'GET',
+ });
+}
+
+// 点赞/取消点赞评论
+export async function toggleCommentLike(commentId: number, like: boolean) {
+ console.log('toggleCommentLike', commentId, like);
+ return request<API.AjaxResult>(`/api/post-center/comment/like/${commentId}`, {
+ method: 'POST',
+ params: { like },
+ });
+}
+
+// 获取待审核帖子列表
+export async function getReviewPosts(params: PostListParams): Promise<API.TableDataInfo<API.Post.PostInfo>> {
+ return request('/api/post/review/list', {
+ method: 'GET',
+ params,
+ });
+}
+
+// 审核帖子(通过/拒绝)
+export async function reviewPost(postId: number, action: 'approve' | 'reject', reason?: string): Promise<API.AjaxResult> {
+ return request(`/api/post/review/${postId}`, {
+ method: 'PUT',
+ data: { action, reason },
+ });
+}
+
+// 强制下架帖子
+export async function takeDownPost(postId: number, reason?: string): Promise<API.AjaxResult> {
+ return request(`/api/post/takedown/${postId}`, {
+ method: 'PUT',
+ data: { reason },
+ });
+}
+
+// 检查帖子推广状态
+export async function getPromotionStatus(postId: number): Promise<API.AjaxResult> {
+ return request(`/api/post-center/promotion-status/${postId}`, {
+ method: 'GET',
+ });
+}
+
+// 确认支付成功
+export async function confirmPayment(paymentId: number): Promise<API.AjaxResult> {
+ return request(`/api/post-center/payment/confirm/${paymentId}`, {
+ method: 'POST',
+ });
+}
+
+// 取消支付
+export async function cancelPayment(paymentId: number): Promise<API.AjaxResult> {
+ return request(`/api/post-center/payment/cancel/${paymentId}`, {
+ method: 'POST',
+ });
+}
+
+// 举报帖子
+export async function reportPost(postId: number, reason: string): Promise<API.AjaxResult> {
+ return request(`/api/post-center/report/${postId}`, {
+ method: 'POST',
+ data: { reason },
+ });
+}
+
+// 获取举报列表
+export async function getReportList(params: PostListParams): Promise<API.TableDataInfo> {
+ return request('/api/post/report/list', {
+ method: 'GET',
+ params,
+ });
+}
+
+// 处理举报
+export async function handleReport(reportId: number, action: 'approve' | 'reject', postId: number, reason?: string): Promise<API.AjaxResult> {
+ return request(`/api/post/report/handle/${reportId}`, {
+ method: 'PUT',
+ data: { action, postId, reason },
+ });
+}
\ No newline at end of file
diff --git a/src/types/post.d.ts b/src/types/post.d.ts
new file mode 100644
index 0000000..03a6a24
--- /dev/null
+++ b/src/types/post.d.ts
@@ -0,0 +1,99 @@
+declare namespace API {
+ namespace Post {
+ interface PostInfo {
+ postId: number;
+ id?: number;
+ title: string;
+ content?: string;
+ summary?: string;
+ coverImage?: string;
+ authorId?: number;
+ author: string;
+ views: number;
+ comments: number;
+ favorites: number;
+ likes?: number;
+ status: string;
+ publishTime: string;
+ tags: string;
+ promotionPlanId?: number;
+ createBy?: string;
+ createTime?: string;
+ updateBy?: string;
+ updateTime?: string;
+ remark?: string;
+ }
+
+ interface PostComment {
+ commentId: number;
+ postId: number;
+ content: string;
+ userId?: number;
+ userName?: string;
+ userAvatar?: string;
+ parentId?: number;
+ replyUserId?: number;
+ replyUserName?: string;
+ status: string;
+ likes: number;
+ createBy?: string;
+ createTime?: string;
+ updateBy?: string;
+ updateTime?: string;
+ remark?: string;
+ }
+
+ interface PostTag {
+ tagId: number;
+ tagName: string;
+ tagColor?: string;
+ postCount: number;
+ status: string;
+ createBy?: string;
+ createTime?: string;
+ updateBy?: string;
+ updateTime?: string;
+ remark?: string;
+ }
+
+ interface PostFavorite {
+ favoriteId: number;
+ postId: number;
+ userId: number;
+ postTitle?: string;
+ postCover?: string;
+ status: string;
+ createBy?: string;
+ createTime?: string;
+ updateBy?: string;
+ updateTime?: string;
+ remark?: string;
+ }
+
+ interface PostLike {
+ likeId: number;
+ postId: number;
+ userId: number;
+ createTime?: string;
+ }
+ }
+
+ // 通用响应类型
+ interface AjaxResult<T = any> {
+ code: number;
+ msg?: string;
+ data?: T;
+ }
+
+ interface TableDataInfo<T = any> {
+ code: number;
+ rows: T[];
+ total: number;
+ msg?: string;
+ }
+
+ interface PageParams {
+ pageNum?: number;
+ pageSize?: number;
+ }
+}
\ No newline at end of file
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..e8ab63b
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,145 @@
+# 单元测试文档
+
+## 概述
+
+本项目包含完整的前端和后端单元测试,确保代码质量和功能正确性。
+
+## 前端测试
+
+### 测试框架
+- Jest: 测试运行器和断言库
+- @testing-library/react: React组件测试工具
+- @testing-library/jest-dom: DOM断言扩展
+
+### 测试覆盖范围
+
+#### 服务层测试 (services/)
+- **post.test.ts**: 帖子相关API接口测试
+ - 帖子列表获取
+ - 帖子详情获取
+ - 评论功能
+ - 收藏和点赞
+ - 帖子发布和管理
+ - 文件上传
+ - 审核功能
+ - 举报功能
+ - 错误处理
+
+### 运行测试
+
+```bash
+# 运行所有测试
+npm run test
+
+# 监听模式运行测试
+npm run test:watch
+
+# 生成覆盖率报告
+npm run test:coverage
+
+# CI环境运行测试
+npm run test:ci
+```
+
+### 测试配置
+
+测试配置文件:
+- `jest.config.ts`: Jest主配置
+- `tests/setup.ts`: 测试环境设置
+
+## 后端测试
+
+### 测试框架
+- JUnit 5: 测试框架
+- Mockito: Mock框架
+- Spring Boot Test: Spring测试支持
+
+### 测试覆盖范围
+
+#### 控制器测试
+- **PostCenterControllerTest**: 帖子中心控制器测试
+ - 帖子列表API
+ - 帖子详情API
+ - 评论API
+ - 收藏和点赞API
+ - 帖子发布API
+ - 文件上传API
+ - 帖子管理API
+ - 举报API
+
+#### 服务层测试
+- **PostServiceTest**: 帖子服务测试
+- **PostCommentServiceTest**: 评论服务测试
+- **PostTagServiceTest**: 标签服务测试
+
+### 运行测试
+
+```bash
+# Maven运行测试
+mvn test
+
+# 生成测试报告
+mvn surefire-report:report
+
+# 运行特定测试类
+mvn test -Dtest=PostCenterControllerTest
+```
+
+## 测试最佳实践
+
+### 1. 测试命名规范
+- 测试方法名应清晰描述测试场景
+- 使用中文描述测试目的
+- 格式:`test[功能名]_[场景]`
+
+### 2. 测试结构
+- **Arrange**: 准备测试数据
+- **Act**: 执行被测试方法
+- **Assert**: 验证结果
+
+### 3. Mock使用
+- 只Mock外部依赖
+- 验证重要的方法调用
+- 使用合理的测试数据
+
+### 4. 覆盖率要求
+- 行覆盖率: ≥80%
+- 分支覆盖率: ≥80%
+- 函数覆盖率: ≥80%
+
+## CI/CD集成
+
+### Jenkins配置
+测试在以下情况自动运行:
+1. 代码提交到Gerrit
+2. 合并请求创建
+3. 定期构建
+
+### 测试报告
+- 前端: 生成HTML覆盖率报告
+- 后端: 生成Surefire测试报告
+- 集成到Jenkins构建结果
+
+## 故障排除
+
+### 常见问题
+1. **测试超时**: 增加超时时间或优化测试逻辑
+2. **Mock失效**: 检查Mock配置和方法签名
+3. **异步测试**: 使用适当的异步测试工具
+
+### 调试技巧
+1. 使用`console.log`输出调试信息
+2. 运行单个测试文件进行调试
+3. 检查测试数据和期望结果
+
+## 维护指南
+
+### 添加新测试
+1. 为新功能编写对应测试
+2. 确保测试覆盖正常和异常情况
+3. 更新测试文档
+
+### 更新现有测试
+1. 功能变更时同步更新测试
+2. 保持测试数据的有效性
+3. 定期重构测试代码
\ No newline at end of file
diff --git a/tests/services/post.test.ts b/tests/services/post.test.ts
new file mode 100644
index 0000000..8481bf6
--- /dev/null
+++ b/tests/services/post.test.ts
@@ -0,0 +1,711 @@
+/**
+ * 帖子服务单元测试
+ *
+ * 测试覆盖所有帖子相关的API接口
+ * 包括:帖子列表、详情、评论、收藏、点赞、发布、审核、举报等功能
+ */
+
+import * as postService from '../../src/services/post';
+
+// Mock request module
+jest.mock('@umijs/max', () => ({
+ request: jest.fn(),
+}));
+
+const { request } = require('@umijs/max');
+
+describe('Post Service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ // 帖子列表相关测试
+ describe('getPostList', () => {
+ it('应该成功获取帖子列表', async () => {
+ const mockResponse = {
+ code: 200,
+ rows: [
+ {
+ postId: 1,
+ title: '测试帖子',
+ content: '测试内容',
+ author: 'testuser',
+ views: 100,
+ comments: 5,
+ favorites: 10,
+ likes: 15,
+ tags: 'tag1,tag2',
+ publishTime: '2024-01-01 12:00:00',
+ status: '1'
+ }
+ ],
+ total: 1
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPostList({ pageNum: 1, pageSize: 10 });
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/list', {
+ method: 'GET',
+ params: { pageNum: 1, pageSize: 10 },
+ });
+ });
+
+ it('应该处理带标签筛选的帖子列表请求', async () => {
+ const mockResponse = { code: 200, rows: [], total: 0 };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ await postService.getPostList({ pageNum: 1, pageSize: 10, tags: '日剧' });
+
+ expect(request).toHaveBeenCalledWith('/api/post-center/list', {
+ method: 'GET',
+ params: { pageNum: 1, pageSize: 10, tags: '日剧' },
+ });
+ });
+
+ it('应该处理搜索关键词的请求', async () => {
+ const mockResponse = { code: 200, rows: [], total: 0 };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ await postService.getPostList({ pageNum: 1, pageSize: 10, title: '搜索关键词' });
+
+ expect(request).toHaveBeenCalledWith('/api/post-center/list', {
+ method: 'GET',
+ params: { pageNum: 1, pageSize: 10, title: '搜索关键词' },
+ });
+ });
+ });
+
+ // 帖子详情相关测试
+ describe('getPostDetail', () => {
+ it('应该成功获取帖子详情', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ post: {
+ postId: 1,
+ title: '测试帖子',
+ content: '测试内容',
+ author: 'testuser',
+ views: 100,
+ comments: 5,
+ favorites: 10,
+ likes: 15,
+ tags: 'tag1,tag2',
+ publishTime: '2024-01-01 12:00:00',
+ status: '1'
+ },
+ tags: [],
+ comments: [],
+ recommendedPosts: [],
+ favorited: false
+ }
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPostDetail(1);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/1', {
+ method: 'GET',
+ });
+ });
+
+ it('应该处理获取不存在帖子的情况', async () => {
+ const mockResponse = { code: 404, msg: '帖子不存在' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPostDetail(999);
+
+ expect(result).toEqual(mockResponse);
+ });
+ });
+
+ // 评论相关测试
+ describe('addComment', () => {
+ it('应该成功添加评论', async () => {
+ const mockResponse = { code: 200, msg: '评论成功', data: { commentId: 1 } };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const commentData = {
+ postId: 1,
+ content: '这是一条测试评论',
+ parentId: 0
+ };
+
+ const result = await postService.addComment(commentData);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/comment', {
+ method: 'POST',
+ data: commentData,
+ });
+ });
+
+ it('应该处理空评论内容', async () => {
+ const mockResponse = { code: 400, msg: '评论内容不能为空' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.addComment({ postId: 1, content: '' });
+
+ expect(result).toEqual(mockResponse);
+ });
+ });
+
+ // 收藏相关测试
+ describe('toggleFavorite', () => {
+ it('应该成功收藏帖子', async () => {
+ const mockResponse = { code: 200, msg: '收藏成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.toggleFavorite(1, true);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/favorite/1', {
+ method: 'POST',
+ params: { favorite: true },
+ });
+ });
+
+ it('应该成功取消收藏帖子', async () => {
+ const mockResponse = { code: 200, msg: '取消收藏成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.toggleFavorite(1, false);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/favorite/1', {
+ method: 'POST',
+ params: { favorite: false },
+ });
+ });
+ });
+
+ // 点赞相关测试
+ describe('toggleLike', () => {
+ it('应该成功点赞帖子', async () => {
+ const mockResponse = { code: 200, msg: '点赞成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.toggleLike(1, true);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/like/1', {
+ method: 'POST',
+ params: { like: true },
+ });
+ });
+
+ it('应该成功取消点赞帖子', async () => {
+ const mockResponse = { code: 200, msg: '取消点赞成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.toggleLike(1, false);
+
+ expect(result).toEqual(mockResponse);
+ });
+ });
+
+ // 评论点赞测试
+ describe('toggleCommentLike', () => {
+ it('应该成功点赞评论', async () => {
+ const mockResponse = { code: 200, msg: '点赞成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.toggleCommentLike(1, true);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/comment/like/1', {
+ method: 'POST',
+ params: { like: true },
+ });
+ });
+ });
+
+ // 发布帖子测试
+ describe('publishPost', () => {
+ it('应该成功发布帖子', async () => {
+ const mockResponse = { code: 200, msg: '发布成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const postData = {
+ title: '新帖子标题',
+ content: '新帖子内容',
+ summary: '新帖子摘要',
+ tags: 'tag1,tag2',
+ promotionPlan: 1
+ };
+
+ const result = await postService.publishPost(postData);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/publish', {
+ method: 'POST',
+ data: postData,
+ });
+ });
+
+ it('应该处理发布帖子失败的情况', async () => {
+ const mockResponse = { code: 400, msg: '标题不能为空' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.publishPost({
+ title: '',
+ content: '内容',
+ summary: '摘要',
+ tags: 'tag1'
+ });
+
+ expect(result).toEqual(mockResponse);
+ });
+ });
+
+ // 个人帖子列表测试
+ describe('getMyPosts', () => {
+ it('应该成功获取我的帖子列表', async () => {
+ const mockResponse = {
+ code: 200,
+ rows: [
+ {
+ postId: 1,
+ title: '我的帖子',
+ author: 'currentUser',
+ status: '1'
+ }
+ ],
+ total: 1
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getMyPosts({ pageNum: 1, pageSize: 10 });
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/my-posts', {
+ method: 'GET',
+ params: { pageNum: 1, pageSize: 10 },
+ });
+ });
+ });
+
+ // 收藏列表测试
+ describe('getMyFavorites', () => {
+ it('应该成功获取我的收藏列表', async () => {
+ const mockResponse = {
+ code: 200,
+ rows: [
+ {
+ postId: 1,
+ title: '收藏的帖子',
+ author: 'otherUser'
+ }
+ ],
+ total: 1
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getMyFavorites({ pageNum: 1, pageSize: 10 });
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/my-favorites', {
+ method: 'GET',
+ params: { pageNum: 1, pageSize: 10 },
+ });
+ });
+ });
+
+ // 更新帖子测试
+ describe('updatePost', () => {
+ it('应该成功更新帖子', async () => {
+ const postData: API.Post.PostInfo = {
+ postId: 1,
+ title: '更新的标题',
+ content: '更新的内容',
+ author: 'testuser',
+ views: 0,
+ comments: 0,
+ favorites: 0,
+ likes: 0,
+ tags: 'tag1,tag2',
+ publishTime: new Date().toISOString(),
+ status: '1'
+ };
+
+ const mockResponse = { code: 200, msg: '更新成功', data: null };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.updatePost(postData);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/update', {
+ method: 'PUT',
+ data: postData,
+ });
+ });
+ });
+
+ // 删除帖子测试
+ describe('deletePost', () => {
+ it('应该成功删除帖子', async () => {
+ const mockResponse = { code: 200, msg: '删除成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.deletePost(1);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/delete/1', {
+ method: 'DELETE',
+ });
+ });
+ });
+
+ // 标签相关测试
+ describe('tag operations', () => {
+ it('应该成功获取热门标签', async () => {
+ const mockResponse = {
+ code: 200,
+ data: [
+ { tagId: 1, tagName: '日剧', postCount: 10 },
+ { tagId: 2, tagName: '电影', postCount: 8 }
+ ]
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getHotTags();
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/tags/hot', {
+ method: 'GET',
+ });
+ });
+
+ it('应该成功获取可用标签', async () => {
+ const mockResponse = {
+ code: 200,
+ data: [
+ { tagId: 1, tagName: '日剧', tagColor: 'blue' },
+ { tagId: 2, tagName: '电影', tagColor: 'green' }
+ ]
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getAvailableTags();
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/tags/available', {
+ method: 'GET',
+ });
+ });
+
+ it('应该成功根据标签获取帖子', async () => {
+ const mockResponse = {
+ code: 200,
+ rows: [{ postId: 1, title: '日剧帖子' }],
+ total: 1
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPostsByTag(1);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/bytag/1', {
+ method: 'GET',
+ });
+ });
+ });
+
+ // 图片上传测试
+ describe('image operations', () => {
+ it('应该成功上传图片', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ url: '/images/123456_test.jpg',
+ filename: '123456_test.jpg',
+ originalName: 'test.jpg',
+ size: '1024'
+ }
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const formData = new FormData();
+ formData.append('file', new Blob(['test'], { type: 'image/jpeg' }));
+
+ const result = await postService.uploadImage(formData);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/upload', {
+ method: 'POST',
+ data: formData,
+ });
+ });
+
+ it('应该成功删除图片', async () => {
+ const mockResponse = { code: 200, msg: '删除成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.deleteImage('test.jpg');
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/upload', {
+ method: 'DELETE',
+ params: { filename: 'test.jpg' },
+ });
+ });
+ });
+
+ // 推广相关测试
+ describe('promotion operations', () => {
+ it('应该成功获取推广帖子', async () => {
+ const mockResponse = {
+ code: 200,
+ data: [
+ {
+ postId: 1,
+ title: '推广帖子',
+ promotionPlanId: 1
+ }
+ ]
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPromotionPosts();
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/promotion', {
+ method: 'GET',
+ });
+ });
+
+ it('应该成功获取推广计划', async () => {
+ const mockResponse = {
+ code: 200,
+ data: [
+ {
+ id: 1,
+ name: '首页推荐',
+ description: '帖子显示在首页推荐位置',
+ price: 50.00,
+ duration: 7
+ }
+ ]
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPromotionPlans();
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/promotion-plans', {
+ method: 'GET',
+ });
+ });
+
+ it('应该成功创建支付记录', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ paymentId: 1,
+ postId: 1,
+ planId: 1,
+ amount: 50.00,
+ paymentStatus: 'pending'
+ }
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const paymentData = {
+ postId: 1,
+ planId: 1,
+ amount: 50.00
+ };
+
+ const result = await postService.createPayment(paymentData);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/payment', {
+ method: 'POST',
+ data: paymentData,
+ });
+ });
+
+ it('应该成功获取推广状态', async () => {
+ const mockResponse = {
+ code: 200,
+ data: {
+ hasPromotion: true,
+ promotionPlanId: 1
+ }
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPromotionStatus(1);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/promotion-status/1', {
+ method: 'GET',
+ });
+ });
+
+ it('应该成功确认支付', async () => {
+ const mockResponse = { code: 200, msg: '支付成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.confirmPayment(1);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/payment/confirm/1', {
+ method: 'POST',
+ });
+ });
+
+ it('应该成功取消支付', async () => {
+ const mockResponse = { code: 200, msg: '支付已取消' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.cancelPayment(1);
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/payment/cancel/1', {
+ method: 'POST',
+ });
+ });
+ });
+
+ // 举报相关测试
+ describe('report operations', () => {
+ it('应该成功举报帖子', async () => {
+ const mockResponse = { code: 200, msg: '举报提交成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.reportPost(1, '内容不当');
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post-center/report/1', {
+ method: 'POST',
+ data: { reason: '内容不当' },
+ });
+ });
+ });
+
+ // 管理员功能测试
+ describe('admin operations', () => {
+ it('应该成功获取待审核帖子', async () => {
+ const mockResponse = {
+ code: 200,
+ rows: [
+ {
+ postId: 1,
+ title: '待审核帖子',
+ status: '0'
+ }
+ ],
+ total: 1
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getReviewPosts({ status: '0' });
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post/review/list', {
+ method: 'GET',
+ params: { status: '0' },
+ });
+ });
+
+ it('应该成功审核帖子', async () => {
+ const mockResponse = { code: 200, msg: '审核成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.reviewPost(1, 'approve', '内容合规');
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post/review/1', {
+ method: 'PUT',
+ data: { action: 'approve', reason: '内容合规' },
+ });
+ });
+
+ it('应该成功下架帖子', async () => {
+ const mockResponse = { code: 200, msg: '下架成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.takeDownPost(1, '违规内容');
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post/takedown/1', {
+ method: 'PUT',
+ data: { reason: '违规内容' },
+ });
+ });
+
+ it('应该成功获取举报列表', async () => {
+ const mockResponse = {
+ code: 200,
+ rows: [
+ {
+ reportId: 1,
+ postId: 1,
+ reportReason: '内容不当',
+ status: '0'
+ }
+ ],
+ total: 1
+ };
+
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getReportList({});
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post/report/list', {
+ method: 'GET',
+ params: {},
+ });
+ });
+
+ it('应该成功处理举报', async () => {
+ const mockResponse = { code: 200, msg: '举报处理成功' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.handleReport(1, 'approve', 1, '举报属实');
+
+ expect(result).toEqual(mockResponse);
+ expect(request).toHaveBeenCalledWith('/api/post/report/handle/1', {
+ method: 'PUT',
+ data: { action: 'approve', postId: 1, reason: '举报属实' },
+ });
+ });
+ });
+
+ // 错误处理测试
+ describe('error handling', () => {
+ it('应该处理网络错误', async () => {
+ const error = new Error('Network Error');
+ (request as jest.Mock).mockRejectedValue(error);
+
+ await expect(postService.getPostList({})).rejects.toThrow('Network Error');
+ });
+
+ it('应该处理服务器错误响应', async () => {
+ const mockResponse = { code: 500, msg: '服务器内部错误' };
+ (request as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await postService.getPostList({});
+
+ expect(result).toEqual(mockResponse);
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 0000000..96ffc69
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,53 @@
+import '@testing-library/jest-dom';
+
+// 全局测试配置
+global.console = {
+ ...console,
+ // 在测试中禁用console.log以减少噪音
+ log: jest.fn(),
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+};
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // deprecated
+ removeListener: jest.fn(), // deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+});
+
+// Mock ResizeObserver
+global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+}));
+
+// Mock IntersectionObserver
+global.IntersectionObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+}));
+
+// Mock localStorage
+const localStorageMock: Storage = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ clear: jest.fn(),
+ length: 0,
+ key: jest.fn(),
+};
+global.localStorage = localStorageMock;
+global.sessionStorage = localStorageMock;
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index c2ea9bc..22e3cf4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "types": ["node"],
+ "types": ["node", "jest"],
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
@@ -20,5 +20,5 @@
"@@test/*": ["./src/.umi-test/*"]
}
},
- "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"]
+ "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx", "src/pages/PostCenter/PostDetail.jsx", "src/pages/PostCenter/PostCard.jsx", "src/pages/PostCenter/index.jsx"]
}