Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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>
)
}