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
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f8fc" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>莞易学 · 多账号学习控制台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "guanyixue-console-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+1503
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#2f6bff" />
<stop offset="1" stop-color="#4fa4ff" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#g)" />
<text x="32" y="44" font-family="Microsoft YaHei, sans-serif" font-size="34" font-weight="700" fill="#fff" text-anchor="middle">学</text>
</svg>

After

Width:  |  Height:  |  Size: 457 B

+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>
)
}
+469
View File
@@ -0,0 +1,469 @@
import { useEffect, useRef, useState } from 'react'
import {
ChevronDown,
CircleStop,
ClipboardCheck,
Ellipsis,
ListVideo,
LogIn,
Play,
RefreshCw,
Square,
Trash2,
Video,
} from 'lucide-react'
import type { Account, Course } from '../types'
import { ageText, cn, countCourses } from '../lib/utils'
import { Badge } from './ui/Badge'
import { Button } from './ui/Button'
type Tone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'running'
function statusMeta(account: Account, pending?: string): { label: string; tone: Tone; pulse: boolean } {
const status =
pending === 'login' || account.status === 'logging' || account.run?.status === 'logging'
? 'logging'
: account.run?.status || account.status || 'idle'
const map: Record<string, { label: string; tone: Tone; pulse?: boolean }> = {
discovering: { label: '发现课程中', tone: 'running', pulse: true },
studying: { label: '学习中', tone: 'running', pulse: true },
finished: { label: '已完成', tone: 'success' },
stopped: { label: '已停止', tone: 'warning' },
error: { label: '出错', tone: 'danger' },
interrupted: { label: '已中断', tone: 'warning' },
logging: { label: '登录中', tone: 'running', pulse: true },
logged_in: { label: '已登录', tone: 'success' },
login_failed: { label: '登录失败', tone: 'danger' },
idle: { label: '未登录', tone: 'neutral' },
}
const item = map[status] || { label: status, tone: 'neutral' as Tone }
return { label: item.label, tone: item.tone, pulse: !!item.pulse }
}
function CourseRow({
account,
course,
disabled,
onToggle,
}: {
account: Account
course: Course
disabled: boolean
onToggle: (planId: string, checked: boolean) => void
}) {
const selected = account.selected?.includes(course.plan_id)
return (
<label
className={cn(
'flex cursor-pointer items-start gap-2.5 rounded-lg border border-transparent px-2 py-1.5 transition',
'hover:border-line hover:bg-slate-50',
disabled && 'pointer-events-none opacity-60',
)}
>
<input
type="checkbox"
className="mt-0.5 size-3.5 shrink-0 accent-brand-500"
checked={!!selected}
disabled={disabled}
onChange={(event) => onToggle(course.plan_id, event.target.checked)}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-medium text-slate-700">
{course.name || course.plan_id}
</span>
<span className="mt-0.5 block truncate text-[11px] text-slate-400">
{[course.section, course.status_label, course.info].filter(Boolean).join(' · ') || '—'}
</span>
</span>
<span className="shrink-0 pt-0.5 text-[11px] tabular-nums text-slate-500">
{course.progress || ''}
</span>
</label>
)
}
export function AccountCard({
account,
platformLabel,
pending,
openLog,
logLines,
onToggleLog,
onLogin,
onRefresh,
onStudy,
onStop,
onCapture,
onStopCapture,
onQuiz,
onDelete,
onToggleCourse,
onSelectAll,
onClearSelection,
onToggleOption,
}: {
account: Account
platformLabel: string
pending?: string
openLog: boolean
logLines: string[]
onToggleLog: () => void
onLogin: () => void
onRefresh: () => void
onStudy: () => void
onStop: () => void
onCapture: () => void
onStopCapture: () => void
onQuiz: () => void
onDelete: () => void
onToggleCourse: (planId: string, checked: boolean) => void
onSelectAll: () => void
onClearSelection: () => void
onToggleOption: (key: 'quiz' | 'feedback', checked: boolean) => void
}) {
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const logRef = useRef<HTMLPreElement>(null)
const stickRef = useRef(true)
useEffect(() => {
if (!menuOpen) return
const onDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false)
}
document.addEventListener('pointerdown', onDown)
return () => document.removeEventListener('pointerdown', onDown)
}, [menuOpen])
useEffect(() => {
const el = logRef.current
if (el && stickRef.current) el.scrollTop = el.scrollHeight
}, [logLines, openLog])
const meta = statusMeta(account, pending)
const busy = !!pending || account.status === 'logging' || account.run?.status === 'logging'
const all = account.courses || []
const studyable = all.filter((course) => course.in_study !== false)
const finished = all.filter((course) => course.in_study === false)
const counts = countCourses(all)
const done = account.run?.done || 0
const total = account.run?.total || 0
const pct = total ? Math.round((done / total) * 100) : 0
const running = ['discovering', 'studying'].includes(account.run?.status || '')
const selectedCount = account.selected?.length || 0
const canStudy = selectedCount > 0 && (account.cookies?.length || 0) > 0
return (
<article
data-id={account.id}
className="card-shadow flex min-w-0 flex-col rounded-2xl border border-line bg-white transition-colors hover:border-slate-300/80"
>
<header className="flex items-start gap-3 px-4 pt-4">
<div className="relative mt-0.5 grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-50 to-sky-50 text-[13px] font-semibold text-brand-700 ring-1 ring-brand-100">
{(account.user || account.username || '?').slice(0, 1).toUpperCase()}
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-white',
meta.tone === 'success'
? 'bg-emerald-500'
: meta.tone === 'danger'
? 'bg-red-500'
: meta.tone === 'running'
? 'bg-sky-500'
: meta.tone === 'warning'
? 'bg-amber-500'
: 'bg-slate-300',
meta.pulse && 'animate-pulse',
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-[13.5px] font-semibold text-slate-900">
{account.user || account.username}
</h2>
<Badge tone={meta.tone} dot>
{meta.label}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-slate-400">
<span className="truncate">{account.username}</span>
{account.note && <span className="truncate">· {account.note}</span>}
<span>· {ageText(account.updated || account.loginAt)}</span>
</div>
</div>
<Badge tone="brand" className="mt-0.5 shrink-0">
{platformLabel}
</Badge>
</header>
<div className="px-4 pt-3">
<div className="flex items-center justify-between gap-2">
<div className="text-[11.5px] text-slate-500">
共 <b className="font-semibold text-slate-700">{counts.total}</b> 门 · 可学{' '}
<b className="font-semibold text-slate-700">{counts.studyable}</b>
{finished.length > 0 && <span className="text-slate-400"> · 已完成 {finished.length}</span>}
</div>
{studyable.length > 0 && (
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy}
className="rounded px-1.5 py-0.5 text-[11px] text-brand-600 transition hover:bg-brand-50 disabled:opacity-40"
onClick={onSelectAll}
>
全选
</button>
<button
type="button"
disabled={busy || selectedCount === 0}
className="rounded px-1.5 py-0.5 text-[11px] text-slate-500 transition hover:bg-slate-100 disabled:opacity-40"
onClick={onClearSelection}
>
清空
</button>
</div>
)}
</div>
<div className="scrollbar-thin mt-1.5 max-h-[196px] min-h-[70px] overflow-y-auto pr-0.5">
{all.length === 0 ? (
<div className="grid min-h-[70px] place-items-center rounded-lg border border-dashed border-line text-[12px] text-slate-400">
还没有课程,先登录或刷新课程
</div>
) : (
<>
{studyable.map((course) => (
<CourseRow
key={course.plan_id}
account={account}
course={course}
disabled={busy}
onToggle={onToggleCourse}
/>
))}
{finished.length > 0 && (
<div className="mt-1 border-t border-dashed border-line pt-1">
{finished.map((course) => (
<div
key={course.plan_id}
className="flex items-start gap-2.5 rounded-lg px-2 py-1.5 text-slate-400"
>
<span className="mt-0.5 grid size-3.5 shrink-0 place-items-center rounded-full bg-emerald-50 text-[9px] text-emerald-600">
✓
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px]">{course.name || course.plan_id}</span>
<span className="mt-0.5 block truncate text-[10.5px] opacity-80">
{[course.section, course.info].filter(Boolean).join(' · ')}
</span>
</span>
<span className="shrink-0 text-[10.5px]">{course.status_label || '已结束'}</span>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t border-line/70 px-4 py-2.5">
<label className="flex cursor-pointer items-center gap-1.5 text-[11.5px] text-slate-600">
<input
type="checkbox"
className="size-3.5 accent-brand-500"
checked={account.optQuiz === true}
disabled={busy}
onChange={(event) => onToggleOption('quiz', event.target.checked)}
/>
自动交测验
</label>
<label className="flex cursor-pointer items-center gap-1.5 text-[11.5px] text-slate-600">
<input
type="checkbox"
className="size-3.5 accent-brand-500"
checked={account.optFeedback !== false}
disabled={busy}
onChange={(event) => onToggleOption('feedback', event.target.checked)}
/>
自动交反馈表
</label>
<div className="flex-1" />
<span className="text-[11px] tabular-nums text-slate-400">
{selectedCount > 0 ? `已选 ${selectedCount} 门` : '未选择课程'}
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5 border-t border-line/70 px-4 py-2.5">
<Button
size="sm"
variant="soft"
loading={pending === 'login' || account.status === 'logging' || account.run?.status === 'logging'}
disabled={busy}
icon={<LogIn className="size-3.5" />}
onClick={onLogin}
>
{account.cookies?.length ? '重新登录' : '登录'}
</Button>
<Button
size="sm"
loading={pending === 'refresh'}
disabled={busy || !account.cookies?.length}
icon={<RefreshCw className="size-3.5" />}
onClick={onRefresh}
>
刷新课程
</Button>
<Button
size="sm"
variant="primary"
loading={pending === 'study'}
disabled={busy || !canStudy}
icon={<Play className="size-3.5" />}
onClick={onStudy}
>
开始学习
</Button>
<div className="flex-1" />
{running && (
<Button
size="icon"
variant="danger"
loading={pending === 'stop'}
onClick={onStop}
title="停止学习"
aria-label="停止学习"
>
<CircleStop className="size-4" />
</Button>
)}
<div className="relative" ref={menuRef}>
<Button
size="icon"
variant="ghost"
onClick={() => setMenuOpen((open) => !open)}
aria-label="更多操作"
title="更多操作"
>
<Ellipsis className="size-4" />
</Button>
{menuOpen && (
<div className="absolute bottom-10 right-0 z-20 w-40 overflow-hidden rounded-xl border border-line bg-white py-1 shadow-xl shadow-slate-900/10">
{[
{ label: '课程日志', icon: ListVideo, action: onToggleLog },
{ label: '打开测验', icon: ClipboardCheck, action: onQuiz },
account.run?.capturing
? { label: '停止抓包', icon: Square, action: onStopCapture }
: { label: '抓包 10 分钟', icon: Video, action: onCapture },
{ label: '删除账号', icon: Trash2, action: onDelete, danger: true },
].map((item) => (
<button
key={item.label}
type="button"
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition hover:bg-slate-50',
item.danger ? 'text-red-600' : 'text-slate-700',
)}
onClick={() => {
setMenuOpen(false)
item.action()
}}
>
<item.icon className="size-3.5 opacity-70" />
{item.label}
</button>
))}
</div>
)}
</div>
</div>
{(total > 0 || account.run?.error) && (
<div className="border-t border-line/70 px-4 py-3">
<div className="mb-1.5 flex items-center justify-between gap-2 text-[11px] text-slate-500">
<span className="truncate">
{account.run?.course || account.run?.status || '学习进度'}
</span>
<span className="shrink-0 tabular-nums">
{done} / {total}
{total > 0 && <span className="ml-1 text-slate-400">({pct}%)</span>}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
account.run?.status === 'error'
? 'bg-red-500'
: account.run?.status === 'finished'
? 'bg-emerald-500'
: 'bg-gradient-to-r from-brand-500 to-sky-400',
)}
style={{ width: `${Math.min(100, pct)}%` }}
/>
</div>
{account.run?.error && (
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-[11px] leading-relaxed text-red-700">
{account.run.error}
</p>
)}
</div>
)}
{account.lastError && (
<div className="border-t border-line/70 px-4 py-2.5">
<p className="rounded-lg bg-red-50/80 px-2.5 py-1.5 text-[11px] leading-relaxed text-red-700">
{account.lastError}
{account.lastErrorAt ? (
<span className="ml-1 text-red-400">· {ageText(account.lastErrorAt)}</span>
) : null}
</p>
</div>
)}
<div className={cn('border-t border-line/70', openLog ? 'block' : 'hidden')}>
<div className="flex items-center gap-2 px-4 pt-2.5 text-[11px] text-slate-400">
<span className="font-medium text-slate-500">课程日志</span>
<span className="tabular-nums">({logLines.length})</span>
<div className="flex-1" />
<button
type="button"
onClick={onToggleLog}
className="rounded px-1.5 py-0.5 text-[11px] text-slate-500 transition hover:bg-slate-100"
>
收起
</button>
</div>
<pre
ref={logRef}
onScroll={(event) => {
const el = event.currentTarget
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
}}
className={cn(
'scrollbar-thin mx-4 mb-3 mt-2 max-h-56 overflow-auto rounded-lg bg-slate-950/95 p-3',
'font-mono text-[11px] leading-relaxed text-slate-300',
logLines.length === 0 && 'text-slate-500',
)}
>
{logLines.length ? logLines.join('\n') : '暂无日志 · 开始学习后这里会实时显示'}
</pre>
</div>
<button
type="button"
onClick={onToggleLog}
className="flex items-center justify-center gap-1 border-t border-line/70 py-1.5 text-[10.5px] text-slate-400 transition hover:bg-slate-50 hover:text-slate-600"
aria-expanded={openLog}
>
<ChevronDown className={cn('size-3 transition-transform', openLog && 'rotate-180')} />
{openLog ? '收起课程日志' : '展开课程日志'}
</button>
</article>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { inputCls } from '../lib/utils'
export interface NewAccount {
username: string
password: string
note: string
}
export function AddAccountDialog({
open,
onClose,
onSubmit,
}: {
open: boolean
onClose: () => void
onSubmit: (account: NewAccount) => Promise<void>
}) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open) return
setUsername('')
setPassword('')
setNote('')
setError('')
setSaving(false)
}, [open])
async function submit() {
if (!username.trim() || !password) {
setError('请填写账号和密码')
return
}
setSaving(true)
setError('')
try {
await onSubmit({ username: username.trim(), password, note: note.trim() })
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="添加账号"
subtitle="账号密码保存在本机 SQLite,用于会话失效时自动重新登录"
footer={
<>
<Button onClick={onClose} disabled={saving}>
取消
</Button>
<Button variant="primary" loading={saving} onClick={submit}>
保存并登录
</Button>
</>
}
>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">登录账号</label>
<input
className={inputCls}
value={username}
autoFocus
placeholder="手机号 / 身份证号 / 教师账号"
onChange={(event) => setUsername(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && submit()}
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">密码</label>
<input
className={inputCls}
type="password"
value={password}
autoComplete="off"
placeholder="平台登录密码"
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && submit()}
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">
备注 <span className="font-normal text-slate-400">(可选)</span>
</label>
<input
className={inputCls}
value={note}
placeholder="例如:张三 · 语文"
onChange={(event) => setNote(event.target.value)}
/>
</div>
<p className="rounded-lg border border-line bg-slate-50 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
保存后会自动尝试登录一次。若密码错误或需要验证码,卡片上会保留「登录」按钮,可手动重试。
</p>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-700">{error}</p>
)}
</div>
</Modal>
)
}
+436
View File
@@ -0,0 +1,436 @@
import { useEffect, useRef, useState } from 'react'
import { Bot, RotateCcw, Send } from 'lucide-react'
import { api, fetchSse } from '../lib/api'
import type { Account, Course, QuizData, QuizOption, QuizQuestion } from '../types'
import { cn, splitKeys, TYPE_LABEL } from '../lib/utils'
import { Badge } from './ui/Badge'
import { Button } from './ui/Button'
import { Modal } from './ui/Modal'
import type { ToastState } from './Toast'
function optionLabel(option: QuizOption): string {
const text = String(option?.text || '').replace(/\s+/g, ' ').trim()
return text.length > 24 ? `${text.slice(0, 24)}…` : text
}
export function QuizDialog({
open,
account,
course,
onClose,
notify,
onSubmitted,
}: {
open: boolean
account: Account | null
course: Course | null
onClose: () => void
notify: (toast: ToastState) => void
onSubmitted: () => void
}) {
const [data, setData] = useState<QuizData | null>(null)
const [answers, setAnswers] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [retaking, setRetaking] = useState(false)
const [aiRunning, setAiRunning] = useState(false)
const [status, setStatus] = useState('')
const [statusTone, setStatusTone] = useState<'info' | 'success' | 'error'>('info')
const gotRef = useRef(0)
async function load(retake = false) {
if (!account || !course) return false
setLoading(true)
setStatus(retake ? '正在生成新答卷…' : '取题中…')
setStatusTone('info')
try {
const body: Record<string, unknown> = {
cookies: account.cookies,
plan_id: course.plan_id,
project_id: course.project_id,
name: course.name || '',
}
if (retake) {
body.retake = true
body.period_id = data?.period_id || ''
}
const response = await api<QuizData>('/api/quiz', {
method: 'POST',
body: JSON.stringify(body),
})
if (!response.ok) {
setData(null)
setStatus(response.error || '取题失败')
setStatusTone('error')
return false
}
setData(response)
setAnswers(
Object.fromEntries((response.questions || []).map((question) => [question.no, question.picked || ''])),
)
const bits: string[] = []
if (response.done) {
const left = response.surplus_exam_count
if (left !== undefined && left !== null) bits.push(`剩 ${left} 次`)
else if (response.allow_exam_count) bits.push(`共 ${response.allow_exam_count} 次`)
if (response.show_answers) bits.push('已显示标准答案')
else if (response.exam_after_watch_answer === '0') bits.push('本测验不开放答案')
setStatus(`已交卷${bits.length ? ` · ${bits.join(' · ')}` : ''}`)
setStatusTone('success')
} else {
setStatus(response.started ? '已自动创建答卷(限时考试已开始计时)' : '')
setStatusTone('info')
}
return true
} catch (error) {
setStatus(error instanceof Error ? error.message : '取题失败')
setStatusTone('error')
return false
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!open || !account || !course) return
setData(null)
setAnswers({})
setAiRunning(false)
void load(false)
// 只在打开或切换课程时取题;轮询更新 account 对象不重新取题
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, account?.id, course?.plan_id])
if (!account || !course) return null
const questions = data?.questions || []
const done = !!data?.done
const show = !!data?.show_answers
const canRetake = !!data?.can_retake
const left = data?.surplus_exam_count
const disabled = done || loading || submitting || aiRunning
function setAnswer(no: string, value: string) {
setAnswers((previous) => ({ ...previous, [no]: value }))
}
function toggleMulti(no: string, key: string, checked: boolean) {
setAnswers((previous) => {
const keys = splitKeys(previous[no])
const next = checked ? [...keys, key] : keys.filter((item) => item !== key)
next.sort()
return { ...previous, [no]: next.join('#') }
})
}
async function submit() {
if (!data || !account) return
const missing = questions.filter((question) => !answers[question.no]).length
if (missing && !window.confirm(`还有 ${missing} 题没作答,确定提交?`)) return
setSubmitting(true)
setStatus('提交中…')
setStatusTone('info')
try {
const response = await api<{ ok: boolean; error?: string; code?: string; score?: number }>(
'/api/quiz/submit',
{
method: 'POST',
body: JSON.stringify({
cookies: account.cookies,
plan_id: data.plan_id,
object_id: data.object_id,
generation: data.generation || 'legacy',
result_id: data.result_id || '',
answers,
}),
},
)
if (!response.ok) {
if (response.code === 'already_finished') await load()
setStatus(response.error || '提交失败')
setStatusTone('error')
return
}
notify({ message: `测验已提交${response.score ? `,得分 ${response.score}` : ''}`, tone: 'success' })
await load()
onSubmitted()
} catch (error) {
setStatus(error instanceof Error ? error.message : '提交失败')
setStatusTone('error')
} finally {
setSubmitting(false)
}
}
async function retake() {
if (!data) return
if (!data.can_retake) {
notify({ message: '没有剩余考试机会', tone: 'error' })
return
}
const tip = left !== undefined && left !== null ? `(剩余 ${left} 次)` : ''
if (!window.confirm(`再考一次会消耗 1 次机会${tip};限时考试会立即开始计时,确定?`)) return
setRetaking(true)
try {
if (await load(true)) notify({ message: '已生成新答卷,可以重新答题了', tone: 'success' })
} finally {
setRetaking(false)
}
}
async function aiAnswer() {
if (!data || !account) return
if (data.done) {
notify({ message: '已交卷,点「再考一次」生成新答卷后再 AI 答题', tone: 'error' })
return
}
setAiRunning(true)
gotRef.current = 0
const started = Date.now()
let failed = 0
let lastError = ''
const timer = window.setInterval(() => {
setStatus(
`AI 答题中… ${Math.round((Date.now() - started) / 1000)}s · 已填 ${gotRef.current}/${questions.length} 题`,
)
setStatusTone('info')
}, 1000)
setStatus(`AI 答题中… 已填 0/${questions.length} 题`)
try {
await fetchSse(
'/api/quiz/answer',
{ questions: data.questions, account_id: account.id },
(event) => {
const batch = event.answers as Record<string, string> | undefined
if (batch && typeof batch === 'object') {
const count = Object.keys(batch).length
if (count) {
gotRef.current += count
setAnswers((previous) => ({ ...previous, ...batch }))
}
}
if (event.error) lastError = String(event.error)
if (event.done && typeof event.failed === 'number') failed = event.failed
},
)
if (!gotRef.current) {
setStatus(lastError ? `AI 答题失败:${lastError}` : 'AI 没有返回答案')
setStatusTone('error')
} else if (failed) {
setStatus(`AI 已填 ${gotRef.current} 题,${failed} 题失败${lastError ? `:${lastError}` : ''}`)
setStatusTone('error')
} else {
setStatus(`AI 已填 ${gotRef.current} 题`)
setStatusTone('success')
}
} catch (error) {
setStatus(error instanceof Error ? error.message : 'AI 答题失败')
setStatusTone('error')
} finally {
window.clearInterval(timer)
setAiRunning(false)
}
}
const retakeLabel = canRetake
? `再考一次${left !== undefined && left !== null ? `(剩 ${left} 次)` : ''}`
: left === 0
? '考试机会已用完'
: '再考一次'
return (
<Modal
open={open}
onClose={onClose}
title={`测验 · ${course.name || course.plan_id}`}
subtitle={status || '取题后可手动作答,或让大模型逐批填写'}
size="xl"
footer={
<>
<span
className={cn(
'mr-auto max-w-[50%] truncate text-[11.5px]',
statusTone === 'error'
? 'text-red-600'
: statusTone === 'success'
? 'text-emerald-600'
: 'text-slate-500',
)}
>
{status}
</span>
{done && (
<Button
size="sm"
loading={retaking}
disabled={loading || !canRetake}
icon={<RotateCcw className="size-3.5" />}
onClick={retake}
>
{retakeLabel}
</Button>
)}
<Button size="sm" onClick={onClose}>
关闭
</Button>
<Button
size="sm"
variant="soft"
loading={aiRunning}
disabled={disabled || !questions.length}
icon={<Bot className="size-3.5" />}
onClick={aiAnswer}
>
AI 答题
</Button>
<Button
size="sm"
variant="primary"
loading={submitting}
disabled={disabled || !questions.length}
icon={<Send className="size-3.5" />}
onClick={submit}
>
提交
</Button>
</>
}
>
{loading && !questions.length ? (
<div className="py-16 text-center text-[13px] text-slate-400">正在打开课程页取题…</div>
) : !questions.length ? (
<div className="py-16 text-center text-[13px] text-red-600">{status || '没有题目'}</div>
) : (
<div className="space-y-2.5">
{questions.map((question) => {
const options = question.options || []
const picked = answers[question.no] || ''
const isMulti = question.type === '02'
const hasOptions = options.length > 0
return (
<div key={question.no} className="rounded-xl border border-line px-3.5 py-3">
<div className="flex items-start gap-2">
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-md bg-slate-100 text-[10.5px] font-semibold tabular-nums text-slate-500">
{question.no}
</span>
<Badge tone="neutral" className="mt-0.5 shrink-0">
{TYPE_LABEL[question.type || ''] || '题目'}
</Badge>
<p className="min-w-0 flex-1 text-[13px] leading-relaxed text-slate-800">
{question.title || `第 ${question.no} 题`}
</p>
{show && question.is_correct !== undefined && (
<span
className={cn(
'shrink-0 text-[12px] font-semibold',
question.is_correct ? 'text-emerald-600' : 'text-red-500',
)}
>
{question.is_correct ? '✓' : '✗'}
</span>
)}
</div>
{hasOptions && (
<div className="mt-2 flex flex-wrap gap-1.5 pl-7">
{options.map((option) => {
const correct = show && splitKeys(question.correct).includes(option.key)
const wrong =
show &&
splitKeys(question.picked).includes(option.key) &&
!splitKeys(question.correct).includes(option.key)
return (
<span
key={option.key}
className={cn(
'max-w-full rounded-lg border px-2 py-1 text-[11.5px] leading-snug',
correct
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: wrong
? 'border-red-200 bg-red-50 text-red-700'
: 'border-line bg-slate-50/70 text-slate-600',
)}
>
<b className="mr-1 font-semibold">{option.key}</b>
{optionLabel(option)}
</span>
)
})}
</div>
)}
<div className="mt-2.5 flex flex-wrap items-center gap-2 pl-7">
{isMulti ? (
<div className="flex flex-wrap gap-1.5">
{options.map((option) => {
const keys = splitKeys(picked)
return (
<label
key={option.key}
title={`${option.key}. ${option.text || ''}`}
className={cn(
'flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-1 text-[11.5px] transition',
keys.includes(option.key)
? 'border-brand-300 bg-brand-50 text-brand-700'
: 'border-line text-slate-600 hover:border-slate-300',
disabled && 'pointer-events-none opacity-60',
)}
>
<input
type="checkbox"
className="size-3 accent-brand-500"
checked={keys.includes(option.key)}
disabled={disabled}
onChange={(event) =>
toggleMulti(question.no, option.key, event.target.checked)
}
/>
{option.key}
</label>
)
})}
</div>
) : hasOptions ? (
<select
className="h-8 w-full max-w-[320px] rounded-lg border border-line bg-white px-2 text-[12px] text-slate-700 focus:border-brand-500 focus:outline-none disabled:bg-slate-50 disabled:text-slate-400"
value={picked}
disabled={disabled}
onChange={(event) => setAnswer(question.no, event.target.value)}
>
<option value="">未选</option>
{options.map((option) => (
<option key={option.key} value={option.key}>
{option.key}. {option.text || ''}
</option>
))}
</select>
) : (
<input
className="h-8 w-full max-w-[320px] rounded-lg border border-line bg-white px-2 text-[12px] text-slate-700 focus:border-brand-500 focus:outline-none disabled:bg-slate-50 disabled:text-slate-400"
value={picked}
placeholder="填写答案"
disabled={disabled}
onChange={(event) => setAnswer(question.no, event.target.value)}
/>
)}
{show && question.correct && (
<span className="text-[11.5px] text-slate-500">
标准答案:<b className="font-semibold text-slate-700">{question.correct}</b>
</span>
)}
</div>
{show && question.analysis && (
<p className="mt-2 rounded-lg bg-slate-50 px-2.5 py-1.5 text-[11.5px] leading-relaxed text-slate-500">
解析:{question.analysis}
</p>
)}
</div>
)
})}
</div>
)}
</Modal>
)
}
+270
View File
@@ -0,0 +1,270 @@
import { useEffect, useState } from 'react'
import { Cpu, Gauge, KeyRound, ServerCog } from 'lucide-react'
import { api } from '../lib/api'
import { inputCls } from '../lib/utils'
import { Button } from './ui/Button'
import { Modal } from './ui/Modal'
import type { ToastState } from './Toast'
interface SettingsResponse {
llm?: {
base_url?: string
model?: string
temperature?: number
timeout?: number
prompt?: string
has_key?: boolean
key_hint?: string
}
study?: { mode?: string; parallel?: number }
}
export function SettingsDialog({
open,
onClose,
notify,
}: {
open: boolean
onClose: () => void
notify: (toast: ToastState) => void
}) {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [form, setForm] = useState({
base_url: '',
api_key: '',
model: '',
temperature: '0.2',
timeout: '120',
prompt: '',
study_mode: 'fast',
study_parallel: '2',
})
const [keyHint, setKeyHint] = useState('sk-...')
useEffect(() => {
if (!open) return
setLoading(true)
api<SettingsResponse>('/api/settings')
.then((response) => {
const llm = response.llm || {}
const study = response.study || {}
setForm({
base_url: llm.base_url || '',
api_key: '',
model: llm.model || '',
temperature: String(llm.temperature ?? 0.2),
timeout: String(llm.timeout ?? 120),
prompt: llm.prompt || '',
study_mode: study.mode || 'fast',
study_parallel: String(study.parallel || 2),
})
setKeyHint(llm.has_key ? `已保存 ${llm.key_hint || ''},留空不修改` : 'sk-...')
})
.catch((error) => notify({ message: `读取设置失败:${error.message}`, tone: 'error' }))
.finally(() => setLoading(false))
}, [open, notify])
function update<K extends keyof typeof form>(key: K, value: string) {
setForm((previous) => ({ ...previous, [key]: value }))
}
async function save() {
setSaving(true)
try {
await api('/api/settings', {
method: 'POST',
body: JSON.stringify({
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
model: form.model.trim(),
temperature: Number.parseFloat(form.temperature) || 0.2,
timeout: Number.parseInt(form.timeout, 10) || 120,
prompt: form.prompt,
study_mode: form.study_mode,
study_parallel: Number.parseInt(form.study_parallel, 10) || 0,
}),
})
notify({ message: '设置已保存', tone: 'success' })
onClose()
} catch (error) {
notify({ message: `保存失败:${error instanceof Error ? error.message : error}`, tone: 'error' })
} finally {
setSaving(false)
}
}
async function test() {
setTesting(true)
try {
const response = await api<{ ok?: boolean; ms?: number; reply?: string; error?: string }>(
'/api/llm/test',
{
method: 'POST',
body: JSON.stringify({
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
model: form.model.trim(),
}),
},
)
notify({
message: response.ok
? `连接正常(${response.ms} ms):${response.reply || ''}`
: `连接失败:${response.error || '未知错误'}`,
tone: response.ok ? 'success' : 'error',
})
} catch (error) {
notify({ message: `连接失败:${error instanceof Error ? error.message : error}`, tone: 'error' })
} finally {
setTesting(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="系统设置"
subtitle="大模型接口、答题提示词与学习模式"
size="lg"
footer={
<>
<Button loading={testing} onClick={test} icon={<Cpu className="size-3.5" />}>
测试连接
</Button>
<Button onClick={onClose} disabled={saving}>
取消
</Button>
<Button variant="primary" loading={saving} onClick={save} disabled={loading}>
保存
</Button>
</>
}
>
{loading ? (
<div className="py-10 text-center text-[12.5px] text-slate-400">读取设置…</div>
) : (
<div className="space-y-6">
<section>
<div className="mb-3 flex items-center gap-2 text-[12.5px] font-semibold text-slate-700">
<ServerCog className="size-4 text-brand-500" />
大模型(OpenAI 兼容)
</div>
<div className="space-y-3">
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">
接口地址 <span className="text-slate-400">(写到 /v1 为止)</span>
</label>
<input
className={inputCls}
value={form.base_url}
placeholder="https://api.openai.com/v1"
onChange={(event) => update('base_url', event.target.value)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">API Key</label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<input
className={`${inputCls} pl-8`}
type="password"
autoComplete="off"
value={form.api_key}
placeholder={keyHint}
onChange={(event) => update('api_key', event.target.value)}
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">模型</label>
<input
className={inputCls}
value={form.model}
placeholder="gpt-4o-mini / deepseek-chat / qwen-plus"
onChange={(event) => update('model', event.target.value)}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">温度</label>
<input
className={inputCls}
type="number"
min={0}
max={2}
step={0.1}
value={form.temperature}
onChange={(event) => update('temperature', event.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">超时(秒)</label>
<input
className={inputCls}
type="number"
min={10}
max={600}
value={form.timeout}
onChange={(event) => update('timeout', event.target.value)}
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">
答题提示词 <span className="text-slate-400">(留空使用内置)</span>
</label>
<textarea
className={`${inputCls} min-h-24 resize-y`}
value={form.prompt}
placeholder='只输出 JSON {"answers":{"题号":"答案"}}'
onChange={(event) => update('prompt', event.target.value)}
/>
</div>
</div>
</section>
<section className="border-t border-line pt-5">
<div className="mb-3 flex items-center gap-2 text-[12.5px] font-semibold text-slate-700">
<Gauge className="size-4 text-brand-500" />
学习模式
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">模式</label>
<select
className={inputCls}
value={form.study_mode}
onChange={(event) => update('study_mode', event.target.value)}
>
<option value="fast">快速学习(只调接口上报)</option>
<option value="normal">常规学习(真实播放视频)</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">常规模式并发数</label>
<input
className={inputCls}
type="number"
min={1}
max={5}
value={form.study_parallel}
onChange={(event) => update('study_parallel', event.target.value)}
/>
</div>
</div>
<p className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
常规学习会开多个无头浏览器同时播视频;平台若按课节顺序放行,会被弹回并自动降为 1
路。快速学习只调用 <code className="rounded bg-white px-1 ring-1 ring-line">period/study/log</code>,不启动浏览器。
</p>
</section>
</div>
)}
</Modal>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { Activity, GraduationCap, ShieldCheck, Users } from 'lucide-react'
import type { Account } from '../types'
export function StatBar({ accounts }: { accounts: Account[] }) {
const loggedIn = accounts.filter(
(account) => account.cookies?.length > 0 || ['logged_in', 'studying'].includes(account.status || ''),
).length
const running = accounts.filter((account) =>
['discovering', 'studying'].includes(account.run?.status || ''),
).length
const done = accounts.reduce((sum, account) => sum + (account.run?.done || 0), 0)
const total = accounts.reduce((sum, account) => sum + (account.run?.total || 0), 0)
const stats = [
{ label: '账号', value: accounts.length, hint: '已添加', icon: Users, tone: 'text-brand-600 bg-brand-50' },
{
label: '已登录',
value: loggedIn,
hint: accounts.length ? `共 ${accounts.length} 个` : '等待添加',
icon: ShieldCheck,
tone: 'text-emerald-600 bg-emerald-50',
},
{
label: '学习中',
value: running,
hint: running ? '正在跑课' : '空闲',
icon: Activity,
tone: 'text-sky-600 bg-sky-50',
},
{
label: '课程进度',
value: total ? `${done}/${total}` : '—',
hint: total ? `${Math.round((done / total) * 100)}%` : '暂无任务',
icon: GraduationCap,
tone: 'text-amber-600 bg-amber-50',
},
]
return (
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{stats.map((stat) => (
<div
key={stat.label}
className="flex items-center gap-3 rounded-xl border border-line bg-white px-4 py-3"
>
<span className={`grid size-9 shrink-0 place-items-center rounded-lg ${stat.tone}`}>
<stat.icon className="size-4" />
</span>
<div className="min-w-0">
<div className="text-[11px] text-slate-400">{stat.label}</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[17px] font-semibold tabular-nums leading-tight text-slate-900">
{stat.value}
</span>
<span className="truncate text-[10.5px] text-slate-400">{stat.hint}</span>
</div>
</div>
</div>
))}
</section>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../lib/api'
import { Button } from './ui/Button'
import { Modal } from './ui/Modal'
export function SystemLogDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const [lines, setLines] = useState<string[]>([])
const [clearing, setClearing] = useState(false)
const preRef = useRef<HTMLPreElement>(null)
const stickRef = useRef(true)
useEffect(() => {
if (!open) return
let alive = true
const pull = async () => {
try {
const response = await api<{ lines?: string[] }>('/api/log/history?key=system&tail=500')
if (alive) setLines(response.lines || [])
} catch {
// 面板轮询失败不打断页面
}
}
setLines([])
pull()
const timer = window.setInterval(() => {
if (!document.hidden) pull()
}, 2500)
return () => {
alive = false
window.clearInterval(timer)
}
}, [open])
useEffect(() => {
const el = preRef.current
if (el && stickRef.current) el.scrollTop = el.scrollHeight
}, [lines, open])
async function clear() {
setClearing(true)
try {
await api('/api/log/history?key=system', { method: 'DELETE' })
setLines([])
} finally {
setClearing(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="系统日志"
subtitle="全局事件与系统级错误,实时刷新"
size="xl"
footer={
<>
<span className="mr-auto text-[11px] text-slate-400">共 {lines.length} 行 · 保留最近 500 行</span>
<Button variant="danger" size="sm" loading={clearing} onClick={clear}>
清空
</Button>
<Button size="sm" onClick={onClose}>
关闭
</Button>
</>
}
bodyClassName="bg-slate-950 p-0"
>
<pre
ref={preRef}
onScroll={(event) => {
const el = event.currentTarget
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
}}
className="scrollbar-thin h-[60vh] overflow-auto whitespace-pre-wrap break-words p-4 font-mono text-[11.5px] leading-relaxed text-slate-300"
>
{lines.length ? lines.join('\n') : '暂无系统日志'}
</pre>
</Modal>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { AlertCircle, CheckCircle2, Info } from 'lucide-react'
import { cn } from '../lib/utils'
export interface ToastState {
message: string
tone?: 'info' | 'success' | 'error'
}
export function Toast({ toast }: { toast: ToastState | null }) {
if (!toast) return null
const tone = toast.tone || 'info'
const Icon = tone === 'error' ? AlertCircle : tone === 'success' ? CheckCircle2 : Info
return (
<div
role="status"
className={cn(
'animate-toast-in fixed bottom-7 left-1/2 z-[60] flex max-w-[85vw] -translate-x-1/2 items-center gap-2',
'rounded-xl px-4 py-2.5 text-[12.5px] font-medium shadow-xl shadow-slate-900/20',
tone === 'error'
? 'bg-red-600 text-white'
: tone === 'success'
? 'bg-emerald-600 text-white'
: 'bg-slate-900 text-white',
)}
>
<Icon className="size-4 shrink-0 opacity-90" />
<span className="break-words">{toast.message}</span>
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { Plus, RefreshCw, ScrollText, Settings2 } from 'lucide-react'
import { Button } from './ui/Button'
export function TopBar({
refreshing,
onRefresh,
onSettings,
onSystemLog,
onAdd,
}: {
refreshing: boolean
onRefresh: () => void
onSettings: () => void
onSystemLog: () => void
onAdd: () => void
}) {
return (
<header className="sticky top-0 z-30 border-b border-line/80 bg-white/85 backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-[1320px] items-center gap-3 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500 to-sky-400 text-[15px] font-bold text-white shadow-md shadow-brand-500/30">
学
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-[15px] font-semibold tracking-tight text-slate-900">
莞易学
</h1>
<span className="hidden rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 sm:inline">
多账号学习控制台
</span>
</div>
<p className="truncate text-[11.5px] text-slate-500">
东莞市教师培训平台 · 新旧版课程自动识别
</p>
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
loading={refreshing}
title="刷新列表"
aria-label="刷新列表"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onSystemLog}
title="系统日志"
aria-label="系统日志"
>
<ScrollText className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onSettings}
title="系统设置"
aria-label="系统设置"
>
<Settings2 className="size-4" />
</Button>
<Button variant="primary" className="ml-1" icon={<Plus className="size-4" />} onClick={onAdd}>
<span className="hidden sm:inline">添加账号</span>
</Button>
</div>
</div>
</header>
)
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/utils'
type Tone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'running'
const TONES: Record<Tone, string> = {
neutral: 'bg-slate-100 text-slate-600 ring-slate-200',
brand: 'bg-brand-50 text-brand-700 ring-brand-100',
success: 'bg-emerald-50 text-emerald-700 ring-emerald-100',
warning: 'bg-amber-50 text-amber-700 ring-amber-100',
danger: 'bg-red-50 text-red-700 ring-red-100',
running: 'bg-sky-50 text-sky-700 ring-sky-100',
}
export function Badge({
tone = 'neutral',
children,
dot = false,
className,
}: {
tone?: Tone
children: ReactNode
dot?: boolean
className?: string
}) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset',
TONES[tone],
className,
)}
>
{dot && <span className="size-1.5 rounded-full bg-current opacity-70" />}
{children}
</span>
)
}
+60
View File
@@ -0,0 +1,60 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '../../lib/utils'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'soft'
type Size = 'sm' | 'md' | 'icon'
const VARIANTS: Record<Variant, string> = {
primary:
'border-brand-500 bg-brand-500 text-white shadow-sm shadow-brand-500/25 hover:border-brand-600 hover:bg-brand-600',
secondary: 'border-line bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50',
ghost: 'border-transparent bg-transparent text-slate-600 hover:bg-slate-100',
danger: 'border-transparent bg-transparent text-red-600 hover:bg-red-50',
soft: 'border-brand-100 bg-brand-50 text-brand-700 hover:border-brand-200 hover:bg-brand-100',
}
const SIZES: Record<Size, string> = {
sm: 'h-8 gap-1.5 rounded-lg px-2.5 text-xs',
md: 'h-9 gap-1.5 rounded-lg px-3.5 text-[13px]',
icon: 'h-9 w-9 rounded-lg',
}
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
size?: Size
loading?: boolean
icon?: ReactNode
}
export function Button({
variant = 'secondary',
size = 'md',
loading = false,
icon,
className,
children,
disabled,
type = 'button',
...rest
}: ButtonProps) {
return (
<button
type={type}
disabled={disabled || loading}
className={cn(
'inline-flex shrink-0 items-center justify-center border font-medium transition',
'focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-brand-500/20',
'disabled:cursor-not-allowed disabled:opacity-50',
VARIANTS[variant],
SIZES[size],
className,
)}
{...rest}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : icon}
{children}
</button>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '../../lib/utils'
const SIZES = {
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-6xl',
}
export function Modal({
open,
onClose,
title,
subtitle,
size = 'md',
footer,
children,
bodyClassName,
}: {
open: boolean
onClose: () => void
title: ReactNode
subtitle?: ReactNode
size?: keyof typeof SIZES
footer?: ReactNode
children: ReactNode
bodyClassName?: string
}) {
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKey)
document.body.style.overflow = previous
}
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
className={cn(
'relative flex max-h-[calc(100vh-2rem)] w-full flex-col overflow-hidden rounded-2xl',
'border border-line bg-white shadow-2xl shadow-slate-900/20',
SIZES[size],
)}
>
<header className="flex items-start gap-3 border-b border-line px-5 py-4">
<div className="min-w-0 flex-1">
<h2 className="truncate text-[15px] font-semibold text-slate-900">{title}</h2>
{subtitle && <p className="mt-0.5 truncate text-xs text-slate-500">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
className="-mr-1 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
aria-label="关闭"
>
<X className="size-4" />
</button>
</header>
<div className={cn('min-h-0 flex-1 overflow-auto px-5 py-4', bodyClassName)}>{children}</div>
{footer && (
<footer className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-slate-50/70 px-5 py-3">
{footer}
</footer>
)}
</div>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif;
--color-brand-50: #eef4ff;
--color-brand-100: #dbe7ff;
--color-brand-200: #bfd4ff;
--color-brand-500: #2f6bff;
--color-brand-600: #1f55e0;
--color-brand-700: #1a46bd;
--color-line: #e6eaf2;
}
@layer base {
html {
color-scheme: light;
scroll-behavior: smooth;
}
body {
@apply min-h-screen bg-[#f5f7fb] text-slate-900 antialiased;
font-family: var(--font-sans);
}
::selection {
background: rgb(47 107 255 / 0.18);
}
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #c9d2e3 transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #c9d2e3;
}
.card-shadow {
box-shadow:
0 1px 2px rgb(16 24 40 / 0.04),
0 18px 36px -24px rgb(16 24 40 / 0.22);
}
.animate-toast-in {
animation: toast-in 0.24s cubic-bezier(0.2, 0.8, 0.2, 1);
}
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 10px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
+68
View File
@@ -0,0 +1,68 @@
export class ApiError extends Error {
offline = false
status?: number
code?: string
constructor(message: string, init: { offline?: boolean; status?: number; code?: string } = {}) {
super(message)
this.name = 'ApiError'
Object.assign(this, init)
}
}
export async function api<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
let res: Response
try {
res = await fetch(path, {
...init,
headers: { 'Content-Type': 'application/json', ...(init.headers || {}) },
})
} catch {
throw new ApiError('后端未连接', { offline: true })
}
if (res.status === 401) {
throw new ApiError('需要访问令牌:请用 /?token=<AUTH_TOKEN> 打开一次', { status: 401 })
}
if (!res.ok) throw new ApiError(`HTTP ${res.status}`, { status: res.status })
return (await res.json()) as T
}
export async function fetchSse(
path: string,
body: unknown,
onEvent: (event: Record<string, unknown>) => void,
): Promise<void> {
let res: Response
try {
res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} catch {
throw new ApiError('后端未连接', { offline: true })
}
if (res.status === 401) throw new ApiError('需要访问令牌:请用 /?token=<AUTH_TOKEN> 打开一次')
if (!res.ok) throw new ApiError(`HTTP ${res.status}`)
if (!res.body) throw new ApiError('浏览器不支持流式响应')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split('\n\n')
buffer = chunks.pop() || ''
for (const chunk of chunks) {
const line = chunk.trim()
if (!line.startsWith('data:')) continue
try {
onEvent(JSON.parse(line.slice(5).trim()) as Record<string, unknown>)
} catch {
// 忽略坏行,下一批继续
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import clsx, { type ClassValue } from 'clsx'
import type { Course } from '../types'
export const cn = (...values: ClassValue[]) => clsx(values)
export const inputCls =
'w-full rounded-lg border border-line bg-white px-3 py-2 text-[13px] text-slate-900 ' +
'placeholder:text-slate-400 transition focus:border-brand-500 focus:outline-none ' +
'focus:ring-3 focus:ring-brand-500/15 disabled:cursor-not-allowed disabled:bg-slate-50'
export function ageText(ts?: number): string {
if (!ts) return '—'
const minutes = Math.floor((Date.now() - ts) / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
return `${Math.floor(minutes / 60)} 小时前`
}
export function countCourses(courses: Course[] = []): { total: number; studyable: number } {
return {
total: courses.length,
studyable: courses.filter((c) => c.in_study !== false).length,
}
}
export function splitKeys(value?: string): string[] {
return String(value || '')
.split('#')
.filter(Boolean)
}
export const TYPE_LABEL: Record<string, string> = {
'01': '单选',
'02': '多选',
'03': '判断',
'04': '问答',
}
export function toastMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+112
View File
@@ -0,0 +1,112 @@
export interface Cookie {
name: string
value: string
domain?: string
path?: string
}
export interface Course {
plan_id: string
project_id: string
name?: string
section?: string
status_label?: string
info?: string
progress?: string
in_study?: boolean
platform?: string
generation?: string
[key: string]: unknown
}
export interface RunState {
status?: string
total?: number
done?: number
course?: string
progress?: string
error?: string
mode?: string
parallel?: number
capturing?: boolean
capture_file?: string
cookies?: Cookie[]
[key: string]: unknown
}
export interface Account {
id: string
username: string
note?: string
user?: string
cookies: Cookie[]
courses: Course[]
selected: string[]
status?: string
lastError?: string
lastErrorAt?: number
optQuiz?: boolean
optFeedback?: boolean
loginAt?: number
created?: number
updated?: number
hasPassword?: boolean
run?: RunState | null
}
export interface PlatformInfo {
id: string
label: string
}
export interface LogResponse {
lines?: string[]
next?: number
status?: string
done?: number
total?: number
course?: string
progress?: string
cookies?: Cookie[]
}
export interface QuizOption {
key: string
text?: string
}
export interface QuizQuestion {
no: string
type?: string
title?: string
options?: QuizOption[]
picked?: string
correct?: string
is_correct?: boolean
analysis?: string
[key: string]: unknown
}
export interface QuizData {
ok: boolean
error?: string
code?: string
platform?: string
generation?: string
plan_id?: string
object_id?: string
result_id?: string
period_id?: string
title?: string
questions: QuizQuestion[]
done?: boolean
review?: boolean
show_answers?: boolean
can_retake?: boolean
surplus_exam_count?: number | null
allow_exam_count?: number | null
score?: number | string
started?: boolean
exam_after_watch_answer?: string
[key: string]: unknown
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"types": ["vite/client"]
},
"include": ["src"]
}
+19
View File
@@ -0,0 +1,19 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// 构建产物直接写进仓库根的 static/,FastAPI 原样托管;开发时用 vite 代理 /api。
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: fileURLToPath(new URL('../static', import.meta.url)),
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8848',
},
},
})