1
0
mirror of https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git synced 2025-08-14 00:25:46 +02:00

Создание чата

This commit is contained in:
MoonTestUse1
2025-01-05 05:43:45 +06:00
parent 357acd11a1
commit ba0b0ec72b
16 changed files with 1619 additions and 29 deletions

View File

@@ -0,0 +1,141 @@
from fastapi import APIRouter, Depends, HTTPException, WebSocket, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
import os
import aiofiles
import uuid
from app.database import get_db
from app.models.user import User
from app.core.auth import get_current_user
from app.schemas.chat import Chat, Message, ChatFile
from app.websockets.chat import handle_chat_connection
router = APIRouter()
# Путь для сохранения файлов
UPLOAD_DIR = "uploads/chat_files"
os.makedirs(UPLOAD_DIR, exist_ok=True)
@router.websocket("/ws/chat")
async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
await handle_chat_connection(websocket, db)
@router.post("/files/")
async def upload_file(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
try:
# Генерируем уникальное имя файла
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(UPLOAD_DIR, unique_filename)
# Сохраняем файл
async with aiofiles.open(file_path, 'wb') as out_file:
content = await file.read()
await out_file.write(content)
return {
"filename": file.filename,
"saved_path": file_path,
"size": len(content)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/messages/", response_model=List[Message])
def get_messages(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Получаем чат пользователя
chat = db.query(Chat).filter(
(Chat.employee_id == current_user.id) |
(Chat.admin_id == current_user.id)
).first()
if not chat:
return []
# Получаем сообщения
messages = db.query(Message).filter(Message.chat_id == chat.id).all()
return messages
@router.get("/unread-count/")
def get_unread_count(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Получаем чат пользователя
chat = db.query(Chat).filter(
(Chat.employee_id == current_user.id) |
(Chat.admin_id == current_user.id)
).first()
if not chat:
return {"unread_count": 0}
# Считаем непрочитанные сообщения
unread_count = db.query(Message).filter(
Message.chat_id == chat.id,
Message.sender_id != current_user.id,
Message.is_read == False
).count()
return {"unread_count": unread_count}
@router.get("/admin/chats/", response_model=List[Chat])
def get_admin_chats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized")
# Получаем все чаты с последними сообщениями и количеством непрочитанных
chats = db.query(Chat).all()
# Для каждого чата добавляем дополнительную информацию
for chat in chats:
# Последнее сообщение
last_message = db.query(Message)\
.filter(Message.chat_id == chat.id)\
.order_by(Message.created_at.desc())\
.first()
chat.last_message = last_message
# Количество непрочитанных сообщений
unread_count = db.query(Message)\
.filter(
Message.chat_id == chat.id,
Message.sender_id != current_user.id,
Message.is_read == False
).count()
chat.unread_count = unread_count
return chats
@router.get("/messages/{chat_id}/", response_model=List[Message])
def get_chat_messages(
chat_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Проверяем доступ к чату
chat = db.query(Chat).filter(Chat.id == chat_id).first()
if not chat:
raise HTTPException(status_code=404, detail="Chat not found")
if not current_user.is_admin and chat.employee_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
# Получаем сообщения чата
messages = db.query(Message)\
.filter(Message.chat_id == chat_id)\
.order_by(Message.created_at.asc())\
.all()
return messages

57
backend/app/core/auth.py Normal file
View File

@@ -0,0 +1,57 @@
from fastapi import Depends, HTTPException, status, WebSocket
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from typing import Optional
from app.core.config import settings
from app.database import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_exception
return user
async def get_current_user_ws(websocket: WebSocket, db: Session = Depends(get_db)) -> Optional[User]:
try:
# Получаем токен из параметров запроса
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return None
# Проверяем токен
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
email: str = payload.get("sub")
if email is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return None
# Получаем пользователя
user = db.query(User).filter(User.email == email).first()
if user is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return None
return user
except JWTError:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return None

View File

@@ -0,0 +1,19 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from app.tasks.cleanup import cleanup_old_messages
scheduler = AsyncIOScheduler()
def setup_scheduler():
"""Настраивает планировщик задач"""
# Запускаем очистку старых сообщений каждый день в полночь
scheduler.add_job(
cleanup_old_messages,
trigger=CronTrigger(hour=0, minute=0),
id='cleanup_old_messages',
name='Cleanup old messages and files',
replace_existing=True
)
scheduler.start()

View File

@@ -1,40 +1,38 @@
"""Main application module""" """Main application module"""
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from . import models from app.api.api import api_router
from .routers import admin, employees, requests, auth, statistics from app.core.config import settings
from app.core.scheduler import setup_scheduler
from app.websockets.chat import handle_chat_connection
app = FastAPI( app = FastAPI(
# Включаем автоматическое перенаправление со слэшем title=settings.project_name,
redirect_slashes=True, openapi_url=f"{settings.api_v1_str}/openapi.json"
# Добавляем описание API
title="Support System API",
description="API для системы поддержки",
version="1.0.0"
) )
# CORS configuration # Настройка CORS
origins = [
"http://localhost",
"http://localhost:8080",
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://127.0.0.1:8080",
"http://185.139.70.62", # Добавляем ваш production домен
]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["*"]
) )
# Include routers # Подключаем роутеры
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(api_router, prefix=settings.api_v1_str)
app.include_router(employees.router, prefix="/api/employees", tags=["employees"])
app.include_router(requests.router, prefix="/api/requests", tags=["requests"]) # WebSocket для чата
app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.add_api_websocket_route("/ws/chat", handle_chat_connection)
app.include_router(statistics.router, prefix="/api/statistics", tags=["statistics"])
@app.on_event("startup")
async def startup_event():
"""Действия при запуске приложения"""
setup_scheduler()
@app.on_event("shutdown")
async def shutdown_event():
"""Действия при остановке приложения"""
from app.core.scheduler import scheduler
scheduler.shutdown()

View File

@@ -0,0 +1,46 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.database import Base
class Chat(Base):
__tablename__ = "chats"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
admin_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Отношения
employee = relationship("User", foreign_keys=[employee_id], back_populates="employee_chats")
admin = relationship("User", foreign_keys=[admin_id], back_populates="admin_chats")
messages = relationship("Message", back_populates="chat", cascade="all, delete-orphan")
class Message(Base):
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
chat_id = Column(Integer, ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
is_read = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Отношения
chat = relationship("Chat", back_populates="messages")
sender = relationship("User", back_populates="sent_messages")
files = relationship("ChatFile", back_populates="message", cascade="all, delete-orphan")
class ChatFile(Base):
__tablename__ = "chat_files"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("messages.id", ondelete="CASCADE"), nullable=False)
file_name = Column(String(255), nullable=False)
file_path = Column(String(255), nullable=False)
file_size = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Отношения
message = relationship("Message", back_populates="files")

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True)
full_name = Column(String)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
# Отношения для чата
employee_chats = relationship("Chat", foreign_keys="[Chat.employee_id]", back_populates="employee")
admin_chats = relationship("Chat", foreign_keys="[Chat.admin_id]", back_populates="admin")
sent_messages = relationship("Message", back_populates="sender")

View File

@@ -0,0 +1,73 @@
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
from app.schemas.user import User
class ChatFileBase(BaseModel):
file_name: str
file_size: int
class ChatFileCreate(ChatFileBase):
pass
class ChatFile(ChatFileBase):
id: int
message_id: int
file_path: str
created_at: datetime
class Config:
from_attributes = True
class MessageBase(BaseModel):
content: str
class MessageCreate(MessageBase):
pass
class Message(MessageBase):
id: int
chat_id: int
sender_id: int
is_read: bool
created_at: datetime
files: List[ChatFile] = []
class Config:
from_attributes = True
class ChatBase(BaseModel):
employee_id: int
admin_id: int
class ChatCreate(ChatBase):
pass
class Chat(ChatBase):
id: int
created_at: datetime
employee: User
admin: User
last_message: Optional[Message] = None
unread_count: Optional[int] = 0
class Config:
from_attributes = True
# Схемы для WebSocket сообщений
class WSMessage(BaseModel):
type: str
content: Optional[str] = None
message_ids: Optional[List[int]] = None
files: Optional[List[dict]] = None
class WSResponse(BaseModel):
type: str
id: Optional[int] = None
sender_id: Optional[int] = None
content: Optional[str] = None
created_at: Optional[datetime] = None
is_read: Optional[bool] = None
message_ids: Optional[List[int]] = None
files: Optional[List[ChatFile]] = None
error: Optional[str] = None

View File

@@ -0,0 +1,38 @@
import os
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.chat import Message, ChatFile
def cleanup_old_messages():
"""Удаляет сообщения и файлы старше 1 месяца"""
db = SessionLocal()
try:
# Определяем дату, до которой нужно удалить сообщения
cutoff_date = datetime.utcnow() - timedelta(days=30)
# Получаем файлы, которые нужно удалить
files_to_delete = db.query(ChatFile)\
.join(Message)\
.filter(Message.created_at < cutoff_date)\
.all()
# Удаляем физические файлы
for file in files_to_delete:
try:
if os.path.exists(file.file_path):
os.remove(file.file_path)
except Exception as e:
print(f"Error deleting file {file.file_path}: {e}")
# Удаляем старые сообщения (каскадно удалятся и записи о файлах)
db.query(Message)\
.filter(Message.created_at < cutoff_date)\
.delete(synchronize_session=False)
db.commit()
except Exception as e:
print(f"Error during cleanup: {e}")
db.rollback()
finally:
db.close()

View File

@@ -0,0 +1,112 @@
from fastapi import WebSocket, WebSocketDisconnect, Depends
from typing import Dict, List, Optional
from datetime import datetime
import json
from app.core.auth import get_current_user_ws
from app.models.user import User
from app.models.chat import Chat, Message, ChatFile
from app.database import get_db
from sqlalchemy.orm import Session
class ConnectionManager:
def __init__(self):
# Хранение активных соединений: {user_id: WebSocket}
self.active_connections: Dict[int, WebSocket] = {}
async def connect(self, websocket: WebSocket, user_id: int):
await websocket.accept()
self.active_connections[user_id] = websocket
def disconnect(self, user_id: int):
if user_id in self.active_connections:
del self.active_connections[user_id]
async def send_personal_message(self, message: dict, user_id: int):
if user_id in self.active_connections:
await self.active_connections[user_id].send_json(message)
def is_connected(self, user_id: int) -> bool:
return user_id in self.active_connections
manager = ConnectionManager()
async def handle_chat_connection(
websocket: WebSocket,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_ws)
):
await manager.connect(websocket, current_user.id)
try:
while True:
data = await websocket.receive_json()
# Обработка различных типов сообщений
message_type = data.get('type')
if message_type == 'message':
# Создание нового сообщения
chat = db.query(Chat).filter(
(Chat.employee_id == current_user.id) |
(Chat.admin_id == current_user.id)
).first()
if not chat:
# Создаем новый чат для сотрудника
admin = db.query(User).filter(User.is_admin == True).first()
chat = Chat(employee_id=current_user.id, admin_id=admin.id)
db.add(chat)
db.commit()
message = Message(
chat_id=chat.id,
sender_id=current_user.id,
content=data['content']
)
db.add(message)
db.commit()
# Определяем получателя
recipient_id = chat.admin_id if current_user.id == chat.employee_id else chat.employee_id
# Отправляем сообщение получателю
message_data = {
'type': 'message',
'id': message.id,
'sender_id': current_user.id,
'content': message.content,
'created_at': message.created_at.isoformat(),
'is_read': False
}
if manager.is_connected(recipient_id):
await manager.send_personal_message(message_data, recipient_id)
elif message_type == 'read':
# Отмечаем сообщения как прочитанные
message_ids = data.get('message_ids', [])
db.query(Message).filter(Message.id.in_(message_ids)).update(
{Message.is_read: True},
synchronize_session=False
)
db.commit()
# Отправляем подтверждение прочтения
chat = db.query(Chat).filter(
(Chat.employee_id == current_user.id) |
(Chat.admin_id == current_user.id)
).first()
if chat:
recipient_id = chat.admin_id if current_user.id == chat.employee_id else chat.employee_id
if manager.is_connected(recipient_id):
await manager.send_personal_message({
'type': 'read_confirmation',
'message_ids': message_ids
}, recipient_id)
except WebSocketDisconnect:
manager.disconnect(current_user.id)
except Exception as e:
print(f"Error in WebSocket connection: {e}")
manager.disconnect(current_user.id)

View File

@@ -3,7 +3,7 @@ uvicorn==0.27.1
sqlalchemy==2.0.27 sqlalchemy==2.0.27
pydantic==2.5.2 pydantic==2.5.2
pydantic-settings==2.2.1 pydantic-settings==2.2.1
python-multipart==0.0.9 python-multipart==0.0.6
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
bcrypt>=4.0.1 bcrypt>=4.0.1
@@ -15,3 +15,6 @@ pytest==8.0.0
httpx==0.26.0 httpx==0.26.0
requests>=2.26.0 requests>=2.26.0
aiogram==3.4.1 aiogram==3.4.1
websockets==12.0
aiofiles==23.2.1
APScheduler==3.10.4

View File

@@ -0,0 +1,367 @@
<template>
<div v-if="show" class="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div class="bg-white dark:bg-gray-800 rounded-2xl w-full max-w-4xl h-[80vh] shadow-2xl flex flex-col">
<!-- Заголовок -->
<div class="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-bold text-gray-800 dark:text-white">Чат поддержки</h3>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Область сообщений -->
<div class="flex-1 overflow-y-auto p-6 space-y-4" ref="messagesContainer">
<template v-for="(messageGroup, date) in groupedMessages" :key="date">
<!-- Дата -->
<div class="flex justify-center">
<span class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-sm px-3 py-1 rounded-full">
{{ formatDate(date) }}
</span>
</div>
<!-- Сообщения за день -->
<div v-for="message in messageGroup" :key="message.id" class="flex flex-col space-y-2">
<div :class="[
'flex max-w-[80%] space-x-2',
message.sender_id === currentUserId ? 'ml-auto' : 'mr-auto'
]">
<!-- Сообщение -->
<div :class="[
'rounded-2xl px-4 py-2 break-words',
message.sender_id === currentUserId
? 'bg-blue-500 text-white ml-auto'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-white'
]">
<p>{{ message.content }}</p>
<!-- Файлы -->
<div v-if="message.files && message.files.length > 0" class="mt-2 space-y-1">
<div v-for="file in message.files" :key="file.id"
class="flex items-center space-x-2 text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<a :href="file.file_path"
download
class="hover:underline"
>
{{ file.file_name }}
</a>
</div>
</div>
<!-- Время и статус -->
<div class="flex items-center justify-end mt-1 space-x-1">
<span class="text-xs opacity-75">
{{ formatTime(message.created_at) }}
</span>
<span v-if="message.sender_id === currentUserId">
<svg v-if="message.is_read" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Форма отправки -->
<div class="p-6 border-t border-gray-200 dark:border-gray-700">
<form @submit.prevent="sendMessage" class="flex flex-col space-y-4">
<!-- Область ввода -->
<div class="flex space-x-4">
<div class="flex-1 relative">
<textarea
v-model="newMessage"
@keydown.enter.exact.prevent="sendMessage"
rows="1"
class="w-full rounded-xl border-gray-200 dark:border-gray-700 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:text-white resize-none"
placeholder="Введите сообщение..."
></textarea>
<!-- Эмодзи -->
<div class="absolute right-3 bottom-3">
<button
type="button"
@click="showEmoji = !showEmoji"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 100-2 1 1 0 000 2zm7-1a1 1 0 11-2 0 1 1 0 012 0zm-7.536 5.879a1 1 0 001.415 0 3 3 0 014.242 0 1 1 0 001.415-1.415 5 5 0 00-7.072 0 1 1 0 000 1.415z" clip-rule="evenodd" />
</svg>
</button>
<!-- Панель эмодзи -->
<div v-if="showEmoji" class="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-2">
<div class="grid grid-cols-8 gap-1">
<button
v-for="emoji in emojis"
:key="emoji"
type="button"
@click="addEmoji(emoji)"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{{ emoji }}
</button>
</div>
</div>
</div>
</div>
<!-- Кнопки -->
<div class="flex space-x-2">
<!-- Загрузка файла -->
<label class="cursor-pointer">
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
class="hidden"
multiple
>
<div class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-600 dark:text-gray-300" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd" />
</svg>
</div>
</label>
<!-- Отправка -->
<button
type="submit"
:disabled="!newMessage.trim() && !selectedFiles.length"
class="w-10 h-10 flex items-center justify-center rounded-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<!-- Предпросмотр файлов -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-2">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="flex items-center space-x-2 bg-gray-100 dark:bg-gray-700 rounded-lg px-3 py-1"
>
<span class="text-sm text-gray-600 dark:text-gray-300 truncate max-w-[200px]">
{{ file.name }}
</span>
<button
type="button"
@click="removeFile(index)"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
const props = defineProps({
show: Boolean,
currentUserId: {
type: Number,
required: true
}
})
const emit = defineEmits(['close'])
// Состояние
const messages = ref([])
const newMessage = ref('')
const selectedFiles = ref([])
const showEmoji = ref(false)
const messagesContainer = ref(null)
const ws = ref(null)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 5
// Эмодзи
const emojis = ['😊', '👍', '❤️', '😂', '🙏', '😭', '🎉', '🔥', '👋', '😉', '🤔', '👌', '😍', '💪', '🙌', '✨']
// Группировка сообщений по датам
const groupedMessages = computed(() => {
const groups = {}
messages.value.forEach(message => {
const date = new Date(message.created_at).toLocaleDateString()
if (!groups[date]) {
groups[date] = []
}
groups[date].push(message)
})
return groups
})
// Форматирование даты
const formatDate = (date) => {
const today = new Date().toLocaleDateString()
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString()
if (date === today) return 'Сегодня'
if (date === yesterday) return 'Вчера'
return date
}
// Форматирование времени
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
// WebSocket подключение
const connectWebSocket = () => {
const token = localStorage.getItem('token')
ws.value = new WebSocket(`ws://${window.location.host}/api/ws/chat?token=${token}`)
ws.value.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'message') {
messages.value.push(data)
scrollToBottom()
} else if (data.type === 'read_confirmation') {
// Обновляем статус прочтения
data.message_ids.forEach(id => {
const message = messages.value.find(m => m.id === id)
if (message) {
message.is_read = true
}
})
}
}
ws.value.onclose = () => {
if (reconnectAttempts.value < maxReconnectAttempts) {
setTimeout(() => {
reconnectAttempts.value++
connectWebSocket()
}, 1000 * Math.min(reconnectAttempts.value + 1, 30))
}
}
}
// Отправка сообщения
const sendMessage = async () => {
if (!newMessage.value.trim() && !selectedFiles.value.length) return
try {
// Загружаем файлы, если есть
const uploadedFiles = []
if (selectedFiles.value.length) {
for (const file of selectedFiles.value) {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/chat/files/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
})
if (response.ok) {
const fileData = await response.json()
uploadedFiles.push(fileData)
}
}
}
// Отправляем сообщение через WebSocket
ws.value.send(JSON.stringify({
type: 'message',
content: newMessage.value,
files: uploadedFiles
}))
// Очищаем форму
newMessage.value = ''
selectedFiles.value = []
showEmoji.value = false
} catch (error) {
console.error('Error sending message:', error)
}
}
// Загрузка файлов
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
selectedFiles.value.push(...files)
event.target.value = '' // Сброс input
}
// Удаление файла
const removeFile = (index) => {
selectedFiles.value.splice(index, 1)
}
// Добавление эмодзи
const addEmoji = (emoji) => {
newMessage.value += emoji
}
// Прокрутка к последнему сообщению
const scrollToBottom = async () => {
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// Загрузка истории сообщений
const loadMessages = async () => {
try {
const response = await fetch('/api/chat/messages/', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (response.ok) {
messages.value = await response.json()
await scrollToBottom()
}
} catch (error) {
console.error('Error loading messages:', error)
}
}
// Жизненный цикл
onMounted(() => {
loadMessages()
connectWebSocket()
})
onUnmounted(() => {
if (ws.value) {
ws.value.close()
}
})
// Следим за показом модального окна
watch(() => props.show, async (newValue) => {
if (newValue) {
await loadMessages()
}
})
</script>

View File

@@ -0,0 +1,78 @@
<template>
<Transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="show" class="fixed bottom-4 right-4 z-50">
<div class="max-w-sm w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div class="ml-3 w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ title }}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ message }}
</p>
<div class="mt-3 flex space-x-4">
<button
type="button"
@click="$emit('action')"
class="bg-blue-500 hover:bg-blue-600 text-white text-sm px-3 py-1.5 rounded-md transition-colors"
>
Открыть чат
</button>
<button
type="button"
@click="$emit('close')"
class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 text-sm px-3 py-1.5 rounded-md transition-colors"
>
Закрыть
</button>
</div>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button
@click="$emit('close')"
class="rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none"
>
<span class="sr-only">Закрыть</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
defineProps({
show: {
type: Boolean,
required: true
},
title: {
type: String,
default: 'Новое сообщение'
},
message: {
type: String,
required: true
}
})
defineEmits(['close', 'action'])
</script>

