/
kochkatech
/
CorpGame
Обзор
Документация
Войти
/
kochkatech
/
CorpGame
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
dev
frontend/src/pages/Admin.tsx
587 строк
24 KB
kochkareal
chore: добавлен .gitattributes, нормализованы переносы строк (LF везде, кроме .bat/.cmd)
06 авг 2026, 15:18
06 авг 2026, 15:18
c25ef1b
Код
Авторство
О чём код?
import { FormEvent, useEffect, useRef, useState } from 'react'; import { api, ApiError } from '../api/client'; import { connectAdminSocket } from '../socket'; import { useGameStore } from '../store/useGameStore'; import { EventLogEntry, GameConfig, RevealState, SessionStatus, Team } from '../types'; import { Timer } from '../components/Timer'; import { AdminTeamRow } from '../components/AdminTeamRow'; import { Button } from '../components/ui/Button'; import { Card } from '../components/ui/Card'; import { Badge } from '../components/ui/Badge'; import { Spinner } from '../components/ui/Spinner'; import { formatEvent } from '../lib/formatEvent'; import { copyText } from '../lib/clipboard'; import { ConfirmModal } from '../components/ConfirmModal'; import { Emoji } from '../components/ui/Emoji'; interface SessionSummary { id: string; publicCode: string; status: SessionStatus; durationSec: number; teams: { id: string; name: string }[]; } interface SessionFull { id: string; publicCode: string; status: SessionStatus; durationSec: number; remainingSec: number; transferEnabled: boolean; sanctionsEnabled: boolean; theftEnabled: boolean; config: GameConfig; teams: (Team & { joinCode: string; players: { id: string; nickname: string }[] })[]; } const FIELD = 'rounded-lg bg-surfaceDeep px-2 py-1'; const EMPTY_REVEAL: RevealState = { active: false, step: null, index: -1, total: 0 }; /** Подсказка ведущему — что сказать вслух на текущем шаге показа итогов */ function revealHint(reveal: RevealState): string { const step = reveal.step; if (!step) return 'Показ ещё не начат — нажмите «Начать показ», когда все готовы смотреть на большой экран.'; if (step.type === 'title') return 'На экране заставка «БИТВА КОРПОРАЦИЙ». Нажмите «Далее», чтобы перейти к номинациям.'; if (step.type === 'nomination_question') { return `Скажите: «${step.title} — ${step.desc}». Затем нажмите «Далее», чтобы объявить победителя номинации.`; } if (step.type === 'nomination_answer') { return `Скажите: «А получает эту награду команда... ${step.teamName}!» и нажмите кнопку.`; } if (step.type === 'place') { return `Скажите: «${step.place}-е место занимает команда ${step.teamName}!»`; } return ''; } export function Admin() { const [authed, setAuthed] = useState<boolean | null>(null); const [password, setPassword] = useState(''); const [loginError, setLoginError] = useState<string | null>(null); const passwordRef = useRef<HTMLInputElement>(null); const [sessions, setSessions] = useState<SessionSummary[]>([]); const [selected, setSelected] = useState<SessionFull | null>(null); const [journal, setJournal] = useState<EventLogEntry[]>([]); const [lobby, setLobby] = useState<{ connected: number; total: number } | null>(null); const [reveal, setReveal] = useState<RevealState>(EMPTY_REVEAL); const [teamsCount, setTeamsCount] = useState(4); const [durationMin, setDurationMin] = useState(30); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const [codeCopied, setCodeCopied] = useState(false); const [savedMsg, setSavedMsg] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const connected = useGameStore((s) => s.connected); function flashSaved() { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 1500); } async function refreshSessions() { const list = await api.get<SessionSummary[]>('/admin/sessions'); setSessions(list); } useEffect(() => { refreshSessions() .then(() => setAuthed(true)) .catch(() => setAuthed(false)); }, []); useEffect(() => { if (authed === false) passwordRef.current?.focus(); }, [authed]); async function login(e: FormEvent) { e.preventDefault(); setLoginError(null); try { await api.post('/admin/login', { password }); setAuthed(true); await refreshSessions(); } catch (err) { setLoginError(err instanceof ApiError ? err.message : 'Ошибка входа'); } } async function createSession(e: FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { await api.post('/admin/sessions', { teamsCount, durationSec: durationMin * 60 }); await refreshSessions(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Не удалось создать сессию'); } finally { setBusy(false); } } async function refreshFull(id: string) { const [full, events, revealState] = await Promise.all([ api.get<SessionFull>(`/admin/sessions/${id}`), api.get<EventLogEntry[]>(`/admin/sessions/${id}/events`), api.get<RevealState>(`/admin/sessions/${id}/reveal`).catch(() => EMPTY_REVEAL), ]); setSelected(full); setJournal(events); setReveal(revealState); } async function select(id: string) { await refreshFull(id); const socket = connectAdminSocket(id, () => refreshFull(id).catch(() => undefined)); socket.off('lobby:update'); socket.off('state:update'); socket.off('session:status'); socket.off('time:sync'); socket.off('event:new'); socket.off('reveal:update'); socket.on('lobby:update', setLobby); socket.on('state:update', () => refreshFull(id)); socket.on('session:status', () => refreshFull(id)); socket.on('time:sync', ({ remainingSec }: { remainingSec: number }) => { setSelected((prev) => (prev && prev.id === id ? { ...prev, remainingSec } : prev)); }); socket.on('event:new', (event: EventLogEntry) => { if (event.type === 'branch:collect') return; setJournal((prev) => [event, ...prev].slice(0, 100)); }); socket.on('reveal:update', (payload: RevealState) => setReveal(payload)); } async function action(fn: () => Promise<unknown>): Promise<boolean> { if (!selected) return false; setBusy(true); setError(null); try { await fn(); await refreshFull(selected.id); return true; } catch (err) { setError(err instanceof ApiError ? err.message : 'Действие не выполнено'); return false; } finally { setBusy(false); } } async function toggleFlag(flag: 'transferEnabled' | 'sanctionsEnabled' | 'theftEnabled', value: boolean) { if (!selected) return; if (await action(() => api.patch(`/admin/sessions/${selected.id}/flags`, { [flag]: value }))) flashSaved(); } async function updateConfig(patch: Partial<{ bankRate: number; maxTheftsPerTeam: number; maxSanctionsPerTeam: number }>) { if (!selected) return; if (await action(() => api.patch(`/admin/sessions/${selected.id}/config`, patch))) flashSaved(); } async function updateDashboardConfig(patch: Partial<{ showResources: boolean; showScore: boolean }>) { if (!selected) return; if (await action(() => api.patch(`/admin/sessions/${selected.id}/dashboard`, patch))) flashSaved(); } async function extendTime(addSec: number) { if (!selected) return; await action(() => api.post(`/admin/sessions/${selected.id}/extend`, { addSec })); } async function renameTeam(teamId: string, name: string) { await action(() => api.patch(`/admin/teams/${teamId}`, { name })); } async function removeTeam(teamId: string) { await action(() => api.delete(`/admin/teams/${teamId}`)); } async function grantResource(teamId: string, resource: string, amount: number) { await action(() => api.post(`/admin/teams/${teamId}/grant`, { resource, amount })); } async function applyTeamEffect(teamId: string, multiplier: number, durationSec: number) { await action(() => api.post(`/admin/teams/${teamId}/effect`, { multiplier, durationSec })); } async function deleteSession(id: string) { setBusy(true); setError(null); try { await api.delete(`/admin/sessions/${id}`); if (selected?.id === id) setSelected(null); await refreshSessions(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Не удалось удалить сессию'); } finally { setBusy(false); } } async function revealStart() { if (!selected) return; await action(async () => setReveal(await api.post<RevealState>(`/admin/sessions/${selected.id}/reveal/start`))); } async function revealNext() { if (!selected) return; await action(async () => setReveal(await api.post<RevealState>(`/admin/sessions/${selected.id}/reveal/next`))); } async function revealPrev() { if (!selected) return; await action(async () => setReveal(await api.post<RevealState>(`/admin/sessions/${selected.id}/reveal/prev`))); } async function copyRoomCode() { if (!selected) return; if (await copyText(selected.publicCode)) { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 1500); } } if (authed === null) { return <Spinner fullScreen />; } if (!authed) { return ( <div className="flex min-h-screen animate-fade-in flex-col items-center justify-center gap-4 bg-main text-white"> <h1 className="text-3xl font-black">Панель администратора</h1> <form onSubmit={login} className="flex flex-col gap-2"> <input ref={passwordRef} type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="пароль" className="rounded-lg border-2 border-white/30 bg-white/10 px-4 py-2 text-center focus:border-focus focus:outline-none" /> {loginError && <div className="text-sm text-red-200">{loginError}</div>} <Button variant="accent">Войти</Button> </form> </div> ); } return ( <div className="min-h-screen animate-fade-in bg-surfaceDeep p-4 text-white"> <div className="flex items-center justify-between"> <h1 className="text-2xl font-black text-main">Панель администратора</h1> {!connected && ( <Badge className="bg-yellow-600 text-black">переподключение…</Badge> )} </div> <div className="mt-4 grid gap-6 lg:grid-cols-[1fr_2fr]"> <div className="flex flex-col gap-4"> <Card className="p-3"> <form onSubmit={createSession} className="flex flex-col gap-2"> <h2 className="font-bold text-focus">Новая игра</h2> <label className="text-sm"> Команд:{' '} <input type="number" min={2} max={12} value={teamsCount} onChange={(e) => setTeamsCount(Number(e.target.value))} className={`w-16 ${FIELD}`} /> </label> <label className="text-sm"> Длительность (мин):{' '} <input type="number" min={1} value={durationMin} onChange={(e) => setDurationMin(Number(e.target.value))} className={`w-16 ${FIELD}`} /> </label> <Button variant="primary" busy={busy} type="submit"> Создать </Button> </form> </Card> <Card className="p-3"> <h2 className="mb-2 font-bold text-focus">Сессии</h2> <div className="flex flex-col gap-1"> {sessions.map((s) => ( <div key={s.id} className={`flex items-center gap-1 rounded-lg px-1 transition ${ selected?.id === s.id ? 'bg-main' : 'bg-surface hover:bg-main/50' }`} > <button onClick={() => select(s.id)} className="flex-1 px-2 py-2 text-left"> <div className="font-mono font-bold">{s.publicCode}</div> <div className="text-xs text-white/70"> {s.status} · {s.teams.length} команд </div> </button> <button onClick={() => setConfirmDeleteId(s.id)} title="Удалить сессию" className="rounded px-2 py-2 text-white/50 transition hover:bg-red-950/60" > <Emoji name="trash" fallback="🗑" label="Удалить" className="h-5 w-5" /> </button> </div> ))} {sessions.length === 0 && <p className="text-sm text-white/50">Сессий пока нет.</p>} </div> </Card> </div> <div className="flex flex-col gap-4"> {error && <div className="rounded-lg bg-red-950/60 px-3 py-2 text-sm text-red-200">{error}</div>} {selected && ( <> <Card className="flex flex-wrap items-center justify-between gap-3 p-3"> <div> <div className="text-sm text-white/60">Код комнаты для подключения</div> <button onClick={copyRoomCode} title="Скопировать код комнаты" className="flex items-center gap-2 font-mono text-3xl font-black text-focus transition hover:brightness-125" > {selected.publicCode}{' '} {codeCopied ? ( <span className="text-lg">✓</span> ) : ( <Emoji name="clipboard" fallback="📋" label="Скопировать" className="h-5 w-5" /> )} </button> </div> <div className="flex flex-col items-center gap-1"> <Timer remainingSec={selected.remainingSec} running={selected.status === 'RUNNING'} /> {selected.status !== 'FINISHED' && ( <div className="flex items-center gap-1"> <Button variant="primary" className="px-2 py-1 text-xs" busy={busy} onClick={() => extendTime(-300)}> −5 мин </Button> <Button variant="primary" className="px-2 py-1 text-xs" busy={busy} onClick={() => extendTime(-60)}> −1 мин </Button> <Button variant="accent" className="px-2 py-1 text-xs" busy={busy} onClick={() => extendTime(60)}> +1 мин </Button> <Button variant="accent" className="px-2 py-1 text-xs" busy={busy} onClick={() => extendTime(300)}> +5 мин </Button> </div> )} </div> <div className="text-sm"> Игроков онлайн: {lobby ? `${lobby.connected}/${lobby.total}` : '—'} </div> <div className="flex gap-2"> {selected.status === 'LOBBY' && ( <Button variant="accent" onClick={() => action(() => api.post(`/admin/sessions/${selected.id}/start`))}> Старт </Button> )} {selected.status === 'RUNNING' && ( <Button variant="primary" onClick={() => action(() => api.post(`/admin/sessions/${selected.id}/pause`))}> Пауза </Button> )} {selected.status === 'PAUSED' && ( <Button variant="accent" onClick={() => action(() => api.post(`/admin/sessions/${selected.id}/resume`))}> Продолжить </Button> )} {(selected.status === 'RUNNING' || selected.status === 'PAUSED') && ( <Button variant="danger" onClick={() => action(() => api.post(`/admin/sessions/${selected.id}/finish`))}> Завершить </Button> )} </div> </Card> {selected.status === 'FINISHED' && ( <Card className="border-2 border-focus p-3"> <h2 className="mb-2 font-bold text-focus">Показ итогов ведущим</h2> <p className="mb-3 text-sm text-white/80">{revealHint(reveal)}</p> <div className="flex flex-wrap items-center gap-2"> {!reveal.active ? ( <Button variant="accent" busy={busy} onClick={revealStart}> Начать показ </Button> ) : ( <> <Button variant="primary" busy={busy} disabled={reveal.index <= 0} onClick={revealPrev}> ← Назад </Button> <Button variant="accent" busy={busy} disabled={reveal.index >= reveal.total - 1} onClick={revealNext} > Далее → </Button> <span className="text-sm text-white/60"> Шаг {reveal.index + 1} / {reveal.total} </span> </> )} </div> </Card> )} <Card className="p-3"> <h2 className="mb-2 font-bold text-focus">Игровые механики</h2> <div className="flex flex-wrap gap-4 text-sm"> <label className="flex items-center gap-2"> <input type="checkbox" checked={selected.transferEnabled} onChange={(e) => toggleFlag('transferEnabled', e.target.checked)} /> Передача ресурсов </label> <label className="flex items-center gap-2"> <input type="checkbox" checked={selected.sanctionsEnabled} onChange={(e) => toggleFlag('sanctionsEnabled', e.target.checked)} /> Санкции </label> <label className="flex items-center gap-2"> <input type="checkbox" checked={selected.theftEnabled} onChange={(e) => toggleFlag('theftEnabled', e.target.checked)} /> Кража ресурсов </label> </div> </Card> <Card className="p-3"> <h2 className="mb-2 font-bold text-focus">Публичный дашборд</h2> <p className="mb-2 text-xs text-white/60"> Что видно зрителям на большом экране. Отключите очки/рейтинг, если не хотите заранее спойлерить, кто выигрывает. </p> <div className="flex flex-wrap gap-4 text-sm"> <label className="flex items-center gap-2"> <input type="checkbox" checked={selected.config.dashboard?.showResources ?? true} onChange={(e) => updateDashboardConfig({ showResources: e.target.checked })} /> Показывать ресурсы команд </label> <label className="flex items-center gap-2"> <input type="checkbox" checked={selected.config.dashboard?.showScore ?? true} onChange={(e) => updateDashboardConfig({ showScore: e.target.checked })} /> Показывать очки и место в рейтинге </label> </div> </Card> <Card className="p-3"> <h2 className="mb-2 font-bold text-focus">Настройки баланса</h2> <div className="flex flex-wrap items-end gap-4 text-sm"> <label className="flex flex-col gap-1"> Курс банка (N:1) <input type="number" min={1} defaultValue={selected.config.bank.rate} onBlur={(e) => updateConfig({ bankRate: Number(e.target.value) })} className={`w-20 ${FIELD}`} /> </label> <label className="flex flex-col gap-1"> Лимит краж на команду <input type="number" min={0} defaultValue={selected.config.interactions.maxTheftsPerTeam} onBlur={(e) => updateConfig({ maxTheftsPerTeam: Number(e.target.value) })} className={`w-20 ${FIELD}`} /> </label> <label className="flex flex-col gap-1"> Лимит санкций на команду <input type="number" min={0} defaultValue={selected.config.interactions.maxSanctionsPerTeam} onBlur={(e) => updateConfig({ maxSanctionsPerTeam: Number(e.target.value) })} className={`w-20 ${FIELD}`} /> </label> </div> </Card> <Card className="p-3"> <h2 className="mb-2 font-bold text-focus">Команды и коды подключения</h2> <div className="grid gap-2 sm:grid-cols-2"> {selected.teams.map((t) => ( <AdminTeamRow key={t.id} team={t} config={selected.config} onRename={renameTeam} onRemove={removeTeam} onGrant={grantResource} onEffect={applyTeamEffect} /> ))} </div> </Card> <Card className="p-3"> <h2 className="mb-2 font-bold text-focus">Журнал событий</h2> <div className="flex max-h-96 flex-col gap-1 overflow-y-auto text-sm"> {journal.length === 0 && <p className="text-white/50">Событий пока нет</p>} {journal.map((e) => ( <div key={e.id} className="animate-fade-in border-b border-line pb-1"> {formatEvent(e, selected.config)} </div> ))} </div> </Card> </> )} {!selected && <p className="text-white/60">Выберите или создайте игровую сессию слева.</p>} </div> </div> {savedMsg && ( <div className="fixed bottom-4 right-4 z-50 animate-slide-in rounded-lg bg-focus px-4 py-2 font-semibold text-black shadow-card"> ✓ {savedMsg} </div> )} <ConfirmModal open={confirmDeleteId !== null} title="Удалить сессию" message="Удалить эту игровую сессию безвозвратно? Все данные пропадут." confirmLabel="Удалить" danger onCancel={() => setConfirmDeleteId(null)} onConfirm={() => { const id = confirmDeleteId; setConfirmDeleteId(null); if (id) deleteSession(id); }} /> </div> ); }