Первоначальная версия VidConf
This commit is contained in:
278
frontend/src/components/admin/AdminSettingsTab.tsx
Normal file
278
frontend/src/components/admin/AdminSettingsTab.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
|
||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
const SUMMARY_RECIPIENTS_OPTIONS = [
|
||||
{ value: 'all', label: 'Всем участникам' },
|
||||
{ value: 'owner', label: 'Только организатору' },
|
||||
]
|
||||
|
||||
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
|
||||
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 <p className="field-hint">Загрузка настроек…</p>
|
||||
}
|
||||
|
||||
return <AdminSettingsForm data={data} />
|
||||
}
|
||||
|
||||
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
||||
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
||||
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
|
||||
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(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 [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
||||
|
||||
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')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function handleSave() {
|
||||
mutation.mutate({
|
||||
chat_enabled: chatEnabled,
|
||||
transcription_enabled: aiEnabled,
|
||||
ai_level: aiLevel,
|
||||
summary_recipients: recipients,
|
||||
display_timezone: timezone.trim(),
|
||||
registration_team_choice: teamChoiceEnabled,
|
||||
registration_email_domain_enabled: domainVerificationEnabled,
|
||||
registration_email_domain: emailDomain.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section className="settings-card">
|
||||
<h2>Модули</h2>
|
||||
<p className="desc">Включение и отключение возможностей инстанса — переключатели плагинов из поставки.</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--spread">
|
||||
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<div className="toggle-copy">
|
||||
<strong>Чат конференции</strong>
|
||||
<span>Текстовый чат внутри комнаты для участников</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={chatEnabled} onChange={(e) => setChatEnabled(e.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Транскрибация и суммаризация (AI)</strong>
|
||||
<span>Батч-обработка записи после конференции: расшифровка речи и саммари</span>
|
||||
{aiEnabled && !data.transcription_queue_served && (
|
||||
<span className="ai-level-reason">
|
||||
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||||
AI включён, но обработка очереди транскрибации недоступна — сеансы не будут обработаны
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={aiEnabled} onChange={(e) => setAiEnabled(e.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Разрешить выбор команды при регистрации</strong>
|
||||
<span>На экране регистрации появится поле «Команда» со списком команд инстанса</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={teamChoiceEnabled}
|
||||
onChange={(e) => setTeamChoiceEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Верификация по домену почты</strong>
|
||||
<span>Регистрация только с email эталонного домена (см. плитку «Эталон mail-домена»)</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={domainVerificationEnabled}
|
||||
onChange={(e) => setDomainVerificationEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Анонс записи конференций. Чекбокс не имеет состояния и не
|
||||
участвует в сохранении формы — функция появится в одной из
|
||||
следующих версий, здесь только заглушка-анонс в общем стиле
|
||||
«Модулей».
|
||||
*/}
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Возможность записи конференций</strong>
|
||||
<span>Функция появится в ближайших версиях</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={false} disabled readOnly />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="settings-stack">
|
||||
<section className="settings-card">
|
||||
<h2>Уровень AI</h2>
|
||||
<p className="desc">Качество распознавания и суммаризации — определяет размер моделей и требования к железу.</p>
|
||||
|
||||
<div className="settings-card-body">
|
||||
<div className="ai-level-list">
|
||||
{data.ai_levels.map((levelStatus) => (
|
||||
<label
|
||||
key={levelStatus.level}
|
||||
className={`ai-level-option${!levelStatus.available ? ' is-disabled' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="ai-level"
|
||||
checked={aiLevel === levelStatus.level}
|
||||
disabled={!levelStatus.available || !aiEnabled}
|
||||
onChange={() => setAiLevel(levelStatus.level)}
|
||||
/>
|
||||
<div>
|
||||
<strong>{AI_LEVEL_LABEL[levelStatus.level]}</strong>
|
||||
{!levelStatus.available && levelStatus.reason && (
|
||||
<span className="ai-level-reason">
|
||||
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||||
{levelStatus.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Эталон mail-домена</h2>
|
||||
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--center">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
|
||||
<input
|
||||
id="settings-email-domain"
|
||||
type="text"
|
||||
placeholder="company.ru"
|
||||
value={emailDomain}
|
||||
disabled={!domainVerificationEnabled}
|
||||
onChange={(e) => setEmailDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Рассылка саммари</h2>
|
||||
<p className="desc">Кому по умолчанию отправляется письмо с саммари после конференции (переопределяется в карточке конференции).</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--center">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label id="settings-recipients-label" htmlFor="settings-recipients">
|
||||
Получатели по умолчанию
|
||||
</label>
|
||||
<Select
|
||||
id="settings-recipients"
|
||||
aria-labelledby="settings-recipients-label"
|
||||
value={recipients}
|
||||
onChange={(v) => setRecipients(v as SummaryRecipientsMode)}
|
||||
options={SUMMARY_RECIPIENTS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Часовой пояс отображения</h2>
|
||||
<p className="desc">IANA-таймзона для времени в письмах и .ics-приглашениях (в БД время всегда хранится в UTC).</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--center">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="settings-timezone">Таймзона</label>
|
||||
<input
|
||||
id="settings-timezone"
|
||||
type="text"
|
||||
list="timezone-options"
|
||||
placeholder="Europe/Moscow"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
/>
|
||||
<datalist id="timezone-options">
|
||||
<option value="Europe/Moscow" />
|
||||
<option value="Europe/Kaliningrad" />
|
||||
<option value="Europe/Samara" />
|
||||
<option value="Asia/Yekaterinburg" />
|
||||
<option value="Asia/Novosibirsk" />
|
||||
<option value="Asia/Krasnoyarsk" />
|
||||
<option value="Asia/Irkutsk" />
|
||||
<option value="Asia/Vladivostok" />
|
||||
<option value="UTC" />
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
||||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user