View File

@@ -0,0 +1,117 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold text-gray-800 dark:text-white">Чаты сотрудников</h2>
</div>
<!-- Список чатов -->
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<div
v-for="chat in chats"
:key="chat.id"
class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
:class="{ 'bg-blue-50 dark:bg-blue-900/30': selectedChatId === chat.id }"
@click="$emit('select-chat', chat)"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<!-- Аватар -->
<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<span class="text-gray-600 dark:text-gray-300 font-medium">
{{ getInitials(chat.employee.full_name) }}
</span>
</div>
<!-- Информация -->
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
{{ chat.employee.full_name }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ chat.last_message ? truncateMessage(chat.last_message.content) : 'Нет сообщений' }}
</p>
</div>
</div>
<!-- Индикаторы -->
<div class="flex flex-col items-end space-y-1">
<!-- Время последнего сообщения -->
<span v-if="chat.last_message" class="text-xs text-gray-500 dark:text-gray-400">
{{ formatTime(chat.last_message.created_at) }}
</span>
<!-- Непрочитанные сообщения -->
<span
v-if="chat.unread_count > 0"
class="bg-blue-500 text-white text-xs font-bold px-2 py-0.5 rounded-full"
>
{{ chat.unread_count }}
</span>
</div>
</div>
</div>
</div>
<!-- Пустое состояние -->
<div
v-if="chats.length === 0"
class="p-8 text-center"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<p class="mt-4 text-gray-500 dark:text-gray-400">
Нет активных чатов
</p>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
chats: {
type: Array,
required: true
},
selectedChatId: {
type: Number,
default: null
}
})
defineEmits(['select-chat'])
// Получение инициалов из полного имени
const getInitials = (fullName) => {
return fullName
.split(' ')
.map(name => name[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
// Форматирование времени
const formatTime = (timestamp) => {
const date = new Date(timestamp)
const now = new Date()
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Вчера'
} else {
return date.toLocaleDateString()
}
}
// Обрезка длинных сообщений
const truncateMessage = (message, length = 50) => {
if (message.length <= length) return message
return message.slice(0, length) + '...'
}
</script>

View File

@@ -0,0 +1,405 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg flex flex-col h-full">
<!-- Заголовок -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div v-if="chat" class="flex items-center space-x-3">
<!-- Аватар -->
<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<span class="text-gray-600 dark:text-gray-300 font-medium">
{{ getInitials(chat.employee.full_name) }}
</span>
</div>
<!-- Информация о сотруднике -->
<div>
<h2 class="text-lg font-bold text-gray-800 dark:text-white">
{{ chat.employee.full_name }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ chat.employee.email }}
</p>
</div>
</div>
<div v-else class="text-center text-gray-500 dark:text-gray-400">
Выберите чат для начала общения
</div>
</div>
<!-- Область сообщений -->
<div v-if="chat" class="flex-1 overflow-y-auto p-6 space-y-4" ref="messagesContainer">
<template v-for="(messageGroup, date) in groupedMessages" :key="date">
<!-- Дата -->
<div class="flex justify-center">
<span class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-sm px-3 py-1 rounded-full">
{{ formatDate(date) }}
</span>
</div>
<!-- Сообщения за день -->
<div v-for="message in messageGroup" :key="message.id" class="flex flex-col space-y-2">
<div :class="[
'flex max-w-[80%] space-x-2',
message.sender_id === currentUserId ? 'ml-auto' : 'mr-auto'
]">
<!-- Сообщение -->
<div :class="[
'rounded-2xl px-4 py-2 break-words',
message.sender_id === currentUserId
? 'bg-blue-500 text-white ml-auto'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-white'
]">
<p>{{ message.content }}</p>
<!-- Файлы -->
<div v-if="message.files && message.files.length > 0" class="mt-2 space-y-1">
<div v-for="file in message.files" :key="file.id"
class="flex items-center space-x-2 text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<a :href="file.file_path"
download
class="hover:underline"
>
{{ file.file_name }}
</a>
</div>
</div>
<!-- Время и статус -->
<div class="flex items-center justify-end mt-1 space-x-1">
<span class="text-xs opacity-75">
{{ formatTime(message.created_at) }}
</span>
<span v-if="message.sender_id === currentUserId">
<svg v-if="message.is_read" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Форма отправки -->
<div v-if="chat" class="p-6 border-t border-gray-200 dark:border-gray-700">
<form @submit.prevent="sendMessage" class="flex flex-col space-y-4">
<!-- Область ввода -->
<div class="flex space-x-4">
<div class="flex-1 relative">
<textarea
v-model="newMessage"
@keydown.enter.exact.prevent="sendMessage"
rows="1"
class="w-full rounded-xl border-gray-200 dark:border-gray-700 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:text-white resize-none"
placeholder="Введите сообщение..."
></textarea>
<!-- Эмодзи -->
<div class="absolute right-3 bottom-3">
<button
type="button"
@click="showEmoji = !showEmoji"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 100-2 1 1 0 000 2zm7-1a1 1 0 11-2 0 1 1 0 012 0zm-7.536 5.879a1 1 0 001.415 0 3 3 0 014.242 0 1 1 0 001.415-1.415 5 5 0 00-7.072 0 1 1 0 000 1.415z" clip-rule="evenodd" />
</svg>
</button>
<!-- Панель эмодзи -->
<div v-if="showEmoji" class="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-2">
<div class="grid grid-cols-8 gap-1">
<button
v-for="emoji in emojis"
:key="emoji"
type="button"
@click="addEmoji(emoji)"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{{ emoji }}
</button>
</div>
</div>
</div>
</div>
<!-- Кнопки -->
<div class="flex space-x-2">
<!-- Загрузка файла -->
<label class="cursor-pointer">
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
class="hidden"
multiple
>
<div class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-600 dark:text-gray-300" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd" />
</svg>
</div>
</label>
<!-- Отправка -->
<button
type="submit"
:disabled="!newMessage.trim() && !selectedFiles.length"
class="w-10 h-10 flex items-center justify-center rounded-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<!-- Предпросмотр файлов -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-2">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="flex items-center space-x-2 bg-gray-100 dark:bg-gray-700 rounded-lg px-3 py-1"
>
<span class="text-sm text-gray-600 dark:text-gray-300 truncate max-w-[200px]">
{{ file.name }}
</span>
<button
type="button"
@click="removeFile(index)"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
const props = defineProps({
chat: {
type: Object,
default: null
},
currentUserId: {
type: Number,
required: true
}
})
// Состояние
const messages = ref([])
const newMessage = ref('')
const selectedFiles = ref([])
const showEmoji = ref(false)
const messagesContainer = ref(null)
const ws = ref(null)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 5
// Эмодзи
const emojis = ['😊', '👍', '❤️', '😂', '🙏', '😭', '🎉', '🔥', '👋', '😉', '🤔', '👌', '😍', '💪', '🙌', '✨']
// Группировка сообщений по датам
const groupedMessages = computed(() => {
const groups = {}
messages.value.forEach(message => {
const date = new Date(message.created_at).toLocaleDateString()
if (!groups[date]) {
groups[date] = []
}
groups[date].push(message)
})
return groups
})
// Получение инициалов
const getInitials = (fullName) => {
return fullName
.split(' ')
.map(name => name[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
// Форматирование даты
const formatDate = (date) => {
const today = new Date().toLocaleDateString()
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString()
if (date === today) return 'Сегодня'
if (date === yesterday) return 'Вчера'
return date
}
// Форматирование времени
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
// WebSocket подключение
const connectWebSocket = () => {
if (!props.chat) return
const token = localStorage.getItem('token')
ws.value = new WebSocket(`ws://${window.location.host}/api/ws/chat?token=${token}`)
ws.value.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'message') {
messages.value.push(data)
scrollToBottom()
} else if (data.type === 'read_confirmation') {
// Обновляем статус прочтения
data.message_ids.forEach(id => {
const message = messages.value.find(m => m.id === id)
if (message) {
message.is_read = true
}
})
}
}
ws.value.onclose = () => {
if (reconnectAttempts.value < maxReconnectAttempts) {
setTimeout(() => {
reconnectAttempts.value++
connectWebSocket()
}, 1000 * Math.min(reconnectAttempts.value + 1, 30))
}
}
}
// Отправка сообщения
const sendMessage = async () => {
if (!newMessage.value.trim() && !selectedFiles.value.length) return
try {
// Загружаем файлы, если есть
const uploadedFiles = []
if (selectedFiles.value.length) {
for (const file of selectedFiles.value) {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/chat/files/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
})
if (response.ok) {
const fileData = await response.json()
uploadedFiles.push(fileData)
}
}
}
// Отправляем сообщение через WebSocket
ws.value.send(JSON.stringify({
type: 'message',
content: newMessage.value,
files: uploadedFiles
}))
// Очищаем форму
newMessage.value = ''
selectedFiles.value = []
showEmoji.value = false
} catch (error) {
console.error('Error sending message:', error)
}
}
// Загрузка файлов
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
selectedFiles.value.push(...files)
event.target.value = '' // Сброс input
}
// Удаление файла
const removeFile = (index) => {
selectedFiles.value.splice(index, 1)
}
// Добавление эмодзи
const addEmoji = (emoji) => {
newMessage.value += emoji
}
// Прокрутка к последнему сообщению
const scrollToBottom = async () => {
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// Загрузка истории сообщений
const loadMessages = async () => {
if (!props.chat) return
try {
const response = await fetch(`/api/chat/messages/${props.chat.id}/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (response.ok) {
messages.value = await response.json()
await scrollToBottom()
// Отмечаем сообщения как прочитанные
const unreadMessages = messages.value
.filter(m => !m.is_read && m.sender_id !== props.currentUserId)
.map(m => m.id)
if (unreadMessages.length > 0) {
ws.value.send(JSON.stringify({
type: 'read',
message_ids: unreadMessages
}))
}
}
} catch (error) {
console.error('Error loading messages:', error)
}
}
// Следим за изменением чата
watch(() => props.chat, async (newChat) => {
if (ws.value) {
ws.value.close()
}
if (newChat) {
messages.value = []
await loadMessages()
connectWebSocket()
}
}, { immediate: true })
// Очистка при размонтировании
onUnmounted(() => {
if (ws.value) {
ws.value.close()
}
})
</script>

View File

@@ -0,0 +1,63 @@
import AdminChatList from '@/components/admin/AdminChatList.vue'
import AdminChatView from '@/components/admin/AdminChatView.vue'
import ChatNotification from '@/components/ChatNotification.vue'
const chats = ref([])
const selectedChat = ref(null)
const showChatNotification = ref(false)
const chatNotificationMessage = ref('')
const loadChats = async () => {
try {
const response = await fetch('/api/chat/admin/chats/', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (response.ok) {
chats.value = await response.json()
}
} catch (error) {
console.error('Error loading chats:', error)
}
}
const handleChatSelect = (chat) => {
selectedChat.value = chat
}
onMounted(() => {
loadChats()
const interval = setInterval(loadChats, 30000)
onUnmounted(() => {
clearInterval(interval)
})
})
<div class="grid grid-cols-1 md:grid-cols-12 gap-6 mb-6">
<!-- Чаты -->
<div class="md:col-span-4">
<AdminChatList
:chats="chats"
:selected-chat-id="selectedChat?.id"
@select-chat="handleChatSelect"
/>
</div>
<!-- Область чата -->
<div class="md:col-span-8">
<AdminChatView
:chat="selectedChat"
:current-user-id="currentUser.id"
/>
</div>
</div>
<!-- Уведомления -->
<ChatNotification
:show="showChatNotification"
:message="chatNotificationMessage"
@close="showChatNotification = false"
@action="handleChatNotificationAction"
/>

View File

@@ -61,12 +61,20 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800 group-hover:text-green-600 transition-colors">Чат поддержки</h2> <h2 class="text-xl font-bold text-gray-800 group-hover:text-green-600 transition-colors">Чат поддержки</h2>
<button <button
class="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-6 py-3 rounded-xl transition-all duration-300 flex items-center space-x-2 transform hover:scale-105" @click="showChatModal = true"
class="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-6 py-3 rounded-xl transition-all duration-300 flex items-center space-x-2 transform hover:scale-105 relative"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clip-rule="evenodd" />
</svg> </svg>
<span>Открыть чат</span> <span>Открыть чат</span>
<!-- Индикатор непрочитанных сообщений -->
<span
v-if="unreadMessagesCount > 0"
class="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold w-5 h-5 flex items-center justify-center rounded-full"
>
{{ unreadMessagesCount }}
</span>
</button> </button>
</div> </div>
<p class="text-gray-600 leading-relaxed"> <p class="text-gray-600 leading-relaxed">
@@ -323,12 +331,29 @@
@close="showNotification = false" @close="showNotification = false"
/> />
</div> </div>
<!-- Добавляем компоненты в конец template -->
<ChatModal
v-if="showChatModal"
:show="showChatModal"
:current-user-id="currentUser.id"
@close="showChatModal = false"
/>
<ChatNotification
:show="showChatNotification"
:message="chatNotificationMessage"
@close="showChatNotification = false"
@action="showChatModal = true"
/>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Notification from '@/components/Notification.vue' import Notification from '@/components/Notification.vue'
import ChatModal from '@/components/ChatModal.vue'
import ChatNotification from '@/components/ChatNotification.vue'
const router = useRouter() const router = useRouter()
const requests = ref([]) const requests = ref([])
@@ -337,6 +362,10 @@ const showRequestModal = ref(false)
const showRequestsModal = ref(false) const showRequestsModal = ref(false)
const isDarkMode = ref(false) const isDarkMode = ref(false)
const showNotification = ref(false) const showNotification = ref(false)
const showChatModal = ref(false)
const showChatNotification = ref(false)
const chatNotificationMessage = ref('')
const unreadMessagesCount = ref(0)
// Типы заявок // Типы заявок
const requestTypes = [ const requestTypes = [
@@ -401,6 +430,14 @@ onMounted(() => {
} }
fetchRequests() fetchRequests()
checkUnreadMessages()
// Проверяем каждые 30 секунд
const interval = setInterval(checkUnreadMessages, 30000)
// Очищаем интервал при размонтировании
onUnmounted(() => {
clearInterval(interval)
})
}) })
// Пагинация // Пагинация
@@ -526,6 +563,23 @@ const getRequestTypeLabel = (type) => {
} }
return labels[type] || type return labels[type] || type
} }
// Добавляем функцию для проверки непрочитанных сообщений
const checkUnreadMessages = async () => {
try {
const response = await fetch('/api/chat/unread-count/', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (response.ok) {
const data = await response.json()
unreadMessagesCount.value = data.unread_count
}
} catch (error) {
console.error('Error checking unread messages:', error)
}
}
</script> </script>
<style> <style>