fix and update

This commit is contained in:
lendry
2026-07-01 19:19:27 +03:00
parent f423f512f8
commit 7e54cec361
16 changed files with 1093 additions and 150 deletions

View File

@@ -2,18 +2,27 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Activity,
CheckCircle2,
Loader2,
MessageSquare,
LogIn,
MessagesSquare,
ScrollText,
Search,
ShieldAlert,
Trash2,
XCircle
} from 'lucide-react';
import { AdminInspectorMessageFeed } from '@/components/admin/admin-inspector-message-body';
import {
AdminInsightsDateFilter,
defaultInsightsPeriodLocal,
localDatetimeToIso
} from '@/components/admin/admin-insights-date-filter';
import {
adminMessageToChatMessage,
useAdminChatMedia
} from '@/components/admin/use-admin-chat-media';
import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
import { VerificationBadge } from '@/components/id/verification-badge';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -22,11 +31,16 @@ import {
AdminChatRoomSummary,
AdminUser,
AdminUserActivityItem,
ChatMessage,
SignInEvent,
apiFetch
} from '@/lib/api';
import { collectChatImageMessages } from '@/lib/chat-media-album';
import { cn } from '@/lib/utils';
const inspectorTabTriggerClass =
'flex items-center justify-center gap-2 rounded-lg px-2 py-2.5 text-sm font-medium text-[#667085] shadow-none transition data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm';
function formatDateTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
@@ -67,6 +81,16 @@ export function UserInspectorDialog({
}) {
const { showToast } = useToast();
const [tab, setTab] = useState('sign-in');
const defaultPeriod = useMemo(() => defaultInsightsPeriodLocal(7), []);
const [periodFrom, setPeriodFrom] = useState(defaultPeriod.from);
const [periodTo, setPeriodTo] = useState(defaultPeriod.to);
const [retentionDays, setRetentionDays] = useState(7);
const resetPeriod = useCallback(() => {
const next = defaultInsightsPeriodLocal(retentionDays);
setPeriodFrom(next.from);
setPeriodTo(next.to);
}, [retentionDays]);
const [signInSearch, setSignInSearch] = useState('');
const [activitySearch, setActivitySearch] = useState('');
@@ -79,6 +103,18 @@ export function UserInspectorDialog({
const debouncedChatSearch = useDebouncedValue(chatSearch);
const debouncedMessageSearch = useDebouncedValue(messageSearch);
const debouncedGlobalMessageSearch = useDebouncedValue(globalMessageSearch);
const debouncedPeriodFrom = useDebouncedValue(periodFrom, 500);
const debouncedPeriodTo = useDebouncedValue(periodTo, 500);
const appendDateParams = useCallback(
(params: URLSearchParams) => {
const fromIso = localDatetimeToIso(debouncedPeriodFrom);
const toIso = localDatetimeToIso(debouncedPeriodTo);
if (fromIso) params.set('dateFrom', fromIso);
if (toIso) params.set('dateTo', toIso);
},
[debouncedPeriodFrom, debouncedPeriodTo]
);
const [signInEvents, setSignInEvents] = useState<SignInEvent[]>([]);
const [signInTotal, setSignInTotal] = useState(0);
@@ -98,8 +134,17 @@ export function UserInspectorDialog({
const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingSearch, setLoadingSearch] = useState(false);
const [deletingMessageId, setDeletingMessageId] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const selectedRoom = useMemo(() => rooms.find((room) => room.id === selectedRoomId) ?? null, [rooms, selectedRoomId]);
const { mediaUrls } = useAdminChatMedia(token, selectedRoomId, roomMessages);
const chatImageMessages = useMemo(
() =>
selectedRoomId
? collectChatImageMessages(roomMessages.map((message) => adminMessageToChatMessage(message, selectedRoomId)))
: [],
[roomMessages, selectedRoomId]
);
const loadSignIn = useCallback(async () => {
if (!token || !user) return;
@@ -108,19 +153,27 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedSignInSearch.trim()) params.set('search', debouncedSignInSearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; events: SignInEvent[] }>(
appendDateParams(params);
const response = await apiFetch<{
total: number;
events: SignInEvent[];
retentionDays?: number;
periodFrom?: string;
periodTo?: string;
}>(
`/admin/users/${user.id}/sign-in-history?${params.toString()}`,
{},
token
);
setSignInEvents(response.events ?? []);
setSignInTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить историю входов');
} finally {
setLoadingSignIn(false);
}
}, [debouncedSignInSearch, showToast, token, user]);
}, [appendDateParams, debouncedSignInSearch, showToast, token, user]);
const loadActivity = useCallback(async () => {
if (!token || !user) return;
@@ -129,19 +182,25 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedActivitySearch.trim()) params.set('search', debouncedActivitySearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; items: AdminUserActivityItem[] }>(
appendDateParams(params);
const response = await apiFetch<{
total: number;
items: AdminUserActivityItem[];
retentionDays?: number;
}>(
`/admin/users/${user.id}/activity?${params.toString()}`,
{},
token
);
setActivityItems(response.items ?? []);
setActivityTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить активность');
} finally {
setLoadingActivity(false);
}
}, [debouncedActivitySearch, showToast, token, user]);
}, [appendDateParams, debouncedActivitySearch, showToast, token, user]);
const loadRooms = useCallback(async () => {
if (!token || !user || !canModerateChats) return;
@@ -150,19 +209,21 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedChatSearch.trim()) params.set('search', debouncedChatSearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[] }>(
appendDateParams(params);
const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[]; retentionDays?: number }>(
`/admin/users/${user.id}/chats?${params.toString()}`,
{},
token
);
setRooms(response.rooms ?? []);
setRoomsTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить чаты');
} finally {
setLoadingRooms(false);
}
}, [canModerateChats, debouncedChatSearch, showToast, token, user]);
}, [appendDateParams, canModerateChats, debouncedChatSearch, showToast, token, user]);
const loadRoomMessages = useCallback(async () => {
if (!token || !user || !selectedRoomId) return;
@@ -171,11 +232,14 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedMessageSearch.trim()) params.set('search', debouncedMessageSearch.trim());
params.set('limit', '80');
appendDateParams(params);
const response = await apiFetch<{
room?: { id: string; name: string; type: string; groupName: string };
messages: AdminChatMessage[];
retentionDays?: number;
}>(`/admin/users/${user.id}/chats/${selectedRoomId}/messages?${params.toString()}`, {}, token);
setRoomMessages(response.messages ?? []);
if (response.retentionDays) setRetentionDays(response.retentionDays);
setRoomContext(
response.room
? { name: response.room.name, groupName: response.room.groupName, type: response.room.type }
@@ -186,7 +250,7 @@ export function UserInspectorDialog({
} finally {
setLoadingMessages(false);
}
}, [debouncedMessageSearch, selectedRoomId, showToast, token, user]);
}, [appendDateParams, debouncedMessageSearch, selectedRoomId, showToast, token, user]);
const loadGlobalSearch = useCallback(async () => {
if (!token || !user || !canModerateChats || !debouncedGlobalMessageSearch.trim()) {
@@ -199,19 +263,21 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
params.set('search', debouncedGlobalMessageSearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; messages: AdminChatMessage[] }>(
appendDateParams(params);
const response = await apiFetch<{ total: number; messages: AdminChatMessage[]; retentionDays?: number }>(
`/admin/users/${user.id}/chats/search?${params.toString()}`,
{},
token
);
setSearchResults(response.messages ?? []);
setSearchTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выполнить поиск');
} finally {
setLoadingSearch(false);
}
}, [canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
}, [appendDateParams, canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
useEffect(() => {
if (!open || !user) return;
@@ -249,9 +315,19 @@ export function UserInspectorDialog({
setSelectedRoomId(null);
setRoomMessages([]);
setSearchResults([]);
setLightboxIndex(null);
const next = defaultInsightsPeriodLocal(7);
setPeriodFrom(next.from);
setPeriodTo(next.to);
setRetentionDays(7);
}
}, [open]);
function openImageLightbox(message: ChatMessage) {
const index = chatImageMessages.findIndex((item) => item.id === message.id);
if (index >= 0) setLightboxIndex(index);
}
async function handleDeleteMessage(messageId: string) {
if (!token || !user || !canModerateChats) return;
const confirmed = window.confirm('Удалить это сообщение? Действие необратимо для пользователей чата.');
@@ -284,19 +360,30 @@ export function UserInspectorDialog({
</p>
</DialogHeader>
<div className="border-b border-[#eceef4] px-6 py-4">
<AdminInsightsDateFilter
from={periodFrom}
to={periodTo}
retentionDays={retentionDays}
onFromChange={setPeriodFrom}
onToChange={setPeriodTo}
onReset={resetPeriod}
/>
</div>
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-[#eceef4] px-6 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sign-in" className="gap-2">
<ShieldAlert className="h-4 w-4" />
<div className="border-b border-[#eceef4] px-6 pt-4 pb-4">
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 rounded-xl bg-[#f4f5f8] p-1">
<TabsTrigger value="sign-in" className={inspectorTabTriggerClass}>
<LogIn className="h-4 w-4 shrink-0" />
Входы
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2">
<Activity className="h-4 w-4" />
<TabsTrigger value="activity" className={inspectorTabTriggerClass}>
<ScrollText className="h-4 w-4 shrink-0" />
Активность
</TabsTrigger>
<TabsTrigger value="chats" className="gap-2" disabled={!canModerateChats}>
<MessageSquare className="h-4 w-4" />
<TabsTrigger value="chats" className={inspectorTabTriggerClass} disabled={!canModerateChats}>
<MessagesSquare className="h-4 w-4 shrink-0" />
Чаты
</TabsTrigger>
</TabsList>
@@ -521,48 +608,15 @@ export function UserInspectorDialog({
Загрузка сообщений...
</div>
) : roomMessages.length ? (
<div className="space-y-2 p-3">
{roomMessages.map((message) => (
<div
key={message.id}
className={cn(
'rounded-2xl border px-3 py-2',
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{message.senderName}</span>
<span className="text-xs text-[#667085]">{formatDateTime(message.createdAt)}</span>
{message.isDeleted ? (
<span className="text-xs font-medium text-red-600">Удалено</span>
) : null}
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-[#344054]">
{message.isEncrypted ? '[E2E недоступно для модерации]' : message.preview}
</p>
</div>
{!message.isDeleted && !message.isEncrypted ? (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={deletingMessageId === message.id}
aria-label="Удалить сообщение"
onClick={() => void handleDeleteMessage(message.id)}
>
{deletingMessageId === message.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
) : null}
</div>
</div>
))}
<div className="p-3">
<AdminInspectorMessageFeed
messages={roomMessages}
roomId={selectedRoomId}
mediaUrls={mediaUrls}
deletingMessageId={deletingMessageId}
onDelete={(messageId) => void handleDeleteMessage(messageId)}
onOpenImage={openImageLightbox}
/>
</div>
) : (
<p className="py-16 text-center text-sm text-[#667085]">Сообщения не найдены</p>
@@ -574,6 +628,15 @@ export function UserInspectorDialog({
)}
</TabsContent>
</Tabs>
<ChatImageLightbox
open={lightboxIndex !== null}
images={chatImageMessages}
index={lightboxIndex ?? 0}
mediaUrls={mediaUrls}
onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex}
/>
</DialogContent>
</Dialog>
);