feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
+267
-210
@@ -1,236 +1,293 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Tag, Space, Select, DatePicker, Spin } from 'antd';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAudit } from '../../hooks/useAudit';
|
||||
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
|
||||
import { useUser } from '../../hooks/useUsers';
|
||||
import { useEvent } from '../../hooks/useEvents';
|
||||
import { AuditRecord, AuditListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
import { useAudit } from '@/hooks/useAudit';
|
||||
import { useAdmin, useAdmins } from '@/hooks/useAdmins';
|
||||
import { useUser } from '@/hooks/useUsers';
|
||||
import { useEvent } from '@/hooks/useEvents';
|
||||
import { AuditRecord, AuditListParams } from '@/types/api';
|
||||
import { getSavedPageSize } from '@/utils/tablePagination';
|
||||
import { DataTable } from '@/components/data-table/DataTable';
|
||||
import { ListPageHeader } from '@/components/ListPageHeader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const TABLE_KEY = 'audit';
|
||||
const ALL = '__all__';
|
||||
|
||||
const roleBadgeVariant = (role: string) => {
|
||||
if (role === 'superadmin') return 'destructive' as const;
|
||||
if (role === 'admin') return 'default' as const;
|
||||
if (role === 'moderator') return 'secondary' as const;
|
||||
return 'outline' as const;
|
||||
};
|
||||
|
||||
const actionBadgeVariant = (action: string) => {
|
||||
if (action === 'create') return 'success' as const;
|
||||
if (action === 'delete') return 'destructive' as const;
|
||||
if (action === 'update') return 'default' as const;
|
||||
if (action === 'freeze' || action === 'block' || action === 'hide') return 'warning' as const;
|
||||
return 'outline' as const;
|
||||
};
|
||||
|
||||
const entityTypeBadgeVariant = (type: string) => {
|
||||
if (type === 'user') return 'default' as const;
|
||||
if (type === 'event') return 'success' as const;
|
||||
if (type === 'review') return 'secondary' as const;
|
||||
if (type === 'report') return 'destructive' as const;
|
||||
return 'outline' as const;
|
||||
};
|
||||
|
||||
const isBadValue = (val: unknown) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
const toDateInput = (iso: string | undefined): string => {
|
||||
if (!iso) return '';
|
||||
return iso.slice(0, 10);
|
||||
};
|
||||
|
||||
const dateToIso = (date: string, endOfDay = false): string | undefined => {
|
||||
if (!date) return undefined;
|
||||
const d = new Date(date);
|
||||
if (endOfDay) d.setHours(23, 59, 59, 999);
|
||||
else d.setHours(0, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
|
||||
const { data: admin, isLoading: loading } = useAdmin(adminId);
|
||||
if (loading) return <Skeleton className="h-4 w-24" />;
|
||||
if (!admin) return <span>{adminId}</span>;
|
||||
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
|
||||
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
|
||||
};
|
||||
|
||||
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
|
||||
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
|
||||
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
|
||||
|
||||
if (entityType === 'user') {
|
||||
if (loadingUser) return <Skeleton className="h-4 w-24" />;
|
||||
if (!user) return <span>{entityId}</span>;
|
||||
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
|
||||
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
|
||||
}
|
||||
|
||||
if (entityType === 'event') {
|
||||
if (loadingEvent) return <Skeleton className="h-4 w-24" />;
|
||||
if (!event) return <span>{entityId}</span>;
|
||||
const name = !isBadValue(event.title) ? event.title : event.id;
|
||||
return <Link to={`/events/${event.id}`}>{name}</Link>;
|
||||
}
|
||||
|
||||
if (entityType === 'review') {
|
||||
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
|
||||
}
|
||||
|
||||
return <span>{entityId}</span>;
|
||||
};
|
||||
|
||||
const AuditPage: React.FC = () => {
|
||||
const [params, setParams] = useState<AuditListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'timestamp', order: 'desc' });
|
||||
const { t } = useTranslation();
|
||||
const [params, setParams] = useState<AuditListParams>({
|
||||
limit: getSavedPageSize(TABLE_KEY),
|
||||
offset: 0,
|
||||
sort: 'timestamp',
|
||||
order: 'desc',
|
||||
});
|
||||
const { data, isLoading } = useAudit(params);
|
||||
|
||||
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
|
||||
|
||||
const [uniqueActions, setUniqueActions] = useState<string[]>([]);
|
||||
const [adminSearch, setAdminSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.data) {
|
||||
const actions = new Set(data.data.map(record => record.action).filter(Boolean));
|
||||
setUniqueActions(prev => {
|
||||
const actions = new Set(data.data.map((record) => record.action).filter(Boolean));
|
||||
setUniqueActions((prev) => {
|
||||
const merged = new Set([...prev, ...actions]);
|
||||
return Array.from(merged).sort();
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
|
||||
const { data: admin, isLoading: loading } = useAdmin(adminId);
|
||||
if (loading) return <Spin size="small" />;
|
||||
if (!admin) return <span>{adminId}</span>;
|
||||
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
|
||||
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
|
||||
};
|
||||
const filteredAdmins = useMemo(() => {
|
||||
const list = admins?.data || [];
|
||||
if (!adminSearch.trim()) return list;
|
||||
const q = adminSearch.toLowerCase();
|
||||
return list.filter((a) => (a.email || a.id).toLowerCase().includes(q));
|
||||
}, [admins, adminSearch]);
|
||||
|
||||
const isBadValue = (val: any) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
|
||||
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
|
||||
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
|
||||
|
||||
if (entityType === 'user') {
|
||||
if (loadingUser) return <Spin size="small" />;
|
||||
if (!user) return <span>{entityId}</span>;
|
||||
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
|
||||
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
|
||||
}
|
||||
|
||||
if (entityType === 'event') {
|
||||
if (loadingEvent) return <Spin size="small" />;
|
||||
if (!event) return <span>{entityId}</span>;
|
||||
const name = !isBadValue(event.title) ? event.title : event.id;
|
||||
return <Link to={`/events/${event.id}`}>{name}</Link>;
|
||||
}
|
||||
|
||||
if (entityType === 'review') {
|
||||
// для review показываем ссылку без загрузки, так как нет API для загрузки названия
|
||||
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
|
||||
}
|
||||
|
||||
return <span>{entityId}</span>;
|
||||
};
|
||||
|
||||
const handleTableChange = (
|
||||
pagination: any,
|
||||
_filters: any,
|
||||
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const handleDateChange = (dates: any) => {
|
||||
if (dates) {
|
||||
setParams({
|
||||
...params,
|
||||
date_from: dates[0]?.toISOString(),
|
||||
date_to: dates[1]?.toISOString(),
|
||||
offset: 0,
|
||||
});
|
||||
} else {
|
||||
setParams({ ...params, date_from: undefined, date_to: undefined, offset: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
superadmin: 'red',
|
||||
admin: 'blue',
|
||||
moderator: 'purple',
|
||||
support: 'cyan',
|
||||
};
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
create: 'green',
|
||||
update: 'blue',
|
||||
delete: 'red',
|
||||
freeze: 'orange',
|
||||
unfreeze: 'cyan',
|
||||
block: 'orange',
|
||||
unblock: 'cyan',
|
||||
hide: 'orange',
|
||||
unhide: 'cyan',
|
||||
login: 'geekblue',
|
||||
logout: 'default',
|
||||
};
|
||||
|
||||
const entityTypeColors: Record<string, string> = {
|
||||
user: 'blue',
|
||||
event: 'green',
|
||||
calendar: 'orange',
|
||||
review: 'purple',
|
||||
report: 'red',
|
||||
ticket: 'default',
|
||||
subscription: 'cyan',
|
||||
admin: 'magenta',
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AuditRecord> = [
|
||||
{
|
||||
title: 'Админ',
|
||||
key: 'admin',
|
||||
render: (_, record) => <AdminCell adminId={record.admin_id} />,
|
||||
},
|
||||
{
|
||||
title: 'Роль',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
render: (role: string) => (
|
||||
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Действие',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
render: (action: string) => (
|
||||
<Tag color={actionColors[action] || 'default'}>{action}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Тип',
|
||||
dataIndex: 'entity_type',
|
||||
key: 'entity_type',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
render: (type: string) => (
|
||||
<Tag color={entityTypeColors[type] || 'default'}>{type}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Наименование',
|
||||
key: 'entity_name',
|
||||
render: (_, record) => (
|
||||
<EntityNameCell entityType={record.entity_type} entityId={record.entity_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Дата',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
sorter: true,
|
||||
},
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{
|
||||
title: 'Причина',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
ellipsis: true,
|
||||
},
|
||||
];
|
||||
const columns = useMemo<ColumnDef<AuditRecord>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'admin',
|
||||
header: t('audit.admin'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <AdminCell adminId={row.original.admin_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: t('common.role'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const role = getValue<string>();
|
||||
return <Badge variant={roleBadgeVariant(role)}>{role}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: t('audit.action'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const action = getValue<string>();
|
||||
return <Badge variant={actionBadgeVariant(action)}>{action}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'entity_type',
|
||||
header: t('common.type'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue<string>();
|
||||
return <Badge variant={entityTypeBadgeVariant(type)}>{type}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'entity_name',
|
||||
header: t('audit.name'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<EntityNameCell entityType={row.original.entity_type} entityId={row.original.entity_id} />
|
||||
),
|
||||
},
|
||||
{ accessorKey: 'timestamp', header: t('common.date'), enableSorting: true },
|
||||
{ accessorKey: 'ip', header: t('common.ip'), enableSorting: false },
|
||||
{
|
||||
accessorKey: 'reason',
|
||||
header: t('common.reason'),
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const reason = getValue<string>();
|
||||
return <span className="line-clamp-2">{reason}</span>;
|
||||
},
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Аудит</h2>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="Администратор"
|
||||
loading={loadingAdmins}
|
||||
allowClear
|
||||
onChange={(val) => setParams(prev => ({ ...prev, admin_id: val || undefined, offset: 0 }))}
|
||||
style={{ width: 250 }}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{(admins?.data || []).map(admin => {
|
||||
const label = admin.email || admin.id;
|
||||
return (
|
||||
<Select.Option key={admin.id} value={admin.id} label={label}>
|
||||
{label}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="Действие"
|
||||
allowClear
|
||||
onChange={(val) => setParams(prev => ({ ...prev, action: val || undefined, offset: 0 }))}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
{uniqueActions.map(action => (
|
||||
<Select.Option key={action} value={action}>{action}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<RangePicker
|
||||
onChange={handleDateChange}
|
||||
allowClear
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
<div className="space-y-6">
|
||||
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audit.searchAdmin')}</Label>
|
||||
<Input
|
||||
placeholder={t('audit.searchAdminPlaceholder')}
|
||||
value={adminSearch}
|
||||
onChange={(e) => setAdminSearch(e.target.value)}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audit.admin')}</Label>
|
||||
<Select
|
||||
value={params.admin_id ?? ALL}
|
||||
onValueChange={(val) =>
|
||||
setParams((prev) => ({ ...prev, admin_id: val === ALL ? undefined : val, offset: 0 }))
|
||||
}
|
||||
disabled={loadingAdmins}
|
||||
>
|
||||
<SelectTrigger className="w-[250px]">
|
||||
<SelectValue placeholder={t('common.selectAdmin')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
|
||||
{filteredAdmins.map((admin) => {
|
||||
const label = admin.email || admin.id;
|
||||
return (
|
||||
<SelectItem key={admin.id} value={admin.id}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audit.action')}</Label>
|
||||
<Select
|
||||
value={params.action ?? ALL}
|
||||
onValueChange={(val) =>
|
||||
setParams((prev) => ({ ...prev, action: val === ALL ? undefined : val, offset: 0 }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder={t('audit.action')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
|
||||
{uniqueActions.map((action) => (
|
||||
<SelectItem key={action} value={action}>
|
||||
{action}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.dateFrom')}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={toDateInput(params.date_from)}
|
||||
onChange={(e) =>
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
date_from: dateToIso(e.target.value, false),
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
className="w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.dateTo')}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={toDateInput(params.date_to)}
|
||||
onChange={(e) =>
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
date_to: dateToIso(e.target.value, true),
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
className="w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
data={data?.data ?? []}
|
||||
total={data?.total}
|
||||
isLoading={isLoading}
|
||||
tableKey={TABLE_KEY}
|
||||
params={params}
|
||||
onParamsChange={setParams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditPage;
|
||||
export default AuditPage;
|
||||
|
||||
Reference in New Issue
Block a user