好友的相关前端

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