Merge remote-tracking branch 'origin/lev' into lev

This commit is contained in:
Андрей Дувакин 2025-11-29 21:36:19 +05:00
commit eed6d1f54e
2 changed files with 888 additions and 0 deletions

View File

@ -0,0 +1,607 @@
import React, { useRef, useEffect } from "react";
import {
Card,
Input,
Avatar,
Badge,
Typography,
Space,
Button,
Tag,
Empty,
Upload,
Drawer,
Row,
Col,
Collapse,
List,
Select,
Divider,
} from "antd";
import {
SendOutlined,
PaperClipOutlined,
SearchOutlined,
TeamOutlined,
UserOutlined,
MessageOutlined,
InfoCircleOutlined,
FileOutlined,
FilterOutlined,
} from "@ant-design/icons";
import useChatPage from "./useChatPage.js";
const { TextArea } = Input;
const { Title, Text } = Typography;
const ChatPage = () => {
const {
selectedChat,
messages,
messageText,
setMessageText,
searchQuery,
setSearchQuery,
selectedCourse,
setSelectedCourse,
selectedGroup,
setSelectedGroup,
selectedTopic,
setSelectedTopic,
courses,
groups,
topics,
currentUser,
handleSelectChat,
handleSendMessage,
handleFileUpload,
filteredChatList,
infoDrawerVisible,
setInfoDrawerVisible,
chatParticipants,
} = useChatPage();
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div style={{ padding: 24, backgroundColor: "#ffffff", minHeight: "100vh" }}>
{/* Заголовок */}
<div style={{ marginBottom: 24 }}>
<Title level={2} style={{ margin: 0, color: "#262626" }}>
<MessageOutlined style={{ marginRight: 12 }} />
Мессенджер
</Title>
</div>
<Row gutter={0}>
{/* Левая колонка — список чатов */}
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Card
style={{
height: "calc(100vh - 180px)",
borderRadius: 12,
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
styles={{
body: {
padding: 0,
height: "100%",
display: "flex",
flexDirection: "column"
}
}}
>
{/* Поиск и фильтры */}
<div style={{ padding: "16px 20px", borderBottom: "1px solid #f0f0f0", flexShrink: 0 }}>
<Input
placeholder="Поиск чатов..."
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
allowClear
size="large"
style={{ marginBottom: 12 }}
/>
<Collapse
bordered={false}
ghost
items={[
{
key: "1",
label: (
<Space size={6}>
<FilterOutlined />
<Text strong>Фильтры</Text>
{(selectedCourse || selectedGroup || selectedTopic) && (
<Badge count="•" style={{ backgroundColor: "#1890ff" }} />
)}
</Space>
),
children: (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Select
placeholder="Курс"
value={selectedCourse}
onChange={setSelectedCourse}
allowClear
style={{ width: "100%" }}
>
{courses.map((c) => (
<Select.Option key={c.id} value={c.id}>
{c.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Группа"
value={selectedGroup}
onChange={setSelectedGroup}
allowClear
style={{ width: "100%" }}
>
{groups.map((g) => (
<Select.Option key={g.id} value={g.id}>
{g.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Тема"
value={selectedTopic}
onChange={setSelectedTopic}
allowClear
style={{ width: "100%" }}
>
{topics.map((t) => (
<Select.Option key={t.id} value={t.id}>
{t.name}
</Select.Option>
))}
</Select>
{(selectedCourse || selectedGroup || selectedTopic) && (
<Button
type="link"
size="small"
onClick={() => {
setSelectedCourse(null);
setSelectedGroup(null);
setSelectedTopic(null);
}}
>
Сбросить фильтры
</Button>
)}
</Space>
),
}
]}
/>
</div>
{/* Список чатов */}
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
{filteredChatList.length === 0 ? (
<Empty
description="Чаты не найдены"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ marginTop: 60 }}
/>
) : (
filteredChatList.map((chat) => (
<div
key={chat.id}
onClick={() => handleSelectChat(chat.id)}
style={{
padding: "14px 20px",
borderBottom: "1px solid #f0f0f0",
background: selectedChat?.id === chat.id ? "#e6f7ff" : "transparent",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
if (selectedChat?.id !== chat.id) e.currentTarget.style.background = "#f9f9f9";
}}
onMouseLeave={(e) => {
if (selectedChat?.id !== chat.id) e.currentTarget.style.background = "transparent";
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<Badge count={chat.unreadCount} size="small">
<Avatar
size={44}
icon={chat.type === "group" ? <TeamOutlined /> : <UserOutlined />}
style={{
backgroundColor: chat.type === "group" ? "#52c41a" : "#1890ff",
flexShrink: 0
}}
/>
</Badge>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: 500,
fontSize: 15,
marginBottom: 4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}>
{chat.name}
</div>
<div
style={{
fontSize: 13,
color: "#8c8c8c",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginBottom: 6
}}
>
{chat.lastMessage || "Нет сообщений"}
</div>
<Space size={4} wrap style={{ marginTop: 6 }}>
{chat.course && <Tag color="blue" style={{ fontSize: 11, margin: 0 }}>{chat.course}</Tag>}
{chat.group && <Tag color="green" style={{ fontSize: 11, margin: 0 }}>{chat.group}</Tag>}
{chat.topic && <Tag color="orange" style={{ fontSize: 11, margin: 0 }}>{chat.topic}</Tag>}
</Space>
</div>
<div style={{
fontSize: 11,
color: "#8c8c8c",
flexShrink: 0,
marginLeft: 8
}}>
{new Date(chat.lastActivity).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
</div>
))
)}
</div>
</Card>
</Col>
{/* Правая колонка — чат */}
<Col xs={24} sm={24} md={16} lg={16} xl={16}>
{selectedChat ? (
<Card
style={{
height: "calc(100vh - 180px)",
borderRadius: 12,
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
styles={{
body: {
padding: 0,
height: "100%",
display: "flex",
flexDirection: "column"
}
}}
>
{/* Шапка чата */}
<div style={{
padding: "16px 24px",
borderBottom: "1px solid #f0f0f0",
flexShrink: 0
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 16,
flexWrap: "wrap"
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0, flex: 1 }}>
<Avatar
size={48}
icon={selectedChat.type === "group" ? <TeamOutlined /> : <UserOutlined />}
style={{
backgroundColor: selectedChat.type === "group" ? "#52c41a" : "#1890ff",
flexShrink: 0
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<Title level={4} style={{
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}>
{selectedChat.name}
</Title>
<Space size={6} wrap style={{ marginTop: 4 }}>
{selectedChat.course && <Tag color="blue" style={{ fontSize: 11 }}>{selectedChat.course}</Tag>}
{selectedChat.group && <Tag color="green" style={{ fontSize: 11 }}>{selectedChat.group}</Tag>}
{selectedChat.topic && <Tag color="orange" style={{ fontSize: 11 }}>{selectedChat.topic}</Tag>}
</Space>
</div>
</div>
<Button
icon={<InfoCircleOutlined />}
onClick={() => setInfoDrawerVisible(true)}
style={{ flexShrink: 0 }}
>
Инфо
</Button>
</div>
</div>
{/* Сообщения */}
<div style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
padding: "24px",
background: "#f8f9fa"
}}>
{messages.length === 0 ? (
<Empty
description="Нет сообщений"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
messages.map((msg) => {
const isOwn = msg.senderId === currentUser.id;
return (
<div
key={msg.id}
style={{
marginBottom: 20,
display: "flex",
justifyContent: isOwn ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "70%",
display: "flex",
gap: 12,
flexDirection: isOwn ? "row-reverse" : "row",
}}
>
{!isOwn && (
<Avatar
style={{
backgroundColor: "#1890ff",
flexShrink: 0
}}
>
{msg.senderName[0].toUpperCase()}
</Avatar>
)}
<div style={{ minWidth: 0 }}>
{!isOwn && (
<Text
strong
style={{
fontSize: 13,
display: "block",
marginBottom: 4
}}
>
{msg.senderName}
</Text>
)}
<div
style={{
background: isOwn ? "#1890ff" : "#fff",
color: isOwn ? "#fff" : "#000",
padding: "10px 16px",
borderRadius: 18,
boxShadow: "0 1px 3px rgba(0,0,0,0.12)",
border: isOwn ? "none" : "1px solid #f0f0f0",
wordBreak: "break-word",
}}
>
{msg.text}
{msg.attachment && (
<div style={{ marginTop: 8 }}>
<Tag
icon={<FileOutlined />}
color={isOwn ? "default" : "blue"}
style={{
background: isOwn ? "rgba(255,255,255,0.2)" : undefined,
borderColor: isOwn ? "rgba(255,255,255,0.3)" : undefined,
color: isOwn ? "#fff" : undefined
}}
>
{msg.attachment}
</Tag>
</div>
)}
</div>
<Text
type="secondary"
style={{
fontSize: 11,
marginTop: 4,
display: "block",
textAlign: isOwn ? "right" : "left"
}}
>
{msg.timestamp}
</Text>
</div>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Поле ввода */}
<div style={{
padding: "16px 24px",
borderTop: "1px solid #f0f0f0",
flexShrink: 0
}}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<Upload
beforeUpload={handleFileUpload}
showUploadList={false}
>
<Button
icon={<PaperClipOutlined />}
size="large"
style={{ flexShrink: 0 }}
/>
</Upload>
<TextArea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="Напишите сообщение..."
autoSize={{ minRows: 1, maxRows: 5 }}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
style={{
resize: "none",
flex: 1
}}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSendMessage}
disabled={!messageText.trim()}
size="large"
style={{ flexShrink: 0 }}
>
Отправить
</Button>
</div>
<Text
type="secondary"
style={{
fontSize: 12,
marginTop: 8,
display: "block"
}}
>
Enter отправить Shift+Enter новая строка
</Text>
</div>
</Card>
) : (
<Card
style={{
height: "calc(100vh - 180px)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fafafa",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<Empty
description="Выберите чат, чтобы начать общение"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
)}
</Col>
</Row>
{/* Drawer — информация о чате */}
<Drawer
title="Информация о чате"
placement="right"
open={infoDrawerVisible}
onClose={() => setInfoDrawerVisible(false)}
width={400}
>
{selectedChat && (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<div>
<Text type="secondary">Название</Text>
<Title level={5} style={{ margin: "4px 0 0" }}>
{selectedChat.name}
</Title>
</div>
<div>
<Text type="secondary">Тип</Text>
<br />
<Tag
color={selectedChat.type === "group" ? "green" : "blue"}
style={{ marginTop: 4 }}
>
{selectedChat.type === "group" ? "Групповой" : "Личный"}
</Tag>
</div>
{selectedChat.course && (
<div>
<Text type="secondary">Курс</Text>
<br />
<Tag color="blue" style={{ marginTop: 4 }}>
{selectedChat.course}
</Tag>
</div>
)}
{selectedChat.group && (
<div>
<Text type="secondary">Группа</Text>
<br />
<Tag color="green" style={{ marginTop: 4 }}>
{selectedChat.group}
</Tag>
</div>
)}
{selectedChat.topic && (
<div>
<Text type="secondary">Тема</Text>
<br />
<Tag color="orange" style={{ marginTop: 4 }}>
{selectedChat.topic}
</Tag>
</div>
)}
<Divider />
<div>
<Text strong>Участники ({chatParticipants.length})</Text>
<List
style={{ marginTop: 12 }}
dataSource={chatParticipants}
renderItem={(p) => (
<List.Item style={{ padding: "12px 0" }}>
<List.Item.Meta
avatar={
<Avatar style={{ backgroundColor: "#1890ff" }}>
{p.name[0]}
</Avatar>
}
title={p.name}
description={
<Tag color={p.role === "teacher" ? "purple" : "default"}>
{p.role === "teacher" ? "Преподаватель" : "Студент"}
</Tag>
}
/>
</List.Item>
)}
/>
</div>
</Space>
)}
</Drawer>
</div>
);
};
export default ChatPage;

View File

@ -0,0 +1,281 @@
import { useState, useMemo, useEffect } from "react";
import { message } from "antd";
const useChatPage = () => {
// Состояния
const [selectedChat, setSelectedChat] = useState(null);
const [messageText, setMessageText] = useState("");
const [selectedCourse, setSelectedCourse] = useState(null);
const [selectedGroup, setSelectedGroup] = useState(null);
const [selectedTopic, setSelectedTopic] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [infoDrawerVisible, setInfoDrawerVisible] = useState(false);
// Текущий пользователь (заглушка)
const currentUser = {
id: 1,
name: "Иван Иванов",
role: "teacher"
};
// Заглушка - курсы
const courses = [
{id: 1, name: "Основы программирования"},
{id: 2, name: "Веб-разработка"},
{id: 3, name: "Базы данных"},
{id: 4, name: "Алгоритмы и структуры данных"},
];
// Заглушка - группы
const groups = [
{id: 1, name: "ПИ-201"},
{id: 2, name: "ПИ-202"},
{id: 3, name: "ПИ-203"},
{id: 4, name: "ПИ-301"},
];
// Заглушка - темы
const topics = [
{id: 1, name: "Домашние задания"},
{id: 2, name: "Лекции"},
{id: 3, name: "Проекты"},
{id: 4, name: "Вопросы по курсу"},
{id: 5, name: "Организационные вопросы"},
];
// Заглушка - список чатов
const chatList = [
{
id: 1,
name: "ПИ-201 - Основы программирования",
type: "group",
course: "Основы программирования",
courseId: 1,
group: "ПИ-201",
groupId: 1,
topic: "Домашние задания",
topicId: 1,
lastMessage: "Не забудьте сдать ДЗ до конца недели",
unreadCount: 3,
lastActivity: new Date().toISOString()
},
{
id: 2,
name: "Веб-разработка - Вопросы по курсу",
type: "group",
course: "Веб-разработка",
courseId: 2,
group: "ПИ-202",
groupId: 2,
topic: "Вопросы по курсу",
topicId: 4,
lastMessage: "Как правильно настроить webpack?",
unreadCount: 0,
lastActivity: new Date().toISOString()
},
{
id: 3,
name: "Мария Петрова",
type: "personal",
course: null,
courseId: null,
group: null,
groupId: null,
topic: null,
topicId: null,
lastMessage: "Здравствуйте, у меня вопрос по заданию...",
unreadCount: 1,
lastActivity: new Date().toISOString()
},
{
id: 4,
name: "ПИ-203 - Базы данных",
type: "group",
course: "Базы данных",
courseId: 3,
group: "ПИ-203",
groupId: 3,
topic: "Лекции",
topicId: 2,
lastMessage: "Материалы по нормализации загружены",
unreadCount: 0,
lastActivity: new Date().toISOString()
},
{
id: 5,
name: "Проектная работа - ПИ-201",
type: "group",
course: "Веб-разработка",
courseId: 2,
group: "ПИ-201",
groupId: 1,
topic: "Проекты",
topicId: 3,
lastMessage: "Когда защита проектов?",
unreadCount: 5,
lastActivity: new Date().toISOString()
},
];
// Заглушка - сообщения выбранного чата
const [messages, setMessages] = useState([
{
id: 1,
senderId: 2,
senderName: "Мария Петрова",
text: "Здравствуйте! У меня вопрос по второму домашнему заданию.",
timestamp: "10:30",
attachment: null
},
{
id: 2,
senderId: 1,
senderName: "Иван Иванов",
text: "Здравствуйте, Мария! Конечно, задавайте вопрос.",
timestamp: "10:32",
attachment: null
},
{
id: 3,
senderId: 2,
senderName: "Мария Петрова",
text: "Не могу понять, как правильно реализовать рекурсию в третьей задаче.",
timestamp: "10:33",
attachment: null
},
{
id: 4,
senderId: 1,
senderName: "Иван Иванов",
text: "Давайте разберем по шагам. Сначала определите базовый случай, когда рекурсия должна остановиться.",
timestamp: "10:35",
attachment: null
},
{
id: 5,
senderId: 2,
senderName: "Мария Петрова",
text: "Вот мой код, подскажите, что не так?",
timestamp: "10:37",
attachment: "code.py"
},
]);
// Заглушка - участники чата
const chatParticipants = [
{
id: 1,
name: "Иван Иванов",
role: "teacher"
},
{
id: 2,
name: "Мария Петрова",
role: "student"
},
{
id: 3,
name: "Алексей Сидоров",
role: "student"
},
{
id: 4,
name: "Екатерина Смирнова",
role: "student"
},
];
// Фильтрация списка чатов
const filteredChatList = useMemo(() => {
return chatList.filter(chat => {
if (searchQuery && !chat.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
if (selectedCourse && chat.courseId !== selectedCourse) {
return false;
}
if (selectedGroup && chat.groupId !== selectedGroup) {
return false;
}
if (selectedTopic && chat.topicId !== selectedTopic) {
return false;
}
return true;
});
}, [searchQuery, selectedCourse, selectedGroup, selectedTopic, chatList]);
// Выбор чата
const handleSelectChat = (chatId) => {
const chat = chatList.find(c => c.id === chatId);
setSelectedChat(chat);
};
// Автоматический выбор первого чата при загрузке
useEffect(() => {
if (chatList.length > 0 && !selectedChat) {
handleSelectChat(chatList[0].id);
}
}, [chatList, selectedChat]);
// Отправка сообщения
const handleSendMessage = () => {
if (!messageText.trim()) return;
const newMessage = {
id: messages.length + 1,
senderId: currentUser.id,
senderName: currentUser.name,
text: messageText,
timestamp: new Date().toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'}),
attachment: null
};
setMessages([...messages, newMessage]);
setMessageText("");
message.success("Сообщение отправлено");
};
// Загрузка файла
const handleFileUpload = (file) => {
const newMessage = {
id: messages.length + 1,
senderId: currentUser.id,
senderName: currentUser.name,
text: "Отправлен файл:",
timestamp: new Date().toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'}),
attachment: file.name
};
setMessages([...messages, newMessage]);
message.success(`Файл ${file.name} отправлен`);
};
return {
chatList,
selectedChat,
messages,
messageText,
setMessageText,
selectedCourse,
setSelectedCourse,
selectedGroup,
setSelectedGroup,
selectedTopic,
setSelectedTopic,
searchQuery,
setSearchQuery,
courses,
groups,
topics,
currentUser,
handleSelectChat,
handleSendMessage,
handleFileUpload,
filteredChatList,
infoDrawerVisible,
setInfoDrawerVisible,
chatParticipants
};
};
export default useChatPage;