This commit is contained in:
Игорь Надеин 2026-01-09 14:12:54 +03:00
parent 413fdd21aa
commit 8b391cb388
3 changed files with 218 additions and 24 deletions

View File

@ -1792,7 +1792,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
# Аватар
if avatar_bytes:
user.photo.save(
user.avatar.save(
f"avatar_{user.id}.jpg",
ContentFile(avatar_bytes),
save=False,

View File

@ -32,6 +32,7 @@ def _file_to_data_uri(uploaded) -> str:
"""
Превращаем загруженный файл (InMemoryUploadedFile/TemporaryUploadedFile)
в data URI (data:image/...;base64,xxxx).
Deprecated: используйте прямую отправку файлов через multipart/form-data.
"""
if not uploaded:
return ""
@ -495,18 +496,18 @@ def employee_edit(request, pk: int):
avatar_file.name,
avatar_file.size,
)
# Конвертируем в data URI для Base64ImageField
data_uri = _file_to_data_uri(avatar_file)
payload['avatar'] = data_uri
logger.info(f"[employee_edit] Avatar converted, data URI length: {len(data_uri)}")
else:
logger.warning("[employee_edit] No avatar in request.FILES")
logger.info(f"[employee_edit] Final payload keys: {list(payload.keys())}")
# Отправляем в DRF как JSON (с base64 аватаром)
# Отправляем в DRF (multipart если есть файл, иначе JSON)
try:
resp = api.patch(f"v1/employees/{pk}/", json=payload)
if avatar_file:
files = {"avatar": (avatar_file.name, avatar_file.read(), avatar_file.content_type)}
resp = api.patch(f"v1/employees/{pk}/", data=payload, files=files)
else:
resp = api.patch(f"v1/employees/{pk}/", json=payload)
logger.info(f"[employee_edit] API response status: {resp.status}")
except Exception as e:
logger.error(f"[employee_edit] API call error: {e}")
@ -612,13 +613,6 @@ def employee_edit_me(request):
avatar_file.name,
avatar_file.size,
)
# Конвертируем в data URI для Base64ImageField
data_uri = _file_to_data_uri(avatar_file)
payload['avatar'] = data_uri
logger.info(
"[employee_edit_me] Avatar converted to data URI, length: %s",
len(data_uri),
)
else:
logger.warning("[employee_edit_me] No avatar in request.FILES")
@ -627,9 +621,13 @@ def employee_edit_me(request):
list(payload.keys()),
)
# Отправляем в DRF как JSON (с base64 аватаром)
# Отправляем в DRF (multipart если есть файл, иначе JSON)
try:
resp = api.patch("v1/employees/me/", json=payload)
if avatar_file:
files = {"avatar": (avatar_file.name, avatar_file.read(), avatar_file.content_type)}
resp = api.patch("v1/employees/me/", data=payload, files=files)
else:
resp = api.patch("v1/employees/me/", json=payload)
logger.info(
"[employee_edit_me] API response status: %s",
resp.status,
@ -710,8 +708,6 @@ def employee_create(request):
except (ValueError, TypeError):
pass
if request.FILES.get("avatar"):
data["avatar"] = _file_to_data_uri(request.FILES["avatar"])
skills = request.POST.getlist("skills")
if skills:
data["skills"] = skills
@ -719,7 +715,16 @@ def employee_create(request):
if password:
data["password"] = password # API сгенерирует сам, если не передали
resp = api.post("v1/employees/", json=data)
# Если есть аватар - multipart, иначе JSON
files = {}
if request.FILES.get("avatar"):
avatar_file = request.FILES["avatar"]
files["avatar"] = (avatar_file.name, avatar_file.read(), avatar_file.content_type)
if files:
resp = api.post("v1/employees/", data=data, files=files)
else:
resp = api.post("v1/employees/", json=data)
if not resp.ok:
messages.error(
request, f"Не удалось создать: {resp.status} — {resp.text}"
@ -775,10 +780,6 @@ def employee_create_modal(request):
except (ValueError, TypeError):
pass
# Обработка аватара
if request.FILES.get("avatar"):
data["avatar"] = _file_to_data_uri(request.FILES["avatar"])
# Обработка навыков (если будут добавлены в форму)
skills = request.POST.getlist("skills")
if skills:
@ -789,8 +790,19 @@ def employee_create_modal(request):
if password:
data["password"] = password
# Если есть аватар - отправляем multipart, иначе JSON
files = {}
if request.FILES.get("avatar"):
avatar_file = request.FILES["avatar"]
files["avatar"] = (avatar_file.name, avatar_file.read(), avatar_file.content_type)
# Отправляем запрос к API
resp = api.post("v1/employees/", json=data)
if files:
# С файлом - multipart/form-data
resp = api.post("v1/employees/", data=data, files=files)
else:
# Без файла - JSON
resp = api.post("v1/employees/", json=data)
if not resp.ok:
# Парсим ошибки от API

View File

@ -0,0 +1,182 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { feedApi } from '@/api/feed';
import type { Comment } from '@/types';
import { Button } from '@/components/ui/Button';
const commentSchema = z.object({
text: z.string().min(1, 'Комментарий не может быть пустым').max(1000, 'Максимум 1000 символов'),
});
type CommentFormData = z.infer<typeof commentSchema>;
interface CommentsListProps {
postId: number;
}
export function CommentsList({ postId }: CommentsListProps) {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { register, handleSubmit, formState: { errors }, reset } = useForm<CommentFormData>({
resolver: zodResolver(commentSchema),
});
const { data: comments, isLoading } = useQuery({
queryKey: ['comments', postId],
queryFn: () => feedApi.getComments(postId),
});
const createMutation = useMutation({
mutationFn: (data: CommentFormData) => feedApi.createComment(postId, data.text),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
reset();
setShowForm(false);
},
});
const likeMutation = useMutation({
mutationFn: (comment: Comment) =>
comment.liked_by_me
? feedApi.unlikeComment(comment.id)
: feedApi.likeComment(comment.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffMins < 1) return 'только что';
if (diffMins < 60) return `${diffMins} мин назад`;
if (diffHours < 24) return `${diffHours} ч назад`;
return date.toLocaleDateString('ru-RU');
};
const onSubmit = (data: CommentFormData) => {
createMutation.mutate(data);
};
if (isLoading) {
return <div className="text-center py-4 text-gray-500">Загрузка...</div>;
}
return (
<div className="space-y-4">
{/* Форма добавления */}
{showForm ? (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-2">
<textarea
{...register('text')}
placeholder="Напишите комментарий..."
rows={3}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
{errors.text && (
<p className="text-sm text-red-600">{errors.text.message}</p>
)}
<div className="flex space-x-2">
<Button
type="submit"
size="sm"
isLoading={createMutation.isPending}
>
Отправить
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowForm(false);
reset();
}}
>
Отмена
</Button>
</div>
</form>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(true)}
className="w-full"
>
💬 Добавить комментарий
</Button>
)}
{/* Список комментариев */}
{comments && comments.length > 0 ? (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.id} className="bg-gray-50 rounded-lg p-3">
<div className="flex items-start space-x-3">
{/* Аватар автора */}
{comment.author.photo ? (
<img
src={comment.author.photo}
alt={comment.author.full_name}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<span className="text-blue-600 font-medium text-xs">
{comment.author.first_name?.[0]}{comment.author.last_name?.[0]}
</span>
</div>
)}
<div className="flex-1 min-w-0">
{/* Заголовок */}
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium text-sm text-gray-900">
{comment.author.full_name}
</span>
<span className="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
</div>
{/* Текст комментария */}
<p className="text-sm text-gray-700 whitespace-pre-wrap break-words">
{comment.text}
</p>
{/* Лайк */}
<button
onClick={() => likeMutation.mutate(comment)}
disabled={likeMutation.isPending}
className={`mt-2 flex items-center space-x-1 text-xs ${
comment.liked_by_me ? 'text-blue-600' : 'text-gray-500'
} hover:text-blue-600 transition-colors`}
>
<span>{comment.liked_by_me ? '❤️' : '🤍'}</span>
{comment.likes_count > 0 && (
<span>{comment.likes_count}</span>
)}
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-sm text-gray-500 py-4">
Комментариев пока нет. Будьте первым!
</p>
)}
</div>
);
}