refactor: React 前端重构,新旧平台合并为莞易学
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
// 忽略坏行,下一批继续
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user