import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AlertTriangle, X } from 'lucide-react' import { getAdminSettings, sendTestEmail, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn, type TestEmailOut, } from '@/api/admin' import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences' import { ApiError, errorDetail } from '@/api/client' import { useAuth } from '@/auth/useAuth' import { Select } from '@/components/ui/Select' import { useToast } from '@/components/ui/ToastProvider' const SUMMARY_RECIPIENTS_OPTIONS = [ { value: 'all', label: 'Всем участникам' }, { value: 'owner', label: 'Только организатору' }, ] const PUBLISH_QUALITY_CAP_OPTIONS = [ { value: 'off', label: 'Без ограничения' }, { value: '720p', label: 'Не выше 720p' }, { value: '360p', label: 'Не выше 360p' }, { value: '180p', label: 'Не выше 180p' }, ] const STAGE_MAX_TILES_OPTIONS = [ { value: '25', label: '25 (5×5, без ограничения)' }, { value: '16', label: '16 (4×4)' }, { value: '9', label: '9 (3×3)' }, { value: '4', label: '4 (2×2)' }, ] const AI_LEVEL_LABEL: Record = { min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)', medium: 'Средний', max: 'Максимальный', } /** * Вкладка «Настройки» админки — раздела нет в макете `admin.html`, построена * по паттерну того же макета (`section-tab`/`table-card` → карточки-секции * с `toggle-row`/radio). * Тумблер «Транскрибация и суммаризация» — единый переключатель AI-модуля * (см. `SettingsOut.transcription_enabled`). * * Форма (`AdminSettingsForm`) вынесена отдельно и монтируется только после * загрузки `data` — локальное состояние инициализируется прямо из пропсов * при монтировании, без `useEffect`-синхронизации (react-hooks/set-state-in-effect). */ export function AdminSettingsTab() { const { data, isLoading } = useQuery({ queryKey: ['admin', 'settings'], queryFn: getAdminSettings }) if (isLoading || !data) { return

Загрузка настроек…

} return } function AdminSettingsForm({ data }: { data: SettingsOut }) { const queryClient = useQueryClient() const toast = useToast() const { user } = useAuth() const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiLevel, setAiLevel] = useState(data.ai_level) const [recipients, setRecipients] = useState(data.summary_recipients) const [timezone, setTimezone] = useState(data.display_timezone) const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice) const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled) const [emailDomains, setEmailDomains] = useState(data.registration_email_domains) const [newDomainInput, setNewDomainInput] = useState('') const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled) const [contactEmail, setContactEmail] = useState(data.contact_email ?? '') const [publishQualityCap, setPublishQualityCap] = useState(data.publish_quality_cap) const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles) const [testEmailTo, setTestEmailTo] = useState('') const [testEmailResult, setTestEmailResult] = useState(null) const mutation = useMutation({ mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload), onSuccess: (result) => { queryClient.setQueryData(['admin', 'settings'], result) toast.show('Настройки сохранены', 'success') }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 400) { toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error') } else { toast.show('Не удалось сохранить настройки', 'error') } }, }) const testEmailMutation = useMutation({ mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}), onSuccess: (result) => setTestEmailResult(result), onError: (err: unknown) => { setTestEmailResult({ success: false, message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо', smtp_host: null, smtp_port: null, }) }, }) function addDomain() { const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '') if (!domain || emailDomains.includes(domain)) { setNewDomainInput('') return } setEmailDomains([...emailDomains, domain]) setNewDomainInput('') } function removeDomain(domain: string) { setEmailDomains(emailDomains.filter((d) => d !== domain)) } function handleSave() { // Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор // опциональных полей именно для этого): иначе, например, ai_level // уходил бы в PUT нетронутым при каждом сохранении и на слабом железе // валился бы в 400, блокируя правку вообще любой другой настройки. const payload: SettingsUpdateIn = {} if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (recipients !== data.summary_recipients) payload.summary_recipients = recipients if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim() if (teamChoiceEnabled !== data.registration_team_choice) { payload.registration_team_choice = teamChoiceEnabled } if (domainVerificationEnabled !== data.registration_email_domain_enabled) { payload.registration_email_domain_enabled = domainVerificationEnabled } if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) { payload.registration_email_domains = emailDomains } if (contactEmailEnabled !== data.contact_email_enabled) { payload.contact_email_enabled = contactEmailEnabled } const trimmedContactEmail = contactEmail.trim() || null if (trimmedContactEmail !== (data.contact_email ?? null)) { payload.contact_email = trimmedContactEmail } if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles mutation.mutate(payload) } return (

Модули

Включение и отключение возможностей инстанса — переключатели плагинов из поставки.

Чат конференции Текстовый чат внутри комнаты для участников
Транскрибация и суммаризация (AI) Батч-обработка записи после конференции: расшифровка речи и саммари {aiEnabled && !data.transcription_queue_served && ( )}
Разрешить выбор команды при регистрации На экране регистрации появится поле «Команда» со списком команд инстанса
Верификация по домену почты Регистрация только с email эталонного домена (см. плитку «Эталон mail-домена»)
{/* Анонс записи конференций. Чекбокс не имеет состояния и не участвует в сохранении формы — функция появится в одной из следующих версий, здесь только заглушка-анонс в общем стиле «Модулей». */}
Возможность записи конференций Функция появится в ближайших версиях

Уровень AI

Качество распознавания и суммаризации — определяет размер моделей и требования к железу.

{data.ai_levels.map((levelStatus) => ( ))}

Эталон mail-домена

Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».

{emailDomains.length > 0 && (
{emailDomains.map((domain) => ( {domain} ))}
)}
setNewDomainInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() addDomain() } }} />

Рассылка саммари

Кому по умолчанию отправляется письмо с саммари после конференции (переопределяется в карточке конференции).

setTimezone(e.target.value)} />

Нагрузка

Рычаги для инстансов на слабом канале/железе — режут исходящий трафик и нагрузку на устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).

setStageMaxTiles(Number(v))} options={STAGE_MAX_TILES_OPTIONS} />

Сверх лимита участники уходят на следующую страницу сетки вместо подписки — меньше одновременных видеопотоков на канал и экран участника

Контактный адрес

Адрес для ответов на письма от инстанса (уходят от no-reply — этот адрес подставляется в заголовок «Reply-To»).

Контактный адрес включён Подставляется в Reply-To подтверждения регистрации, приглашений и саммари
setContactEmail(e.target.value)} />

Тестовое письмо

Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.

setTestEmailTo(e.target.value)} />
{testEmailResult && (

{testEmailResult.message} {testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}

)}
) }