Files
study-tool/web/src/App.tsx
T
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

848 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
import { Plus, RefreshCw, Users, WifiOff } from 'lucide-react'
import { AccountCard } from './components/AccountCard'
import { AccountEditDialog, type AccountEdit } from './components/AccountEditDialog'
import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog'
import { PluginsDialog } from './components/PluginsDialog'
import { QuizDialog } from './components/QuizDialog'
import { SettingsDialog } from './components/SettingsDialog'
import { StatBar } from './components/StatBar'
import { SystemLogDialog } from './components/SystemLogDialog'
import { Toast, type ToastState } from './components/Toast'
import { TopBar } from './components/TopBar'
import { Button } from './components/ui/Button'
import { useConfirm } from './components/ui/ConfirmDialog'
import { ApiError, api } from './lib/api'
import { countCourses, toastMessage } from './lib/utils'
import type { Account, Cookie, Course, LogResponse, PlatformInfo, RunState } from './types'
interface AccountsResponse {
ok?: boolean
accounts: Account[]
}
interface LoginResponse {
ok?: boolean
error?: string
code?: string
warn?: string
user?: string
cookies?: Cookie[]
courses?: Course[]
}
interface CoursesResponse {
ok?: boolean
error?: string
code?: string
courses?: Course[]
user?: string
cookies?: Cookie[]
}
interface InlineLog {
lines: string[]
since: number
busy: boolean
historyTried: boolean
}
type DialogState =
| { type: 'add' }
| { type: 'edit'; accountId: string }
| { type: 'settings' }
| { type: 'plugins' }
| { type: 'system' }
| { type: 'quiz'; accountId: string; course: Course }
| null
const LOG_STORAGE_KEY = 'guanyixue.inlineLogsOpen'
function readOpenLogs(): Set<string> {
try {
const ids = JSON.parse(localStorage.getItem(LOG_STORAGE_KEY) || '[]')
return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [])
} catch {
return new Set()
}
}
function persistOpenLogs(ids: Set<string>) {
try {
localStorage.setItem(LOG_STORAGE_KEY, JSON.stringify([...ids]))
} catch {
// 隐私模式禁用 localStorage 时忽略
}
}
function accountPayload(account: Account) {
return {
id: account.id,
username: account.username,
platform: account.platform || '',
note: account.note || '',
user: account.user || '',
cookies: account.cookies || [],
courses: account.courses || [],
selected: account.selected || [],
status: account.status || 'idle',
lastError: account.lastError || '',
lastErrorAt: account.lastErrorAt || 0,
optQuiz: account.optQuiz === true,
optFeedback: account.optFeedback !== false,
loginAt: account.loginAt || 0,
}
}
export default function App() {
const [accounts, setAccounts] = useState<Account[]>([])
const accountsRef = useRef(accounts)
const [platforms, setPlatforms] = useState<Record<string, string>>({})
const [platformList, setPlatformList] = useState<PlatformInfo[]>([])
const [offline, setOffline] = useState(false)
const [toast, setToast] = useState<ToastState | null>(null)
const [pending, setPending] = useState<Record<string, string>>({})
const [refreshing, setRefreshing] = useState(false)
const [dialog, setDialog] = useState<DialogState>(null)
const [openLogs, setOpenLogs] = useState<Set<string>>(() => readOpenLogs())
const openLogsRef = useRef(openLogs)
const [logs, setLogs] = useState<Record<string, InlineLog>>({})
const logsRef = useRef(logs)
const toastTimer = useRef<number | undefined>(undefined)
const { ask: askConfirm, dialog: confirmDialog } = useConfirm()
useEffect(() => {
accountsRef.current = accounts
}, [accounts])
useEffect(() => {
logsRef.current = logs
}, [logs])
useEffect(() => {
openLogsRef.current = openLogs
persistOpenLogs(openLogs)
}, [openLogs])
useEffect(() => () => window.clearTimeout(toastTimer.current), [])
const notify = useCallback((next: ToastState) => {
setToast(next)
window.clearTimeout(toastTimer.current)
toastTimer.current = window.setTimeout(() => setToast(null), 3800)
}, [])
const saveAccount = useCallback(async (account: Account) => {
const response = await api<{ ok?: boolean; error?: string }>('/api/accounts', {
method: 'POST',
body: JSON.stringify(accountPayload(account)),
})
if (response.ok === false) throw new Error(response.error || '保存失败')
return response
}, [])
const replaceAccount = useCallback((account: Account) => {
setAccounts((previous) => {
const exists = previous.some((item) => item.id === account.id)
const next = exists
? previous.map((item) => (item.id === account.id ? account : item))
: [...previous, account]
accountsRef.current = next
return next
})
}, [])
const load = useCallback(
async (silent = true) => {
try {
const [runs, list] = await Promise.all([
api<Record<string, RunState>>('/api/runs'),
api<AccountsResponse>('/api/accounts'),
])
const next = (list.accounts || [])
.map((account) => ({ ...account, run: runs[account.id] || null }))
.sort((a, b) => (a.created || 0) - (b.created || 0))
setAccounts(next)
accountsRef.current = next
setOffline(false)
for (const account of next) {
// 只同步「正在跑」的那一轮:运行结束后 run 里留的是开跑时的旧会话,
// 再回写会把刚登录的新 cookie 盖回失效的那一份,刷新课程就会一直重登。
const status = account.run?.status
if (status !== 'discovering' && status !== 'studying') continue
const cookies = account.run?.cookies
if (
cookies &&
cookies.length &&
JSON.stringify(cookies) !== JSON.stringify(account.cookies || [])
) {
const updated = { ...account, cookies }
account.cookies = cookies
saveAccount(updated).catch(() => {})
}
}
} catch (error) {
if (error instanceof ApiError && error.offline) {
setOffline(true)
} else if (!silent) {
notify({ message: `读取账号失败:${toastMessage(error)}`, tone: 'error' })
}
}
},
[notify, saveAccount],
)
useEffect(() => {
void load()
const timer = window.setInterval(() => {
if (!document.hidden) void load()
}, 5000)
return () => window.clearInterval(timer)
}, [load])
const loadPlatforms = useCallback(async () => {
try {
const response = await api<{ platforms: PlatformInfo[] }>('/api/platforms')
const list = response.platforms || []
setPlatformList(list)
const map: Record<string, string> = {}
for (const platform of list) map[platform.id] = platform.label
setPlatforms(map)
} catch {
// 后端未连接时保留上一次的平台列表
}
}, [])
useEffect(() => {
void loadPlatforms()
}, [loadPlatforms])
const pullLog = useCallback(
async (id: string) => {
const current = logsRef.current[id] || {
lines: [],
since: 0,
busy: false,
historyTried: false,
}
if (current.busy) return
const state: InlineLog = { ...current, busy: true }
const commit = (value: InlineLog) => {
logsRef.current = { ...logsRef.current, [id]: value }
setLogs((previous) => ({ ...previous, [id]: value }))
}
commit(state)
try {
let response = await api<LogResponse>(
`/api/log?key=${encodeURIComponent(id)}&since=${state.since}`,
)
// 服务端日志超过上限会裁掉最旧的行,游标比长度大时整体重拉
if (typeof response.next === 'number' && response.next < state.since) {
state.lines = []
state.since = 0
response = await api<LogResponse>(`/api/log?key=${encodeURIComponent(id)}&since=0`)
}
let lines = response.lines || []
// 服务重启后内存日志为空,退回读一次 SQLite 历史。
// 不能拿 status 当条件:重启会把上次的运行状态标成 interrupted,
// status 非空但内存日志是空的,历史就永远读不出来。
if (!lines.length && !state.historyTried) {
state.historyTried = true
try {
const history = await api<{ lines?: string[] }>(
`/api/log/history?key=${encodeURIComponent(id)}&tail=300`,
)
if (history.lines?.length) lines = history.lines
} catch {
// 历史日志读不到就只显示实时日志
}
}
if (lines.length) state.lines = [...state.lines, ...lines].slice(-600)
if (typeof response.next === 'number') state.since = Math.max(state.since, response.next)
const cookies = response.cookies
if (cookies?.length) {
const account = accountsRef.current.find((item) => item.id === id)
if (account && JSON.stringify(account.cookies || []) !== JSON.stringify(cookies)) {
const updated = { ...account, cookies }
replaceAccount(updated)
saveAccount(updated).catch(() => {})
}
}
} catch {
// 单个账号拉日志失败不打断整页轮询
} finally {
state.busy = false
commit(state)
}
},
[replaceAccount, saveAccount],
)
useEffect(() => {
const timer = window.setInterval(() => {
if (document.hidden) return
openLogsRef.current.forEach((id) => void pullLog(id))
}, 1500)
return () => window.clearInterval(timer)
}, [pullLog])
const setLogOpen = useCallback(
(id: string, open?: boolean) => {
const shouldOpen = open === undefined ? !openLogsRef.current.has(id) : open
const next = new Set(openLogsRef.current)
if (shouldOpen) next.add(id)
else next.delete(id)
openLogsRef.current = next
setOpenLogs(next)
if (shouldOpen) void pullLog(id)
},
[pullLog],
)
async function withPending<T>(id: string, tag: string, task: () => Promise<T>) {
setPending((previous) => ({ ...previous, [id]: tag }))
try {
return await task()
} finally {
setPending((previous) => {
const next = { ...previous }
delete next[id]
return next
})
}
}
async function login(account: Account, quiet = false) {
return withPending(account.id, 'login', async () => {
replaceAccount({ ...account, status: 'logging', lastError: '' })
try {
await saveAccount({ ...account, status: 'logging', lastError: '' })
const response = await api<LoginResponse>('/api/login', {
method: 'POST',
body: JSON.stringify({ account_id: account.id }),
})
if (!response.ok) {
const failed: Account = {
...account,
status: 'login_failed',
lastError: response.error || `HTTP ${response.code || '失败'}`,
lastErrorAt: Date.now(),
run: null,
}
replaceAccount(failed)
await saveAccount(failed).catch(() => {})
if (!quiet) notify({ message: `登录失败:${failed.lastError}`, tone: 'error' })
return false
}
const courses = response.courses || []
const updated: Account = {
...account,
status: 'logged_in',
cookies: response.cookies || [],
user: response.user || account.user || '',
courses,
selected: (account.selected || []).filter((plan) =>
courses.some((course) => course.plan_id === plan),
),
loginAt: Date.now(),
updated: Date.now(),
lastError: '',
lastErrorAt: 0,
run: null,
}
replaceAccount(updated)
await saveAccount(updated).catch(() => {})
if (!quiet) {
notify({
message: response.warn
? `登录成功,但课程读取异常:${response.warn}`
: `登录成功:${updated.user || updated.username},读到 ${countCourses(courses).total} 门`,
tone: response.warn ? 'info' : 'success',
})
}
void load()
return true
} catch (error) {
const failed: Account = {
...account,
status: 'login_failed',
lastError: toastMessage(error),
lastErrorAt: Date.now(),
}
replaceAccount(failed)
await saveAccount(failed).catch(() => {})
if (!quiet) notify({ message: `登录失败:${failed.lastError}`, tone: 'error' })
if (error instanceof ApiError && error.offline) setOffline(true)
return false
}
})
}
async function refreshCourses(account: Account) {
return withPending(account.id, 'refresh', async () => {
try {
let response = await api<CoursesResponse>('/api/courses', {
method: 'POST',
body: JSON.stringify({
cookies: account.cookies,
platform: account.platform || '',
}),
})
if (!response.ok && response.code === 'session_expired') {
notify({ message: '会话已失效,正在自动重新登录…' })
if (!(await login(account, true))) return
const latest = accountsRef.current.find((item) => item.id === account.id)
if (!latest) return
response = await api<CoursesResponse>('/api/courses', {
method: 'POST',
body: JSON.stringify({
cookies: latest.cookies,
// 重试也要带平台:漏了它后端会拿到空平台,报「平台「」还没有实现刷新课程」
platform: latest.platform || account.platform || '',
}),
})
}
if (!response.ok) {
notify({ message: `读取失败:${response.error || response.code || '未知错误'}`, tone: 'error' })
return
}
const latest = accountsRef.current.find((item) => item.id === account.id) || account
const updated: Account = {
...latest,
courses: response.courses || [],
user: response.user || latest.user,
cookies: response.cookies?.length ? response.cookies : latest.cookies,
status: 'logged_in',
updated: Date.now(),
lastError: '',
lastErrorAt: 0,
}
replaceAccount(updated)
await saveAccount(updated).catch(() => {})
notify({ message: `已刷新,读到 ${countCourses(updated.courses).total} 门`, tone: 'success' })
} catch (error) {
notify({ message: `读取失败:${toastMessage(error)}`, tone: 'error' })
}
})
}
async function startStudy(account: Account) {
const selected = account.selected || []
const courses = (account.courses || []).filter(
(course) => selected.includes(course.plan_id) && course.in_study !== false,
)
if (!courses.length) {
notify({ message: '请先勾选要学习的课程', tone: 'error' })
return
}
if (!account.cookies?.length) {
notify({ message: '请先登录', tone: 'error' })
return
}
return withPending(account.id, 'study', async () => {
try {
const response = await api<{
ok?: boolean
error?: string
study?: { mode?: string; parallel?: number }
}>('/api/study', {
method: 'POST',
body: JSON.stringify({
key: account.id,
username: account.user || account.username,
cookies: account.cookies,
courses,
workers: 40,
cap_minutes: 90,
enable_quiz: account.optQuiz === true,
enable_feedback: account.optFeedback !== false,
}),
})
if (!response.ok) {
notify({ message: `启动失败:${response.error || '未知错误'}`, tone: 'error' })
return
}
const mode =
response.study?.mode === 'normal'
? `常规真播 ${response.study?.parallel || 1} 路并发`
: '快速模式(只调接口)'
notify({
message: `已开始 ${courses.length} 门课程 · ${mode}${
account.optQuiz ? ' · 含自动答题' : ' · 测验需手动作答'
}`,
tone: 'success',
})
setLogOpen(account.id, true)
void load()
} catch (error) {
notify({ message: `启动失败:${toastMessage(error)}`, tone: 'error' })
}
})
}
async function stopStudy(account: Account) {
return withPending(account.id, 'stop', async () => {
try {
await api('/api/stop', {
method: 'POST',
body: JSON.stringify({ key: account.id }),
})
notify({ message: '已请求停止' })
void load()
} catch (error) {
notify({ message: `停止失败:${toastMessage(error)}`, tone: 'error' })
}
})
}
async function capture(account: Account) {
if (!account.cookies?.length) {
notify({ message: '请先登录', tone: 'error' })
return
}
return withPending(account.id, 'capture', async () => {
try {
const response = await api<{ ok?: boolean; error?: string }>('/api/capture', {
method: 'POST',
body: JSON.stringify({ key: account.id, cookies: account.cookies, minutes: 10 }),
})
if (!response.ok) {
notify({ message: `抓包失败:${response.error || '未知错误'}`, tone: 'error' })
return
}
notify({
message: '已打开浏览器,请手动走一遍课程页;最多记录 10 分钟',
tone: 'success',
})
} catch (error) {
notify({ message: `抓包失败:${toastMessage(error)}`, tone: 'error' })
}
})
}
async function stopCapture(account: Account) {
return withPending(account.id, 'capture', async () => {
try {
await api('/api/capture/stop', {
method: 'POST',
body: JSON.stringify({ key: account.id }),
})
notify({ message: '已请求停止抓包' })
void load()
} catch (error) {
notify({ message: `停止抓包失败:${toastMessage(error)}`, tone: 'error' })
}
})
}
async function saveAccountEdit(account: Account, data: AccountEdit) {
const updated: Account = {
...account,
username: data.username,
note: data.note,
platform: data.platform,
}
const response = await api<{ ok?: boolean; error?: string }>('/api/accounts', {
method: 'POST',
body: JSON.stringify({ ...accountPayload(updated), password: data.password }),
})
if (response.ok === false) throw new Error(response.error || '保存失败')
replaceAccount(updated)
notify({ message: '账号信息已保存,正在用新凭据重新登录…' })
// 先让弹窗关掉,重新登录在后台跑:失败了会走 toast + 卡片红条提示
void login(updated)
}
async function removeAccount(account: Account) {
const ok = await askConfirm({
title: '删除账号',
message: `删除「${account.user || account.username}」?课程缓存和日志不会自动清理,删掉后要重新添加账号才能继续学习。`,
confirmText: '删除账号',
})
if (!ok) return
try {
await api(`/api/accounts/${encodeURIComponent(account.id)}`, { method: 'DELETE' })
setAccounts((previous) => {
const next = previous.filter((item) => item.id !== account.id)
accountsRef.current = next
return next
})
notify({ message: '账号已删除' })
} catch (error) {
notify({ message: `删除失败:${toastMessage(error)}`, tone: 'error' })
}
}
async function updateSelection(account: Account, selected: string[]) {
const updated = { ...account, selected }
replaceAccount(updated)
try {
await saveAccount(updated)
} catch (error) {
notify({ message: `保存勾选失败:${toastMessage(error)}`, tone: 'error' })
}
}
function toggleCourse(account: Account, planId: string, checked: boolean) {
const current = account.selected || []
void updateSelection(
account,
checked ? [...new Set([...current, planId])] : current.filter((plan) => plan !== planId),
)
}
function selectAll(account: Account) {
const studyable = (account.courses || [])
.filter((course) => course.in_study !== false)
.map((course) => course.plan_id)
void updateSelection(account, [...new Set([...(account.selected || []), ...studyable])])
}
async function toggleOption(account: Account, key: 'quiz' | 'feedback', checked: boolean) {
const updated =
key === 'quiz' ? { ...account, optQuiz: checked } : { ...account, optFeedback: checked }
replaceAccount(updated)
try {
await saveAccount(updated)
notify({
message:
key === 'quiz'
? checked
? '已开启自动交测验(注意正确率)'
: '已关闭自动交测验'
: checked
? '已开启自动交反馈表'
: '已关闭自动交反馈表',
})
} catch (error) {
notify({ message: `保存失败:${toastMessage(error)}`, tone: 'error' })
}
}
async function addAccount(data: NewAccount) {
const response = await api<{ ok?: boolean; error?: string; account?: Account }>('/api/accounts', {
method: 'POST',
body: JSON.stringify({
username: data.username,
platform: data.platform,
password: data.password,
note: data.note,
cookies: [],
courses: [],
selected: [],
status: 'idle',
created: Date.now(),
}),
})
if (response.ok === false) throw new Error(response.error || '保存失败')
const list = await api<AccountsResponse>('/api/accounts')
const created =
response.account || (list.accounts || []).find((account) => account.username === data.username)
if (!created) {
notify({ message: '已保存,但列表里没有找到新账号,请刷新页面', tone: 'error' })
await load()
return
}
notify({ message: '已保存,正在尝试首次登录…' })
const ok = await login(created, true)
const latest = accountsRef.current.find((account) => account.id === created.id)
if (ok) {
notify({ message: `登录成功:${latest?.user || data.username}`, tone: 'success' })
} else {
notify({
message: `登录失败:${latest?.lastError || '未知原因'}(请手动点击「登录」重试)`,
tone: 'error',
})
}
await load()
}
async function refreshNow() {
setRefreshing(true)
try {
await load(false)
} finally {
setRefreshing(false)
}
}
function platformLabel(account: Account) {
const id =
account.platform || (account.courses || []).find((course) => course.platform)?.platform
return (id && platforms[id]) || id || '未选择平台'
}
function platformCaps(account: Account) {
return platformList.find((item) => item.id === account.platform)?.capabilities || []
}
const editTarget =
dialog?.type === 'edit'
? accounts.find((account) => account.id === dialog.accountId) || null
: null
const quizAccount =
dialog?.type === 'quiz'
? accounts.find((account) => account.id === dialog.accountId) || null
: null
const quizCourse =
dialog?.type === 'quiz'
? quizAccount?.courses.find((course) => course.plan_id === dialog.course.plan_id) ||
dialog.course
: null
return (
<div className="min-h-screen">
<TopBar
refreshing={refreshing}
onRefresh={() => void refreshNow()}
onSettings={() => setDialog({ type: 'settings' })}
onSystemLog={() => setDialog({ type: 'system' })}
onPlugins={() => setDialog({ type: 'plugins' })}
onAdd={() => setDialog({ type: 'add' })}
/>
<main className="mx-auto max-w-[1320px] px-4 pb-16 pt-5 sm:px-6">
{offline && (
<div className="mb-4 flex flex-wrap items-center gap-2.5 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-[12.5px] text-red-700">
<WifiOff className="size-4 shrink-0" />
<span className="font-medium">后端未连接</span>
<span className="text-red-500">
页面显示的是上一次数据,请确认服务已启动、端口没有变化。
</span>
<div className="flex-1" />
<Button size="sm" variant="secondary" loading={refreshing} onClick={() => void refreshNow()}>
重试
</Button>
</div>
)}
<StatBar accounts={accounts} />
<div className="mb-3 mt-6 flex items-end justify-between gap-3">
<div>
<h2 className="text-[14px] font-semibold text-slate-900">账号列表</h2>
</div>
{accounts.length > 0 && (
<Button
size="sm"
variant="secondary"
icon={<RefreshCw className="size-3.5" />}
loading={refreshing}
onClick={() => void refreshNow()}
>
刷新
</Button>
)}
</div>
{accounts.length === 0 ? (
<div className="card-shadow grid place-items-center rounded-2xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center">
<div className="grid size-12 place-items-center rounded-2xl bg-brand-50 text-brand-600">
<Users className="size-6" />
</div>
<h3 className="mt-4 text-[14px] font-semibold text-slate-800">还没有账号</h3>
<p className="mt-1.5 max-w-md text-[12.5px] leading-relaxed text-slate-500">
添加平台账号后,可自动读取课程、勾选学习、提交反馈表与测验。
</p>
<Button
variant="primary"
className="mt-5"
icon={<Plus className="size-4" />}
onClick={() => setDialog({ type: 'add' })}
>
添加第一个账号
</Button>
</div>
) : (
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
platformLabel={platformLabel(account)}
capabilities={platformCaps(account)}
pending={pending[account.id]}
openLog={openLogs.has(account.id)}
logLines={logs[account.id]?.lines || []}
onToggleLog={() => setLogOpen(account.id)}
onLogin={() => void login(account)}
onRefresh={() => void refreshCourses(account)}
onStudy={() => void startStudy(account)}
onStop={() => void stopStudy(account)}
onCapture={() => void capture(account)}
onStopCapture={() => void stopCapture(account)}
onEdit={() => setDialog({ type: 'edit', accountId: account.id })}
onDelete={() => void removeAccount(account)}
onQuiz={() => {
const list = (account.courses || []).filter((course) => course.in_study !== false)
const course =
list.find((item) => account.selected?.includes(item.plan_id)) || list[0]
if (!course) {
notify({ message: '这门课还没有课程数据,先点「刷新课程」', tone: 'error' })
return
}
if (!account.cookies?.length) {
notify({ message: '请先登录', tone: 'error' })
return
}
setDialog({ type: 'quiz', accountId: account.id, course })
}}
onToggleCourse={(planId, checked) => toggleCourse(account, planId, checked)}
onSelectAll={() => selectAll(account)}
onClearSelection={() => void updateSelection(account, [])}
onToggleOption={(key, checked) => void toggleOption(account, key, checked)}
/>
))}
</div>
)}
</main>
<footer className="border-t border-line/70 py-5 text-center text-[11px] text-slate-400">
多账号学习控制台 · 数据保存在本机
</footer>
<AddAccountDialog
open={dialog?.type === 'add'}
platforms={platformList}
onClose={() => setDialog(null)}
onSubmit={addAccount}
/>
<AccountEditDialog
open={dialog?.type === 'edit' && !!editTarget}
account={editTarget}
platforms={platformList}
onClose={() => setDialog(null)}
onSubmit={(data) => (editTarget ? saveAccountEdit(editTarget, data) : Promise.resolve())}
/>
<SettingsDialog
open={dialog?.type === 'settings'}
onClose={() => setDialog(null)}
notify={notify}
/>
<SystemLogDialog open={dialog?.type === 'system'} onClose={() => setDialog(null)} />
<PluginsDialog
open={dialog?.type === 'plugins'}
onClose={() => setDialog(null)}
notify={notify}
onChanged={() => void loadPlatforms()}
/>
<QuizDialog
open={dialog?.type === 'quiz' && !!quizAccount}
account={quizAccount}
course={quizCourse}
onClose={() => setDialog(null)}
notify={notify}
onSubmitted={() => void load()}
/>
{confirmDialog}
<Toast toast={toast} />
</div>
)
}