585a7a28dd
- 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
1202 lines
61 KiB
HTML
1202 lines
61 KiB
HTML
<!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 {"answers":{"题号":"答案"}}"></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 => ({'&':'&','<':'<','>':'>','"':'"'}[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,'"')}" ${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,'"')}">${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,'"')}" 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>
|