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

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
+63 -17
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Plus, RefreshCw, Users, WifiOff } from 'lucide-react'
import { AccountCard } from './components/AccountCard'
import { AccountEditDialog, type AccountEdit } from './components/AccountEditDialog'
import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog'
import { PluginsDialog } from './components/PluginsDialog'
import { QuizDialog } from './components/QuizDialog'
@@ -11,6 +12,7 @@ import { SystemLogDialog } from './components/SystemLogDialog'
import { Toast, type ToastState } from './components/Toast'
import { TopBar } from './components/TopBar'
import { Button } from './components/ui/Button'
import { useConfirm } from './components/ui/ConfirmDialog'
import { ApiError, api } from './lib/api'
import { countCourses, toastMessage } from './lib/utils'
import type { Account, Cookie, Course, LogResponse, PlatformInfo, RunState } from './types'
@@ -48,6 +50,7 @@ interface InlineLog {
type DialogState =
| { type: 'add' }
| { type: 'edit'; accountId: string }
| { type: 'settings' }
| { type: 'plugins' }
| { type: 'system' }
@@ -72,12 +75,11 @@ function persistOpenLogs(ids: Set<string>) {
// 隐私模式禁用 localStorage 时忽略
}
}
function accountPayload(account: Account) {
return {
id: account.id,
username: account.username,
platform: account.platform || 'guanyixue',
platform: account.platform || '',
note: account.note || '',
user: account.user || '',
cookies: account.cookies || [],
@@ -95,10 +97,8 @@ function accountPayload(account: Account) {
export default function App() {
const [accounts, setAccounts] = useState<Account[]>([])
const accountsRef = useRef(accounts)
const [platforms, setPlatforms] = useState<Record<string, string>>({ guanyixue: '莞易学' })
const [platformList, setPlatformList] = useState<PlatformInfo[]>([
{ id: 'guanyixue', label: '莞易学' },
])
const [platforms, setPlatforms] = useState<Record<string, string>>({})
const [platformList, setPlatformList] = useState<PlatformInfo[]>([])
const [offline, setOffline] = useState(false)
const [toast, setToast] = useState<ToastState | null>(null)
const [pending, setPending] = useState<Record<string, string>>({})
@@ -109,6 +109,7 @@ export default function App() {
const [logs, setLogs] = useState<Record<string, InlineLog>>({})
const logsRef = useRef(logs)
const toastTimer = useRef<number | undefined>(undefined)
const { ask: askConfirm, dialog: confirmDialog } = useConfirm()
useEffect(() => {
accountsRef.current = accounts
@@ -166,6 +167,10 @@ export default function App() {
setOffline(false)
for (const account of next) {
// 只同步「正在跑」的那一轮:运行结束后 run 里留的是开跑时的旧会话,
// 再回写会把刚登录的新 cookie 盖回失效的那一份,刷新课程就会一直重登。
const status = account.run?.status
if (status !== 'discovering' && status !== 'studying') continue
const cookies = account.run?.cookies
if (
cookies &&
@@ -383,7 +388,7 @@ export default function App() {
method: 'POST',
body: JSON.stringify({
cookies: account.cookies,
platform: account.platform || 'guanyixue',
platform: account.platform || '',
}),
})
if (!response.ok && response.code === 'session_expired') {
@@ -393,7 +398,11 @@ export default function App() {
if (!latest) return
response = await api<CoursesResponse>('/api/courses', {
method: 'POST',
body: JSON.stringify({ cookies: latest.cookies }),
body: JSON.stringify({
cookies: latest.cookies,
// 重试也要带平台:漏了它后端会拿到空平台,报「平台「」还没有实现刷新课程」
platform: latest.platform || account.platform || '',
}),
})
}
if (!response.ok) {
@@ -529,9 +538,31 @@ export default function App() {
})
}
async function saveAccountEdit(account: Account, data: AccountEdit) {
const updated: Account = {
...account,
username: data.username,
note: data.note,
platform: data.platform,
}
const response = await api<{ ok?: boolean; error?: string }>('/api/accounts', {
method: 'POST',
body: JSON.stringify({ ...accountPayload(updated), password: data.password }),
})
if (response.ok === false) throw new Error(response.error || '保存失败')
replaceAccount(updated)
notify({ message: '账号信息已保存,正在用新凭据重新登录…' })
// 先让弹窗关掉,重新登录在后台跑:失败了会走 toast + 卡片红条提示
void login(updated)
}
async function removeAccount(account: Account) {
if (!window.confirm(`删除账号「${account.user || account.username}」?课程缓存和日志不会自动清理。`))
return
const ok = await askConfirm({
title: '删除账号',
message: `删除「${account.user || account.username}」?课程缓存和日志不会自动清理,删掉后要重新添加账号才能继续学习。`,
confirmText: '删除账号',
})
if (!ok) return
try {
await api(`/api/accounts/${encodeURIComponent(account.id)}`, { method: 'DELETE' })
setAccounts((previous) => {
@@ -642,9 +673,17 @@ export default function App() {
function platformLabel(account: Account) {
const id =
account.platform || (account.courses || []).find((course) => course.platform)?.platform
return (id && platforms[id]) || '莞易学'
return (id && platforms[id]) || id || '未选择平台'
}
function platformCaps(account: Account) {
return platformList.find((item) => item.id === account.platform)?.capabilities || []
}
const editTarget =
dialog?.type === 'edit'
? accounts.find((account) => account.id === dialog.accountId) || null
: null
const quizAccount =
dialog?.type === 'quiz'
? accounts.find((account) => account.id === dialog.accountId) || null
@@ -686,9 +725,6 @@ export default function App() {
<div className="mb-3 mt-6 flex items-end justify-between gap-3">
<div>
<h2 className="text-[14px] font-semibold text-slate-900">账号列表</h2>
<p className="mt-0.5 text-[11.5px] text-slate-400">
新旧版课程自动识别为「莞易学」,每 5 秒自动刷新状态
</p>
</div>
{accounts.length > 0 && (
<Button
@@ -710,8 +746,7 @@ export default function App() {
</div>
<h3 className="mt-4 text-[14px] font-semibold text-slate-800">还没有账号</h3>
<p className="mt-1.5 max-w-md text-[12.5px] leading-relaxed text-slate-500">
添加东莞市教师培训平台账号后,可自动读取课程、勾选学习、提交反馈表与测验。
新旧版课程页会统一识别为「莞易学」。
添加平台账号后,可自动读取课程、勾选学习、提交反馈表与测验。
</p>
<Button
variant="primary"
@@ -729,6 +764,7 @@ export default function App() {
key={account.id}
account={account}
platformLabel={platformLabel(account)}
capabilities={platformCaps(account)}
pending={pending[account.id]}
openLog={openLogs.has(account.id)}
logLines={logs[account.id]?.lines || []}
@@ -739,6 +775,7 @@ export default function App() {
onStop={() => void stopStudy(account)}
onCapture={() => void capture(account)}
onStopCapture={() => void stopCapture(account)}
onEdit={() => setDialog({ type: 'edit', accountId: account.id })}
onDelete={() => void removeAccount(account)}
onQuiz={() => {
const list = (account.courses || []).filter((course) => course.in_study !== false)
@@ -765,7 +802,7 @@ export default function App() {
</main>
<footer className="border-t border-line/70 py-5 text-center text-[11px] text-slate-400">
莞易学 · 多账号学习控制台 · 数据保存在本机
多账号学习控制台 · 数据保存在本机
</footer>
<AddAccountDialog
@@ -774,6 +811,13 @@ export default function App() {
onClose={() => setDialog(null)}
onSubmit={addAccount}
/>
<AccountEditDialog
open={dialog?.type === 'edit' && !!editTarget}
account={editTarget}
platforms={platformList}
onClose={() => setDialog(null)}
onSubmit={(data) => (editTarget ? saveAccountEdit(editTarget, data) : Promise.resolve())}
/>
<SettingsDialog
open={dialog?.type === 'settings'}
onClose={() => setDialog(null)}
@@ -795,6 +839,8 @@ export default function App() {
onSubmitted={() => void load()}
/>
{confirmDialog}
<Toast toast={toast} />
</div>
)
+27 -10
View File
@@ -10,6 +10,7 @@ import {
RefreshCw,
Square,
Trash2,
UserPen,
Video,
} from 'lucide-react'
@@ -17,6 +18,7 @@ import type { Account, Course } from '../types'
import { ageText, cn, countCourses } from '../lib/utils'
import { Badge } from './ui/Badge'
import { Button } from './ui/Button'
import { LogLines } from './ui/LogLines'
type Tone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'running'
@@ -87,6 +89,7 @@ function CourseRow({
export function AccountCard({
account,
platformLabel,
capabilities,
pending,
openLog,
logLines,
@@ -98,6 +101,7 @@ export function AccountCard({
onCapture,
onStopCapture,
onQuiz,
onEdit,
onDelete,
onToggleCourse,
onSelectAll,
@@ -106,6 +110,7 @@ export function AccountCard({
}: {
account: Account
platformLabel: string
capabilities: string[]
pending?: string
openLog: boolean
logLines: string[]
@@ -117,6 +122,7 @@ export function AccountCard({
onCapture: () => void
onStopCapture: () => void
onQuiz: () => void
onEdit: () => void
onDelete: () => void
onToggleCourse: (planId: string, checked: boolean) => void
onSelectAll: () => void
@@ -355,11 +361,18 @@ export function AccountCard({
{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: UserPen, action: onEdit },
{ label: '课程日志', icon: ListVideo, action: onToggleLog },
{ label: '打开测验', icon: ClipboardCheck, action: onQuiz },
account.run?.capturing
? { label: '停止抓包', icon: Square, action: onStopCapture }
: { label: '抓包 10 分钟', icon: Video, action: onCapture },
...(capabilities.includes('quiz')
? [{ label: '打开测验', icon: ClipboardCheck, action: onQuiz }]
: []),
...(capabilities.includes('capture')
? [
account.run?.capturing
? { label: '停止抓包', icon: Square, action: onStopCapture }
: { label: '抓包 10 分钟', icon: Video, action: onCapture },
]
: []),
{ label: '删除账号', icon: Trash2, action: onDelete, danger: true },
].map((item) => (
<button
@@ -423,6 +436,14 @@ export function AccountCard({
<span className="ml-1 text-red-400">· {ageText(account.lastErrorAt)}</span>
) : null}
</p>
<button
type="button"
onClick={onEdit}
className="mt-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-red-600 transition hover:bg-red-50"
>
<UserPen className="size-3" />
修改账号信息
</button>
</div>
)}
@@ -445,13 +466,9 @@ export function AccountCard({
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',
)}
className="scrollbar-thin mx-4 mb-3 mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-line bg-slate-50 p-3 font-mono text-[11px] leading-relaxed text-slate-600"
>
{logLines.length ? logLines.join('\n') : '暂无日志 · 开始学习后这里会实时显示'}
<LogLines lines={logLines} empty="暂无日志 · 开始学习后这里会实时显示" />
</pre>
</div>
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Eye, EyeOff, GraduationCap, KeyRound, UserPen } from 'lucide-react'
import type { Account, PlatformInfo } from '../types'
import { inputCls } from '../lib/utils'
import { Button } from './ui/Button'
import { Modal } from './ui/Modal'
export interface AccountEdit {
username: string
password: string
note: string
platform: string
}
export function AccountEditDialog({
open,
account,
platforms,
onClose,
onSubmit,
}: {
open: boolean
account: Account | null
platforms: PlatformInfo[]
onClose: () => void
onSubmit: (data: AccountEdit) => Promise<void>
}) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [note, setNote] = useState('')
const [platform, setPlatform] = useState('')
const [showPwd, setShowPwd] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
// 卡片每 5 秒轮询会换掉 account 对象,只在打开 / 换账号时回填,避免正在输入的内容被重置
const accountRef = useRef(account)
accountRef.current = account
const platformsRef = useRef(platforms)
platformsRef.current = platforms
useEffect(() => {
if (!open) return
const acc = accountRef.current
setUsername(acc?.username || '')
setPassword('')
setNote(acc?.note || '')
setPlatform(acc?.platform || platformsRef.current[0]?.id || '')
setShowPwd(false)
setSaving(false)
setError('')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, account?.id])
async function submit() {
if (!username.trim()) {
setError('登录账号不能为空')
return
}
if (!platform) {
setError('请选择学习平台')
return
}
setSaving(true)
setError('')
try {
await onSubmit({ username: username.trim(), password, note: note.trim(), platform })
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={
<span className="flex items-center gap-2">
<UserPen className="size-4 text-brand-600" />
修改账号信息
</span>
}
subtitle={
account?.user ? `当前会话:${account.user}` : '这个账号还没有登录成功,改完会自动重试登录'
}
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>
<div className="relative">
<GraduationCap className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<select
className={`${inputCls} appearance-none pl-8 pr-8`}
value={platform}
onChange={(event) => setPlatform(event.target.value)}
>
{platforms.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
</div>
</div>
<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 flex items-center gap-1.5 text-[12px] font-medium text-slate-600">
<KeyRound className="size-3.5 text-slate-400" />
密码
<span className="font-normal text-slate-400">(留空表示不修改)</span>
</label>
<div className="relative">
<input
className={`${inputCls} pr-9`}
type={showPwd ? 'text' : 'password'}
value={password}
autoComplete="new-password"
placeholder={account?.hasPassword ? '已保存密码,需要更换时再填' : '还没有保存密码,请填写'}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && submit()}
/>
<button
type="button"
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
onClick={() => setShowPwd((value) => !value)}
aria-label={showPwd ? '隐藏密码' : '显示密码'}
>
{showPwd ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</button>
</div>
</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>
)
}
+7 -3
View File
@@ -27,7 +27,7 @@ export function AddAccountDialog({
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [note, setNote] = useState('')
const [platform, setPlatform] = useState(platforms[0]?.id || 'guanyixue')
const [platform, setPlatform] = useState(platforms[0]?.id || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@@ -36,12 +36,16 @@ export function AddAccountDialog({
setUsername('')
setPassword('')
setNote('')
setPlatform(platforms[0]?.id || 'guanyixue')
setPlatform(platforms[0]?.id || '')
setError('')
setSaving(false)
}, [open, platforms])
async function submit() {
if (!platform) {
setError('没有可用的学习平台,请先在「插件」里启用一个平台插件')
return
}
if (!username.trim() || !password) {
setError('请填写账号和密码')
return
@@ -94,7 +98,7 @@ export function AddAccountDialog({
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-slate-400">
选「莞易学」后,后台会自动识别旧版 / 新版课程页并按对应实现学习;后续新增的平台会出现在这里。
账号的登录、刷新课程、学习都按所选平台的插件实现;新增或启用平台插件后会出现在这里。
</p>
</div>
<div>
+57 -7
View File
@@ -14,6 +14,20 @@ interface PluginsResponse {
plugins?: PluginInfo[]
}
// 能力名(contract.CAPABILITIES 的键)→ 界面文案
const CAPABILITY_LABELS: Record<string, string> = {
login: '登录',
session: '会话',
renew: '续期',
profile: '账号信息',
courses: '课程列表',
discover: '课程详情',
study: '学习',
quiz: '测验',
capture: '抓包',
push: '推送',
}
export function PluginsDialog({
open,
onClose,
@@ -68,12 +82,11 @@ export function PluginsDialog({
open={open}
onClose={onClose}
title="插件管理"
subtitle="pugins/<名字>/ 下的平台插件:启停立即生效,改完代码重载即可,不用重启服务"
size="lg"
footer={
<>
<span className="mr-auto text-[11px] text-slate-400">
共 {plugins.length} 个 · 停用后该平台会从「添加账号」的平台下拉里消失
共 {plugins.length} 个 · 停用后平台会从「添加账号」下拉里消失,推送渠道也会停止推送
</span>
<Button
variant="soft"
@@ -106,10 +119,47 @@ export function PluginsDialog({
)}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-slate-500">
<span>平台 {plugin.label || '—'}</span>
<span>id {plugin.id || '—'}</span>
<span>能力 {plugin.capabilities.length ? plugin.capabilities.join(' / ') : '无'}</span>
<div className="mt-2 space-y-1.5 rounded-lg border border-line/80 bg-white px-3 py-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="w-[52px] shrink-0 text-[11px] text-slate-400">适配网站</span>
{plugin.sites?.length ? (
plugin.sites.map((site) => (
<span
key={site}
className="rounded-md border border-line bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-600"
>
{site}
</span>
))
) : (
<span className="text-[11px] text-slate-400">—</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="w-[52px] shrink-0 text-[11px] text-slate-400">能力</span>
{plugin.capabilities.length ? (
plugin.capabilities.map((cap) => (
<span
key={cap}
className="rounded-md bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700 ring-1 ring-inset ring-brand-100"
>
{CAPABILITY_LABELS[cap] || cap}
</span>
))
) : (
<span className="text-[11px] text-slate-400">无</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 pl-[52px] text-[11px] text-slate-400">
<span>
平台 <span className="text-slate-600">{plugin.label || '—'}</span>
</span>
<span>
id <span className="text-slate-600">{plugin.id || '—'}</span>
</span>
</div>
</div>
<div className="mt-2.5 flex items-center gap-2">
@@ -144,7 +194,7 @@ export function PluginsDialog({
{!plugins.length && !loading && (
<p className="py-8 text-center text-[12.5px] text-slate-400">
pugins/ 下还没有插件。建一个含 pugin.json + main.py 的目录丢进去,刷新即可看到。
plugins/ 下还没有插件。建一个含 plugin.json + main.py 的目录丢进去,刷新即可看到。
</p>
)}
</div>
+23 -3
View File
@@ -6,6 +6,7 @@ import type { Account, Course, QuizData, QuizOption, QuizQuestion } from '../typ
import { cn, splitKeys, TYPE_LABEL } from '../lib/utils'
import { Badge } from './ui/Badge'
import { Button } from './ui/Button'
import { useConfirm } from './ui/ConfirmDialog'
import { Modal } from './ui/Modal'
import type { ToastState } from './Toast'
@@ -38,6 +39,7 @@ export function QuizDialog({
const [status, setStatus] = useState('')
const [statusTone, setStatusTone] = useState<'info' | 'success' | 'error'>('info')
const gotRef = useRef(0)
const { ask, dialog } = useConfirm()
async function load(retake = false) {
if (!account || !course) return false
@@ -47,7 +49,7 @@ export function QuizDialog({
try {
const body: Record<string, unknown> = {
cookies: account.cookies,
platform: account.platform || 'guanyixue',
platform: account.platform || '',
plan_id: course.plan_id,
project_id: course.project_id,
name: course.name || '',
@@ -128,7 +130,16 @@ export function QuizDialog({
async function submit() {
if (!data || !account) return
const missing = questions.filter((question) => !answers[question.no]).length
if (missing && !window.confirm(`还有 ${missing} 题没作答,确定提交?`)) return
if (
missing &&
!(await ask({
title: '提交测验',
message: `还有 ${missing} 题没作答,提交后就不能再改了,确定提交?`,
confirmText: '提交',
tone: 'normal',
}))
)
return
setSubmitting(true)
setStatus('提交中…')
setStatusTone('info')
@@ -139,6 +150,7 @@ export function QuizDialog({
method: 'POST',
body: JSON.stringify({
cookies: account.cookies,
platform: account.platform || '',
plan_id: data.plan_id,
object_id: data.object_id,
generation: data.generation || 'legacy',
@@ -171,7 +183,13 @@ export function QuizDialog({
return
}
const tip = left !== undefined && left !== null ? `(剩余 ${left} 次)` : ''
if (!window.confirm(`再考一次会消耗 1 次机会${tip};限时考试会立即开始计时,确定?`)) return
const ok = await ask({
title: '再考一次',
message: `再考一次会消耗 1 次机会${tip};限时考试会立即开始计时,确定继续?`,
confirmText: '再考一次',
tone: 'normal',
})
if (!ok) return
setRetaking(true)
try {
if (await load(true)) notify({ message: '已生成新答卷,可以重新答题了', tone: 'success' })
@@ -432,6 +450,8 @@ export function QuizDialog({
})}
</div>
)}
{dialog}
</Modal>
)
}
+341 -55
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { Cpu, Gauge, KeyRound, ServerCog } from 'lucide-react'
import { useEffect, useState, type ReactNode } from 'react'
import { Bell, Cpu, Gauge, KeyRound, Send, ServerCog } from 'lucide-react'
import { api } from '../lib/api'
import { inputCls } from '../lib/utils'
import { cn, inputCls } from '../lib/utils'
import { Button } from './ui/Button'
import { Modal } from './ui/Modal'
import type { ToastState } from './Toast'
@@ -18,6 +18,75 @@ interface SettingsResponse {
key_hint?: string
}
study?: { mode?: string; parallel?: number }
notify?: {
enabled?: boolean
base_url?: string
app_key?: string
channel_name?: string
has_secret?: boolean
secret_hint?: string
on_error?: boolean
on_login?: boolean
on_progress?: boolean
on_retry?: boolean
retry_threshold?: number
cooldown_min?: number
}
}
const TABS = [
{ key: 'llm', label: '大模型', icon: ServerCog },
{ key: 'study', label: '学习模式', icon: Gauge },
{ key: 'notify', label: '故障推送', icon: Bell },
] as const
type TabKey = (typeof TABS)[number]['key']
function Check({
checked,
label,
onChange,
}: {
checked: boolean
label: string
onChange: (value: boolean) => void
}) {
return (
<label className="flex cursor-pointer items-center gap-1.5 text-[12px] text-slate-600">
<input
type="checkbox"
className="size-3.5 accent-brand-600"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
)
}
function Pane({
title,
desc,
action,
children,
}: {
title: string
desc: string
action?: ReactNode
children: ReactNode
}) {
return (
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-[13.5px] font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11.5px] text-slate-500">{desc}</p>
</div>
{action}
</div>
<div className="space-y-3.5">{children}</div>
</div>
)
}
export function SettingsDialog({
@@ -32,6 +101,8 @@ export function SettingsDialog({
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testingPush, setTestingPush] = useState(false)
const [tab, setTab] = useState<TabKey>('llm')
const [form, setForm] = useState({
base_url: '',
api_key: '',
@@ -43,14 +114,34 @@ export function SettingsDialog({
study_parallel: '2',
})
const [keyHint, setKeyHint] = useState('sk-...')
// 密钥只回显前后几位,「留空 = 保留」;要删掉已保存的密钥只能显式点「清空」
const [clearKey, setClearKey] = useState(false)
const [nt, setNt] = useState({
enabled: true,
base_url: '',
app_key: '',
app_secret: '',
channel_name: '',
on_error: true,
on_login: true,
on_progress: true,
on_retry: true,
retry_threshold: '3',
cooldown_min: '10',
})
const [secretHint, setSecretHint] = useState('未设置')
const [clearSecret, setClearSecret] = useState(false)
useEffect(() => {
if (!open) return
setLoading(true)
setClearKey(false)
setClearSecret(false)
api<SettingsResponse>('/api/settings')
.then((response) => {
const llm = response.llm || {}
const study = response.study || {}
const push = response.notify || {}
setForm({
base_url: llm.base_url || '',
api_key: '',
@@ -62,6 +153,20 @@ export function SettingsDialog({
study_parallel: String(study.parallel || 2),
})
setKeyHint(llm.has_key ? `已保存 ${llm.key_hint || ''},留空不修改` : 'sk-...')
setNt({
enabled: push.enabled !== false,
base_url: push.base_url || '',
app_key: push.app_key || '',
app_secret: '',
channel_name: push.channel_name || '',
on_error: push.on_error !== false,
on_login: push.on_login !== false,
on_progress: push.on_progress !== false,
on_retry: push.on_retry !== false,
retry_threshold: String(push.retry_threshold ?? 3),
cooldown_min: String(push.cooldown_min ?? 10),
})
setSecretHint(push.has_secret ? `已保存 ${push.secret_hint || ''},留空不修改` : '未设置')
})
.catch((error) => notify({ message: `读取设置失败:${error.message}`, tone: 'error' }))
.finally(() => setLoading(false))
@@ -71,6 +176,10 @@ export function SettingsDialog({
setForm((previous) => ({ ...previous, [key]: value }))
}
function updateNt<K extends keyof typeof nt>(key: K, value: (typeof nt)[K]) {
setNt((previous) => ({ ...previous, [key]: value }))
}
async function save() {
setSaving(true)
try {
@@ -79,12 +188,27 @@ export function SettingsDialog({
body: JSON.stringify({
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
clear_api_key: clearKey,
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: {
base_url: nt.base_url.trim(),
app_key: nt.app_key.trim(),
app_secret: nt.app_secret.trim(),
clear_app_secret: clearSecret,
channel_name: nt.channel_name.trim(),
enabled: nt.enabled,
on_error: nt.on_error,
on_login: nt.on_login,
on_progress: nt.on_progress,
on_retry: nt.on_retry,
retry_threshold: Number.parseInt(nt.retry_threshold, 10) || 0,
cooldown_min: Number.parseInt(nt.cooldown_min, 10) || 0,
},
}),
})
notify({ message: '设置已保存', tone: 'success' })
@@ -123,18 +247,34 @@ export function SettingsDialog({
}
}
async function testPush() {
setTestingPush(true)
try {
const response = await api<{ ok?: boolean; message?: string }>('/api/notify/test', { method: 'POST' })
notify({
message: response.message || (response.ok ? '推送成功' : '推送失败'),
tone: response.ok ? 'success' : 'error',
})
} catch (error) {
notify({ message: `推送失败:${error instanceof Error ? error.message : error}`, tone: 'error' })
} finally {
setTestingPush(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="系统设置"
subtitle="大模型接口、答题提示词与学习模式"
size="lg"
subtitle="大模型接口、学习模式与故障推送"
size="settings"
bodyClassName="p-0"
footer={
<>
<Button loading={testing} onClick={test} icon={<Cpu className="size-3.5" />}>
测试连接
</Button>
<span className="mr-auto text-[11px] text-slate-400">
保存后立即生效;正在学习的账号从下一门课开始按新设置执行
</span>
<Button onClick={onClose} disabled={saving}>
取消
</Button>
@@ -144,20 +284,44 @@ export function SettingsDialog({
</>
}
>
{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 className="grid min-h-[540px] grid-cols-[188px_1fr]">
<nav className="border-r border-line bg-slate-50/70 p-2.5">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
'mb-1 flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[12.5px] font-medium transition',
tab === key
? 'bg-white text-brand-700 shadow-sm ring-1 ring-line'
: 'text-slate-600 hover:bg-white/70',
)}
>
<Icon className="size-4 shrink-0" />
{label}
</button>
))}
<p className="mt-3 px-2.5 text-[11px] leading-relaxed text-slate-400">
设置只保存在本机 SQLite,不会同步到学习平台。
</p>
</nav>
<div className="min-w-0 px-5 py-4">
{loading ? (
<div className="py-20 text-center text-[12.5px] text-slate-400">读取设置…</div>
) : tab === 'llm' ? (
<Pane
title="大模型(OpenAI 兼容)"
desc="用于自动答题;接口地址写到 /v1 为止。"
action={
<Button size="sm" loading={testing} onClick={test} icon={<Cpu className="size-3.5" />}>
测试连接
</Button>
}
>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">
接口地址 <span className="text-slate-400">(写到 /v1 为止)</span>
</label>
<label className="mb-1.5 block text-[12px] text-slate-600">接口地址</label>
<input
className={inputCls}
value={form.base_url}
@@ -167,7 +331,21 @@ export function SettingsDialog({
</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>
<label className="mb-1.5 flex items-center justify-between text-[12px] text-slate-600">
<span>API Key</span>
{keyHint.startsWith('已保存') && (
<button
type="button"
className="text-[11px] text-slate-400 hover:text-rose-500"
onClick={() => {
setClearKey(true)
setKeyHint('保存后清空')
}}
>
{clearKey ? '将清空' : '清空'}
</button>
)}
</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
@@ -220,51 +398,159 @@ export function SettingsDialog({
答题提示词 <span className="text-slate-400">(留空使用内置)</span>
</label>
<textarea
className={`${inputCls} min-h-24 resize-y`}
className={`${inputCls} min-h-32 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>
</Pane>
) : tab === 'study' ? (
<Pane title="学习模式" desc="快速上报只调接口,常规模式开无头浏览器真播。">
<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="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>
</Pane>
) : (
<Pane
title="故障推送"
desc="进度异常、反复重试、运行/登录异常时推送到配置的渠道。"
action={
<Button
size="sm"
loading={testingPush}
onClick={testPush}
icon={<Send className="size-3.5" />}
>
测试推送
</Button>
}
>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">常规模式并发数</label>
<label className="mb-1.5 block text-[12px] text-slate-600">
推送地址 <span className="text-slate-400">(baseurl,不带 /api)</span>
</label>
<input
className={inputCls}
type="number"
min={1}
max={5}
value={form.study_parallel}
onChange={(event) => update('study_parallel', event.target.value)}
value={nt.base_url}
placeholder="https://corn.hanhan.ltd"
onChange={(event) => updateNt('base_url', 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 className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">app_key</label>
<input
className={inputCls}
value={nt.app_key}
placeholder="应用 app_key"
onChange={(event) => updateNt('app_key', event.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">
推送 channel_name <span className="text-slate-400">(留空 = 默认渠道)</span>
</label>
<input
className={inputCls}
value={nt.channel_name}
placeholder="我的邮箱"
onChange={(event) => updateNt('channel_name', event.target.value)}
/>
</div>
</div>
<div>
<label className="mb-1.5 flex items-center justify-between text-[12px] text-slate-600">
<span>app_secret</span>
{secretHint.startsWith('已保存') && (
<button
type="button"
className="text-[11px] text-slate-400 hover:text-rose-500"
onClick={() => {
setClearSecret(true)
setSecretHint('保存后清空')
}}
>
{clearSecret ? '将清空' : '清空'}
</button>
)}
</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={nt.app_secret}
placeholder={secretHint}
onChange={(event) => updateNt('app_secret', 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={2}
max={10}
value={nt.retry_threshold}
onChange={(event) => updateNt('retry_threshold', event.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] text-slate-600">同账号同类型冷却(分钟)</label>
<input
className={inputCls}
type="number"
min={1}
max={1440}
value={nt.cooldown_min}
onChange={(event) => updateNt('cooldown_min', event.target.value)}
/>
</div>
</div>
<div className="flex flex-wrap gap-x-5 gap-y-2 rounded-lg bg-slate-50 px-3 py-2.5">
<Check checked={nt.enabled} label="启用推送" onChange={(value) => updateNt('enabled', value)} />
<Check checked={nt.on_progress} label="进度异常" onChange={(value) => updateNt('on_progress', value)} />
<Check checked={nt.on_retry} label="多次重试" onChange={(value) => updateNt('on_retry', value)} />
<Check checked={nt.on_error} label="运行/课程异常" onChange={(value) => updateNt('on_error', value)} />
<Check checked={nt.on_login} label="登录/会话异常" onChange={(value) => updateNt('on_login', value)} />
</div>
<p className="text-[11.5px] leading-relaxed text-slate-500">
推送由「插件管理」里的推送渠道插件发出(默认自带「呆呆面板推送」)。关闭某个开关只是不推该类故障,
不影响学习流程。
</p>
</Pane>
)}
</div>
)}
</div>
</Modal>
)
}
+4 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { api } from '../lib/api'
import { Button } from './ui/Button'
import { LogLines } from './ui/LogLines'
import { Modal } from './ui/Modal'
export function SystemLogDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
@@ -67,7 +68,7 @@ export function SystemLogDialog({ open, onClose }: { open: boolean; onClose: ()
</Button>
</>
}
bodyClassName="bg-slate-950 p-0"
bodyClassName="bg-slate-50 p-0"
>
<pre
ref={preRef}
@@ -75,9 +76,9 @@ export function SystemLogDialog({ open, onClose }: { open: boolean; onClose: ()
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"
className="scrollbar-thin h-[60vh] overflow-auto whitespace-pre-wrap break-words p-4 font-mono text-[11.5px] leading-relaxed text-slate-600"
>
{lines.length ? lines.join('\n') : '暂无系统日志'}
<LogLines lines={lines} empty="暂无系统日志" />
</pre>
</Modal>
)
+83 -81
View File
@@ -1,89 +1,91 @@
import { Plus, Puzzle, RefreshCw, ScrollText, Settings2 } from 'lucide-react'
import {Plus, Puzzle, RefreshCw, ScrollText, Settings2} from 'lucide-react'
import { Button } from './ui/Button'
import {Button} from './ui/Button'
export function TopBar({
refreshing,
onRefresh,
onSettings,
onSystemLog,
onPlugins,
onAdd,
}: {
refreshing: boolean
onRefresh: () => void
onSettings: () => void
onSystemLog: () => void
onPlugins: () => void
onAdd: () => void
refreshing,
onRefresh,
onSettings,
onSystemLog,
onPlugins,
onAdd,
}: {
refreshing: boolean
onRefresh: () => void
onSettings: () => void
onSystemLog: () => void
onPlugins: () => 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">
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={onPlugins}
title="插件管理"
aria-label="插件管理"
>
<Puzzle 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>
<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={onPlugins}
title="插件管理"
aria-label="插件管理"
>
<Puzzle 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>
)
</header>
)
}
+3 -1
View File
@@ -3,7 +3,7 @@ import { Loader2 } from 'lucide-react'
import { cn } from '../../lib/utils'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'soft'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid' | 'soft'
type Size = 'sm' | 'md' | 'icon'
const VARIANTS: Record<Variant, string> = {
@@ -12,6 +12,8 @@ const VARIANTS: Record<Variant, string> = {
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',
'danger-solid':
'border-red-500 bg-red-500 text-white shadow-sm shadow-red-500/25 hover:border-red-600 hover:bg-red-600',
soft: 'border-brand-100 bg-brand-50 text-brand-700 hover:border-brand-200 hover:bg-brand-100',
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useRef, useState, type ReactNode } from 'react'
import { AlertTriangle, HelpCircle } from 'lucide-react'
import { cn } from '../../lib/utils'
import { Button } from './Button'
import { Modal } from './Modal'
export interface ConfirmOptions {
title: string
message: ReactNode
confirmText?: string
cancelText?: string
tone?: 'danger' | 'normal'
}
interface PendingConfirm extends ConfirmOptions {
resolve: (ok: boolean) => void
}
/**
* 项目内统一的确认弹窗,替代 window.confirm:
* const { ask, dialog } = useConfirm(); if (await ask({...})) { ... };把 {dialog} 渲染出去。
*/
export function useConfirm() {
const [pending, setPending] = useState<PendingConfirm | null>(null)
const pendingRef = useRef<PendingConfirm | null>(null)
pendingRef.current = pending
const ask = useCallback(
(options: ConfirmOptions) =>
new Promise<boolean>((resolve) => setPending({ ...options, resolve })),
[],
)
const settle = useCallback((ok: boolean) => {
const current = pendingRef.current
pendingRef.current = null
setPending(null)
current?.resolve(ok)
}, [])
const danger = (pending?.tone || 'danger') === 'danger'
const Icon = danger ? AlertTriangle : HelpCircle
const dialog = (
<Modal
open={!!pending}
onClose={() => settle(false)}
size="sm"
title={pending?.title || ''}
footer={
<>
<Button onClick={() => settle(false)}>{pending?.cancelText || '取消'}</Button>
<Button
variant={danger ? 'danger-solid' : 'primary'}
onClick={() => settle(true)}
autoFocus
>
{pending?.confirmText || '确定'}
</Button>
</>
}
>
<div className="flex items-start gap-3">
<span
className={cn(
'grid size-9 shrink-0 place-items-center rounded-xl ring-1',
danger
? 'bg-red-50 text-red-600 ring-red-100'
: 'bg-brand-50 text-brand-600 ring-brand-100',
)}
>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1 pt-1 text-[13px] leading-relaxed text-slate-600">
{pending?.message}
</div>
</div>
</Modal>
)
return { ask, dialog }
}
+40
View File
@@ -0,0 +1,40 @@
import { cn } from '../../lib/utils'
/** 日志行按级别配色:runtime.log 拼的格式固定是「时间 [级别] 内容」。 */
const LEVEL_TONE: Record<string, string> = {
error: 'text-red-600',
fail: 'text-red-600',
failed: 'text-red-600',
warn: 'text-amber-600',
warning: 'text-amber-600',
success: 'text-emerald-600',
info: 'text-slate-600',
}
const LINE_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[([a-zA-Z]+)\] ([\s\S]*)$/
export function LogLines({ lines, empty }: { lines: string[]; empty: string }) {
if (!lines.length) return <span className="text-slate-400">{empty}</span>
return (
<>
{lines.map((line, index) => {
const matched = LINE_RE.exec(line)
if (!matched) {
return (
<span key={index} className="block text-slate-600">
{line}
</span>
)
}
const tone = LEVEL_TONE[matched[2].toLowerCase()] || 'text-slate-600'
return (
<span key={index} className="block">
<span className="text-slate-400">{matched[1]}</span>{' '}
<span className={cn('font-medium', tone)}>[{matched[2]}]</span>{' '}
<span className={tone}>{matched[3]}</span>
</span>
)
})}
</>
)
}
+3 -1
View File
@@ -4,9 +4,11 @@ import { X } from 'lucide-react'
import { cn } from '../../lib/utils'
const SIZES = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-6xl',
settings: 'max-w-4xl',
}
export function Modal({
@@ -70,7 +72,7 @@ export function Modal({
<X className="size-4" />
</button>
</header>
<div className={cn('min-h-0 flex-1 overflow-auto px-5 py-4', bodyClassName)}>{children}</div>
<div className={cn('min-h-0 flex-1 overflow-auto', bodyClassName ?? 'px-5 py-4')}>{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}
+5 -1
View File
@@ -3,9 +3,13 @@ import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
import QueryPage from './pages/QueryPage'
// /query 是不需要 token 的公开查询页,其余路径是控制台
const Page = window.location.pathname.replace(/\/+$/, '') === '/query' ? QueryPage : App
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<Page />
</StrictMode>,
)
+147
View File
@@ -0,0 +1,147 @@
import { useState, type FormEvent } from 'react'
import { Search } from 'lucide-react'
import { Badge } from '../components/ui/Badge'
import { Button } from '../components/ui/Button'
import { api } from '../lib/api'
interface QueryCourse {
name?: string
section?: string
status_label?: string
info?: string
progress?: string
}
interface QueryAccount {
username: string
user: string
platform: string
status: string
courses: QueryCourse[]
run: { status?: string; done?: number; total?: number; course?: string; progress?: string; error?: string }
}
type Tone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'running'
const STATUS: Record<string, { label: string; tone: Tone }> = {
discovering: { label: '发现课程中', tone: 'running' },
studying: { label: '学习中', tone: 'running' },
finished: { label: '已完成', tone: 'success' },
stopped: { label: '已停止', tone: 'warning' },
error: { label: '出错', tone: 'danger' },
interrupted: { label: '已中断', tone: 'warning' },
logged_in: { label: '已登录', tone: 'success' },
login_failed: { label: '登录失败', tone: 'danger' },
idle: { label: '未开始', tone: 'neutral' },
}
function AccountResult({ account }: { account: QueryAccount }) {
const { run } = account
const meta = STATUS[run.status || account.status] || { label: run.status || account.status, tone: 'neutral' as Tone }
const percent = run.total ? Math.min(100, Math.round(((run.done || 0) / run.total) * 100)) : null
return (
<section className="rounded-2xl border border-line bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-[15px] font-semibold text-slate-900">{account.user || account.username}</h2>
<Badge tone="brand">{account.platform || '未知平台'}</Badge>
<Badge tone={meta.tone} dot>
{meta.label}
</Badge>
</div>
{(percent !== null || run.course || run.error) && (
<div className="mt-3 space-y-1.5">
{percent !== null && (
<div className="flex items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500 transition-all" style={{ width: `${percent}%` }} />
</div>
<span className="text-xs tabular-nums text-slate-500">
{run.done || 0}/{run.total}({percent}%)
</span>
</div>
)}
{run.course && (
<p className="truncate text-xs text-slate-500">
当前:{run.course}
{run.progress ? ` · ${run.progress}` : ''}
</p>
)}
{run.error && <p className="text-xs text-red-600">{run.error}</p>}
</div>
)}
<div className="mt-4 divide-y divide-line/70">
{account.courses.length === 0 && <p className="py-3 text-sm text-slate-400">暂无课程</p>}
{account.courses.map((c, i) => (
<div key={i} className="flex items-start gap-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-700">{c.name || '—'}</div>
<div className="mt-0.5 truncate text-[11.5px] text-slate-400">
{[c.section, c.status_label, c.info].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<span className="shrink-0 text-xs tabular-nums text-slate-600">{c.progress || ''}</span>
</div>
))}
</div>
</section>
)
}
export default function QueryPage() {
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [accounts, setAccounts] = useState<QueryAccount[] | null>(null)
async function search(event: FormEvent) {
event.preventDefault()
const name = username.trim()
if (!name) return
setLoading(true)
setError('')
try {
const res = await api<{ ok: boolean; error?: string; accounts?: QueryAccount[] }>(
`/api/query?username=${encodeURIComponent(name)}`,
)
if (!res.ok) throw new Error(res.error || '查询失败')
setAccounts(res.accounts || [])
} catch (e) {
setAccounts(null)
setError(e instanceof Error ? e.message : '查询失败')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-slate-50">
<main className="mx-auto max-w-[760px] px-4 py-10 sm:px-6">
<h1 className="text-xl font-semibold tracking-tight text-slate-900">学习进度查询</h1>
<p className="mt-1 text-sm text-slate-500">输入账号用户名,查看课程列表与当前学习进度</p>
<form onSubmit={search} className="mt-6 flex gap-2">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入账号用户名"
autoFocus
className="h-10 min-w-0 flex-1 rounded-lg border border-line bg-white px-3 text-sm outline-none transition focus:border-brand-500 focus:ring-3 focus:ring-brand-500/20"
/>
<Button type="submit" variant="primary" loading={loading} disabled={!username.trim()} icon={<Search className="size-4" />}>
查询
</Button>
</form>
<div className="mt-6 space-y-4">
{error && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
{accounts?.length === 0 && <p className="py-10 text-center text-sm text-slate-400">没有找到该账号</p>}
{accounts?.map((a, i) => <AccountResult key={i} account={a} />)}
</div>
</main>
</div>
)
}
+2
View File
@@ -58,6 +58,7 @@ export interface Account {
export interface PlatformInfo {
id: string
label: string
capabilities?: string[]
}
export interface PluginInfo {
@@ -67,6 +68,7 @@ export interface PluginInfo {
enabled: boolean
loaded: boolean
label: string
sites?: string[]
capabilities: string[]
}