帖子的相关用户前端与管理员后端
Change-Id: I957181738817aeeaa89fabe23ceaf59d00c37a71
diff --git a/src/api/__tests__/post.test.js b/src/api/__tests__/post.test.js
new file mode 100644
index 0000000..89fe48f
--- /dev/null
+++ b/src/api/__tests__/post.test.js
@@ -0,0 +1,160 @@
+const axios = require('axios');
+const MockAdapter = require('axios-mock-adapter');
+
+const {
+ createPost,
+ togglePinPost,
+ deletePost,
+ updatePost,
+ searchPosts,
+ likePost,
+ unlikePost,
+ pinPost,
+ unpinPost,
+ findPostsByUserId,
+ findPinnedPosts,
+ getAllPostsSorted,
+ getPostById
+} = require('../post'); // 注意根据你的实际路径修改
+
+jest.mock('axios');
+
+describe('Post API Tests', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('createPost should post form data', async () => {
+ const formData = new FormData();
+ formData.append('userid', '1');
+ formData.append('post_title', 'Test');
+ axios.post.mockResolvedValue({ data: true });
+
+ const response = await createPost(formData);
+
+ expect(axios.post).toHaveBeenCalledWith(
+ 'http://localhost:8080/post/create',
+ formData,
+ { headers: { 'Content-Type': 'multipart/form-data' } }
+ );
+ expect(response.data).toBe(true);
+ });
+
+ test('togglePinPost should send PUT request', async () => {
+ axios.put.mockResolvedValue({ data: true });
+
+ const response = await togglePinPost(123);
+
+ expect(axios.put).toHaveBeenCalledWith('http://localhost:8080/post/togglePin/123');
+ expect(response.data).toBe(true);
+ });
+
+ test('deletePost should send DELETE request', async () => {
+ axios.delete.mockResolvedValue({ data: true });
+
+ const response = await deletePost(123);
+
+ expect(axios.delete).toHaveBeenCalledWith('http://localhost:8080/post/delete/123');
+ expect(response.data).toBe(true);
+ });
+
+ test('updatePost should send PUT request with JSON', async () => {
+ const post = { postid: 1, post_title: 'Updated Title' };
+ axios.put.mockResolvedValue({ data: true });
+
+ const response = await updatePost(post);
+
+ expect(axios.put).toHaveBeenCalledWith(
+ 'http://localhost:8080/post/update',
+ JSON.stringify(post),
+ { headers: { 'Content-Type': 'application/json' } }
+ );
+ expect(response.data).toBe(true);
+ });
+
+ test('searchPosts should send GET request with keyword', async () => {
+ const mockData = { data: [{ post_title: 'Keyword Match' }] };
+ axios.get.mockResolvedValue(mockData);
+
+ const response = await searchPosts('test');
+
+ expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/post/search?keyword=test');
+ expect(response.data).toEqual(mockData.data);
+ });
+
+ test('likePost should send PUT request', async () => {
+ axios.put.mockResolvedValue({ data: true });
+
+ const response = await likePost(1);
+
+ expect(axios.put).toHaveBeenCalledWith('http://localhost:8080/post/like/1');
+ expect(response.data).toBe(true);
+ });
+
+ test('unlikePost should send PUT request', async () => {
+ axios.put.mockResolvedValue({ data: true });
+
+ const response = await unlikePost(1);
+
+ expect(axios.put).toHaveBeenCalledWith('http://localhost:8080/post/unlike/1');
+ expect(response.data).toBe(true);
+ });
+
+ test('pinPost should send PUT request', async () => {
+ axios.put.mockResolvedValue({ data: true });
+
+ const response = await pinPost(1);
+
+ expect(axios.put).toHaveBeenCalledWith('http://localhost:8080/post/pin/1');
+ expect(response.data).toBe(true);
+ });
+
+ test('unpinPost should send PUT request', async () => {
+ axios.put.mockResolvedValue({ data: true });
+
+ const response = await unpinPost(1);
+
+ expect(axios.put).toHaveBeenCalledWith('http://localhost:8080/post/unpin/1');
+ expect(response.data).toBe(true);
+ });
+
+ test('findPostsByUserId should fetch user posts', async () => {
+ const mockData = { data: [{ postid: 1, userid: 2 }] };
+ axios.get.mockResolvedValue(mockData);
+
+ const response = await findPostsByUserId(2);
+
+ expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/post/findByUserid?userid=2');
+ expect(response.data).toEqual(mockData.data);
+ });
+
+ test('findPinnedPosts should fetch pinned posts', async () => {
+ const mockData = { data: [{ postid: 1, is_pinned: 1 }] };
+ axios.get.mockResolvedValue(mockData);
+
+ const response = await findPinnedPosts();
+
+ expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/post/findPinned');
+ expect(response.data).toEqual(mockData.data);
+ });
+
+ test('getAllPostsSorted should fetch all posts sorted', async () => {
+ const mockData = { data: [{ postid: 1 }] };
+ axios.get.mockResolvedValue(mockData);
+
+ const response = await getAllPostsSorted();
+
+ expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/post/all');
+ expect(response.data).toEqual(mockData.data);
+ });
+
+ test('getPostById should fetch post by ID', async () => {
+ const mockData = { data: { postid: 1 } };
+ axios.get.mockResolvedValue(mockData);
+
+ const response = await getPostById(1);
+
+ expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/post/get/1');
+ expect(response.data).toEqual(mockData.data);
+ });
+});
diff --git a/src/api/post.js b/src/api/post.js
new file mode 100644
index 0000000..5891f99
--- /dev/null
+++ b/src/api/post.js
@@ -0,0 +1,128 @@
+const BASE_URL = 'http://localhost:8080/post';
+
+/**
+ * 创建帖子(带图片)
+ * @param {FormData} formData 包含 userid、post_title、post_content、tags、rannge、is_pinned、photo
+ */
+export const createPost = (formData) => {
+ return fetch(`${BASE_URL}/create`, {
+ method: 'POST',
+ body: formData,
+ }).then(res => res.json());
+};
+
+/**
+ * 切换置顶状态
+ * @param {number} postid 帖子 ID
+ */
+export const togglePinPost = (postid) => {
+ return fetch(`${BASE_URL}/togglePin/${postid}`, {
+ method: 'PUT',
+ }).then(res => res.json());
+};
+
+/**
+ * 删除帖子
+ * @param {number} postid 帖子 ID
+ */
+export const deletePost = (postid) => {
+ return fetch(`${BASE_URL}/delete/${postid}`, {
+ method: 'DELETE',
+ }).then(res => res.json());
+};
+
+/**
+ * 更新帖子(JSON 格式)
+ * @param {Object} post 帖子对象
+ */
+export const updatePost = (post) => {
+ return fetch(`${BASE_URL}/update`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(post),
+ }).then(res => res.json());
+};
+
+/**
+ * 关键词搜索帖子
+ * @param {string} keyword 搜索关键词
+ */
+export const searchPosts = (keyword) => {
+ return fetch(`${BASE_URL}/search?keyword=${encodeURIComponent(keyword)}`)
+ .then(res => res.json());
+};
+
+/**
+ * 点赞帖子
+ * @param {number} postid 帖子 ID
+ */
+export const likePost = (postid) => {
+ return fetch(`${BASE_URL}/like/${postid}`, {
+ method: 'PUT',
+ }).then(res => res.json());
+};
+
+/**
+ * 取消点赞帖子
+ * @param {number} postid 帖子 ID
+ */
+export const unlikePost = (postid) => {
+ return fetch(`${BASE_URL}/unlike/${postid}`, {
+ method: 'PUT',
+ }).then(res => res.json());
+};
+
+/**
+ * 置顶帖子
+ * @param {number} postid 帖子 ID
+ */
+export const pinPost = (postid) => {
+ return fetch(`${BASE_URL}/pin/${postid}`, {
+ method: 'PUT',
+ }).then(res => res.json());
+};
+
+/**
+ * 取消置顶帖子
+ * @param {number} postid 帖子 ID
+ */
+export const unpinPost = (postid) => {
+ return fetch(`${BASE_URL}/unpin/${postid}`, {
+ method: 'PUT',
+ }).then(res => res.json());
+};
+
+/**
+ * 获取某用户所有帖子
+ * @param {number} userid 用户 ID
+ */
+export const findPostsByUserId = (userid) => {
+ return fetch(`${BASE_URL}/findByUserid?userid=${userid}`)
+ .then(res => res.json());
+};
+
+/**
+ * 获取所有置顶帖子
+ */
+export const findPinnedPosts = () => {
+ return fetch(`${BASE_URL}/findPinned`)
+ .then(res => res.json());
+};
+
+/**
+ * 获取所有帖子(排序后)
+ */
+export const getAllPostsSorted = () => {
+ return fetch(`${BASE_URL}/all`)
+ .then(res => res.json());
+};
+
+/**
+ * 根据 postid 获取帖子详情(若你后期需要)
+ */
+export const getPostById = (postid) => {
+ return fetch(`${BASE_URL}/get/${postid}`)
+ .then(res => res.json());
+};
diff --git a/src/components/Post.css b/src/components/Post.css
new file mode 100644
index 0000000..40d787e
--- /dev/null
+++ b/src/components/Post.css
@@ -0,0 +1,483 @@
+.post-container {
+ padding: 2rem;
+ background: #f8f9fa;
+ min-height: 100vh;
+ max-width: 900px;
+ margin: auto;
+}
+
+.post-form-card {
+ background: white;
+ border-radius: 12px;
+ padding: 2rem;
+ box-shadow: 0 8px 20px rgba(255, 102, 0, 0.15);
+ margin-bottom: 2rem;
+ animation: fadeInUp 0.5s ease-in-out;
+}
+
+.post-form-title {
+ font-size: 1.5rem;
+ font-weight: bold;
+ margin-bottom: 1rem;
+ color: #ff6600;
+}
+
+.post-input,
+.post-textarea {
+ width: 100%;
+ padding: 12px 14px;
+ border-radius: 8px;
+ border: 1px solid #ffbf80;
+ margin-bottom: 1rem;
+ font-size: 1rem;
+ transition: border-color 0.3s ease;
+}
+
+.post-input:focus,
+.post-textarea:focus {
+ outline: none;
+ border-color: #ff6600;
+ box-shadow: 0 0 8px rgba(255, 102, 0, 0.5);
+}
+
+.post-textarea {
+ height: 100px;
+ resize: vertical;
+}
+
+.post-file {
+ margin-bottom: 1rem;
+}
+
+.post-button {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ margin-right: 0.5rem;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ box-shadow: 0 4px 6px rgba(255, 102, 0, 0.3);
+}
+
+/* 浅橙色实底按钮 */
+.primary {
+ background: linear-gradient(45deg, #FFB366, #FFC299);
+ color: white;
+ box-shadow: 0 6px 10px rgba(255, 179, 102, 0.5);
+}
+
+.primary:hover {
+ background: linear-gradient(45deg, #FFAA33, #FFBF66);
+ box-shadow: 0 8px 14px rgba(255, 170, 51, 0.7);
+}
+
+/* 橙色边框按钮 */
+.primary-outline {
+ background: transparent;
+ border: 2px solid #ff6600;
+ color: #ff6600;
+ box-shadow: none;
+}
+
+.primary-outline:hover {
+ background: #ff6600;
+ color: white;
+ box-shadow: 0 6px 10px rgba(255, 102, 0, 0.4);
+}
+
+.post-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.post-card {
+ background: white;
+ border-radius: 12px;
+ padding: 1.5rem;
+ box-shadow: 0 4px 15px rgba(255, 102, 0, 0.12);
+ animation: fadeInUp 0.3s ease-out;
+ transition: box-shadow 0.3s ease;
+}
+
+.post-card:hover {
+ box-shadow: 0 8px 30px rgba(255, 102, 0, 0.25);
+}
+
+.post-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.post-info {
+ flex: 1;
+}
+
+.post-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #cc5200;
+ margin-bottom: 0.75rem;
+ padding-left: 12px;
+ border-left: 4px solid #ff6600;
+ transition: color 0.3s ease;
+}
+
+.post-title:hover {
+ color: #ff6600;
+}
+
+.post-content {
+ color: #444;
+ margin-bottom: 0.75rem;
+}
+
+.post-image {
+ width: 160px;
+ height: 120px;
+ object-fit: cover;
+ border-radius: 6px;
+ box-shadow: 0 4px 8px rgba(255, 102, 0, 0.3);
+ flex-shrink: 0;
+ cursor: pointer;
+ transition: transform 0.3s ease;
+}
+
+.post-image:hover {
+ transform: scale(1.05);
+}
+
+.post-meta {
+ font-size: 0.9rem;
+ color: #777;
+ margin-top: 0.5rem;
+}
+
+.post-actions-inline {
+ margin-top: 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ font-size: 0.95rem;
+}
+
+.post-link {
+ cursor: pointer;
+ text-decoration: underline;
+ transition: color 0.3s ease;
+}
+
+.post-link.like {
+ color: #ff6600;
+}
+
+.post-link.like:hover {
+ color: #ff8533;
+}
+
+.post-link.unlike {
+ color: #cc3300;
+}
+
+.post-link.unlike:hover {
+ color: #ff3300;
+}
+
+.post-comment {
+ margin-top: 1rem;
+ border-top: 1px solid #ffe6cc;
+ padding-top: 1rem;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* 图片模态框 */
+.image-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0, 0, 0, 0.75);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+}
+
+.image-modal-content {
+ position: relative;
+ max-width: 90%;
+ max-height: 90%;
+}
+
+.image-modal-img {
+ width: 100%;
+ max-height: 80vh;
+ border-radius: 12px;
+ box-shadow: 0 0 20px rgba(255, 102, 0, 0.8);
+}
+
+.image-modal-close {
+ position: absolute;
+ top: -20px;
+ right: -20px;
+ background: white;
+ border: none;
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ font-size: 24px;
+ line-height: 36px;
+ text-align: center;
+ cursor: pointer;
+ box-shadow: 0 2px 10px rgba(255, 102, 0, 0.7);
+}
+
+.action-group {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.post-empty-state {
+ text-align: center;
+ padding: 2rem;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 4px 15px rgba(255, 102, 0, 0.12);
+}
+
+.post-empty-message {
+ font-size: 1.2rem;
+ color: #cc5200;
+ margin-bottom: 1rem;
+}
+
+/* RequestBoard组件样式保持一致 */
+.request-board-container {
+ background: white;
+ border-radius: 12px;
+ padding: 1.5rem;
+ box-shadow: 0 4px 15px rgba(255, 102, 0, 0.12);
+ margin-top: 1rem;
+}
+
+.request-board-section {
+ margin-bottom: 1.5rem;
+}
+
+.request-board-form {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.request-board-input {
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid #ffbf80;
+ font-size: 0.95rem;
+}
+
+.request-board-input:focus {
+ border-color: #ff6600;
+ box-shadow: 0 0 8px rgba(255, 102, 0, 0.5);
+ outline: none;
+}
+
+.request-board-textarea {
+ grid-column: span 2;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid #ffbf80;
+ resize: vertical;
+ min-height: 100px;
+}
+
+.request-board-textarea:focus {
+ border-color: #ff6600;
+ box-shadow: 0 0 8px rgba(255, 102, 0, 0.5);
+ outline: none;
+}
+
+.request-board-file {
+ grid-column: span 2;
+}
+
+.request-board-list {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+
+.request-board-item {
+ background: white;
+ border-radius: 10px;
+ padding: 1.25rem;
+ box-shadow: 0 2px 8px rgba(255, 102, 0, 0.08);
+}
+
+.request-board-item-title {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin-bottom: 0.75rem;
+ color: hsl(24, 67%, 55%);
+ border-bottom: 2px solid #ffe0b3;
+ padding-bottom: 0.5rem;
+}
+
+.request-board-item-content {
+ color: #555;
+ margin-bottom: 1rem;
+}
+
+.request-board-item-meta {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ color: #777;
+ font-size: 0.9rem;
+}
+
+.request-board-item-image {
+ max-width: 200px;
+ max-height: 150px;
+ border-radius: 6px;
+ margin-bottom: 1rem;
+}
+
+.request-board-item-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.request-board-small-input {
+ padding: 8px 10px;
+ border-radius: 6px;
+ border: 1px solid #ffbf80;
+ flex: 1;
+}
+
+.request-board-small-input:focus {
+ border-color: #ff6600;
+ box-shadow: 0 0 8px rgba(255, 102, 0, 0.5);
+ outline: none;
+}
+
+/* 新增的用户信息显示样式 */
+.post-form-user-info {
+ display: flex;
+ align-items: center;
+ margin-bottom: 20px;
+ padding: 10px;
+ background-color: #f8f9fa;
+ border-radius: 8px;
+ border: 1px solid #e9ecef;
+}
+
+.post-form-user-avatar {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ object-fit: cover;
+ margin-right: 15px;
+ border: 2px solid #40a9ff;
+}
+
+.post-form-user-avatar-placeholder {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background-color: #e9ecef;
+ margin-right: 15px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid #40a9ff;
+}
+
+.avatar-initial {
+ font-size: 24px;
+ font-weight: bold;
+ color: #495057;
+}
+
+.post-form-username {
+ font-weight: 600;
+ font-size: 18px;
+ color: #212529;
+}
+
+.post-form-user-label {
+ margin-left: auto;
+ padding: 5px 10px;
+ background-color: #e6f7ff;
+ color: #1890ff;
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+/* 帖子作者信息样式 */
+.post-author-info {
+ display: flex;
+ align-items: center;
+ padding: 10px 15px;
+ border-bottom: 1px solid #f0f0f0;
+ margin-bottom: 15px;
+}
+
+.post-author-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ margin-right: 12px;
+ border: 1px solid #e8e8e8;
+}
+
+.post-author-avatar-placeholder {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background-color: #f5f5f5;
+ margin-right: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #e8e8e8;
+}
+
+.post-author-details {
+ display: flex;
+ flex-direction: column;
+}
+
+.post-author-name {
+ font-weight: 600;
+ font-size: 16px;
+ margin-bottom: 3px;
+ color: #333;
+}
+
+.post-meta {
+ font-size: 12px;
+ color: #888;
+ margin: 2px 0;
+}
\ No newline at end of file
diff --git a/src/components/Post.jsx b/src/components/Post.jsx
new file mode 100644
index 0000000..c6b2e69
--- /dev/null
+++ b/src/components/Post.jsx
@@ -0,0 +1,294 @@
+import React, { useState, useEffect } from 'react';
+import {
+ createPost,
+ findPinnedPosts,
+ likePost,
+ unlikePost,
+ searchPosts,
+ getAllPostsSorted,
+ findPostsByUserId,
+} from '../api/post';
+import Comment from './Comment';
+import RequestBoard from './RequestBoard';
+import './Post.css';
+
+const Post = () => {
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [tags, setTags] = useState('');
+ const [photo, setPhoto] = useState(null);
+ const [posts, setPosts] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [imagePreview, setImagePreview] = useState(null);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [currentView, setCurrentView] = useState('posts');
+ const [currentUser, setCurrentUser] = useState(null);
+ const [userDecorations, setUserDecorations] = useState({});
+
+ useEffect(() => {
+ const storedUser = JSON.parse(localStorage.getItem('user'));
+ if (storedUser) setCurrentUser(storedUser);
+ }, []);
+
+ useEffect(() => {
+ loadPinnedPosts();
+ }, []);
+
+ useEffect(() => {
+ if (posts.length > 0) {
+ fetchUserDecorations(posts);
+ }
+ }, [posts]);
+
+ const fetchUserDecorations = async (posts) => {
+ const decorations = {};
+ await Promise.all(
+ posts.map(async (post) => {
+ if (!decorations[post.userid]) {
+ try {
+ const res = await fetch(`http://localhost:8080/user/getDecoration?userid=${post.userid}`);
+ const json = await res.json();
+ if (json.success) {
+ decorations[post.userid] = json.data;
+ }
+ } catch (error) {
+ console.error(`获取用户 ${post.userid} 的装饰信息失败`, error);
+ }
+ }
+ })
+ );
+ setUserDecorations(decorations);
+ };
+
+ const loadPinnedPosts = async () => {
+ const data = await findPinnedPosts();
+ setPosts(data);
+ setCurrentView('posts');
+ };
+
+ const loadAllPosts = async () => {
+ const data = await getAllPostsSorted();
+ setPosts(data);
+ setCurrentView('posts');
+ };
+
+ const loadMyPosts = async () => {
+ if (!currentUser) return;
+ const data = await findPostsByUserId(currentUser.userid);
+ setPosts(data);
+ setCurrentView('posts');
+ };
+
+ const handleCreate = async () => {
+ if (!currentUser) {
+ alert('请先登录再发帖');
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('userid', currentUser.userid);
+ formData.append('post_title', title);
+ formData.append('post_content', content);
+ formData.append('tags', tags);
+ formData.append('rannge', 'public');
+ if (photo) formData.append('photo', photo);
+
+ const success = await createPost(formData);
+ if (success) {
+ alert('帖子创建成功');
+ loadPinnedPosts();
+ setTitle('');
+ setContent('');
+ setTags('');
+ setPhoto(null);
+ setShowCreateForm(false);
+ } else {
+ alert('创建失败');
+ }
+ };
+
+ const handleLike = async (postid) => {
+ await likePost(postid);
+ loadPinnedPosts();
+ };
+
+ const handleUnlike = async (postid) => {
+ await unlikePost(postid);
+ loadPinnedPosts();
+ };
+
+ const handleSearch = async () => {
+ const result = await searchPosts(searchKeyword);
+ setPosts(result);
+ setCurrentView('posts');
+ };
+
+ const showRequestBoard = () => {
+ setCurrentView('requests');
+ };
+
+ return (
+ <div className="post-container">
+ <div className="post-actions">
+ <div className="action-group">
+ {currentUser && (
+ <button
+ className="post-button primary"
+ onClick={() => setShowCreateForm(!showCreateForm)}
+ >
+ {showCreateForm ? '收起发布表单' : '创建新帖子'}
+ </button>
+ )}
+ <input
+ type="text"
+ placeholder="搜索关键词"
+ value={searchKeyword}
+ onChange={(e) => setSearchKeyword(e.target.value)}
+ className="post-input"
+ />
+ <button className="post-button primary-outline" onClick={handleSearch}>搜索</button>
+ </div>
+
+ <div className="action-group">
+ <button className="post-button primary-outline" onClick={loadPinnedPosts}>置顶帖子</button>
+ <button className="post-button primary-outline" onClick={loadAllPosts}>所有帖子</button>
+ {currentUser && (
+ <button className="post-button primary-outline" onClick={loadMyPosts}>我的帖子</button>
+ )}
+ <button className="post-button primary-outline" onClick={showRequestBoard}>需求帖子</button>
+ </div>
+ </div>
+
+ {showCreateForm && currentUser && currentView !== 'requests' && (
+ <div className="post-form-card">
+ <div className="post-form-user-info">
+ {currentUser.image ? (
+ <img
+ src={currentUser.image}
+ alt="头像"
+ className="post-form-user-avatar"
+ />
+ ) : (
+ <div className="post-form-user-avatar-placeholder">
+ <div className="avatar-initial">
+ {currentUser.username?.charAt(0) || 'U'}
+ </div>
+ </div>
+ )}
+ <div className="post-form-username">{currentUser.username}</div>
+ <div className="post-form-user-label">发布者</div>
+ </div>
+
+ <h2 className="post-form-title">发布新帖子</h2>
+ <input
+ type="text"
+ placeholder="标题"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ className="post-input"
+ />
+ <textarea
+ placeholder="内容"
+ value={content}
+ onChange={(e) => setContent(e.target.value)}
+ className="post-textarea"
+ />
+ <input
+ type="text"
+ placeholder="标签(逗号分隔)"
+ value={tags}
+ onChange={(e) => setTags(e.target.value)}
+ className="post-input"
+ />
+ <input
+ type="file"
+ onChange={(e) => setPhoto(e.target.files[0])}
+ className="post-file"
+ />
+ <button className="post-button primary" onClick={handleCreate}>
+ 发布
+ </button>
+ </div>
+ )}
+
+ {currentView === 'requests' ? (
+ <RequestBoard
+ currentUserId={currentUser?.userid}
+ onBack={() => setCurrentView('posts')}
+ />
+ ) : (
+ posts.length > 0 ? (
+ <div className="post-list">
+ {posts.map((post) => {
+ const decoration = userDecorations[post.userid] || {};
+ const avatar = decoration.image || '';
+ const username = decoration.username || '匿名用户';
+
+ return (
+ <div className="post-card" key={post.postid}>
+ <div className="post-author-info">
+ {avatar ? (
+ <img
+ src={avatar}
+ alt="头像"
+ className="post-author-avatar"
+ />
+ ) : (
+ <div className="post-author-avatar-placeholder">
+ <div className="avatar-initial">{username.charAt(0)}</div>
+ </div>
+ )}
+ <div className="post-author-details">
+ <div className="post-author-name">{username}</div>
+ <div className="post-meta">发布时间: {post.postCreatedTime}</div>
+ </div>
+ </div>
+
+ <div className="post-header">
+ <div className="post-info">
+ <h3 className="post-title">{post.postTitle}</h3>
+ <p className="post-content">{post.postContent}</p>
+ <div className="post-meta">标签: {post.tags || '无标签'}</div>
+ <div className="post-actions-inline">
+ <span>👍 {post.likes}</span>
+ <button className="post-link like" onClick={() => handleLike(post.postid)}>点赞</button>
+ <button className="post-link unlike" onClick={() => handleUnlike(post.postid)}>取消点赞</button>
+ </div>
+ </div>
+ {post.photo && (
+ <img
+ src={`http://localhost:8080${post.photo}`}
+ alt="post"
+ className="post-image"
+ onClick={() => setImagePreview(`http://localhost:8080${post.photo}`)}
+ />
+ )}
+ </div>
+
+ <div className="post-comment">
+ <Comment postId={post.postid} currentUser={currentUser} />
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ ) : (
+ <div className="post-empty-state">
+ <p className="post-empty-message">暂无内容</p>
+ </div>
+ )
+ )}
+
+ {imagePreview && (
+ <div className="image-modal" onClick={() => setImagePreview(null)}>
+ <div className="image-modal-content" onClick={(e) => e.stopPropagation()}>
+ <img src={imagePreview} alt="preview" className="image-modal-img" />
+ <button className="image-modal-close" onClick={() => setImagePreview(null)}>×</button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+};
+
+export default Post;
diff --git a/src/components/PostAdminPanel.jsx b/src/components/PostAdminPanel.jsx
new file mode 100644
index 0000000..a01df08
--- /dev/null
+++ b/src/components/PostAdminPanel.jsx
@@ -0,0 +1,232 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Table,
+ Button,
+ Modal,
+ Image,
+ message,
+ Tag,
+ Space,
+ Input,
+ Tooltip,
+} from 'antd';
+import { ExclamationCircleOutlined, SearchOutlined } from '@ant-design/icons';
+import {
+ getAllPostsSorted,
+ searchPosts,
+ deletePost,
+ togglePinPost,
+} from '../api/post';
+
+const { confirm } = Modal;
+
+const PostAdminPanel = () => {
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [keyword, setKeyword] = useState('');
+
+ const fetchPosts = async () => {
+ setLoading(true);
+ try {
+ const data = keyword.trim()
+ ? await searchPosts(keyword.trim())
+ : await getAllPostsSorted();
+ setPosts(data);
+ } catch (error) {
+ message.error('获取帖子失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchPosts();
+ }, []);
+
+ const handleSearch = () => {
+ fetchPosts();
+ };
+
+ const handleDelete = (postid) => {
+ confirm({
+ title: '确认删除该帖子吗?',
+ icon: <ExclamationCircleOutlined />,
+ okText: '删除',
+ okType: 'danger',
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ const res = await deletePost(postid);
+ if (res) {
+ message.success('删除成功');
+ fetchPosts();
+ } else {
+ message.error('删除失败');
+ }
+ } catch {
+ message.error('删除请求失败');
+ }
+ },
+ });
+ };
+
+ const handlePinToggle = async (postid) => {
+ try {
+ const res = await togglePinPost(postid);
+ if (res === true || res === false) {
+ // ✅ 用后端返回的状态更新 UI
+ const newPosts = posts.map(post => {
+ if (post.postid === postid) {
+ return {
+ ...post,
+ is_pinned: res ? 1 : 0,
+ };
+ }
+ return post;
+ });
+ setPosts(newPosts);
+ message.success(`帖子${res ? '已置顶' : '已取消置顶'}`);
+ } else {
+ message.error('更新失败');
+ }
+ } catch {
+ message.error('更新置顶状态失败');
+ }
+ };
+
+
+
+
+ const columns = [
+ {
+ title: 'ID',
+ dataIndex: 'postid',
+ key: 'postid',
+ width: 80,
+ fixed: 'left',
+ },
+ {
+ title: '用户ID',
+ dataIndex: 'userid',
+ key: 'userid',
+ width: 100,
+ },
+ {
+ title: '标题',
+ dataIndex: 'postTitle',
+ key: 'postTitle',
+ width: 200,
+ ellipsis: true,
+ },
+ {
+ title: '内容',
+ dataIndex: 'postContent',
+ key: 'postContent',
+ ellipsis: { showTitle: false },
+ render: (text) => (
+ <Tooltip placement="topLeft" title={text}>
+ {text}
+ </Tooltip>
+ ),
+ },
+ {
+ title: '标签',
+ dataIndex: 'tags',
+ key: 'tags',
+ render: (tags) =>
+ tags ? tags.split(',').map((tag) => <Tag key={tag}>{tag}</Tag>) : <Tag color="default">无</Tag>,
+ },
+ {
+ title: '图片',
+ dataIndex: 'photo',
+ key: 'photo',
+ width: 100,
+ render: (url) =>
+ url ? (
+ <Image
+ src={`http://localhost:8080${url}`}
+ width={80}
+ height={80}
+ style={{ objectFit: 'cover' }}
+ />
+ ) : (
+ <Tag color="default">无</Tag>
+ ),
+ },
+ {
+ title: '点赞数',
+ dataIndex: 'likes',
+ key: 'likes',
+ width: 100,
+ render: (likes) => <Tag color="blue">{likes}</Tag>,
+ },
+ {
+ title: '置顶状态',
+ dataIndex: 'is_pinned',
+ key: 'is_pinned',
+ width: 100,
+ render: (val) =>
+ val ? <Tag color="green">已置顶</Tag> : <Tag color="default">未置顶</Tag>,
+ },
+ {
+ title: '发布时间',
+ dataIndex: 'postCreatedTime',
+ key: 'postCreatedTime',
+ width: 180,
+ render: (time) =>
+ time ? new Date(time).toLocaleString() : <Tag color="default">暂无</Tag>,
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 180,
+ fixed: 'right',
+ render: (_, record) => (
+ <Space size="middle">
+ <Button
+ type="primary"
+ onClick={() => handlePinToggle(record.postid, record.is_pinned)}
+ >
+ {Boolean(record.is_pinned) ? '取消置顶' : '置顶'}
+ </Button>
+
+ <Button danger onClick={() => handleDelete(record.postid)}>
+ 删除
+ </Button>
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div style={{ padding: 20 }}>
+ <h2 style={{ marginBottom: 20 }}>帖子管理面板</h2>
+ <Space style={{ marginBottom: 16 }}>
+ <Input
+ placeholder="请输入关键词"
+ value={keyword}
+ onChange={(e) => setKeyword(e.target.value)}
+ style={{ width: 300 }}
+ />
+ <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
+ 搜索
+ </Button>
+ <Button onClick={() => { setKeyword(''); fetchPosts(); }}>
+ 重置
+ </Button>
+ </Space>
+ <Table
+ rowKey="postid"
+ columns={columns}
+ dataSource={posts}
+ loading={loading}
+ scroll={{ x: 1600 }}
+ pagination={{ pageSize: 10 }}
+ bordered
+ size="middle"
+ />
+ </div>
+ );
+};
+
+export default PostAdminPanel;
diff --git a/src/components/PostCard.jsx b/src/components/PostCard.jsx
new file mode 100644
index 0000000..5051cec
--- /dev/null
+++ b/src/components/PostCard.jsx
@@ -0,0 +1,103 @@
+import React, { useState, useEffect } from 'react';
+import {
+ likePost,
+ unlikePost,
+ togglePinPost,
+ deletePost,
+} from '../api/post';
+
+const PostCard = ({ post, onDeleted, onUpdated }) => {
+ const [likes, setLikes] = useState(post.likes);
+ const [isLiked, setIsLiked] = useState(false); // 默认未点赞
+ const [isPinned, setIsPinned] = useState(post.is_pinned || false);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ setIsPinned(post.is_pinned || false);
+ }, [post.is_pinned]);
+
+ const handleLike = async () => {
+ setLoading(true);
+ try {
+ if (!isLiked) {
+ const res = await likePost(post.postid);
+ if (res) {
+ setLikes(likes + 1);
+ setIsLiked(true);
+ }
+ } else {
+ const res = await unlikePost(post.postid);
+ if (res) {
+ setLikes(likes - 1);
+ setIsLiked(false);
+ }
+ }
+ } catch (error) {
+ console.error('点赞操作失败', error);
+ }
+ setLoading(false);
+ };
+
+ const handlePin = async () => {
+ setLoading(true);
+ try {
+ const res = await togglePinPost(post.postid);
+ if (res) {
+ onUpdated && onUpdated(); // 通知父组件刷新数据
+ }
+ } catch (error) {
+ console.error('置顶切换失败', error);
+ }
+ setLoading(false);
+ };
+
+ const handleDelete = async () => {
+ if (!window.confirm('确认删除该帖子吗?')) return;
+ setLoading(true);
+ try {
+ const res = await deletePost(post.postid);
+ if (res) {
+ onDeleted && onDeleted(post.postid);
+ }
+ } catch (error) {
+ console.error('删除失败', error);
+ }
+ setLoading(false);
+ };
+
+ return (
+ <div className="post-card" style={{ border: '1px solid #ddd', padding: 16, marginBottom: 12 }}>
+ <h3>{post.postTitle}</h3>
+ <p>{post.postContent}</p>
+ {post.photo && (
+ <img
+ src={`http://localhost:8080${post.photo}`}
+ alt="帖子图片"
+ style={{ maxWidth: '100%', maxHeight: 200 }}
+ />
+ )}
+ <p>
+ <strong>标签:</strong> {post.tags || '无'}
+ </p>
+ <p>
+ <strong>作者ID:</strong> {post.userid} | <strong>点赞数:</strong> {likes}
+ </p>
+ <p>
+ <strong>发布时间:</strong> {new Date(post.postCreatedTime).toLocaleString()}
+ </p>
+ <p>
+ <button onClick={handleLike} disabled={loading}>
+ {isLiked ? '取消点赞' : '点赞'}
+ </button>{' '}
+ <button onClick={handlePin} disabled={loading}>
+ {isPinned ? '取消置顶' : '置顶'}
+ </button>{' '}
+ <button onClick={handleDelete} disabled={loading}>
+ 删除
+ </button>
+ </p>
+ </div>
+ );
+};
+
+export default PostCard;
diff --git a/src/pages/Community.jsx b/src/pages/Community.jsx
new file mode 100644
index 0000000..38d6623
--- /dev/null
+++ b/src/pages/Community.jsx
@@ -0,0 +1,53 @@
+// src/pages/Home.jsx
+import React, { useState, useEffect } from 'react';
+import { getActivityPreviews, getFullActivities } from '../api/activity';
+import Post from '../components/Post';
+import Navbar from '../components/Navbar'; // ✅ 导入导航栏组件
+import { AppstoreOutlined } from '@ant-design/icons';
+import { Row, Col, Card, Space } from 'antd';
+import './Home.css';
+
+const Community = () => {
+ const [activityPreviews, setActivityPreviews] = useState([]);
+ const [fullActivities, setFullActivities] = useState([]);
+ const [selectedActivityId, setSelectedActivityId] = useState(null);
+
+ useEffect(() => {
+ getActivityPreviews().then(res => setActivityPreviews(res.data));
+ getFullActivities().then(res => setFullActivities(res.data));
+ }, []);
+
+ return (
+ <div style={{ minHeight: '100vh', backgroundColor: '#f0f2f5' }}>
+ {/* 导航栏 */}
+ <Navbar />
+
+ {/* 内容区域 */}
+ <div style={{ padding: 24 }}>
+ {/* 帖子区域 */}
+ <Row justify="center">
+ <Col
+ xs={24}
+ sm={24}
+ md={24}
+ lg={22}
+ xl={20}
+ xxl={18}
+ style={{ maxWidth: 1400, width: '100%' }}
+ >
+ <Card
+ title={<Space><AppstoreOutlined />最新帖子</Space>}
+ bordered={false}
+ bodyStyle={{ padding: 24 }}
+ style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.15)', borderRadius: 8 }}
+ >
+ <Post />
+ </Card>
+ </Col>
+ </Row>
+ </div>
+ </div>
+ );
+};
+
+export default Community;