feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
+63
-17
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user