好友的相关前端
Change-Id: Iebe50bff7e96fcf6c13b57c159182f54d0a38b93
diff --git a/src/api/__tests__/friends.test.js b/src/api/__tests__/friends.test.js
new file mode 100644
index 0000000..2358e04
--- /dev/null
+++ b/src/api/__tests__/friends.test.js
@@ -0,0 +1,90 @@
+const axios = require('axios');
+jest.mock('axios'); // mock axios
+
+const {
+ addFriend,
+ acceptFriend,
+ rejectFriend,
+ deleteFriend,
+ getFriendsByUserId,
+ getPendingRequests
+} = require('../friends'); // 引入你的 API 方法
+
+describe('Friends API Tests', () => {
+ beforeEach(() => {
+ jest.clearAllMocks(); // 每个测试前清除 mock 状态
+ });
+
+ test('addFriend should post friend request data', async () => {
+ const mockFriendData = {
+ friend1: 1,
+ friend2: 2
+ };
+ axios.post.mockResolvedValue({ data: true });
+
+ const response = await addFriend(mockFriendData);
+
+ expect(axios.post).toHaveBeenCalledWith(
+ 'http://localhost:8080/friends/add',
+ mockFriendData
+ );
+ expect(response.data).toBe(true);
+ });
+
+ test('acceptFriend should send accept request with params', async () => {
+ axios.post.mockResolvedValue({ data: true });
+
+ const response = await acceptFriend(1, 2);
+
+ expect(axios.post).toHaveBeenCalledWith(
+ 'http://localhost:8080/friends/accept',
+ null,
+ { params: { friend1: 1, friend2: 2 } }
+ );
+ expect(response.data).toBe(true);
+ });
+
+ test('rejectFriend should delete pending friend request', async () => {
+ axios.delete.mockResolvedValue({ data: true });
+
+ const response = await rejectFriend(1, 2);
+
+ expect(axios.delete).toHaveBeenCalledWith(
+ 'http://localhost:8080/friends/delete',
+ { params: { friend1: 1, friend2: 2 } }
+ );
+ expect(response.data).toBe(true);
+ });
+
+ test('deleteFriend should delete a friend relationship', async () => {
+ axios.delete.mockResolvedValue({ data: true });
+
+ const response = await deleteFriend(3, 4);
+
+ expect(axios.delete).toHaveBeenCalledWith(
+ 'http://localhost:8080/friends/delete',
+ { params: { friend1: 3, friend2: 4 } }
+ );
+ expect(response.data).toBe(true);
+ });
+
+ test('getFriendsByUserId should get all accepted friends for user', async () => {
+ const mockData = { data: [{ relationid: 1, friend1: 1, friend2: 2, status: 'accepted' }] };
+ axios.get.mockResolvedValue(mockData);
+
+ const response = await getFriendsByUserId(1);
+
+ expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/friends/list/1');
+ expect(response.data).toEqual(mockData.data);
+ });
+
+ test('getPendingRequests should fetch pending friend requests', async () => {
+ const mockData = { data: [{ relationid: 2, friend1: 3, friend2: 1, status: 'pending' }] };
+ axios.get.mockResolvedValue(mockData);
+
+ const response = await getPendingRequests(1);
+
+ expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/friends/pending/1');
+ expect(response.data).toEqual(mockData.data);
+ });
+});
diff --git a/src/api/friends.js b/src/api/friends.js
new file mode 100644
index 0000000..794e061
--- /dev/null
+++ b/src/api/friends.js
@@ -0,0 +1,39 @@
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:8080/friends';
+
+// 添加好友(发起好友请求)
+export const addFriend = (friendData) => {
+ return axios.post(`${BASE_URL}/add`, friendData);
+};
+
+// 同意好友申请
+export const acceptFriend = (friend1, friend2) => {
+ return axios.post(`${BASE_URL}/accept`, null, {
+ params: { friend1, friend2 }
+ });
+};
+
+// 拒绝好友申请(删除 pending 状态的好友关系)
+export const rejectFriend = (friend1, friend2) => {
+ return axios.delete(`${BASE_URL}/delete`, {
+ params: { friend1, friend2 }
+ });
+};
+
+// 删除好友
+export const deleteFriend = (friend1, friend2) => {
+ return axios.delete(`${BASE_URL}/delete`, {
+ params: { friend1, friend2 }
+ });
+};
+
+// 查询某用户所有已通过好友
+export const getFriendsByUserId = (userid) => {
+ return axios.get(`${BASE_URL}/list/${userid}`);
+};
+
+// 查询某用户收到的好友申请(pending 状态)
+export const getPendingRequests = (userid) => {
+ return axios.get(`${BASE_URL}/pending/${userid}`);
+};
diff --git a/src/components/FriendManager.css b/src/components/FriendManager.css
new file mode 100644
index 0000000..97cf2a9
--- /dev/null
+++ b/src/components/FriendManager.css
@@ -0,0 +1,155 @@
+/* FriendManager.css */
+
+.friend-manager-container {
+ max-width: 800px;
+ margin: 20px auto;
+ padding: 0 15px;
+}
+
+.friend-card {
+ border-radius: 10px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+.friend-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.header-icon {
+ font-size: 24px;
+ color: #1890ff;
+ margin-right: 12px;
+}
+
+.header-title {
+ margin: 0 !important;
+}
+
+.search-section {
+ display: flex;
+ margin-bottom: 24px;
+ gap: 12px;
+}
+
+.search-input {
+ flex: 1;
+ border-radius: 20px;
+ padding: 10px 16px;
+}
+
+.search-button {
+ border-radius: 20px;
+ padding: 0 24px;
+ height: 40px;
+}
+
+.search-results-container {
+ margin-bottom: 24px;
+ background: #fafafa;
+ border-radius: 8px;
+ padding: 16px;
+ border: 1px solid #f0f0f0;
+}
+
+.results-title {
+ margin-bottom: 16px !important;
+ color: #333;
+}
+
+.user-item {
+ padding: 12px;
+ border-radius: 8px;
+ transition: all 0.3s;
+ cursor: pointer;
+}
+
+.user-item:hover {
+ background-color: #f5f7fa;
+}
+
+.user-avatar {
+ background-color: #1890ff;
+}
+
+.add-friend-button {
+ border-radius: 16px;
+}
+
+.section-title {
+ margin-bottom: 16px !important;
+ color: #333;
+ position: relative;
+ padding-left: 10px;
+}
+
+.section-title::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 5px;
+ height: 20px;
+ width: 4px;
+ background-color: #1890ff;
+ border-radius: 2px;
+}
+
+.request-item,
+.friend-item {
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ border: 1px solid #f0f0f0;
+ transition: all 0.3s;
+}
+
+.request-item:hover,
+.friend-item:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ transform: translateY(-2px);
+ border-color: #d0e0ff;
+}
+
+.accept-button {
+ background-color: #52c41a;
+ border-color: #52c41a;
+}
+
+.reject-button {
+ background-color: #f5222d;
+ border-color: #f5222d;
+ color: white;
+}
+
+.delete-button {
+ border-radius: 16px;
+}
+
+.friend-list-section {
+ margin-top: 24px;
+}
+
+.friend-list-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.refresh-button {
+ color: #1890ff;
+}
+
+@media (max-width: 768px) {
+ .search-section {
+ flex-direction: column;
+ }
+
+ .search-button {
+ width: 100%;
+ }
+
+ .friend-card {
+ padding: 16px;
+ }
+}
\ No newline at end of file
diff --git a/src/components/FriendManager.jsx b/src/components/FriendManager.jsx
new file mode 100644
index 0000000..374ac83
--- /dev/null
+++ b/src/components/FriendManager.jsx
@@ -0,0 +1,359 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Input,
+ Button,
+ List,
+ Typography,
+ Space,
+ Spin,
+ Popconfirm,
+ message,
+ Divider,
+ Avatar,
+} from 'antd';
+import {
+ UserAddOutlined,
+ DeleteOutlined,
+ ReloadOutlined,
+ CheckOutlined,
+ CloseOutlined,
+} from '@ant-design/icons';
+import {
+ addFriend,
+ deleteFriend,
+ getFriendsByUserId,
+ getPendingRequests,
+ acceptFriend,
+ rejectFriend,
+} from '../api/friends';
+import axios from 'axios';
+
+const { Title, Text } = Typography;
+
+const FriendManager = ({ currentUser, onSelectRelation }) => {
+ const currentUserId = currentUser?.userid;
+
+ const [friendName, setFriendName] = useState('');
+ const [friends, setFriends] = useState([]);
+ const [pendingRequests, setPendingRequests] = useState([]);
+ const [userInfoMap, setUserInfoMap] = useState({});
+ const [loading, setLoading] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [pendingLoading, setPendingLoading] = useState(false);
+
+ useEffect(() => {
+ if (currentUserId) {
+ refreshData();
+ }
+ }, [currentUserId]);
+
+ const refreshData = () => {
+ loadFriends(currentUserId);
+ loadPendingRequests(currentUserId);
+ };
+
+ const fetchUserInfo = async (userId) => {
+ if (userInfoMap[userId]) return;
+ try {
+ const res = await axios.get(`http://localhost:8080/user/getDecoration?userid=${userId}`);
+ const info = res.data?.data;
+ if (info) {
+ setUserInfoMap((prev) => ({
+ ...prev,
+ [userId]: {
+ username: info.username,
+ avatar: info.image,
+ },
+ }));
+ }
+ } catch {
+ setUserInfoMap((prev) => ({
+ ...prev,
+ [userId]: {
+ username: `用户${userId}`,
+ avatar: null,
+ },
+ }));
+ }
+ };
+
+ const loadFriends = async (userId) => {
+ setRefreshing(true);
+ try {
+ const res = await getFriendsByUserId(userId);
+ const list = res.data || [];
+ setFriends(list);
+ list.forEach(f => fetchUserInfo(getFriendUserId(f)));
+ } catch {
+ message.error('加载好友失败,请稍后重试');
+ }
+ setRefreshing(false);
+ };
+
+ const loadPendingRequests = async (userId) => {
+ setPendingLoading(true);
+ try {
+ const res = await getPendingRequests(userId);
+ const list = res.data || [];
+ setPendingRequests(list);
+ list.forEach(req => {
+ const otherId = req.friend1 === currentUserId ? req.friend2 : req.friend1;
+ fetchUserInfo(otherId);
+ });
+ } catch {
+ message.error('加载好友申请失败');
+ }
+ setPendingLoading(false);
+ };
+
+ const handleAddFriend = async () => {
+ if (!friendName.trim()) return message.warning('请输入好友用户名');
+
+ setLoading(true);
+ try {
+ const res = await axios.get(`http://localhost:8080/user/getUserid?username=${friendName.trim()}`);
+ const newFriendId = res.data?.data;
+ if (!newFriendId) {
+ message.error('未找到该用户名对应的用户');
+ setLoading(false);
+ return;
+ }
+
+ if (newFriendId === currentUserId) {
+ message.warning('不能添加自己为好友');
+ setLoading(false);
+ return;
+ }
+
+ const isAlreadyFriend = friends.some(f =>
+ (f.friend1 === currentUserId && f.friend2 === newFriendId) ||
+ (f.friend1 === newFriendId && f.friend2 === currentUserId)
+ );
+ if (isAlreadyFriend) {
+ message.warning('该用户已是您的好友');
+ setLoading(false);
+ return;
+ }
+
+ const result = await addFriend({ friend1: currentUserId, friend2: newFriendId });
+ if (result.data) {
+ message.success('好友请求已发送');
+ setFriendName('');
+ loadPendingRequests(currentUserId);
+ } else {
+ message.error('添加失败');
+ }
+ } catch {
+ message.error('添加好友失败,请稍后重试');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async (friend1, friend2) => {
+ setLoading(true);
+ try {
+ const res = await deleteFriend(friend1, friend2);
+ if (res.data) {
+ message.success('删除成功');
+ loadFriends(currentUserId);
+ } else {
+ message.error('删除失败');
+ }
+ } catch {
+ message.error('删除好友失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAccept = async (friend1, friend2) => {
+ setPendingLoading(true);
+ try {
+ const res = await acceptFriend(friend1, friend2);
+ if (res.data) {
+ message.success('已同意好友请求');
+ refreshData();
+ } else {
+ message.error('操作失败');
+ }
+ } catch {
+ message.error('同意失败');
+ } finally {
+ setPendingLoading(false);
+ }
+ };
+
+ const handleReject = async (friend1, friend2) => {
+ setPendingLoading(true);
+ try {
+ const res = await rejectFriend(friend1, friend2);
+ if (res.data) {
+ message.info('已拒绝好友请求');
+ loadPendingRequests(currentUserId);
+ } else {
+ message.error('操作失败');
+ }
+ } catch {
+ message.error('拒绝失败');
+ } finally {
+ setPendingLoading(false);
+ }
+ };
+
+ const getFriendUserId = (f) => f.friend1 === currentUserId ? f.friend2 : f.friend1;
+
+ const renderUserMeta = (userId, timeLabel) => {
+ const user = userInfoMap[userId] || {};
+ return {
+ avatar: <Avatar src={user.avatar} />,
+ title: user.username ? `${user.username}(ID: ${userId})` : `用户ID:${userId}`,
+ description: timeLabel,
+ };
+ };
+
+ return (
+ <div style={{ maxWidth: 700, margin: 'auto', padding: 24 }}>
+ <Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
+ 好友管理
+ </Title>
+
+ <Space style={{ marginBottom: 24 }} align="start">
+ <Input
+ placeholder="输入好友用户名"
+ value={friendName}
+ onChange={(e) => setFriendName(e.target.value)}
+ style={{ width: 220 }}
+ allowClear
+ prefix={<UserAddOutlined />}
+ />
+ <Button
+ type="primary"
+ loading={loading}
+ onClick={handleAddFriend}
+ disabled={!friendName.trim()}
+ >
+ 添加好友
+ </Button>
+ </Space>
+
+ <Divider />
+
+ <Title level={4}>好友申请</Title>
+ <Spin spinning={pendingLoading}>
+ {pendingRequests.length === 0 ? (
+ <Text type="secondary">暂无好友申请</Text>
+ ) : (
+ <List
+ itemLayout="horizontal"
+ dataSource={pendingRequests}
+ renderItem={(item) => {
+ const otherId = item.friend1 === currentUserId ? item.friend2 : item.friend1;
+ return (
+ <List.Item
+ actions={[
+ <Button
+ key="accept"
+ type="primary"
+ icon={<CheckOutlined />}
+ onClick={() => handleAccept(item.friend1, item.friend2)}
+ loading={pendingLoading}
+ size="small"
+ >
+ 同意
+ </Button>,
+ <Popconfirm
+ key="reject"
+ title="确定拒绝该好友请求?"
+ onConfirm={() => handleReject(item.friend1, item.friend2)}
+ okText="确认"
+ cancelText="取消"
+ >
+ <Button
+ danger
+ icon={<CloseOutlined />}
+ loading={pendingLoading}
+ size="small"
+ >
+ 拒绝
+ </Button>
+ </Popconfirm>,
+ ]}
+ >
+ <List.Item.Meta {...renderUserMeta(otherId, `申请时间:${new Date(item.requestTime).toLocaleString()}`)} />
+ </List.Item>
+ );
+ }}
+ />
+ )}
+ </Spin>
+
+ <Divider />
+
+ <Space align="center" style={{ marginBottom: 12, justifyContent: 'space-between', width: '100%' }}>
+ <Title level={4} style={{ margin: 0 }}>
+ 我的好友列表
+ </Title>
+ <Button
+ icon={<ReloadOutlined />}
+ onClick={() => refreshData()}
+ loading={refreshing || pendingLoading}
+ type="link"
+ >
+ 刷新
+ </Button>
+ </Space>
+ <Spin spinning={refreshing}>
+ {friends.length === 0 ? (
+ <Text type="secondary">暂无好友</Text>
+ ) : (
+ <List
+ itemLayout="horizontal"
+ dataSource={friends}
+ renderItem={(f) => {
+ const friendUserId = getFriendUserId(f);
+ return (
+ <List.Item
+ onClick={() =>
+ onSelectRelation({
+ relationid: f.relationid,
+ friendId: friendUserId,
+ })
+ }
+ style={{ cursor: 'pointer' }}
+ actions={[
+ <Popconfirm
+ title="确定删除该好友?"
+ onConfirm={(e) => {
+ e.stopPropagation();
+ handleDelete(f.friend1, f.friend2);
+ }}
+ okText="确认"
+ cancelText="取消"
+ key="delete"
+ >
+ <Button
+ danger
+ icon={<DeleteOutlined />}
+ loading={loading}
+ size="small"
+ >
+ 删除
+ </Button>
+ </Popconfirm>,
+ ]}
+ >
+ <List.Item.Meta
+ {...renderUserMeta(friendUserId, `添加时间:${new Date(f.requestTime).toLocaleString()}`)}
+ />
+ </List.Item>
+ );
+ }}
+ />
+ )}
+ </Spin>
+ </div>
+ );
+};
+
+export default FriendManager;
diff --git a/src/pages/Friend.css b/src/pages/Friend.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/pages/Friend.css
diff --git a/src/pages/Friend.jsx b/src/pages/Friend.jsx
new file mode 100644
index 0000000..fbb384d
--- /dev/null
+++ b/src/pages/Friend.jsx
@@ -0,0 +1,128 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { getActivityPreviews, getFullActivities } from '../api/activity';
+import FriendManager from '../components/FriendManager';
+import ChatBox from '../components/ChatBox';
+import Navbar from '../components/Navbar';
+import { TeamOutlined, MessageOutlined } from '@ant-design/icons';
+import { Layout, Row, Col, Typography, Empty, Spin } from 'antd';
+import './Friend.css';
+
+const { Title, Text } = Typography;
+const { Content } = Layout;
+
+const Friend = () => {
+ const navigate = useNavigate();
+
+ // 使用 localStorage 获取当前登录用户
+ const [currentUser, setCurrentUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ // 从 localStorage 获取用户信息
+ const userData = localStorage.getItem('user');
+ if (userData) {
+ try {
+ const user = JSON.parse(userData);
+ setCurrentUser(user);
+ } catch (e) {
+ console.error('Failed to parse user data');
+ navigate('/login');
+ } finally {
+ setLoading(false);
+ }
+ } else {
+ // 未登录则重定向到登录页
+ navigate('/login');
+ }
+ }, [navigate]);
+
+ const [selectedRelation, setSelectedRelation] = useState(null);
+ const [activityPreviews, setActivityPreviews] = useState([]);
+ const [fullActivities, setFullActivities] = useState([]);
+ const [selectedActivityId, setSelectedActivityId] = useState(null);
+
+ useEffect(() => {
+ if (currentUser) {
+ getActivityPreviews().then(res => setActivityPreviews(res.data));
+ getFullActivities().then(res => setFullActivities(res.data));
+ }
+ }, [currentUser]);
+
+ if (loading) {
+ return (
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
+ <Spin size="large" />
+ </div>
+ );
+ }
+
+ return (
+ <Layout style={{ minHeight: '100vh', background: '#f0f2f5', padding: '24px' }}>
+ <Content>
+ <Navbar />
+ <Row justify="space-between" align="middle" style={{ marginBottom: 24 }} />
+
+ <Row gutter={24} style={{ height: 'calc(100vh - 140px)' }}>
+ {/* 好友管理 - 左侧 */}
+ <Col xs={24} sm={24} md={10} lg={8} xl={6} style={{ background: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 2px 8px rgba(0,0,0,0.1)', overflowY: 'auto' }}>
+ <Title level={4} style={{ color: '#722ed1', marginBottom: 24 }}>
+ <TeamOutlined style={{ marginRight: 8 }} />
+ 好友管理
+ </Title>
+ <FriendManager
+ currentUser={currentUser} // 传递真实登录用户
+ onSelectRelation={setSelectedRelation}
+ />
+ </Col>
+
+ {/* 聊天窗口 - 右侧 */}
+ <Col
+ xs={24}
+ sm={24}
+ md={14}
+ lg={16}
+ xl={18}
+ style={{
+ background: '#fff',
+ borderRadius: 8,
+ padding: 24,
+ boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
+ display: 'flex',
+ flexDirection: 'column',
+ }}
+ >
+ <Title level={4} style={{ color: '#eb2f96', marginBottom: 24 }}>
+ <MessageOutlined style={{ marginRight: 8 }} />
+ 聊天窗口
+ </Title>
+
+ <div style={{ flex: 1, minHeight: 0 }}>
+ {selectedRelation ? (
+ <div style={{ height: 'calc(100vh - 220px)' }}>
+ <ChatBox
+ senderId={currentUser.userid} // 使用真实用户ID
+ receiverId={selectedRelation.friendId}
+ />
+ </div>
+ ) : (
+ <Empty
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description={<Text type="secondary">请选择一位好友开始聊天</Text>}
+ style={{
+ height: '100%',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ }}
+ />
+ )}
+ </div>
+ </Col>
+ </Row>
+ </Content>
+ </Layout>
+ );
+};
+
+export default Friend;
\ No newline at end of file