Добавить UI настроек и inbox автомодерации.
CI / test (push) Failing after 3m46s
CI / push-image (push) Has been skipped
CI / deploy-ift (push) Has been skipped
CI / ci-done (push) Failing after 0s
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
CI / test (push) Failing after 3m46s
CI / push-image (push) Has been skipped
CI / deploy-ift (push) Has been skipped
CI / ci-done (push) Failing after 0s
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Refs EventHub/EventHubFrontAdmin#40 Refs EventHub/EventHubFrontAdmin#41
This commit is contained in:
@@ -243,6 +243,72 @@ export async function tryHandleContentApi(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- automod ----
|
||||
if (!('_automod' in content)) {
|
||||
(content as { _automod?: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod = {
|
||||
settings: {
|
||||
keyword_action: 'flag',
|
||||
match_mode: 'word_boundary',
|
||||
report_threshold: 3,
|
||||
updated_at: '2026-07-14T12:00:00Z',
|
||||
updated_by: 'admin-e2e-1',
|
||||
},
|
||||
hits: [
|
||||
{
|
||||
id: 'hit-1',
|
||||
entity_type: 'event',
|
||||
entity_id: 'evt-1',
|
||||
trigger: 'keyword',
|
||||
matched_words: ['spam'],
|
||||
action_taken: 'flag',
|
||||
status: 'open',
|
||||
created_at: '2026-07-14T12:00:00Z',
|
||||
resolved_at: null,
|
||||
resolved_by: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const automod = (content as { _automod: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod;
|
||||
if (method === 'GET' && path.endsWith('/v1/admin/automod/settings')) {
|
||||
await json(route, 200, automod.settings);
|
||||
return true;
|
||||
}
|
||||
if (method === 'PUT' && path.endsWith('/v1/admin/automod/settings')) {
|
||||
const payload = postData() as Record<string, unknown>;
|
||||
automod.settings = { ...automod.settings, ...payload };
|
||||
await json(route, 200, automod.settings);
|
||||
return true;
|
||||
}
|
||||
if (method === 'GET' && path.endsWith('/v1/admin/automod/hits')) {
|
||||
const status = url.searchParams.get('status');
|
||||
const rows = status
|
||||
? automod.hits.filter((h) => h.status === status)
|
||||
: automod.hits;
|
||||
await json(route, 200, rows);
|
||||
return true;
|
||||
}
|
||||
{
|
||||
const m = path.match(/\/v1\/admin\/automod\/hits\/([^/]+)$/);
|
||||
if (m && method === 'PUT') {
|
||||
const id = decodeURIComponent(m[1]);
|
||||
const payload = postData() as { status?: string };
|
||||
const idx = automod.hits.findIndex((h) => h.id === id);
|
||||
if (idx >= 0 && payload.status) {
|
||||
automod.hits[idx] = {
|
||||
...automod.hits[idx],
|
||||
status: payload.status,
|
||||
resolved_at: '2026-07-14T13:00:00Z',
|
||||
resolved_by: 'admin-e2e-1',
|
||||
};
|
||||
await json(route, 200, automod.hits[idx]);
|
||||
return true;
|
||||
}
|
||||
await json(route, 404, { error: 'not found' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- admins ----
|
||||
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
|
||||
const page = listSlice(content.admins, url);
|
||||
|
||||
@@ -19,6 +19,8 @@ import ReportDetailPage from './pages/reports/ReportDetailPage';
|
||||
import ReviewListPage from './pages/reviews/ReviewListPage';
|
||||
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
|
||||
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
|
||||
import AutomodSettingsPage from './pages/automod/AutomodSettingsPage';
|
||||
import AutomodInboxPage from './pages/automod/AutomodInboxPage';
|
||||
import TicketListPage from './pages/tickets/TicketListPage';
|
||||
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
||||
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
|
||||
@@ -65,6 +67,7 @@ const App: React.FC = () => {
|
||||
|
||||
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
|
||||
<Route path="/inbox/reports" element={<ReportInboxPage />} />
|
||||
<Route path="/inbox/automod" element={<AutomodInboxPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
|
||||
@@ -79,6 +82,7 @@ const App: React.FC = () => {
|
||||
<Route path="/events" element={<EventListPage />} />
|
||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||
<Route path="/banned-words" element={<BannedWordsPage />} />
|
||||
<Route path="/automod-settings" element={<AutomodSettingsPage />} />
|
||||
<Route path="/subscriptions" element={<SubscriptionListPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export type KeywordAction = 'reject' | 'flag' | 'censor';
|
||||
export type MatchMode = 'word_boundary' | 'substring';
|
||||
export type AutomodHitStatus = 'open' | 'approved' | 'rejected';
|
||||
export type AutomodTrigger = 'keyword' | 'report_threshold';
|
||||
|
||||
export interface AutomodSettings {
|
||||
keyword_action: KeywordAction;
|
||||
match_mode: MatchMode;
|
||||
report_threshold: number;
|
||||
updated_at?: string;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
export interface AutomodHit {
|
||||
id: string;
|
||||
entity_type: 'calendar' | 'event' | 'review';
|
||||
entity_id: string;
|
||||
trigger: AutomodTrigger;
|
||||
matched_words: string[];
|
||||
action_taken: string;
|
||||
status: AutomodHitStatus;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
resolved_by: string;
|
||||
}
|
||||
|
||||
export interface AutomodHitListParams {
|
||||
status?: AutomodHitStatus;
|
||||
trigger?: AutomodTrigger;
|
||||
}
|
||||
|
||||
export const automodApi = {
|
||||
getSettings: async (): Promise<AutomodSettings> => {
|
||||
const { data } = await apiClient.get('/v1/admin/automod/settings');
|
||||
return data;
|
||||
},
|
||||
updateSettings: async (payload: Partial<AutomodSettings>): Promise<AutomodSettings> => {
|
||||
const { data } = await apiClient.put('/v1/admin/automod/settings', payload);
|
||||
return data;
|
||||
},
|
||||
getHits: async (params?: AutomodHitListParams): Promise<AutomodHit[]> => {
|
||||
const { data } = await apiClient.get('/v1/admin/automod/hits', { params });
|
||||
return data;
|
||||
},
|
||||
resolveHit: async (id: string, status: 'approved' | 'rejected'): Promise<AutomodHit> => {
|
||||
const { data } = await apiClient.put(`/v1/admin/automod/hits/${id}`, { status });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -7,12 +7,14 @@ export const ROUTE_ACCESS: Record<string, AdminRole[] | undefined> = {
|
||||
'/monitoring': undefined,
|
||||
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
|
||||
'/inbox/tickets': ['superadmin', 'admin', 'support'],
|
||||
'/inbox/automod': ['superadmin', 'admin', 'moderator'],
|
||||
'/users': ['superadmin', 'admin'],
|
||||
'/calendars': ['superadmin', 'admin'],
|
||||
'/events': ['superadmin', 'admin'],
|
||||
'/reports': ['superadmin', 'admin', 'moderator'],
|
||||
'/reviews': ['superadmin', 'admin', 'moderator'],
|
||||
'/banned-words': ['superadmin', 'admin'],
|
||||
'/automod-settings': ['superadmin', 'admin'],
|
||||
'/tickets': ['superadmin', 'admin', 'support'],
|
||||
'/subscriptions': ['superadmin', 'admin'],
|
||||
'/admins': ['superadmin'],
|
||||
@@ -39,6 +41,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||
{ key: '/inbox/tickets', label: 'nav.inboxTickets', roles: ['superadmin', 'admin', 'support'] },
|
||||
{ key: '/inbox/automod', label: 'nav.inboxAutomod', roles: ['superadmin', 'admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -66,6 +69,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ key: '/reports', label: 'nav.reports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
|
||||
{ key: '/banned-words', label: 'nav.bannedWords', roles: ['superadmin', 'admin'] },
|
||||
{ key: '/automod-settings', label: 'nav.automodSettings', roles: ['superadmin', 'admin'] },
|
||||
{ key: '/tickets', label: 'nav.tickets', roles: ['superadmin', 'admin', 'support'] },
|
||||
],
|
||||
},
|
||||
@@ -129,13 +133,14 @@ export function getMenuSelectedKey(pathname: string): string {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created', string> = {
|
||||
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created' | 'automod_hit', string> = {
|
||||
report_created: '/inbox/reports',
|
||||
ticket_created: '/inbox/tickets',
|
||||
automod_hit: '/inbox/automod',
|
||||
};
|
||||
|
||||
export function canReceiveWsNotification(
|
||||
type: 'report_created' | 'ticket_created',
|
||||
type: 'report_created' | 'ticket_created' | 'automod_hit',
|
||||
role: string | undefined
|
||||
): boolean {
|
||||
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { automodApi, AutomodHitListParams, AutomodSettings } from '@/api/automodApi';
|
||||
|
||||
export const useAutomodSettings = () =>
|
||||
useQuery({
|
||||
queryKey: ['automod-settings'],
|
||||
queryFn: () => automodApi.getSettings(),
|
||||
});
|
||||
|
||||
export const useUpdateAutomodSettings = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Partial<AutomodSettings>) => automodApi.updateSettings(payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-settings'] }),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAutomodHits = (params?: AutomodHitListParams) =>
|
||||
useQuery({
|
||||
queryKey: ['automod-hits', params],
|
||||
queryFn: () => automodApi.getHits(params),
|
||||
});
|
||||
|
||||
export const useResolveAutomodHit = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'rejected' }) =>
|
||||
automodApi.resolveHit(id, status),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-hits'] }),
|
||||
});
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Ticket,
|
||||
CreditCard,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
FileSearch,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
Rows3,
|
||||
Check,
|
||||
Languages,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
|
||||
@@ -64,12 +66,14 @@ const navIconByKey: Record<string, React.ComponentType<{ className?: string }>>
|
||||
'/dashboard': LayoutDashboard,
|
||||
'/inbox/reports': Inbox,
|
||||
'/inbox/tickets': Ticket,
|
||||
'/inbox/automod': ShieldAlert,
|
||||
'/users': Users,
|
||||
'/calendars': Calendar,
|
||||
'/events': CalendarDays,
|
||||
'/reports': AlertTriangle,
|
||||
'/reviews': Star,
|
||||
'/banned-words': Ban,
|
||||
'/automod-settings': Settings2,
|
||||
'/tickets': Ticket,
|
||||
'/subscriptions': CreditCard,
|
||||
'/admins': Shield,
|
||||
@@ -184,6 +188,24 @@ const ControlCenterLayout: React.FC = () => {
|
||||
},
|
||||
}
|
||||
);
|
||||
} else if (msg.type === 'automod_hit') {
|
||||
if (!canReceiveWsNotification('automod_hit', user?.role)) return;
|
||||
const { entity_type, trigger } = msg.data || {};
|
||||
notify.info(
|
||||
[
|
||||
t('ws.automodHit'),
|
||||
entity_type ? `(${entity_type})` : '',
|
||||
trigger ? `[${trigger}]` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
{
|
||||
action: {
|
||||
label: t('ws.open'),
|
||||
onClick: () => navigate('/inbox/automod'),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener('admin-ws-message', handler as EventListener);
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"nav": {
|
||||
"inboxReports": "Queue · reports",
|
||||
"inboxTickets": "Queue · tickets",
|
||||
"inboxAutomod": "Queue · automod",
|
||||
"dashboard": "Dashboard",
|
||||
"monitoring": "Monitoring",
|
||||
"users": "Users",
|
||||
@@ -159,6 +160,7 @@
|
||||
"reports": "Reports · archive",
|
||||
"reviews": "Reviews",
|
||||
"bannedWords": "Banned words",
|
||||
"automodSettings": "Automoderation",
|
||||
"tickets": "Tickets · archive",
|
||||
"admins": "Admins",
|
||||
"audit": "Audit",
|
||||
@@ -324,6 +326,7 @@
|
||||
"onTarget": "on {{type}}",
|
||||
"newTicket": "New ticket created",
|
||||
"ticketCreated": "Ticket #{{id}} created",
|
||||
"automodHit": "Automoderation hit",
|
||||
"open": "Open"
|
||||
},
|
||||
"errors": {
|
||||
@@ -471,6 +474,40 @@
|
||||
"addedBy": "Added by",
|
||||
"addedAt": "Added at"
|
||||
},
|
||||
"automodSettings": {
|
||||
"title": "Automoderation settings",
|
||||
"description": "Keyword policy, matching mode, and report threshold",
|
||||
"keywordAction": "Action on banned word",
|
||||
"actionFlag": "Flag (queue + soft-hide)",
|
||||
"actionReject": "Reject save",
|
||||
"actionCensor": "Replace with ***",
|
||||
"matchMode": "Match mode",
|
||||
"matchWordBoundary": "Word boundary",
|
||||
"matchSubstring": "Substring",
|
||||
"reportThreshold": "Pending reports threshold",
|
||||
"thresholdInvalid": "Threshold must be ≥ 1",
|
||||
"saved": "Settings saved",
|
||||
"saveFailed": "Failed to save settings"
|
||||
},
|
||||
"automodInbox": {
|
||||
"title": "Automoderation queue",
|
||||
"description": "Hits from keywords and report threshold",
|
||||
"createdAt": "When",
|
||||
"trigger": "Trigger",
|
||||
"entity": "Entity",
|
||||
"words": "Words",
|
||||
"action": "Action",
|
||||
"status": "Status",
|
||||
"actions": "Decision",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"filterOpen": "Open",
|
||||
"filterAll": "All",
|
||||
"filterApproved": "Approved",
|
||||
"filterRejected": "Rejected",
|
||||
"resolved": "Decision saved",
|
||||
"resolveFailed": "Failed to save decision"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
"users": "Users",
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"nav": {
|
||||
"inboxReports": "Очередь · жалобы",
|
||||
"inboxTickets": "Очередь · тикеты",
|
||||
"inboxAutomod": "Очередь · автомодерация",
|
||||
"dashboard": "Дашборд",
|
||||
"monitoring": "Мониторинг",
|
||||
"users": "Пользователи",
|
||||
@@ -159,6 +160,7 @@
|
||||
"reports": "Жалобы · архив",
|
||||
"reviews": "Отзывы",
|
||||
"bannedWords": "Бан-слова",
|
||||
"automodSettings": "Автомодерация",
|
||||
"tickets": "Тикеты · архив",
|
||||
"admins": "Администраторы",
|
||||
"audit": "Аудит",
|
||||
@@ -324,6 +326,7 @@
|
||||
"onTarget": "на {{type}}",
|
||||
"newTicket": "Создан новый тикет",
|
||||
"ticketCreated": "Тикет #{{id}} создан",
|
||||
"automodHit": "Срабатывание автомодерации",
|
||||
"open": "Открыть"
|
||||
},
|
||||
"errors": {
|
||||
@@ -471,6 +474,40 @@
|
||||
"addedBy": "Кем добавлено",
|
||||
"addedAt": "Дата добавления"
|
||||
},
|
||||
"automodSettings": {
|
||||
"title": "Настройки автомодерации",
|
||||
"description": "Политика по бан-словам, matching и порог жалоб",
|
||||
"keywordAction": "Действие при бан-слове",
|
||||
"actionFlag": "Флаг (очередь + soft-hide)",
|
||||
"actionReject": "Отклонить сохранение",
|
||||
"actionCensor": "Заменить на ***",
|
||||
"matchMode": "Режим совпадения",
|
||||
"matchWordBoundary": "Граница слова",
|
||||
"matchSubstring": "Подстрока",
|
||||
"reportThreshold": "Порог pending-жалоб",
|
||||
"thresholdInvalid": "Порог должен быть ≥ 1",
|
||||
"saved": "Настройки сохранены",
|
||||
"saveFailed": "Не удалось сохранить настройки"
|
||||
},
|
||||
"automodInbox": {
|
||||
"title": "Очередь автомодерации",
|
||||
"description": "Срабатывания по словам и порогу жалоб",
|
||||
"createdAt": "Когда",
|
||||
"trigger": "Триггер",
|
||||
"entity": "Сущность",
|
||||
"words": "Слова",
|
||||
"action": "Действие",
|
||||
"status": "Статус",
|
||||
"actions": "Решение",
|
||||
"approve": "Одобрить",
|
||||
"reject": "Отклонить",
|
||||
"filterOpen": "Открытые",
|
||||
"filterAll": "Все",
|
||||
"filterApproved": "Одобренные",
|
||||
"filterRejected": "Отклонённые",
|
||||
"resolved": "Решение сохранено",
|
||||
"resolveFailed": "Не удалось сохранить решение"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Дашборд",
|
||||
"users": "Пользователи",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ListPageHeader } from '@/components/ListPageHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useAutomodHits, useResolveAutomodHit } from '@/hooks/useAutomod';
|
||||
import type { AutomodHit, AutomodHitStatus } from '@/api/automodApi';
|
||||
import { formatDateTime } from '@/utils/datetime';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
function entityLink(hit: AutomodHit): string {
|
||||
if (hit.entity_type === 'event') return `/events/${hit.entity_id}`;
|
||||
if (hit.entity_type === 'calendar') return `/calendars/${hit.entity_id}`;
|
||||
return `/reviews/${hit.entity_id}`;
|
||||
}
|
||||
|
||||
const AutomodInboxPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<AutomodHitStatus | 'all'>('open');
|
||||
const params = useMemo(
|
||||
() => (status === 'all' ? undefined : { status }),
|
||||
[status]
|
||||
);
|
||||
const { data, isLoading } = useAutomodHits(params);
|
||||
const resolve = useResolveAutomodHit();
|
||||
const rows = data ?? [];
|
||||
|
||||
const onResolve = (id: string, next: 'approved' | 'rejected') => {
|
||||
resolve.mutate(
|
||||
{ id, status: next },
|
||||
{
|
||||
onSuccess: () => notify.success(t('automodInbox.resolved')),
|
||||
onError: () => notify.error(t('automodInbox.resolveFailed')),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="page-automod-inbox">
|
||||
<ListPageHeader
|
||||
title={t('automodInbox.title')}
|
||||
description={t('automodInbox.description')}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as AutomodHitStatus | 'all')}>
|
||||
<SelectTrigger className="w-48" data-testid="filter-automod-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">{t('automodInbox.filterOpen')}</SelectItem>
|
||||
<SelectItem value="all">{t('automodInbox.filterAll')}</SelectItem>
|
||||
<SelectItem value="approved">{t('automodInbox.filterApproved')}</SelectItem>
|
||||
<SelectItem value="rejected">{t('automodInbox.filterRejected')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('automodInbox.createdAt')}</TableHead>
|
||||
<TableHead>{t('automodInbox.trigger')}</TableHead>
|
||||
<TableHead>{t('automodInbox.entity')}</TableHead>
|
||||
<TableHead>{t('automodInbox.words')}</TableHead>
|
||||
<TableHead>{t('automodInbox.action')}</TableHead>
|
||||
<TableHead>{t('automodInbox.status')}</TableHead>
|
||||
<TableHead>{t('automodInbox.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t('common.noData')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((hit) => (
|
||||
<TableRow key={hit.id} data-testid={`row-automod-hit-${hit.id}`}>
|
||||
<TableCell>{formatDateTime(hit.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{hit.trigger}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link className="underline" to={entityLink(hit)}>
|
||||
{hit.entity_type}:{hit.entity_id.slice(0, 8)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{hit.matched_words.join(', ') || '—'}</TableCell>
|
||||
<TableCell>{hit.action_taken}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{hit.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{hit.status === 'open' ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid={`btn-approve-hit-${hit.id}`}
|
||||
onClick={() => onResolve(hit.id, 'approved')}
|
||||
disabled={resolve.isPending}
|
||||
>
|
||||
{t('automodInbox.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid={`btn-reject-hit-${hit.id}`}
|
||||
onClick={() => onResolve(hit.id, 'rejected')}
|
||||
disabled={resolve.isPending}
|
||||
>
|
||||
{t('automodInbox.reject')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomodInboxPage;
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListPageHeader } from '@/components/ListPageHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAutomodSettings, useUpdateAutomodSettings } from '@/hooks/useAutomod';
|
||||
import type { KeywordAction, MatchMode } from '@/api/automodApi';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
const AutomodSettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useAutomodSettings();
|
||||
const update = useUpdateAutomodSettings();
|
||||
const [keywordAction, setKeywordAction] = useState<KeywordAction>('flag');
|
||||
const [matchMode, setMatchMode] = useState<MatchMode>('word_boundary');
|
||||
const [threshold, setThreshold] = useState('3');
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setKeywordAction(data.keyword_action);
|
||||
setMatchMode(data.match_mode);
|
||||
setThreshold(String(data.report_threshold));
|
||||
}, [data]);
|
||||
|
||||
const onSave = () => {
|
||||
const n = parseInt(threshold, 10);
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
notify.error(t('automodSettings.thresholdInvalid'));
|
||||
return;
|
||||
}
|
||||
update.mutate(
|
||||
{
|
||||
keyword_action: keywordAction,
|
||||
match_mode: matchMode,
|
||||
report_threshold: n,
|
||||
},
|
||||
{
|
||||
onSuccess: () => notify.success(t('automodSettings.saved')),
|
||||
onError: () => notify.error(t('automodSettings.saveFailed')),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4" data-testid="page-automod-settings">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="page-automod-settings">
|
||||
<ListPageHeader
|
||||
title={t('automodSettings.title')}
|
||||
description={t('automodSettings.description')}
|
||||
/>
|
||||
<div className="max-w-lg space-y-4 rounded-lg border bg-card p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyword-action">{t('automodSettings.keywordAction')}</Label>
|
||||
<Select
|
||||
value={keywordAction}
|
||||
onValueChange={(v) => setKeywordAction(v as KeywordAction)}
|
||||
>
|
||||
<SelectTrigger id="keyword-action" data-testid="select-keyword-action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flag">{t('automodSettings.actionFlag')}</SelectItem>
|
||||
<SelectItem value="reject">{t('automodSettings.actionReject')}</SelectItem>
|
||||
<SelectItem value="censor">{t('automodSettings.actionCensor')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="match-mode">{t('automodSettings.matchMode')}</Label>
|
||||
<Select value={matchMode} onValueChange={(v) => setMatchMode(v as MatchMode)}>
|
||||
<SelectTrigger id="match-mode" data-testid="select-match-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="word_boundary">
|
||||
{t('automodSettings.matchWordBoundary')}
|
||||
</SelectItem>
|
||||
<SelectItem value="substring">{t('automodSettings.matchSubstring')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="threshold">{t('automodSettings.reportThreshold')}</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
data-testid="input-report-threshold"
|
||||
type="number"
|
||||
min={1}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button data-testid="btn-save-automod-settings" onClick={onSave} disabled={update.isPending}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomodSettingsPage;
|
||||
Reference in New Issue
Block a user