feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
+63 -17
View File
@@ -2,6 +2,7 @@ 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'
@@ -11,6 +12,7 @@ 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'
@@ -48,6 +50,7 @@ interface InlineLog {
type DialogState =
| { type: 'add' }
| { type: 'edit'; accountId: string }
| { type: 'settings' }
| { type: 'plugins' }
| { type: 'system' }
@@ -72,12 +75,11 @@ function persistOpenLogs(ids: Set<string>) {
// 隐私模式禁用 localStorage 时忽略
}
}
function accountPayload(account: Account) {
return {
id: account.id,
username: account.username,
platform: account.platform || 'guanyixue',
platform: account.platform || '',
note: account.note || '',
user: account.user || '',
cookies: account.cookies || [],
@@ -95,10 +97,8 @@ function accountPayload(account: Account) {
export default function App() {
const [accounts, setAccounts] = useState<Account[]>([])
const accountsRef = useRef(accounts)
const [platforms, setPlatforms] = useState<Record<string, string>>({ guanyixue: '莞易学' })
const [platformList, setPlatformList] = useState<PlatformInfo[]>([
{ id: 'guanyixue', label: '莞易学' },
])
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>>({})
@@ -109,6 +109,7 @@ export default function App() {
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
@@ -166,6 +167,10 @@ export default function App() {
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 &&
@@ -383,7 +388,7 @@ export default function App() {
method: 'POST',
body: JSON.stringify({
cookies: account.cookies,
platform: account.platform || 'guanyixue',
platform: account.platform || '',
}),
})
if (!response.ok && response.code === 'session_expired') {
@@ -393,7 +398,11 @@ export default function App() {
if (!latest) return
response = await api<CoursesResponse>('/api/courses', {
method: 'POST',
body: JSON.stringify({ cookies: latest.cookies }),
body: JSON.stringify({
cookies: latest.cookies,
// 重试也要带平台:漏了它后端会拿到空平台,报「平台「」还没有实现刷新课程」
platform: latest.platform || account.platform || '',
}),
})
}
if (!response.ok) {
@@ -529,9 +538,31 @@ export default function App() {
})
}
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) {
if (!window.confirm(`删除账号「${account.user || account.username}」?课程缓存和日志不会自动清理。`))
return
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) => {
@@ -642,9 +673,17 @@ export default function App() {
function platformLabel(account: Account) {
const id =
account.platform || (account.courses || []).find((course) => course.platform)?.platform
return (id && platforms[id]) || '莞易学'
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
@@ -686,9 +725,6 @@ export default function App() {
<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
@@ -710,8 +746,7 @@ export default function App() {
</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"
@@ -729,6 +764,7 @@ export default function App() {
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 || []}
@@ -739,6 +775,7 @@ export default function App() {
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)
@@ -765,7 +802,7 @@ export default function App() {
</main>
<footer className="border-t border-line/70 py-5 text-center text-[11px] text-slate-400">
莞易学 · 多账号学习控制台 · 数据保存在本机
多账号学习控制台 · 数据保存在本机
</footer>
<AddAccountDialog
@@ -774,6 +811,13 @@ export default function App() {
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)}
@@ -795,6 +839,8 @@ export default function App() {
onSubmitted={() => void load()}
/>
{confirmDialog}
<Toast toast={toast} />
</div>
)