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 { 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) { 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([]) const accountsRef = useRef(accounts) const [platforms, setPlatforms] = useState>({}) const [platformList, setPlatformList] = useState([]) const [offline, setOffline] = useState(false) const [toast, setToast] = useState(null) const [pending, setPending] = useState>({}) const [refreshing, setRefreshing] = useState(false) const [dialog, setDialog] = useState(null) const [openLogs, setOpenLogs] = useState>(() => readOpenLogs()) const openLogsRef = useRef(openLogs) const [logs, setLogs] = useState>({}) const logsRef = useRef(logs) const toastTimer = useRef(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>('/api/runs'), api('/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 = {} 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( `/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(`/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(id: string, tag: string, task: () => Promise) { 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('/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('/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('/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('/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 (
void refreshNow()} onSettings={() => setDialog({ type: 'settings' })} onSystemLog={() => setDialog({ type: 'system' })} onPlugins={() => setDialog({ type: 'plugins' })} onAdd={() => setDialog({ type: 'add' })} />
{offline && (
后端未连接 页面显示的是上一次数据,请确认服务已启动、端口没有变化。
)}

账号列表

{accounts.length > 0 && ( )}
{accounts.length === 0 ? (

还没有账号

添加平台账号后,可自动读取课程、勾选学习、提交反馈表与测验。

) : (
{accounts.map((account) => ( 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)} /> ))}
)}
多账号学习控制台 · 数据保存在本机
setDialog(null)} onSubmit={addAccount} /> setDialog(null)} onSubmit={(data) => (editTarget ? saveAccountEdit(editTarget, data) : Promise.resolve())} /> setDialog(null)} notify={notify} /> setDialog(null)} /> setDialog(null)} notify={notify} onChanged={() => void loadPlatforms()} /> setDialog(null)} notify={notify} onSubmitted={() => void load()} /> {confirmDialog}
) }