Files
study-tool/static/index.html
T
study-tool 585a7a28dd feat: study modes (fast/parallel) and course-container completion fix
- settings: study.mode fast|normal and study.parallel 1-5

- normal mode: one headless browser per video, visibility keep-alive, auto-degrade when the platform enforces order

- report study/log/detail/save before isComplete; drop lastProgressTime on completion

- complete periodType 10/13 container nodes and recompute counters via PUT calFinalScore

- capture mode: POST /api/capture records all XHR/fetch including new tabs

- UI: study mode in settings, capture / stop-capture menu items
2026-10-27 22:30:57 +08:00

1202 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>多账号学习控制台 · 东莞市教师培训平台</title>
<style>
:root{
color-scheme:light;
--bg:#f7f8fa; --panel:#ffffff; --panel2:#f3f5f8;
--line:#e7eaf0; --line2:#eef1f6;
--fg:#0f172a; --muted:#6b7484;
--accent:#2f6bff; --accent2:#1f55e0;
--ok:#0e9f6e; --warn:#a16207; --err:#e02424;
--radius:14px;
--shadow:0 1px 2px rgba(16,24,40,.04),0 1px 3px rgba(16,24,40,.03);
--shadow-lg:0 10px 30px rgba(16,24,40,.10);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:13.5px/1.6 "Inter","Segoe UI",-apple-system,BlinkMacSystemFont,"Microsoft YaHei",sans-serif;
-webkit-font-smoothing:antialiased}
::selection{background:rgba(47,107,255,.18)}
/* ---------- 顶栏 ---------- */
header.top{position:sticky;top:0;z-index:20;height:56px;padding:0 22px;
display:flex;align-items:center;gap:12px;
background:rgba(255,255,255,.84);backdrop-filter:saturate(180%) blur(10px);
border-bottom:1px solid var(--line)}
header.top h1{font-size:14.5px;margin:0;font-weight:650;letter-spacing:-.01em;
display:flex;align-items:center;gap:10px}
header.top h1::before{content:"学";width:24px;height:24px;border-radius:7px;flex:none;
display:grid;place-items:center;font-size:12px;font-weight:700;color:#fff;
background:linear-gradient(135deg,#2f6bff,#6d9bff);
box-shadow:0 2px 6px rgba(47,107,255,.32)}
header.top .sub{color:var(--muted);font-size:12px;letter-spacing:.01em}
.spacer{flex:1}
/* ---------- 主区 ---------- */
main{padding:24px 22px 40px;max-width:1280px;margin:0 auto}
/* ---------- 按钮 ---------- */
.btn{position:relative;overflow:visible;
height:32px;padding:0 13px;border:1px solid var(--line);border-radius:9px;
background:var(--panel);color:var(--fg);font:inherit;font-size:12.5px;font-weight:500;
display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer;
transition:background .15s,border-color .15s,box-shadow .15s,color .15s;white-space:nowrap}
/* 点击反馈:图标插在文字前,按钮宽高在 JS 里先锁死,布局零位移 */
.btn.is-loading{cursor:progress;pointer-events:none}
.btn .spin{display:inline-block;width:10px;height:10px;flex:none;border-radius:50%;
border:1.8px solid currentColor;border-top-color:transparent;opacity:.85;
margin-left:-2px;animation:btnspin .62s linear infinite}
@keyframes btnspin{to{transform:rotate(360deg)}}
.btn:hover{background:#f5f7fb;border-color:#d9dee8}
.btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(47,107,255,.18);border-color:var(--accent)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff;
box-shadow:0 1px 2px rgba(47,107,255,.28)}
.btn.primary:hover{background:var(--accent2);border-color:var(--accent2)}
.btn.danger{color:#b42318}
.btn.danger:hover{background:#fef3f2;border-color:#f5c2c0;color:var(--err)}
.btn.sm{height:28px;padding:0 10px;font-size:12px;border-radius:8px}
.btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
/* 登录中:按钮上的转圈由 render() 按状态输出,轮询重建 DOM 也不会消失 */
.btn.is-loading:disabled{opacity:.92}
.card.is-logging .courses,.card.is-logging .opts{opacity:.55}
.card.is-logging .course{cursor:default}
/* ---------- 卡片 ---------- */
/* 等高布局:同一行卡片高度由最高的一张决定,其余拉伸补齐;
卡片内部各段 flex:none,底部进度区固定高度,刷新时不会跳高跳矮 */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(440px,1fr));gap:16px;align-items:stretch}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
padding:18px 18px 16px;box-shadow:var(--shadow);transition:box-shadow .18s,border-color .18s;
display:flex;flex-direction:column;height:100%;min-height:470px}
.card:hover{box-shadow:0 4px 16px rgba(16,24,40,.06);border-color:#dfe4ec}
.card h3{margin:0;font-size:14.5px;font-weight:650;letter-spacing:-.01em;
display:flex;align-items:center;gap:9px;flex-wrap:wrap}
/* ---------- 状态徽章 ---------- */
.badge{font-size:11px;font-weight:500;line-height:18px;padding:0 8px;border-radius:999px;
color:var(--muted);background:#f2f4f8;border:1px solid transparent;
display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.badge::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;opacity:.85}
.badge.ok{color:#0b7a55;background:#e8f8f1}
.badge.run{color:#1f55e0;background:#eaf0ff}
.badge.err{color:#c81e1e;background:#feeceb}
.badge.warn{color:#a16207;background:#fdf6e3}
/* ---------- 元信息 / 进度 ---------- */
.meta{color:var(--muted);font-size:12px;margin:8px 0 0;display:flex;flex-wrap:wrap;gap:4px 18px}
.meta b{color:#111827;font-weight:500}
.bar{height:4px;background:#eef1f6;border-radius:999px;overflow:hidden;margin:0}
.bar i{display:block;height:100%;width:0;border-radius:999px;
background:linear-gradient(90deg,#2f6bff,#7aa2ff);transition:width .45s cubic-bezier(.4,0,.2,1)}
/* ---------- 底部进度区(固定高度,永远占位) ---------- */
.prog{flex:none;margin-top:auto;height:64px;padding-top:12px;border-top:1px solid var(--line2);
display:flex;flex-direction:column;justify-content:flex-end;gap:7px}
.progline{display:flex;align-items:baseline;gap:18px;font-size:11.5px;color:var(--muted);
white-space:nowrap;overflow:hidden}
.progline b{color:#111827;font-weight:500}
.progline .spacer{flex:1}
.progline .ptxt{overflow:hidden;text-overflow:ellipsis;max-width:58%}
/* ---------- 课程列表 ---------- */
.courses{flex:none;margin-top:14px;padding-top:12px;border-top:1px solid var(--line2)}
.courses .ct{font-size:11.5px;font-weight:500;color:var(--muted);letter-spacing:.02em;margin-bottom:6px}
/* 固定两行高度:第 3 门起在框内滚动,卡片本身高度不变(具体像素由 fitCourseLists 实测两行后写入) */
.courses .clist{max-height:146px;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;
margin:0 -6px;padding:0 6px;scrollbar-width:thin;scrollbar-color:#cfd6e2 transparent}
.courses .clist::-webkit-scrollbar{width:8px}
.courses .clist::-webkit-scrollbar-track{background:transparent}
.courses .clist::-webkit-scrollbar-thumb{background:#d5dbe5;border-radius:8px;border:2px solid var(--panel)}
.courses .clist::-webkit-scrollbar-thumb:hover{background:#bcc5d3}
.course{display:flex;align-items:flex-start;gap:10px;margin:0;padding:8px 9px;border-radius:9px;cursor:pointer;
transition:background .12s}
.course:hover{background:#f6f8fc}
.course input{margin:2px 0 0;accent-color:var(--accent);width:15px;height:15px;flex:none;cursor:pointer}
.course .cn{font-size:12.8px;font-weight:500;color:#111827}
.course .ci{color:var(--muted);font-size:11.5px;line-height:1.5}
.course .cp{margin-left:auto;color:var(--muted);font-size:11.5px;white-space:nowrap;padding-top:1px}
.course.done{cursor:default;opacity:.65}
.course.done:hover{background:transparent}
.course .tick{width:15px;flex:none;text-align:center;color:var(--ok);font-size:12px;margin-top:2px}
/* ---------- 开关行 ---------- */
.opts{flex:none;display:flex;gap:18px;flex-wrap:wrap;margin-top:12px;padding-top:12px;border-top:1px solid var(--line2)}
.opt{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);cursor:pointer;margin:0}
.opt input{width:14px;height:14px;accent-color:var(--accent);flex:none;margin:0;cursor:pointer}
.opt:hover{color:var(--fg)}
.row{flex:none;display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}
.row.end{justify-content:flex-end}
/* ---------- 卡片内「课程日志」折叠面板 ---------- */
.logfold{flex:none;margin-top:10px;padding-top:6px;border-top:1px solid var(--line2)}
.logtoggle{width:100%;height:29px;padding:0 7px;border:1px solid transparent;border-radius:8px;
background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:500;
display:flex;align-items:center;gap:7px;cursor:pointer;
transition:background .15s,border-color .15s,color .15s}
.logtoggle:hover{background:#f5f7fb;border-color:var(--line2);color:var(--fg)}
.logtoggle:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(47,107,255,.18)}
.logtoggle .chev{width:13px;height:13px;flex:none;opacity:.75;transition:transform .2s ease}
.logtoggle[aria-expanded="true"]{color:var(--fg)}
.logtoggle[aria-expanded="true"] .chev{transform:rotate(180deg);opacity:1}
.logtoggle .logcnt{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 7px;border-radius:999px;
color:var(--muted);background:#f2f4f8;font-variant-numeric:tabular-nums}
.logtoggle .logcnt[hidden]{display:none}
/* 0fr → 1fr 的网格过渡:展开/收起都不写死高度,日志多长都不会抖 */
.logpanel{display:grid;grid-template-rows:0fr;opacity:0;margin-top:0;
transition:grid-template-rows .24s cubic-bezier(.4,0,.2,1),opacity .16s ease,margin-top .24s}
.logpanel.open{grid-template-rows:1fr;opacity:1;margin-top:8px}
.logpanel .loginner{overflow:hidden;min-height:0}
.loglines{margin:0;max-height:176px;overflow:auto;overscroll-behavior:contain;tab-size:2;
background:#fbfcfe;border:1px solid var(--line2);border-radius:10px;padding:9px 11px;
font:11.5px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;color:#26303f;
white-space:pre-wrap;word-break:break-all;scrollbar-width:thin;scrollbar-color:#cfd6e2 transparent}
.loglines:focus-visible{outline:none;border-color:#c3d4ff;box-shadow:0 0 0 3px rgba(47,107,255,.12)}
.loglines::-webkit-scrollbar{width:8px}
.loglines::-webkit-scrollbar-thumb{background:#d5dbe5;border-radius:8px;border:2px solid #fbfcfe}
.loglines.is-empty{color:#8a93a3}
/* ---------- 卡片头部操作区:登录/重新登录 + 「更多」下拉 ---------- */
.card h3 .acts{margin-left:auto;display:flex;align-items:center;gap:7px;flex:none}
.more{position:relative;display:inline-flex}
.more>.btn{padding:0 9px}
.more .dots{display:block;width:14px;height:14px;flex:none;pointer-events:none}
/* 下拉菜单:贴「更多」按钮右下展开,点外部 / 选中任一项后收起 */
.menu{position:absolute;right:0;top:calc(100% + 6px);min-width:134px;padding:5px;
background:var(--panel);border:1px solid var(--line);border-radius:11px;
box-shadow:var(--shadow-lg);z-index:12;display:none;flex-direction:column;gap:2px}
.more.open .menu{display:flex}
.menu button{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;
height:30px;padding:0 9px;border-radius:7px;font:inherit;font-size:12.5px;color:var(--fg);
cursor:pointer;white-space:nowrap}
.menu button:hover{background:#f5f7fb}
.menu button:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(47,107,255,.18)}
.menu button.danger{color:#b42318}
.menu button.danger:hover{background:#fef3f2;color:var(--err)}
.menu .sp{width:14px;flex:none;text-align:center;color:var(--muted);font-size:11.5px}
/* ---------- 对话框 ---------- */
dialog{border:1px solid var(--line);background:var(--panel);color:var(--fg);border-radius:16px;
padding:0;width:min(520px,92vw);box-shadow:var(--shadow-lg)}
dialog::backdrop{background:rgba(15,23,42,.32);backdrop-filter:blur(2px)}
dialog .body{padding:22px}
dialog h3{margin:0 0 16px;font-size:15px;font-weight:650}
dialog label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin:14px 0 6px}
input,textarea,select{width:100%;background:#fff;border:1px solid var(--line);color:var(--fg);
height:36px;padding:0 11px;border-radius:9px;font:inherit;font-size:13px;
transition:border-color .15s,box-shadow .15s}
input::placeholder{color:#9aa3b2}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(47,107,255,.14)}
.hint{color:var(--muted);font-size:11.5px;margin-top:10px;line-height:1.65;
background:#f7f9fc;border:1px solid var(--line2);border-radius:9px;padding:10px 12px}
.hint code{font-size:11px;background:#fff;border:1px solid var(--line);border-radius:4px;padding:0 4px}
/* ---------- 空态 / 提示 ---------- */
.empty{color:var(--muted);text-align:center;padding:80px 20px;font-size:13px;line-height:1.9}
.toast{position:fixed;left:50%;transform:translate(-50%,8px);bottom:26px;background:#111827;color:#fff;
padding:9px 16px;border-radius:10px;font-size:12.5px;z-index:60;opacity:0;
box-shadow:0 12px 32px rgba(16,24,40,.24);
transition:opacity .2s,transform .2s;pointer-events:none;max-width:80vw}
.toast.show{opacity:1;transform:translate(-50%,0)}
.lastErr{margin-top:10px;padding:9px 11px;border:1px solid #f7d4d2;background:#fef4f3;
color:#9f1c1c;border-radius:9px;font-size:12px;line-height:1.6;word-break:break-all}
#offline{margin:0 0 16px;padding:11px 14px;border:1px solid #f7d4d2;background:#fef4f3;
color:#9f1c1c;border-radius:10px;font-size:12.5px;
display:none;align-items:center;gap:10px;flex-wrap:wrap}
#offline.on{display:flex}
#offline code{background:#fff;border:1px solid #f3d0ce;border-radius:5px;padding:1px 5px;font-size:11.5px}
#offline .od{color:#c03636;font-size:11.5px;opacity:.85}
/* ---------- 测验弹窗 ---------- */
dialog.wide{width:min(1000px,95vw)}
dialog .hd{display:flex;align-items:center;gap:10px;padding:15px 22px;border-bottom:1px solid var(--line)}
dialog .hd h3{margin:0}
dialog .ft{display:flex;align-items:center;gap:8px;padding:13px 22px;border-top:1px solid var(--line);
background:#fbfcfe;border-radius:0 0 16px 16px}
.qbody{max-height:min(58vh,540px);overflow:auto}
.qtable{width:100%;border-collapse:collapse;font-size:12.5px}
.qtable th{position:sticky;top:0;z-index:1;background:#f7f9fc;color:var(--muted);font-weight:500;
text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);font-size:11.5px}
.qtable td{padding:10px 12px;border-bottom:1px solid var(--line2);vertical-align:top}
.qtable tr:last-child td{border-bottom:none}
.qtable .no{width:46px;color:var(--muted)}
.qtable .ty{width:70px;color:var(--muted)}
.qtable .an{width:160px}
.qopt{display:block;color:var(--muted);font-size:12px;margin-top:3px;line-height:1.5}
.qopt b{color:var(--fg);font-weight:500;margin-right:5px}
.qans{width:100%;height:30px;font-size:12.5px;padding:0 8px;border-radius:7px}
.qans-multi{display:flex;flex-wrap:wrap;gap:6px}
.qans-multi label{display:inline-flex;align-items:center;gap:4px;margin:0;font-size:12px;color:var(--fg);
background:#f5f7fb;border:1px solid var(--line);border-radius:7px;padding:3px 8px;cursor:pointer}
.qans-multi label:has(input:checked){background:#eaf0ff;border-color:#c3d4ff;color:#1f55e0}
.qans-multi input{margin:0;width:13px;height:13px;accent-color:var(--accent)}
.qstatus{font-size:12px;color:var(--muted);margin-right:auto;max-width:60%;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap}
.qstatus.err{color:#c03636}
.qstatus.ok{color:#0b7a55}
.qhint{font-size:12px;color:var(--muted);margin-left:10px}
.qhint.warn{color:#b45309}
.qhint.ok{color:#0b7a55}
/* 交卷后看答案:正确选项绿色,选错的红线;标准答案/解析跟在作答控件下面 */
.qopt.ok{color:#0b7a55}
.qopt.ok b{color:#0b7a55}
.qopt.bad{color:#c03636;text-decoration:line-through}
.qopt.bad b{color:#c03636}
.qcorrect{font-size:11.5px;color:#0b7a55;margin-top:5px;line-height:1.5}
.qanalysis{font-size:11.5px;color:var(--muted);margin-top:3px;line-height:1.5}
.setgrid{display:flex;gap:12px}
.setgrid>div{flex:1}
textarea{padding:9px 11px;height:auto;min-height:84px;resize:vertical}
@media (max-width:900px){
.grid{grid-template-columns:1fr}
header.top .sub{display:none}
}
</style>
</head>
<body>
<header class="top">
<h1>多账号学习控制台</h1>
<span class="sub">东莞市教师培训平台 · jspx.dgjy.net</span>
<span class="spacer"></span>
<button class="btn" id="setBtn">设置</button>
<button class="btn" id="refreshAll">刷新</button>
<button class="btn primary" id="addBtn">+ 添加账号</button>
</header>
<main>
<div id="offline">
<span>后端未连接:<b id="offlineUrl"></b> 没有响应(ERR_CONNECTION_REFUSED)。</span>
<span>请先运行 <code>start.bat</code> 或 <code>python app.py</code>,再点重试。</span>
<button class="btn sm" id="offlineRetry">重试</button>
<span class="od" id="offlineDetail"></span>
</div>
<div id="list" class="grid"></div>
<div id="empty" class="empty">还没有账号,点右上角「添加账号」开始。<br>
<span style="font-size:12px">账号、密码与会话都保存在服务端 SQLite(<code>data/study.db</code>)。</span></div>
</main>
<div class="toast" id="toast"></div>
<dialog id="dlg">
<div class="body">
<h3>添加账号</h3>
<label>账号(用户名 / 手机号 / 身份证)</label>
<input id="f_user" autocomplete="off">
<label>密码</label>
<input id="f_pass" type="password" autocomplete="off">
<label>备注</label>
<input id="f_note" placeholder="例如:贺雯 · 南城悦珈幼儿园">
<div class="hint">账号与密码保存在服务端 <b>SQLite</b>(<code>data/study.db</code>),换浏览器/远程打开都还在;
登录后会自动读取 <code>/admin/main.action</code> 的「在学课程」列表,勾选后即可开始学习。</div>
<div class="row end" style="margin-top:18px">
<button class="btn" id="dlgCancel">取消</button>
<button class="btn primary" id="dlgSave">保存</button>
</div>
</div>
</dialog>
<dialog id="quizDlg" class="wide">
<div class="hd">
<h3 id="quizTitle">测验</h3>
<span class="qstatus" id="quizStatus"></span>
<button class="btn sm" id="quizClose">关闭</button>
</div>
<div class="qbody">
<table class="qtable">
<thead><tr><th class="no">#</th><th class="ty">题型</th><th>题目 / 选项</th><th class="an">答案</th></tr></thead>
<tbody id="quizRows"><tr><td colspan="4" style="color:#6b7484">加载中…</td></tr></tbody>
</table>
</div>
<div class="ft">
<span class="spacer"></span>
<span class="qhint" id="quizHint"></span>
<button class="btn" id="quizRetake" style="display:none">再考一次</button>
<button class="btn" id="quizAi">AI 答题</button>
<button class="btn primary" id="quizSubmit">提交</button>
</div>
</dialog>
<dialog id="setDlg">
<div class="body">
<h3>系统设置 · 大模型</h3>
<label>接口地址(OpenAI 兼容,写到 /v1 为止)</label>
<input id="s_base" placeholder="https://api.openai.com/v1">
<label>API Key</label>
<input id="s_key" type="password" autocomplete="off" placeholder="留空 = 不修改">
<label>模型</label>
<input id="s_model" placeholder="gpt-4o-mini / deepseek-chat / qwen-plus">
<div class="setgrid">
<div><label>温度</label><input id="s_temp" type="number" step="0.1" min="0" max="2"></div>
<div><label>超时(秒)</label><input id="s_timeout" type="number" min="10" max="600"></div>
</div>
<label>答题提示词(留空用内置)</label>
<textarea id="s_prompt" rows="4" placeholder="留空使用内置:只输出 JSON {&quot;answers&quot;:{&quot;题号&quot;:&quot;答案&quot;}}"></textarea>
<h3 style="margin-top:22px">系统设置 · 学习方式</h3>
<div class="setgrid">
<div>
<label>模式</label>
<select id="s_mode">
<option value="fast">快速学习(只调接口上报)</option>
<option value="normal">常规学习(真播视频)</option>
</select>
</div>
<div><label>常规模式并发数</label><input id="s_parallel" type="number" min="1" max="5" value="2"></div>
</div>
<div class="hint">常规学习会开多个无头浏览器同时播视频;平台若按课节顺序放行,会被弹回并自动降为 1 路。快速学习只调 <code>period/study/log</code>,不起浏览器。</div>
<div class="hint">配置存服务端 SQLite 的 <code>settings</code> 表;点「AI 答题」时题目会发到上面这个接口。</div>
<div class="row end" style="margin-top:18px">
<button class="btn" id="setTest">测试连接</button>
<button class="btn" id="setCancel">取消</button>
<button class="btn primary" id="setSave">保存</button>
</div>
</div>
</dialog>
<script>
const $ = s => document.querySelector(s);
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const api = async (p, o) => {
let r;
try{
r = await fetch(p, Object.assign({headers:{'Content-Type':'application/json'}}, o||{}));
}catch(e){
const err = new Error('后端未连接');
err.offline = true; // 连接层失败(服务没起来/端口变了),不是业务错误
throw err;
}
if(r.status === 401) throw new Error('需要访问令牌:请用 /?token=<AUTH_TOKEN> 打开一次');
if(!r.ok) throw new Error('HTTP '+r.status);
return r.json();
};
function setOffline(on, detail){
const el=$('#offline');
el.classList.toggle('on', !!on);
if(on){
$('#offlineUrl').textContent = location.origin;
$('#offlineDetail').textContent = detail||'';
}
}
function handleErr(e){
if(e && e.offline){ setOffline(true); toast('后端未连接,请先启动服务'); return; }
console.error(e); toast('请求失败:'+(e&&e.message||e));
}
$('#offlineRetry').onclick=async()=>{ try{ await load(); if(!$('#offline').classList.contains('on')) toast('已连接'); }catch(e){ handleErr(e); } };
function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove('show'),3000);}
/* ---------------- 按钮点击反馈 ----------------
所有 <button> 点击后:在文字前插一个转圈图标,宽高在插入前锁定为原值,
插入图标不会让按钮变大/变小/抖动。请求结束后自动移除;没有请求的动作
按最短时长收尾。按钮带 data-nospin 时跳过。 */
const MIN_BUSY = 400; // 最短反馈时长(ms),太快完成的动作也能看到图标
let netPending = 0; // 全局在途请求数
const rawFetch = window.fetch.bind(window);
window.fetch = function(){
netPending++;
return rawFetch.apply(null, arguments).then(
r=>{ netPending--; return r; },
e=>{ netPending--; throw e; }
);
};
const busySet = new Set();
function busyStart(btn){
if(btn._busy) return false;
const rect = btn.getBoundingClientRect();
btn._busy = true;
btn.style.width = rect.width + 'px'; // 锁尺寸:后面插图标不影响盒子大小
btn.style.height = rect.height + 'px';
const sp = document.createElement('span');
sp.className = 'spin';
btn.insertBefore(sp, btn.firstChild);
btn.classList.add('is-loading');
btn.setAttribute('aria-busy','true');
btn._spin = sp; btn._t0 = performance.now();
busySet.add(btn);
return true;
}
function busyStop(btn){
if(!btn || !btn._busy) return;
btn._busy = false;
btn.classList.remove('is-loading');
btn.removeAttribute('aria-busy');
btn.style.width = ''; btn.style.height = '';
if(btn._spin && btn._spin.parentNode) btn._spin.parentNode.removeChild(btn._spin);
btn._spin = null;
busySet.delete(btn);
}
document.addEventListener('click', e=>{
const btn = e.target.closest && e.target.closest('button');
if(!btn || btn.disabled || btn.dataset.nospin !== undefined) return;
if(!busyStart(btn)) return; // 已在加载中,忽略重复点击
const base = netPending; // 点击瞬间的在途请求基线
const t0 = btn._t0;
let quiet = 0;
const iv = setInterval(()=>{
if(!btn.isConnected){ clearInterval(iv); busyStop(btn); return; } // 卡片重绘,按钮已换新
if(performance.now() - t0 < MIN_BUSY) return;
if(netPending > base){ quiet = 0; return; } // 本次点击引发的请求还没回来
if(++quiet < 2) return; // 连续两次空闲(~120ms)才算真结束
clearInterval(iv); busyStop(btn);
}, 60);
}, true);
/* ---------------- 账号存储(服务端 SQLite,浏览器只做展示) ---------------- */
async function accAll(){
const r = await api('/api/accounts');
return (r && r.accounts) || [];
}
async function accPut(rec){
const r = await api('/api/accounts', {method:'POST', body:JSON.stringify(rec)});
if(r && r.ok===false) throw new Error(r.error||'保存失败');
return r;
}
async function accDel(id){
await api('/api/accounts/'+encodeURIComponent(id), {method:'DELETE'});
}
/* ---------------- state ---------------- */
let accounts=[], moreOpen=null;
/* 卡片内「课程日志」折叠面板:5s 轮询会重建卡片 DOM,展开状态、已拉到的行和滚动位置
都存在这里,重建后由 paintInlineLog 原样还原;展开时另外单独轮询该账号的日志。 */
const inlineLogs = new Map(); // id -> {open,since,lines,busy,stick,top,hist}
const IL_EMPTY = '暂无日志 · 开始学习后这里会实时显示';
function iLog(id){
let s = inlineLogs.get(id);
if(!s) inlineLogs.set(id, s = {open:false, since:0, lines:[], busy:false, stick:true, top:0, hist:false});
return s;
}
// 本页正在登录的账号:登录请求要 10~30s,期间这张卡片的任何操作都不能点。
// 服务端也会把 status 落库成 logging,所以刷新页面后仍然能显示转圈。
const loggingIds = new Set();
const isLogging = a => !!a && (loggingIds.has(a.id) || a.status==='logging' ||
(a.run||{}).status==='logging');
function statusOf(a){
const s=isLogging(a)?'logging':((a.run||{}).status||a.status||'');
const map={discovering:['run','发现课程中'],studying:['run','学习中'],
finished:['ok','已完成'],stopped:['warn','已停止'],error:['err','出错'],
interrupted:['warn','已中断'],
logging:['run','登录中'],logged_in:['ok','已登录'],login_failed:['err','登录失败'],idle:['','未登录']};
const [cls,txt]=map[s]||['',s||'未登录'];
return `<span class="badge ${cls}">${txt}</span>`;
}
function ageText(t){
if(!t) return '—';
const m=Math.floor((Date.now()-t)/60000);
return m<1?'刚刚':(m<60?m+' 分钟前':Math.floor(m/60)+' 小时前');
}
function render(){
// 账号删掉后它的日志面板状态一并清掉,别让面板轮询继续拉不存在的账号
inlineLogs.forEach((s,id)=>{ if(!accounts.some(a=>a.id===id)) inlineLogs.delete(id); });
// 轮询每 5s 重建一次 DOM,先把各课程列表的滚动位置存下来,重建后还原
const keepScroll={};
document.querySelectorAll('.card').forEach(c=>{
[...c.querySelectorAll('.clist')].forEach((cl,i)=>{ keepScroll[c.dataset.id+':'+i]=cl.scrollTop; });
});
$('#empty').style.display = accounts.length?'none':'block';
$('#list').innerHTML = accounts.map(a=>{
const r=a.run||{}, total=r.total||0, done=r.done||0;
const pct = total?Math.round(done/total*100):0;
const all=a.courses||[];
const inStudy=all.filter(c=>c.in_study!==false);
const finished=all.filter(c=>c.in_study===false);
const busy=isLogging(a); // 登录中:整张卡片只读,登录按钮显示转圈
const dis=busy?'disabled':'';
const lg=iLog(a.id); // 卡片内日志面板:展开状态 + 已拉到的行
const selRow=c=>{
const sel=(a.selected||[]).includes(c.plan_id);
return `<label class="course"><input type="checkbox" data-cid="${a.id}" data-plan="${c.plan_id}"
data-proj="${c.project_id}" data-name="${(c.name||'').replace(/"/g,'&quot;')}" ${sel?'checked':''} ${dis}>
<span><span class="cn">${c.name||c.plan_id}</span><br><span class="ci">${c.section||''} ${c.status_label||''} ${c.info||''}</span></span>
<span class="cp">${c.progress||''}</span></label>`;
};
const doneRow=c=>`<div class="course done"><span class="tick">✓</span>
<span><span class="cn">${c.name||c.plan_id}</span><br><span class="ci">${c.section||''} ${c.info||''}</span></span>
<span class="cp">${c.status_label||'已结束'}</span></div>`;
const loginTxt = busy ? '<span class="spin"></span>登录中…'
: (a.cookies&&a.cookies.length ? '重新登录' : '登录');
return `<div class="card${busy?' is-logging':''}" data-id="${a.id}">
<h3>${a.username} ${statusOf(a)}
<span class="acts">
<button class="btn sm primary${busy?' is-loading':''}" data-act="login" ${dis}
${busy?'aria-busy="true"':''} title="${busy?'正在登录,请稍候':'用服务端保存的账号密码登录一次'}">${loginTxt}</button>
<span class="more${moreOpen===a.id&&!busy?' open':''}">
<button class="btn sm" data-more data-nospin ${dis} title="更多操作" aria-label="更多操作" aria-haspopup="true">
<svg class="dots" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="13" cy="8" r="1.5"/></svg>
</button>
<span class="menu" role="menu">
<button type="button" role="menuitem" data-act="stop" data-nospin ${dis}><span class="sp">■</span>停止</button>
<button type="button" role="menuitem" data-act="capture" data-nospin ${dis}><span class="sp">◉</span>抓包</button>
<button type="button" role="menuitem" data-act="captureStop" data-nospin ${dis}><span class="sp">□</span>停止抓包</button>
<button type="button" role="menuitem" data-act="log" data-nospin ${dis}><span class="sp">≡</span>日志</button>
<button type="button" role="menuitem" data-act="del" data-nospin class="danger" ${dis}><span class="sp">✕</span>删除</button>
</span>
</span>
</span>
</h3>
<div class="meta"><span>备注 <b>${a.note||'—'}</b></span><span>姓名 <b>${a.user||'—'}</b></span>
<span>登录于 <b>${ageText(a.loginAt)}</b></span>
<span>进度 <b>${r.progress||a.progress||'—'}</b></span></div>
${a.lastError?`<div class="lastErr">登录失败(${ageText(a.lastErrorAt)}):${a.lastError}</div>`:''}
${inStudy.length?`<div class="courses"><div class="ct">在学课程 · 勾选后点「开始学习」(${inStudy.length} 门)</div><div class="clist">${inStudy.map(selRow).join('')}</div></div>`:''}
${finished.length?`<div class="courses"><div class="ct">已学完 / 已结束 · 仅供查看(${finished.length} 门)</div><div class="clist">${finished.map(doneRow).join('')}</div></div>`:''}
${(!inStudy.length&&!finished.length)?`<div class="courses"><div class="ct">该账号名下没有任何课程(在学 / 助学 / 督学 三个板块都为空)</div></div>`:''}
<div class="opts">
<label class="opt" title="答题正确率无法保证,可能因不及格卡住「完成」,建议保持关闭">
<input type="checkbox" data-opt="quiz" data-id="${a.id}" ${a.optQuiz===true?'checked':''} ${dis}>
自动交测验(答题)</label>
<label class="opt" title="反馈表是主观问卷,没有对错,提交即完成">
<input type="checkbox" data-opt="feedback" data-id="${a.id}" ${a.optFeedback!==false?'checked':''} ${dis}>
自动交反馈表</label>
</div>
<div class="row">
<button class="btn sm" data-act="courses" ${dis}>刷新课程</button>
<button class="btn sm" data-act="quiz" ${dis}>获取测验</button>
<button class="btn sm primary" data-act="study" ${dis}>开始学习</button>
</div>
<div class="prog">
<div class="bar"><i style="width:${pct}%"></i></div>
<div class="progline">
<span>本次 <b>${total?`${done}/${total} · ${pct}%`:'—'}</b></span>
<span class="spacer"></span>
<span class="ptxt" title="${(r.course||'').replace(/"/g,'&quot;')}">${r.course?'当前:'+r.course:''}</span>
</div>
</div>
<div class="logfold">
<button type="button" class="logtoggle" data-log-toggle data-nospin
title="展开 / 收起课程日志" aria-expanded="${lg.open?'true':'false'}" aria-controls="lg-${a.id}">
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6.5l4 3.5 4-3.5"/></svg>
<span>课程日志</span>
<span class="logcnt" ${lg.lines.length?'':'hidden'}>${lg.lines.length||''}</span>
</button>
</div>
<div class="logpanel${lg.open?' open':''}" id="lg-${a.id}">
<div class="loginner"><pre class="loglines is-empty" tabindex="-1" aria-label="课程日志"></pre></div>
</div></div>`;
}).join('');
fitCourseLists();
document.querySelectorAll('.card').forEach(c=>{
[...c.querySelectorAll('.clist')].forEach((cl,i)=>{
const v=keepScroll[c.dataset.id+':'+i];
if(v) cl.scrollTop=v;
});
});
paintOpenLogs();
}
/* 课程列表只显示两门:按前两门的实际高度卡 max-height,第三门起在框内滚动。
用实测而不是写死像素,课程名换行时也不会切半行。 */
function fitCourseLists(){
document.querySelectorAll('.clist').forEach(cl=>{
const rows=[...cl.children];
if(!rows.length){ cl.style.maxHeight=''; return; }
const last=rows[Math.min(2,rows.length)-1];
const top=cl.getBoundingClientRect().top - cl.scrollTop;
cl.style.maxHeight = Math.ceil(last.getBoundingClientRect().bottom - top) + 'px';
});
}
async function load(){
let runs;
try{
runs = await api('/api/runs');
}catch(e){
if(e.offline){ setOffline(true); return; }
throw e;
}
setOffline(false);
accounts = (await accAll()).sort((x,y)=>(x.created||0)-(y.created||0));
for(const a of accounts){
a.run = runs[a.id] || null;
// 跑课过程中补上的 study.dgjy.net 会话(Middle-Token)写回服务端账号
const ck = a.run && a.run.cookies;
if(ck && ck.length && JSON.stringify(ck)!==JSON.stringify(a.cookies||[])){
a.cookies = ck;
try{ await accPut(a); }catch(e){}
}
}
render();
}
/* ---------------- actions ---------------- */
const cnt = a => {
const all=a.courses||[], s=all.filter(c=>c.in_study!==false).length;
return `${all.length} 门(可学 ${s})`;
};
/* ---------------- 测验 ---------------- */
const TYPE_LABEL = {'01':'单选','02':'多选','03':'判断','04':'问答'};
let quizCtx = null;
function quizTarget(a){
const list=(a.courses||[]).filter(c=>c.in_study!==false);
const sel=a.selected||[];
return list.find(c=>sel.includes(c.plan_id)) || list[0] || null;
}
function setQuizStatus(msg, cls){
const el=$('#quizStatus'); el.textContent=msg||''; el.className='qstatus'+(cls?' '+cls:'');
}
/* 交卷后:按平台剩余的考试次数给「再考一次」,并按 examAfterWatchAnswer 展示标准答案 */
function syncQuizActions(r){
const done = !!(r && r.done);
const canRetake = !!(r && r.can_retake);
const left = r && r.surplus_exam_count;
const allow = r && r.allow_exam_count;
const sub = $('#quizSubmit'), ai = $('#quizAi'), hint = $('#quizHint'), rt = $('#quizRetake');
if(!sub || !ai) return;
sub.disabled = done;
ai.disabled = done;
sub.textContent = done ? '已交卷' : '提交';
sub.title = done ? '本次已交卷;要再做请点「再考一次」' : '';
ai.title = done ? '本次已交卷;点「再考一次」后可继续 AI 答题' : '';
if(rt){
rt.style.display = done ? '' : 'none';
rt.disabled = !canRetake;
if(done){
rt.textContent = canRetake && left !== null && left !== undefined
? `再考一次(剩 ${left} 次)`
: (left === 0 ? '考试机会已用完' : '再考一次');
rt.title = canRetake ? '消耗 1 次机会,让平台生成新答卷' : '没有剩余考试机会';
}
}
if(hint){
if(done){
const bits = [];
if(left !== null && left !== undefined) bits.push(`剩 ${left} 次`);
else if(allow !== null && allow !== undefined) bits.push(`共 ${allow} 次`);
if(r.show_answers) bits.push('已显示标准答案');
else if(r.exam_after_watch_answer === '0') bits.push('本测验不开放答案');
hint.textContent = '已交卷' + (bits.length?' · '+bits.join(' · '):'') +
(canRetake?' · 可再考':'');
hint.className = 'qhint' + (canRetake ? ' ok' : ' warn');
}else{
hint.textContent = '';
hint.className = 'qhint';
}
}
}
async function fetchQuiz(opts){
const o = opts || {};
const ctx = quizCtx;
if(!ctx) return false;
const c = ctx.course;
setQuizStatus(o.retake ? '正在生成新答卷…' : '取题中…');
const body = {cookies: ctx.acc.cookies, plan_id: c.plan_id, project_id: c.project_id,
name: c.name||''};
if(o.retake){
body.retake = true;
body.period_id = (ctx.data && ctx.data.period_id) || '';
}
try{
const r = await api('/api/quiz',{method:'POST',body:JSON.stringify(body)});
if(!r.ok){
$('#quizRows').innerHTML = `<tr><td colspan="4" style="color:#c03636;line-height:1.8">${r.error||'取题失败'}</td></tr>`;
setQuizStatus(r.error||'取题失败','err'); return false;
}
ctx.data = r;
$('#quizTitle').textContent = '测验 · ' + (c.name||r.title||'');
renderQuiz(r.questions||[], r);
syncQuizActions(r);
if(r.done){
const bits = [];
if(r.score!==undefined && r.score!==null) bits.push('得分 '+r.score);
if(r.passed_score!==undefined && r.passed_score!==null) bits.push('及格 '+r.passed_score);
if(r.passed) bits.push('已通过');
setQuizStatus('已完成' + (bits.length?' · '+bits.join(' · '):'') +
(r.can_retake?' · 可再考':' · 机会已用完'), 'ok');
}else{
const tips = [];
if(r.started) tips.push('平台已自动创建答卷');
if(r.surplus_exam_count!==undefined && r.surplus_exam_count!==null)
tips.push('剩 '+r.surplus_exam_count+' 次机会');
setQuizStatus(`${(r.questions||[]).length} 题 · 未完成` +
(tips.length?' · '+tips.join(' · '):''));
if(r.started) toast('已自动创建答卷(限时考试会开始计时),无需手动点「开始考试」');
}
return true;
}catch(e){
$('#quizRows').innerHTML = `<tr><td colspan="4" style="color:#c03636">${e.message||e}</td></tr>`;
setQuizStatus('请求失败','err'); return false;
}
}
async function openQuiz(acc){
const c = quizTarget(acc);
if(!c){ toast('这门课还没有课程数据,先点「刷新课程」'); return; }
if(!acc.cookies||!acc.cookies.length){ toast('请先登录'); return; }
quizCtx = {acc, course:c, data:null};
$('#quizTitle').textContent = '测验 · ' + (c.name||c.plan_id);
$('#quizRows').innerHTML = '<tr><td colspan="4" style="color:#6b7484">正在打开课程页取题…</td></tr>';
setQuizStatus('取题中…');
$('#quizDlg').showModal();
syncQuizActions({done:false});
await fetchQuiz();
}
function renderQuiz(qs, r){
const done = !!(r && r.done);
const show = !!(r && r.show_answers);
if(!qs.length){ $('#quizRows').innerHTML='<tr><td colspan="4">没有题目</td></tr>'; return; }
const olabel = o => {
const t = String(o.text||'').replace(/\s+/g,' ').trim();
return t ? o.key + '. ' + (t.length>22 ? t.slice(0,22)+'…' : t) : o.key;
};
const has = (s,key) => (s||'').split('#').filter(Boolean).includes(key);
$('#quizRows').innerHTML = qs.map(q=>{
const opts=(q.options||[]).map(o=>{
const cls='qopt'+(show&&has(q.correct,o.key)?' ok':'')+
(show&&has(q.picked,o.key)&&!has(q.correct,o.key)?' bad':'');
return `<span class="${cls}"><b>${o.key}</b>${o.text||''}</span>`;
}).join('');
const picked = q.picked||'';
const dis = done ? 'disabled' : '';
let ctl;
if(q.type==='02'){
ctl = `<div class="qans-multi" data-no="${q.no}">` + (q.options||[]).map(o=>
`<label title="${esc(o.key + '. ' + (o.text||''))}"><input type="checkbox" value="${o.key}" ${picked.split('#').includes(o.key)?'checked':''} ${dis}>${o.key}</label>`).join('') + '</div>';
}else if((q.options||[]).length){
ctl = `<select class="qans" data-no="${q.no}" ${dis}><option value="">未选</option>` +
(q.options||[]).map(o=>`<option value="${o.key}" ${picked===o.key?'selected':''}>${esc(olabel(o))}</option>`).join('') + '</select>';
}else{
ctl = `<input class="qans" data-no="${q.no}" value="${picked.replace(/"/g,'&quot;')}" placeholder="答案" ${dis}>`;
}
let extra='';
if(show && q.correct){
const mark = q.is_correct===true ? ' ✓' : (q.is_correct===false ? ' ✗' : '');
extra += `<div class="qcorrect">标准答案:${esc(q.correct)}${mark}</div>`;
}
if(show && q.analysis) extra += `<div class="qanalysis">解析:${esc(q.analysis)}</div>`;
return `<tr><td class="no">${q.no}</td><td class="ty">${TYPE_LABEL[q.type]||'单选'}</td>
<td><div>${q.title||''}</div>${opts}</td><td class="an">${ctl}${extra}</td></tr>`;
}).join('');
}
function applyAnswers(answers){
$('#quizRows').querySelectorAll('[data-no]').forEach(el=>{
const no = el.dataset.no, v = answers[no];
if(v===undefined) return;
if(el.classList.contains('qans-multi')){
const keys = String(v).split('#');
el.querySelectorAll('input[type=checkbox]').forEach(cb=>{ cb.checked = keys.includes(cb.value); });
}else if(el.tagName==='SELECT'){
el.value = String(v).split('#')[0];
}else{
el.value = v;
}
});
}
function collectAnswers(){
const out={};
$('#quizRows').querySelectorAll('[data-no]').forEach(el=>{
const no=el.dataset.no;
if(el.classList.contains('qans-multi')){
const keys=[...el.querySelectorAll('input:checked')].map(x=>x.value);
if(keys.length) out[no]=keys.join('#');
}else if(el.value){
out[no]=el.value.trim().toUpperCase();
}
});
return out;
}
$('#quizClose').onclick = ()=>$('#quizDlg').close();
$('#quizRetake').onclick = async ()=>{
const d = quizCtx && quizCtx.data;
if(!d){ toast('还没取到题目'); return; }
if(!d.can_retake){ toast('没有剩余考试机会'); return; }
const left = d.surplus_exam_count;
const tip = (left !== undefined && left !== null) ? `(剩余 ${left} 次)` : '';
if(!confirm(`再考一次会消耗 1 次机会${tip};限时考试会立即开始计时,确定?`)) return;
const ok = await fetchQuiz({retake:true});
if(ok) toast('已生成新答卷,可以重新答题了');
};
$('#quizAi').onclick = async ()=>{
const d = quizCtx && quizCtx.data;
if(!d){ toast('还没取到题目'); return; }
if(d.done){
setQuizStatus('本次已交卷;点「再考一次」生成新答卷后再 AI 答题','err');
toast('已交卷,点「再考一次」重新答题'); return;
}
const t0 = Date.now();
setQuizStatus('AI 答题中…');
const timer = setInterval(()=>setQuizStatus(`AI 答题中… ${Math.round((Date.now()-t0)/1000)}s`), 1000);
try{
const r = await api('/api/quiz/answer',{method:'POST',body:JSON.stringify({questions:d.questions})});
if(!r.ok){ setQuizStatus(r.error||'AI 答题失败','err'); return; }
applyAnswers(r.answers||{});
const got = Object.keys(r.answers||{}).length;
setQuizStatus(`AI 已填 ${got}/${d.questions.length} 题` +
((r.errors&&r.errors.length)?` · ${r.errors.length} 批超时`:'') + ',核对后点「提交」',
got===d.questions.length?'ok':'');
}catch(e){ setQuizStatus(e.message||'AI 答题失败','err'); }
finally{ clearInterval(timer); }
};
$('#quizSubmit').onclick = async ()=>{
const d = quizCtx && quizCtx.data;
if(!d){ toast('还没取到题目'); return; }
if(d.done){
setQuizStatus('本次已交卷;点「再考一次」重新答题','err');
toast('已交卷,点「再考一次」重新答题'); return;
}
const answers = collectAnswers();
const miss = d.questions.filter(q=>!answers[q.no]).length;
if(miss && !confirm(`还有 ${miss} 题没选答案,确定提交?`)) return;
setQuizStatus('提交中…');
try{
const r = await api('/api/quiz/submit',{method:'POST',body:JSON.stringify({
cookies: quizCtx.acc.cookies, plan_id: d.plan_id, object_id: d.object_id,
platform: d.platform||'jspx', result_id: d.result_id||'', answers})});
if(!r.ok){
if(r.code==='already_finished') await fetchQuiz(); // 刷新成已交卷/可重考状态
setQuizStatus(r.error||'提交失败','err');
return;
}
toast('测验已提交' + (r.score?`,得分 ${r.score}`:''));
// 重新取一次:review 接口带标准答案/解析,同时刷新剩余次数和「再考一次」
await fetchQuiz();
load().catch(handleErr);
}catch(e){ setQuizStatus(e.message||'提交失败','err'); }
};
/* ---------------- 系统设置 ---------------- */
async function openSettings(){
$('#setDlg').showModal();
try{
const r = await api('/api/settings');
const l=(r&&r.llm)||{};
const st=(r&&r.study)||{};
$('#s_base').value=l.base_url||'';
$('#s_model').value=l.model||'';
$('#s_temp').value=l.temperature??0.2;
$('#s_timeout').value=l.timeout??120;
$('#s_prompt').value=l.prompt||'';
$('#s_mode').value=st.mode||'fast';
$('#s_parallel').value=st.parallel||2;
$('#s_key').value='';
$('#s_key').placeholder = l.has_key ? `已保存 ${l.key_hint},留空不修改` : 'sk-...';
}catch(e){ handleErr(e); }
}
$('#setBtn').onclick = openSettings;
$('#setCancel').onclick = ()=>$('#setDlg').close();
$('#setSave').onclick = async ()=>{
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({
base_url:$('#s_base').value.trim(), api_key:$('#s_key').value.trim(),
model:$('#s_model').value.trim(), temperature:parseFloat($('#s_temp').value)||0.2,
timeout:parseInt($('#s_timeout').value)||120, prompt:$('#s_prompt').value,
study_mode:$('#s_mode').value, study_parallel:parseInt($('#s_parallel').value)||0})});
toast('已保存'); $('#setDlg').close();
}catch(e){ handleErr(e); }
};
$('#setTest').onclick = async ()=>{
$('#setTest').disabled=true; toast('正在测试…');
try{
const r = await api('/api/llm/test',{method:'POST',body:JSON.stringify({
base_url:$('#s_base').value.trim(), api_key:$('#s_key').value.trim(),
model:$('#s_model').value.trim()})});
toast(r.ok?`连接正常(${r.ms} ms):${r.reply||''}`:`失败:${r.error}`);
}catch(e){ handleErr(e); }finally{ $('#setTest').disabled=false; }
};
/* quiet=true:不弹自动提示,由调用方(保存后首登)自己决定反馈文案 */
async function doLogin(acc, quiet){
// 登录是 10~30s 的同步请求:先占位,任何重复操作直接挡掉;
// 转圈图标由 render() 按 loggingIds / status 输出,轮询重建 DOM 也不会丢。
if(!acc || loggingIds.has(acc.id)) return false;
const t0 = performance.now();
loggingIds.add(acc.id);
acc.status='logging'; acc.lastError=''; acc.lastErrorAt=0;
if(moreOpen===acc.id) moreOpen=null;
render();
try{
try{ await accPut(acc); }catch(e){}
// 密码在服务端 SQLite 里,前端只发账号 id
const r = await api('/api/login',{method:'POST',body:JSON.stringify({account_id:acc.id})});
if(!r.ok){
acc.status='login_failed'; acc.lastError=r.error||('HTTP '+(r.code||'失败')); acc.lastErrorAt=Date.now();
try{ await accPut(acc); }catch(e){}
if(!quiet) toast('登录失败:'+acc.lastError);
load().catch(handleErr); return false;
}
acc.lastError=''; acc.lastErrorAt=0;
acc.cookies=r.cookies||[]; acc.user=r.user||''; acc.courses=r.courses||[];
acc.selected=(acc.selected||[]).filter(p=>(r.courses||[]).some(c=>c.plan_id===p));
acc.status='logged_in'; acc.updated=Date.now(); acc.loginAt=Date.now();
try{ await accPut(acc); }catch(e){}
if(!quiet) toast(r.warn ? `登录成功,但课程读取异常:${r.warn}`
: `登录成功:${acc.user||acc.username},读到 ${cnt(acc)}`);
load().catch(handleErr); return true;
}catch(e){
// 网络层失败(服务没起来/超时)也必须把状态收尾,否则按钮会一直转圈
if(e && e.offline) setOffline(true);
acc.status='login_failed'; acc.lastError=(e&&e.message)||String(e); acc.lastErrorAt=Date.now();
try{ await accPut(acc); }catch(_){}
if(!quiet) toast('登录失败:'+acc.lastError);
load().catch(handleErr); return false;
}finally{
// 登录失败得太快时也让「登录中…」至少显示 350ms,否则看起来像没反应
const wait = 350 - (performance.now() - t0);
if(wait > 0) await new Promise(r=>setTimeout(r, wait));
loggingIds.delete(acc.id);
// 5s 轮询可能已经把 accounts 里的对象换掉了:把收尾状态同步到当前对象,
// 断网导致服务端还停在 logging 时,页面也不会一直转圈。
const cur=accounts.find(x=>x.id===acc.id);
if(cur && cur!==acc){
cur.status=acc.status; cur.lastError=acc.lastError; cur.lastErrorAt=acc.lastErrorAt;
if(acc.status==='logged_in'){ cur.cookies=acc.cookies; cur.user=acc.user; cur.courses=acc.courses; cur.loginAt=acc.loginAt; }
}
render(); // 立刻把「登录中…」换回正常态,不用等下一次轮询
}
}
async function act(id, a){
const acc = accounts.find(x=>x.id===id); if(!acc) return;
if(isLogging(acc)){ toast('正在登录,请稍候…'); return; }
if(a==='login'){ return doLogin(acc); }
if(a==='quiz'){ return openQuiz(acc); }
if(a==='courses'){
if(!acc.cookies||!acc.cookies.length){ toast('请先登录'); return; }
let r = await api('/api/courses',{method:'POST',body:JSON.stringify({cookies:acc.cookies})});
if(!r.ok && (r.code==='session_expired'||r.code==='no_cookie')){
// 会话过期:服务端保存了账号密码,自动重新登录一次,再刷新课程
toast('会话已失效,正在自动重新登录…');
if(!await doLogin(acc)) return;
r = {ok:true, courses:acc.courses||[], user:acc.user};
}
if(!r.ok){ toast('读取失败:'+r.error); return; }
acc.courses=r.courses||[]; acc.user=r.user||acc.user; acc.updated=Date.now();
// 服务端可能刚用 CASTGC 补了 jspx 会话(JSESSIONID/route 换新),存回去下次直接用
if(r.cookies && r.cookies.length) acc.cookies=r.cookies;
await accPut(acc); toast(`已刷新,读到 ${cnt(acc)}`); return load();
}
if(a==='study'){
const sel = (acc.selected||[]);
const courses = (acc.courses||[]).filter(c=>sel.includes(c.plan_id) && c.in_study!==false);
if(!courses.length){ toast('请先勾选要学习的课程'); return; }
if(!acc.cookies||!acc.cookies.length){ toast('请先登录'); return; }
const r = await api('/api/study',{method:'POST',body:JSON.stringify({
key:acc.id, username:acc.user||acc.username, cookies:acc.cookies, courses:courses,
workers:40, cap_minutes:90,
enable_quiz: acc.optQuiz===true,
enable_feedback: acc.optFeedback!==false })});
if(!r.ok){ toast('失败:'+r.error); return; }
const md = (r.study&&r.study.mode==='normal')
? `,常规真播 ${r.study.parallel} 路并发` : ',快速模式(只调接口)';
toast(`已开始 ${courses.length} 门课程(每门内部并发)${md}` +
(acc.optQuiz===true ? ',含自动答题' : ',测验将跳过(需手动作答)'));
showInlineLog(acc.id); return load();
}
if(a==='stop'){ await api('/api/stop',{method:'POST',body:JSON.stringify({key:acc.id})}); toast('已请求停止'); return load(); }
if(a==='capture'){
if(!acc.cookies||!acc.cookies.length){ toast('请先登录'); return; }
const r = await api('/api/capture',{method:'POST',body:JSON.stringify({key:acc.id,cookies:acc.cookies,minutes:10})});
if(!r.ok){ toast('失败:'+r.error); return; }
toast('抓包已开始:点课程 → 播几秒 → 交评价,完事点「停止抓包」');
showInlineLog(acc.id); return load();
}
if(a==='captureStop'){
await api('/api/capture/stop',{method:'POST',body:JSON.stringify({key:acc.id})});
toast('已停止抓包,结果见日志'); return load();
}
if(a==='log'){ return showInlineLog(acc.id); }
if(a==='del'){
const typed = prompt('删除账号:'+acc.username+'\n\n此操作不可恢复(账号、密码、会话都会一并删除)。\n请输入该账号的用户名以确认:');
if(typed===null) return;
if(typed.trim()!==acc.username){ toast('用户名不匹配,已取消删除'); return; }
await accDel(acc.id); toast('已删除'); return load();
}
}
$('#list').addEventListener('click', e=>{
const b=e.target.closest('button[data-act]'); if(!b) return;
act(e.target.closest('.card').dataset.id, b.dataset.act).catch(handleErr);
});
/* 「更多」下拉:同一时刻只开一个;点按钮切换,点菜单项或页面空白处收起。
5s 轮询会重建 DOM,打开状态存在 moreOpen 里,重建后由 render() 还原。 */
function syncMore(){
document.querySelectorAll('.card').forEach(c=>{
const m=c.querySelector('.more'); if(m) m.classList.toggle('open', moreOpen===c.dataset.id);
});
}
document.addEventListener('click', e=>{
const box=e.target.closest && e.target.closest('.more');
const id = box ? box.closest('.card').dataset.id : null;
if(box && e.target.closest('[data-more]')) moreOpen = (moreOpen===id ? null : id); // 点「更多」:开/合
else moreOpen = null; // 其他任何点击:收起
syncMore();
});
$('#list').addEventListener('change', async e=>{
const cb=e.target.closest('input[type=checkbox]'); if(!cb) return;
// 答题 / 反馈表开关
if(cb.dataset.opt){
const acc=accounts.find(x=>x.id===cb.dataset.id); if(!acc) return;
if(cb.dataset.opt==='quiz') acc.optQuiz = cb.checked;
if(cb.dataset.opt==='feedback') acc.optFeedback = cb.checked;
await accPut(acc);
toast(cb.dataset.opt==='quiz'
? (cb.checked?'已开启自动交测验(注意正确率)':'已关闭自动交测验')
: (cb.checked?'已开启自动交反馈表':'已关闭自动交反馈表'));
return;
}
// 课程勾选
const acc=accounts.find(x=>x.id===cb.dataset.cid); if(!acc) return;
acc.selected = acc.selected||[];
const plan=cb.dataset.plan;
if(cb.checked){ if(!acc.selected.includes(plan)) acc.selected.push(plan); }
else acc.selected = acc.selected.filter(p=>p!==plan);
await accPut(acc);
});
/* ---------------- 卡片内「课程日志」折叠面板 ----------------
展开状态、已拉到的行、滚动位置都放在 inlineLogs 里:5s 的整页轮询会重建卡片 DOM,
但重建后 paintOpenLogs() 会原样填回去。展开的卡片另有 1.5s 的独立轮询,
学习中的日志写入很快,不必等整页刷新。 */
function cardById(id){
return [...document.querySelectorAll('.card')].find(c=>c.dataset.id===id) || null;
}
function paintInlineLog(id){
const card=cardById(id); if(!card) return;
const s=iLog(id);
const panel=card.querySelector('.logpanel'), pre=card.querySelector('.loglines'),
btn=card.querySelector('[data-log-toggle]');
if(!panel||!pre||!btn) return;
panel.classList.toggle('open', !!s.open);
btn.setAttribute('aria-expanded', s.open?'true':'false');
const txt=s.lines.length ? s.lines.join('\n') : IL_EMPTY;
if(pre.textContent!==txt){
pre.textContent=txt;
pre.classList.toggle('is-empty', !s.lines.length);
if(s.stick) pre.scrollTop=pre.scrollHeight; // 用户没往上翻时始终跟到最新一行
}
pre.tabIndex = s.open ? 0 : -1;
const cnt=btn.querySelector('.logcnt');
if(cnt){
cnt.textContent = s.lines.length ? String(s.lines.length) : '';
cnt.hidden = !s.lines.length;
}
if(s.open && !s.stick) pre.scrollTop=s.top; // 用户翻到上面看历史时别拽回底部
}
function paintOpenLogs(){
inlineLogs.forEach((s,id)=>{ if(s.open) paintInlineLog(id); });
}
async function pullInlineLog(id){
const s=iLog(id);
if(s.busy) return null;
s.busy=true;
try{
const url = k => '/api/log?key='+encodeURIComponent(id)+'&since='+k;
let r = await api(url(s.since||0));
// 服务端日志超过 1000 行会裁掉最旧的 300 行,游标比长度还大时要整体重拉
if(typeof r.next==='number' && r.next < (s.since||0)){
s.lines=[]; s.since=0;
r = await api(url(0));
}
let lines=r.lines||[];
// 服务端重启后内存里的运行日志是空的,退回读一次 SQLite 里的历史日志
if(!lines.length && !r.status && !s.hist){
s.hist=true;
try{
const h = await api('/api/log/history?key='+encodeURIComponent(id)+'&tail=300');
if(h.lines && h.lines.length) lines = h.lines.slice();
}catch(e){}
}
if(lines.length) s.lines = s.lines.concat(lines);
if(typeof r.next==='number') s.since = Math.max(s.since, r.next);
if(s.lines.length>600) s.lines.splice(0, s.lines.length-600);
// 新版平台(study.dgjy.net)的会话是跑课程时才补上的,写回服务端供下次直接使用
if(r.cookies && r.cookies.length){
const acc=accounts.find(a=>a.id===id);
if(acc && JSON.stringify(acc.cookies||[])!==JSON.stringify(r.cookies)){
acc.cookies=r.cookies; acc.loginAt=acc.loginAt||Date.now();
try{ await accPut(acc); }catch(e){}
}
}
paintInlineLog(id);
return r;
}catch(e){
return null; // 单个账号拉日志失败不打断整页轮询
}finally{
s.busy=false;
}
}
function saveInlineOpen(){
try{
localStorage.setItem('inlineLogsOpen', JSON.stringify(
[...inlineLogs].filter(([,s])=>s.open).map(([id])=>id)));
}catch(e){}
}
function restoreInlineOpen(){
let ids=[];
try{ ids=JSON.parse(localStorage.getItem('inlineLogsOpen')||'[]'); }catch(e){}
(Array.isArray(ids)?ids:[]).forEach(id=>{ iLog(id).open=true; });
}
/* 从「更多 → 日志」或「开始学习」进入时:展开该卡片的日志面板并拉到最新 */
function showInlineLog(id){
const s=iLog(id);
s.open=true;
saveInlineOpen();
paintInlineLog(id);
pullInlineLog(id);
const card=cardById(id);
const anchor=card && card.querySelector('.logfold');
if(anchor) anchor.scrollIntoView({block:'nearest', behavior:'smooth'});
}
$('#list').addEventListener('click', e=>{
const b=e.target.closest('[data-log-toggle]'); if(!b) return;
const card=b.closest('.card'); if(!card) return;
const s=iLog(card.dataset.id);
s.open=!s.open;
paintInlineLog(card.dataset.id);
if(s.open) pullInlineLog(card.dataset.id);
saveInlineOpen();
});
/* scroll 不冒泡,用捕获阶段监听:记录滚动位置,并判断是否还贴着底部 */
$('#list').addEventListener('scroll', e=>{
const pre=e.target;
if(!pre.classList || !pre.classList.contains('loglines')) return;
const card=pre.closest('.card'); if(!card) return;
const s=iLog(card.dataset.id);
s.top=pre.scrollTop;
s.stick = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
}, true);
/* 展开中的面板单独轮询:只请求展开的账号,页面在后台时不动 */
setInterval(()=>{
if(document.hidden) return;
inlineLogs.forEach((s,id)=>{ if(s.open && !s.busy) pullInlineLog(id); });
}, 1500);
/* ---------------- add dialog ---------------- */
$('#addBtn').onclick=()=>$('#dlg').showModal();
$('#dlgCancel').onclick=()=>$('#dlg').close();
$('#dlgSave').onclick=async()=>{
const u=$('#f_user').value.trim(), p=$('#f_pass').value;
if(!u||!p){ toast('请填写账号和密码'); return; }
const rec={ id:'a'+Date.now().toString(36)+Math.random().toString(36).slice(2,6),
username:u, password:p, note:$('#f_note').value.trim(),
cookies:[], courses:[], selected:[], status:'idle', created:Date.now(), updated:Date.now() };
try{ await accPut(rec); }catch(e){ toast('保存失败:'+(e.message||e)); return; }
$('#dlg').close(); $('#f_user').value=$('#f_pass').value=$('#f_note').value='';
toast('已保存,正在尝试首次登录…');
await load();
// 保存后自动做第一次登录尝试:成功提示成功;失败只提示失败并停下,
// 卡片右上角留「登录」按钮,等人手动点,不自动重试(密码错/需验证码时重试只会刷屏)。
const acc = accounts.find(x=>x.id===rec.id);
if(!acc) return;
const ok = await doLogin(acc, true);
toast(ok ? `登录成功:${acc.user||acc.username}`
: `登录失败:${acc.lastError||'未知原因'}(请手动点击「登录」重试)`);
};
$('#refreshAll').onclick=()=>load().catch(handleErr);
addEventListener('resize', ()=>fitCourseLists());
/* 刷新页面后保持卡片内日志面板的展开状态 */
(async()=>{
restoreInlineOpen();
try{ await load(); }catch(e){ handleErr(e); }
inlineLogs.forEach((s,id)=>{ if(s.open) pullInlineLog(id); });
})();
async function tick(){
try{ await load(); }catch(e){ handleErr(e); }
}
setInterval(tick, 5000);
</script>
</body>
</html>