Добавить 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 ----
|
// ---- admins ----
|
||||||
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
|
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
|
||||||
const page = listSlice(content.admins, url);
|
const page = listSlice(content.admins, url);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import ReportDetailPage from './pages/reports/ReportDetailPage';
|
|||||||
import ReviewListPage from './pages/reviews/ReviewListPage';
|
import ReviewListPage from './pages/reviews/ReviewListPage';
|
||||||
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
|
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
|
||||||
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
|
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 TicketListPage from './pages/tickets/TicketListPage';
|
||||||
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
||||||
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
|
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
|
||||||
@@ -65,6 +67,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
|
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
|
||||||
<Route path="/inbox/reports" element={<ReportInboxPage />} />
|
<Route path="/inbox/reports" element={<ReportInboxPage />} />
|
||||||
|
<Route path="/inbox/automod" element={<AutomodInboxPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
|
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
|
||||||
@@ -79,6 +82,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="/events" element={<EventListPage />} />
|
<Route path="/events" element={<EventListPage />} />
|
||||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||||
<Route path="/banned-words" element={<BannedWordsPage />} />
|
<Route path="/banned-words" element={<BannedWordsPage />} />
|
||||||
|
<Route path="/automod-settings" element={<AutomodSettingsPage />} />
|
||||||
<Route path="/subscriptions" element={<SubscriptionListPage />} />
|
<Route path="/subscriptions" element={<SubscriptionListPage />} />
|
||||||
</Route>
|
</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,
|
'/monitoring': undefined,
|
||||||
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
|
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
|
||||||
'/inbox/tickets': ['superadmin', 'admin', 'support'],
|
'/inbox/tickets': ['superadmin', 'admin', 'support'],
|
||||||
|
'/inbox/automod': ['superadmin', 'admin', 'moderator'],
|
||||||
'/users': ['superadmin', 'admin'],
|
'/users': ['superadmin', 'admin'],
|
||||||
'/calendars': ['superadmin', 'admin'],
|
'/calendars': ['superadmin', 'admin'],
|
||||||
'/events': ['superadmin', 'admin'],
|
'/events': ['superadmin', 'admin'],
|
||||||
'/reports': ['superadmin', 'admin', 'moderator'],
|
'/reports': ['superadmin', 'admin', 'moderator'],
|
||||||
'/reviews': ['superadmin', 'admin', 'moderator'],
|
'/reviews': ['superadmin', 'admin', 'moderator'],
|
||||||
'/banned-words': ['superadmin', 'admin'],
|
'/banned-words': ['superadmin', 'admin'],
|
||||||
|
'/automod-settings': ['superadmin', 'admin'],
|
||||||
'/tickets': ['superadmin', 'admin', 'support'],
|
'/tickets': ['superadmin', 'admin', 'support'],
|
||||||
'/subscriptions': ['superadmin', 'admin'],
|
'/subscriptions': ['superadmin', 'admin'],
|
||||||
'/admins': ['superadmin'],
|
'/admins': ['superadmin'],
|
||||||
@@ -39,6 +41,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
|
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
{ key: '/inbox/tickets', label: 'nav.inboxTickets', roles: ['superadmin', 'admin', 'support'] },
|
{ 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: '/reports', label: 'nav.reports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
|
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
{ key: '/banned-words', label: 'nav.bannedWords', roles: ['superadmin', 'admin'] },
|
{ 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'] },
|
{ key: '/tickets', label: 'nav.tickets', roles: ['superadmin', 'admin', 'support'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -129,13 +133,14 @@ export function getMenuSelectedKey(pathname: string): string {
|
|||||||
return pathname;
|
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',
|
report_created: '/inbox/reports',
|
||||||
ticket_created: '/inbox/tickets',
|
ticket_created: '/inbox/tickets',
|
||||||
|
automod_hit: '/inbox/automod',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function canReceiveWsNotification(
|
export function canReceiveWsNotification(
|
||||||
type: 'report_created' | 'ticket_created',
|
type: 'report_created' | 'ticket_created' | 'automod_hit',
|
||||||
role: string | undefined
|
role: string | undefined
|
||||||
): boolean {
|
): boolean {
|
||||||
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
|
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,
|
Ticket,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
Activity,
|
Activity,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
Rows3,
|
Rows3,
|
||||||
Check,
|
Check,
|
||||||
Languages,
|
Languages,
|
||||||
|
Settings2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
|
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
|
||||||
@@ -64,12 +66,14 @@ const navIconByKey: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
'/dashboard': LayoutDashboard,
|
'/dashboard': LayoutDashboard,
|
||||||
'/inbox/reports': Inbox,
|
'/inbox/reports': Inbox,
|
||||||
'/inbox/tickets': Ticket,
|
'/inbox/tickets': Ticket,
|
||||||
|
'/inbox/automod': ShieldAlert,
|
||||||
'/users': Users,
|
'/users': Users,
|
||||||
'/calendars': Calendar,
|
'/calendars': Calendar,
|
||||||
'/events': CalendarDays,
|
'/events': CalendarDays,
|
||||||
'/reports': AlertTriangle,
|
'/reports': AlertTriangle,
|
||||||
'/reviews': Star,
|
'/reviews': Star,
|
||||||
'/banned-words': Ban,
|
'/banned-words': Ban,
|
||||||
|
'/automod-settings': Settings2,
|
||||||
'/tickets': Ticket,
|
'/tickets': Ticket,
|
||||||
'/subscriptions': CreditCard,
|
'/subscriptions': CreditCard,
|
||||||
'/admins': Shield,
|
'/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);
|
window.addEventListener('admin-ws-message', handler as EventListener);
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"inboxReports": "Queue · reports",
|
"inboxReports": "Queue · reports",
|
||||||
"inboxTickets": "Queue · tickets",
|
"inboxTickets": "Queue · tickets",
|
||||||
|
"inboxAutomod": "Queue · automod",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
"reports": "Reports · archive",
|
"reports": "Reports · archive",
|
||||||
"reviews": "Reviews",
|
"reviews": "Reviews",
|
||||||
"bannedWords": "Banned words",
|
"bannedWords": "Banned words",
|
||||||
|
"automodSettings": "Automoderation",
|
||||||
"tickets": "Tickets · archive",
|
"tickets": "Tickets · archive",
|
||||||
"admins": "Admins",
|
"admins": "Admins",
|
||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
@@ -324,6 +326,7 @@
|
|||||||
"onTarget": "on {{type}}",
|
"onTarget": "on {{type}}",
|
||||||
"newTicket": "New ticket created",
|
"newTicket": "New ticket created",
|
||||||
"ticketCreated": "Ticket #{{id}} created",
|
"ticketCreated": "Ticket #{{id}} created",
|
||||||
|
"automodHit": "Automoderation hit",
|
||||||
"open": "Open"
|
"open": "Open"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -471,6 +474,40 @@
|
|||||||
"addedBy": "Added by",
|
"addedBy": "Added by",
|
||||||
"addedAt": "Added at"
|
"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": {
|
"menu": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"inboxReports": "Очередь · жалобы",
|
"inboxReports": "Очередь · жалобы",
|
||||||
"inboxTickets": "Очередь · тикеты",
|
"inboxTickets": "Очередь · тикеты",
|
||||||
|
"inboxAutomod": "Очередь · автомодерация",
|
||||||
"dashboard": "Дашборд",
|
"dashboard": "Дашборд",
|
||||||
"monitoring": "Мониторинг",
|
"monitoring": "Мониторинг",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
"reports": "Жалобы · архив",
|
"reports": "Жалобы · архив",
|
||||||
"reviews": "Отзывы",
|
"reviews": "Отзывы",
|
||||||
"bannedWords": "Бан-слова",
|
"bannedWords": "Бан-слова",
|
||||||
|
"automodSettings": "Автомодерация",
|
||||||
"tickets": "Тикеты · архив",
|
"tickets": "Тикеты · архив",
|
||||||
"admins": "Администраторы",
|
"admins": "Администраторы",
|
||||||
"audit": "Аудит",
|
"audit": "Аудит",
|
||||||
@@ -324,6 +326,7 @@
|
|||||||
"onTarget": "на {{type}}",
|
"onTarget": "на {{type}}",
|
||||||
"newTicket": "Создан новый тикет",
|
"newTicket": "Создан новый тикет",
|
||||||
"ticketCreated": "Тикет #{{id}} создан",
|
"ticketCreated": "Тикет #{{id}} создан",
|
||||||
|
"automodHit": "Срабатывание автомодерации",
|
||||||
"open": "Открыть"
|
"open": "Открыть"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -471,6 +474,40 @@
|
|||||||
"addedBy": "Кем добавлено",
|
"addedBy": "Кем добавлено",
|
||||||
"addedAt": "Дата добавления"
|
"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": {
|
"menu": {
|
||||||
"dashboard": "Дашборд",
|
"dashboard": "Дашборд",
|
||||||
"users": "Пользователи",
|
"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