refactor: React 前端重构,新旧平台合并为莞易学

This commit is contained in:
study-tool
2026-10-29 23:50:46 +08:00
parent 35853891c9
commit e8634a6317
62 changed files with 8923 additions and 4623 deletions
+773
View File
@@ -0,0 +1,773 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Plus, RefreshCw, Users, WifiOff } from 'lucide-react'
import { AccountCard } from './components/AccountCard'
import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog'
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 { 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: 'settings' }
| { 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,
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>>({ guanyixue: '莞易学' })
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)
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) {
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])
useEffect(() => {
api<{ platforms: PlatformInfo[] }>('/api/platforms')
.then((response) => {
const map: Record<string, string> = {}
for (const platform of response.platforms || []) map[platform.id] = platform.label
if (Object.keys(map).length) setPlatforms(map)
})
.catch(() => {})
}, [])
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 历史
if (!lines.length && !response.status && !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 }),
})
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 }),
})
}
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 removeAccount(account: Account) {
if (!window.confirm(`删除账号「${account.user || account.username}」?课程缓存和日志不会自动清理。`))
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,
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.courses || []).find((course) => course.platform)?.platform
return (id && platforms[id]) || '莞易学'
}
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' })}
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>
<p className="mt-0.5 text-[11.5px] text-slate-400">
新旧版课程自动识别为「莞易学」,每 5 秒自动刷新状态
</p>
</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)}
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)}
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'}
onClose={() => setDialog(null)}
onSubmit={addAccount}
/>
<SettingsDialog
open={dialog?.type === 'settings'}
onClose={() => setDialog(null)}
notify={notify}
/>
<SystemLogDialog open={dialog?.type === 'system'} onClose={() => setDialog(null)} />
<QuizDialog
open={dialog?.type === 'quiz' && !!quizAccount}
account={quizAccount}
course={quizCourse}
onClose={() => setDialog(null)}
notify={notify}
onSubmitted={() => void load()}
/>
<Toast toast={toast} />
</div>
)
}