aa48145557
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
848 lines
29 KiB
TypeScript
848 lines
29 KiB
TypeScript
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>
|
||
)
|
||
}
|