最近因为学校早读需要一些工作,我闲的没事干,做了一个,这个是本地存储,因为每个学校不同吧,我没有打算给老师,不然得被叫嘉豪了,又想表达出来,所以我发这里了

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>早读打卡</title>
<style>
/* ============ 小尧nb ============ */
:root{
  --font-task:"huangkaihuaLawyerfont","DymonShouXieTi","STKaiti","KaiTi","华文行楷",cursive;
  --font-body:"DymonShouXieTi","huangkaihuaLawyerfont","PingFang SC","Microsoft YaHei",sans-serif;
  --safe-bottom:env(safe-area-inset-bottom,0px);
  --radius:18px;
  --w-narrow:640px;--w-wide:1100px;
  /* 6 组色:组块/头像/进度会按组取色 */
  --g1:#22c55e;--g2:#06b6d4;--g3:#3b82f6;--g4:#f59e0b;--g5:#ec4899;--g6:#8b5cf6;
  /* 默认:青草绿 */
  --primary:#22c55e;--primary-dark:#15803d;--primary-soft:#bbf7d0;
  --bg:#eefaf1;--bg-2:#dcfce7;--bg-3:#d1fae5;--card:#ffffff;
  --text:#1f2937;--text-soft:#6b7280;--border:#d1fae5;
  --tint:#f0fdf4;--hd-1:#22c55e;--hd-2:#15803d;--hd-txt:#ffffff;--hd-sub:#dcfce7;
  --glow:rgba(34,197,94,.12);--glow-strong:rgba(34,197,94,.28);--glow-soft:rgba(255,255,255,.16);
  --fs-bg-1:#0f3d24;--fs-bg-2:#0a2c19;--fs-bg-3:#061c10;
  --fs-txt:#eafff0;--fs-tint:#86efac;--fs-tint-2:#a7f3d0;--fs-task:#4ade80;--fs-task-shadow:#166534;
  --ok:#16a34a;--ok-txt:#166534;
}
[data-theme="mint"]{
  --primary:#06b6d4;--primary-dark:#0e7490;--primary-soft:#cffafe;
  --bg:#e6f7fa;--bg-2:#cffafe;--bg-3:#a5f3fc;--card:#ffffff;
  --text:#0f2f33;--text-soft:#5b7d83;--border:#bae6fd;
  --tint:#ecfeff;--hd-1:#06b6d4;--hd-2:#0e7490;--hd-txt:#ffffff;--hd-sub:#cffafe;
  --glow:rgba(6,182,212,.14);--glow-strong:rgba(6,182,212,.3);--glow-soft:rgba(255,255,255,.18);
  --fs-bg-1:#0c2f38;--fs-bg-2:#08222a;--fs-bg-3:#05171d;
  --fs-txt:#e6fdff;--fs-tint:#67e8f9;--fs-tint-2:#a5f3fc;--fs-task:#22d3ee;--fs-task-shadow:#0e7490;
  --ok:#0d9488;--ok-txt:#0f4f4a;
}
[data-theme="ocean"]{
  --primary:#3b82f6;--primary-dark:#1d4ed8;--primary-soft:#dbeafe;
  --bg:#eef4ff;--bg-2:#dbeafe;--bg-3:#bfdbfe;--card:#ffffff;
  --text:#1e293b;--text-soft:#5b7080;--border:#c7ddf7;
  --tint:#eff6ff;--hd-1:#3b82f6;--hd-2:#1d4ed8;--hd-txt:#ffffff;--hd-sub:#dbeafe;
  --glow:rgba(59,130,246,.15);--glow-strong:rgba(29,78,216,.3);--glow-soft:rgba(255,255,255,.18);
  --fs-bg-1:#102a4d;--fs-bg-2:#0a1f3a;--fs-bg-3:#06142a;
  --fs-txt:#eaf2ff;--fs-tint:#93c5fd;--fs-tint-2:#bfdbfe;--fs-task:#60a5fa;--fs-task-shadow:#1d4ed8;
  --ok:#1d9488;--ok-txt:#0f3d5a;
}
[data-theme="warm"]{
  --primary:#f59e0b;--primary-dark:#b45309;--primary-soft:#fde68a;
  --bg:#fff7ea;--bg-2:#fdeecb;--bg-3:#fde68a;--card:#ffffff;
  --text:#3a2a17;--text-soft:#8a7355;--border:#fde1b8;
  --tint:#fffbeb;--hd-1:#f59e0b;--hd-2:#b45309;--hd-txt:#ffffff;--hd-sub:#fde68a;
  --glow:rgba(245,158,11,.16);--glow-strong:rgba(180,83,9,.28);--glow-soft:rgba(255,255,255,.2);
  --fs-bg-1:#3a2608;--fs-bg-2:#2b1c05;--fs-bg-3:#1c1203;
  --fs-txt:#fff6e2;--fs-tint:#fcd34d;--fs-tint-2:#fde68a;--fs-task:#fbbf24;--fs-task-shadow:#92400e;
  --ok:#d97706;--ok-txt:#78350f;
}
[data-theme="cherry"]{
  --primary:#ec4899;--primary-dark:#be185d;--primary-soft:#fbcfe8;
  --bg:#fff0f6;--bg-2:#fbcfe8;--bg-3:#f9a8d4;--card:#ffffff;
  --text:#3a1f2c;--text-soft:#8a6075;--border:#f5c6dd;
  --tint:#fff1f8;--hd-1:#ec4899;--hd-2:#be185d;--hd-txt:#ffffff;--hd-sub:#fbcfe8;
  --glow:rgba(236,72,153,.15);--glow-strong:rgba(190,24,93,.28);--glow-soft:rgba(255,255,255,.2);
  --fs-bg-1:#3a1226;--fs-bg-2:#2b0d1d;--fs-bg-3:#1c0814;
  --fs-txt:#ffeaf4;--fs-tint:#f9a8d4;--fs-tint-2:#fbcfe8;--fs-task:#f472b6;--fs-task-shadow:#9d174d;
  --ok:#db2777;--ok-txt:#831843;
}
[data-theme="dark"]{
  --primary:#a78bfa;--primary-dark:#7c3aed;--primary-soft:#3b2f6b;
  --bg:#12101a;--bg-2:#1a1726;--bg-3:#241f36;--card:#1e1b2b;
  --text:#e8e6f0;--text-soft:#a39fb8;--border:#2e2944;
  --tint:#221e33;--hd-1:#7c3aed;--hd-2:#5b21b6;--hd-txt:#ffffff;--hd-sub:#ddd6fe;
  --glow:rgba(167,139,250,.18);--glow-strong:rgba(124,58,237,.35);--glow-soft:rgba(255,255,255,.1);
  --fs-bg-1:#0d0b14;--fs-bg-2:#100d1c;--fs-bg-3:#0a0814;
  --fs-txt:#eeeaff;--fs-tint:#c4b5fd;--fs-tint-2:#ddd6fe;--fs-task:#a78bfa;--fs-task-shadow:#5b21b6;
  --ok:#22c55e;--ok-txt:#0e3d24;
}
:root{--danger:#ef4444;--danger-dark:#dc2626;--warning:#f59e0b;--success:var(--ok);--gray:#9ca3af;}

*{box-sizing:border-box;margin:0}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);-webkit-tap-highlight-color:transparent;
  font-size:clamp(14px,3.4vw,16px);transition:background .25s,color .25s}
.app{max-width:min(500px,100% - 30px);margin:0 auto;padding:0 15px calc(40px + var(--safe-bottom));min-height:100vh;position:relative}
@media (min-width:641px){
  .app{max-width:min(900px,100% - 40px);padding-left:20px;padding-right:20px}
  body{font-size:15px}
  .kpis{gap:12px;margin-top:18px}
  .kpi{padding:14px 4px;border-radius:16px}
  .kpi b{font-size:26px}
}
@media (min-width:1101px){
  body{font-size:15px;background:
    radial-gradient(1200px 600px at 12% -8%,var(--primary-soft),transparent 60%),
    radial-gradient(1000px 500px at 88% 0%,var(--tint),transparent 55%),var(--bg)}
  .app{max-width:min(1320px,100% - 48px);padding:0 24px 56px}
  .kpi{padding:16px 6px}.kpi b{font-size:30px}
  .kpi span{font-size:11px;margin-top:2px;display:inline-block}
  .dash{display:grid;grid-template-columns:1.15fr .85fr;gap:18px;align-items:start}
  .dash .card{margin-top:18px}
  .grp-grid{grid-template-columns:repeat(3,1fr)!important}
  .grp{border-radius:20px}
  .grp-h .gn{font-size:17px}
  .grp-students{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
}
/* 头部 */
.hd{position:sticky;top:0;z-index:20;margin:0 -15px;padding:15px 20px;
  background:linear-gradient(135deg,var(--hd-1),var(--hd-2));
  border-radius:0 0 22px 22px;box-shadow:0 6px 18px rgba(21,128,61,.28);
  display:flex;align-items:center;justify-content:space-between;gap:10px}
@media (min-width:641px){.hd{margin:0 -20px;padding:16px 24px;border-radius:0 0 24px 24px}}
.hd h1{font-size:clamp(16px,4.6vw,19px);color:var(--card);font-weight:800;letter-spacing:1px;display:flex;align-items:center;gap:8px}
.hd .day{font-size:11px;color:var(--bg-2);font-weight:600;background:rgba(255,255,255,.18);padding:3px 9px;border-radius:20px;white-space:nowrap}
.icon-btn{border:none;background:rgba(255,255,255,.18);color:var(--card);width:36px;height:36px;border-radius:50%;font-size:18px;cursor:pointer;font-family:inherit}
.icon-btn:active{transform:scale(.92)}
@media (min-width:1101px){.hd{padding:18px 26px;border-radius:0 0 26px 26px}.hd h1{font-size:22px}.icon-btn{width:40px;height:40px;font-size:19px}}

.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-top:15px}
.kpi{background:var(--card);border:1px solid var(--border);border-radius:15px;padding:11px 2px;text-align:center;box-shadow:0 2px 10px rgba(0,0,0,.04);min-width:0}
.kpi b{display:block;font-size:clamp(19px,6vw,23px);font-weight:800;line-height:1.1;overflow:hidden;text-overflow:ellipsis}
.kpi span{font-size:10px;color:var(--text-soft);letter-spacing:.5px}
.kpi.g b{color:var(--success)}.kpi.r b{color:var(--danger)}.kpi.y b{color:#d97706}.kpi.p b{color:var(--primary)}
.g6{color:var(--g6)!important}.g5{color:var(--g5)!important}.g4{color:var(--g4)!important}
.g3{color:var(--g3)!important}.g2{color:var(--g2)!important}.g1{color:var(--g1)!important}

.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 2px 12px rgba(0,0,0,.05);padding:15px;margin-top:15px;animation:up .4s ease both}
.ch{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;gap:8px;flex-wrap:wrap}
.ch h2{font-size:15px;font-weight:800;color:var(--text);display:flex;align-items:center;gap:7px}
.ch h2::before{content:"";width:5px;height:16px;border-radius:4px;background:linear-gradient(var(--primary),var(--primary-dark))}
.mini{font-size:11px;color:var(--text-soft);background:var(--tint);padding:3px 9px;border-radius:20px}
.btn{padding:7px 13px;border:none;border-radius:20px;background:var(--primary);color:var(--card);font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.btn.gray{background:#9ca3af}.btn.line{background:var(--card);color:var(--primary-dark);border:1px solid var(--primary)}
.btn.ghost{background:var(--tint);color:var(--primary-dark);border:1px dashed var(--primary)}
.btn:active{transform:scale(.95)}
@media (min-width:1101px){.btn{padding:8px 15px;font-size:13px}}

/* 任务条 */
.task-box{display:flex;align-items:stretch;gap:10px}
.task-list{flex:1;min-width:0}
.task-item{display:flex;align-items:center;gap:9px;padding:11px 13px;margin-bottom:8px;
  background:linear-gradient(90deg,var(--tint),var(--card));border:1px solid var(--border);
  border-left:5px solid var(--primary);border-radius:13px;font-size:15px;font-weight:600;color:var(--ok-txt);word-break:break-word}
.task-item .n{width:20px;height:20px;border-radius:50%;background:var(--primary);color:var(--card);font-size:11px;display:flex;align-items:center;justify-content:center;flex:none;font-weight:800}
.task-item:last-child{margin-bottom:0}
.empty{color:var(--text-soft);font-size:13px;text-align:center;padding:14px}

/* ===== 分组点名墙 ===== */
.roster-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:2px 0 11px}
.filters{display:flex;gap:6px;flex-wrap:wrap;margin-left:auto}
.fchip{border:1px solid var(--border);background:var(--tint);color:var(--text-soft);border-radius:20px;padding:5px 11px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:5px}
.fchip.on{background:var(--primary);color:var(--card);border-color:var(--primary)}
.fchip .dot{width:8px;height:8px;border-radius:50%}
.fchip.all.on{background:var(--text);border-color:var(--text)}
.grp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:12px}
.grp{border:1px solid var(--border);border-radius:16px;background:var(--card);overflow:hidden}
.grp-h{display:flex;align-items:center;gap:9px;padding:11px 13px;cursor:pointer;position:relative}
.grp-h::after{content:"⤢";position:absolute;right:12px;font-size:12px;color:var(--text-soft);transition:transform .25s}
.grp.collapsed .grp-h::after{transform:rotate(-90deg)}
.grp-badge{width:26px;height:26px;border-radius:9px;color:#fff;font-weight:800;font-size:12px;display:flex;align-items:center;justify-content:center;flex:none;font-family:var(--font-body)}
.grp-ttl{flex:1;min-width:0}
.grp-ttl .gn{font-size:15px;font-weight:800;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.grp-ttl .gm{font-size:10px;color:var(--text-soft);line-height:1.2}
.grp-bar{height:4px;background:var(--bg);margin:0 13px 9px;border-radius:10px;overflow:hidden}
.grp-bar i{display:block;height:100%;border-radius:10px;transition:width .35s}
.grp-students{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(96px,26vw),1fr));gap:8px;padding:0 12px 12px}
.grp.collapsed .grp-students,.grp.collapsed .grp-bar{display:none}
.stu{border-radius:13px;padding:11px 4px;text-align:center;cursor:pointer;transition:.18s;
  border:2px solid transparent;background:var(--tint);user-select:none;position:relative;overflow:hidden}
.stu .mk{font-size:18px;margin-bottom:2px;line-height:1;font-family:var(--font-task)}
.stu .nm{font-size:14px;font-weight:800;line-height:1.25;word-break:break-all}
.stu .lv{font-size:9px;color:var(--text-soft);line-height:1;margin-top:3px;min-height:11px}
.stu.s0{color:#9ca3af}.stu.s0 .mk{color:#d1d5db}
.stu.s1{border-color:var(--success);background:var(--tint);color:var(--ok-txt);box-shadow:0 0 10px rgba(22,163,74,.2)}
.stu.s1 .mk{color:var(--success);text-shadow:0 0 10px rgba(22,163,74,.5)}
.stu.s2{border-color:var(--danger);background:#fef2f2;color:#991b1b;box-shadow:0 0 10px rgba(239,68,68,.2)}
.stu.s2 .mk{color:var(--danger);text-shadow:0 0 10px rgba(239,68,68,.5)}
.stu.leader{border-color:currentColor;box-shadow:0 0 0 2px currentColor inset,0 0 12px var(--glow)}
.stu.leader::before{content:"👑";position:absolute;top:2px;right:2px;font-size:10px;line-height:1}
.stu.pick{animation:pick .5s ease infinite alternate;border-color:#f59e0b!important;background:#fffbeb!important;color:#b45309!important;box-shadow:0 0 18px rgba(245,158,11,.6)}
@keyframes pick{from{transform:scale(1)}to{transform:scale(1.06)}}
.stu:active{transform:scale(.94)}
@media (min-width:641px){.grp-students{grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:10px}
  .stu{flex-direction:row;align-items:center;text-align:left;padding:10px 12px;gap:9px}
  .stu .mk{font-size:19px;margin-bottom:0;flex:none}.stu .nm{font-size:15px}.stu .lv{margin-top:0;margin-left:auto;text-align:right}}
.roster-hint{font-size:11px;color:var(--text-soft);text-align:center;margin-top:9px;line-height:1.7}
.pbar{height:9px;background:#e5e7eb;border-radius:20px;overflow:hidden;margin-top:11px}
.pbar i{display:block;height:100%;background:linear-gradient(90deg,var(--primary),var(--success));transition:width .35s}

/* 倒计时 */
.cd{text-align:center}
.clock{font-family:var(--font-task);font-size:clamp(54px,22vw,110px);font-weight:800;
  color:var(--primary);letter-spacing:5px;line-height:1;text-shadow:0 2px 14px rgba(34,197,94,.3);font-variant-numeric:tabular-nums}
.clock.warn{color:var(--warning)}.clock.dang{color:var(--danger);animation:throb 1s infinite}
.ring{width:clamp(90px,32vw,120px);height:clamp(90px,32vw,120px);border-radius:50%;margin:14px auto 0;position:relative}
.ring::before{content:"";position:absolute;inset:8px;border-radius:50%;background:conic-gradient(var(--primary) var(--p,0deg),#e5e7eb 0deg);transition:--p .4s}
.ring::after{content:"";position:absolute;inset:16px;border-radius:50%;background:var(--card);box-shadow:inset 0 2px 8px rgba(0,0,0,.06)}
.cdrow{display:flex;align-items:center;justify-content:center;gap:9px;flex-wrap:wrap;margin-top:14px}
.circle{width:46px;height:46px;border-radius:50%;border:none;background:var(--tint);color:var(--text-soft);font-size:11px;font-weight:700;cursor:pointer;font-family:inherit}
.circle:active{background:var(--border)}
.big{width:74px;height:74px;border-radius:50%;border:none;color:var(--card);font-size:16px;font-weight:800;cursor:pointer;
  background:linear-gradient(135deg,var(--primary),var(--primary-dark));box-shadow:0 6px 16px rgba(21,128,61,.4);font-family:inherit}
.big:active{transform:scale(.94)}.big.on{background:linear-gradient(135deg,var(--danger),var(--danger-dark))}
@media (min-width:1101px){.ring{width:150px;height:150px;margin:8px auto 4px}.ring::after{inset:20px}
  .circle{width:54px;height:54px;font-size:12px}.big{width:88px;height:88px;font-size:17px}.cdrow{margin-top:18px;gap:12px}}

/* 图表 */
.charts{display:flex;flex-direction:column;gap:16px}
.chart-block h4{font-size:13px;margin-bottom:10px;color:var(--text-soft);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
.bars{display:flex;align-items:flex-end;gap:6px;height:130px;padding:0 2px;overflow-x:auto}
.bar-col{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;gap:3px;min-width:18px}
.bar-stack{width:100%;max-width:30px;display:flex;flex-direction:column;justify-content:flex-end;border-radius:6px 6px 0 0;overflow:hidden}
.bar-stack i{flex:1;min-height:2px}
.bar-stack .g{background:linear-gradient(180deg,var(--fs-task),var(--ok))}
.bar-stack .r{background:linear-gradient(180deg,#fca5a5,var(--danger))}
.bar-stack .x{background:linear-gradient(180deg,#d1d5db,#9ca3af)}
.bar-col .lb{font-size:9px;color:var(--text-soft);transform:rotate(-20deg);white-space:nowrap}
.bar-col .vl{font-size:9px;color:var(--text);font-weight:800}
@media (min-width:1101px){.bar-col .lb{transform:none}.bar-stack{max-width:42px}}
.legend{display:flex;gap:14px;font-size:11px;color:var(--text-soft);justify-content:center;margin-top:8px;flex-wrap:wrap}
.legend em{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:-1px}
.legend em.g1{background:var(--g1)}.legend em.g2{background:var(--g2)}.legend em.g3{background:var(--g3)}
.legend em.g4{background:var(--g4)}.legend em.g5{background:var(--g5)}.legend em.g6{background:var(--g6)}
.hist-list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:260px;overflow:auto}
.hist-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;background:var(--tint);border:1px solid var(--border);cursor:pointer}
.hist-row:hover{border-color:var(--primary)}
.hist-row .dot{font-size:15px;flex:none}
.hist-row .dt{flex:1;font-size:13px;font-weight:700;min-width:0}
.hist-row .dt small{display:block;font-size:10px;color:var(--text-soft);font-weight:500}
.hist-row .rate{font-size:12px;font-weight:800;flex:none}
.hist-row .rate.g{color:var(--success)}.hist-row .rate.r{color:var(--danger)}.hist-row .rate.y{color:#d97706}
.grp-mini{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px}
.grp-mini b{font-size:9px;font-weight:800;padding:1px 5px;border-radius:6px;color:#fff}
table{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px}
th,td{padding:8px 4px;text-align:center;border-bottom:1px solid var(--border);word-break:break-all}
th{color:var(--text-soft);font-weight:700;background:var(--tint)}
.mut{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.mut .btn{flex:1;min-width:120px}

/* ===== 任务全屏页 ===== */
.fs-mask{position:fixed;inset:0;z-index:9000;display:none;overflow:hidden;
  background:radial-gradient(120% 90% at 50% 0%,var(--fs-bg-1) 0%,var(--fs-bg-2) 45%,var(--fs-bg-3) 100%);
  color:var(--fs-txt);flex-direction:column}
.fs-mask.show{display:flex}
.fs-top{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;flex:none;gap:10px;flex-wrap:wrap}
.fs-tag{font-size:12px;letter-spacing:4px;color:var(--fs-tint);font-weight:700;background:rgba(34,197,94,.16);padding:5px 14px;border-radius:30px;border:1px solid rgba(134,239,172,.4);white-space:nowrap}
.fs-close{width:44px;height:44px;border-radius:50%;border:1px solid rgba(255,255,255,.3);background:rgba(255,255,255,.1);color:var(--card);font-size:22px;cursor:pointer;backdrop-filter:blur(6px);flex:none}
.fs-body{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0 26px;text-align:center;gap:22px;min-height:0}
.fs-title{font-size:clamp(40px,13vw,120px);line-height:1.12;font-family:var(--font-task);font-weight:800;
  background:linear-gradient(180deg,var(--card) 0%,var(--fs-tint-2) 45%,var(--fs-task) 100%);
  -webkit-background-clip:text;background-clip:text;color:transparent;text-shadow:0 0 40px rgba(74,222,128,.35);letter-spacing:2px}
.fs-title .t{display:block}
.fs-sub{font-size:clamp(15px,4vw,22px);color:var(--fs-tint-2);letter-spacing:6px;font-weight:600}
.fs-tasks{flex:1;width:100%;max-width:620px;overflow-y:auto;display:flex;flex-direction:column;justify-content:center;gap:16px;padding:10px 0}
.fs-task{font-family:var(--font-task);font-size:clamp(26px,7.5vw,52px);font-weight:800;color:var(--card);
  text-shadow:0 2px 0 var(--ok-txt),0 0 24px rgba(134,239,172,.6);line-height:1.35;
  padding:14px 20px;border-radius:18px;background:rgba(255,255,255,.06);border:1px solid rgba(134,239,172,.3);
  border-left:8px solid var(--fs-task);word-break:break-word}
.fs-foot{flex:none;width:100%;max-width:620px;margin:0 auto;padding:0 18px calc(22px + var(--safe-bottom));display:flex;flex-direction:column;gap:12px}
.fs-prog{height:8px;border-radius:20px;background:rgba(255,255,255,.16);overflow:hidden}
.fs-prog i{display:block;height:100%;background:linear-gradient(90deg,var(--fs-task),var(--ok));transition:width .4s}
.fs-meta{display:flex;justify-content:space-between;align-items:center;color:var(--fs-tint-2);font-size:13px;font-weight:600;gap:10px;flex-wrap:wrap}
.fs-clock{font-family:var(--font-task);font-size:clamp(26px,8vw,44px);color:var(--card);letter-spacing:3px;text-shadow:0 0 20px rgba(74,222,128,.7);font-variant-numeric:tabular-nums;font-weight:800}
.fs-btns{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.fs-btn{border:none;border-radius:30px;padding:11px 22px;font-size:14px;font-weight:800;cursor:pointer;font-family:var(--font-body);
  background:rgba(255,255,255,.14);color:var(--card);border:1px solid rgba(255,255,255,.3);min-width:120px}
.fs-btn.on{background:var(--danger);border-color:var(--danger-dark)}
.fs-flash{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle at 50% 50%,rgba(251,146,60,.55),transparent 60%);opacity:0;transition:opacity .3s}
.fs-flash.boom{animation:flash 2.6s ease-out}

/* 随机点名浮层 */
.pick-mask{position:fixed;inset:0;z-index:9500;display:none;align-items:center;justify-content:center;
  background:radial-gradient(120% 90% at 50% 40%,#1a4d2e 0%,var(--fs-bg-2) 55%,var(--fs-bg-3) 100%)}
.pick-mask.show{display:flex}
.pick-box{text-align:center;color:var(--fs-txt);padding:0 24px;width:100%;max-width:620px}
.pick-lbl{font-size:13px;letter-spacing:6px;color:var(--fs-tint);font-weight:700;margin-bottom:18px;display:block}
.pick-name{font-family:var(--font-task);font-weight:800;line-height:1.05;font-size:clamp(58px,22vw,160px);color:var(--card);text-shadow:0 0 50px rgba(74,222,128,.6);min-height:1.1em;word-break:break-all}
.pick-name.hit{background:linear-gradient(180deg,var(--card),var(--fs-tint-2) 55%,var(--fs-task));-webkit-background-clip:text;background-clip:text;color:transparent}
.pick-name.blank{font-size:clamp(26px,8vw,56px);color:var(--fs-tint);font-family:var(--font-body);letter-spacing:4px}
.pick-who{font-size:14px;color:var(--fs-tint-2);margin-top:14px;min-height:20px;font-weight:600}
.pick-btns{display:flex;gap:12px;justify-content:center;margin-top:30px;flex-wrap:wrap}
.pick-btn{border:none;border-radius:30px;padding:13px 28px;font-size:16px;font-weight:800;cursor:pointer;font-family:var(--font-body);
  background:linear-gradient(135deg,var(--hd-1),var(--hd-2));color:var(--card);box-shadow:0 6px 18px rgba(21,128,61,.5);min-width:150px}
.pick-btn.gh{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.35);box-shadow:none}
.pick-btn:active{transform:scale(.95)}
.pick-btn.on{background:linear-gradient(135deg,var(--danger),var(--danger-dark))}
.pick-stat{position:absolute;top:16px;left:50%;transform:translateX(-50%);font-size:12px;color:var(--fs-tint);font-weight:700;letter-spacing:2px;
  background:rgba(34,197,94,.16);padding:5px 16px;border-radius:30px;border:1px solid rgba(134,239,172,.35);white-space:nowrap;max-width:92vw;overflow:hidden;text-overflow:ellipsis}
.pick-flash{position:absolute;inset:0;pointer-events:none;border-radius:50%;box-shadow:0 0 0 0 rgba(251,146,60,.7);opacity:0}
.pick-flash.boom{animation:ringout 2.4s ease-out}

/* 通用全屏铺满层 */
.scrim{position:fixed;inset:0;z-index:8000;display:none;background:var(--tint);flex-direction:column}
.scrim.show{display:flex}
.scrim .shead{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;background:linear-gradient(135deg,var(--hd-1),var(--hd-2));color:var(--card);flex:none;gap:10px}
.scrim .shead b{font-size:16px;font-weight:800;word-break:break-all}
.scrim .sbody{flex:1;overflow:auto;padding:20px}
.scrim .sfoot{flex:none;padding:12px 18px calc(14px + var(--safe-bottom));background:var(--card);border-top:1px solid var(--border);text-align:center}
.scrim .sfoot .fs-clock{color:var(--primary-dark);font-size:clamp(34px,11vw,52px);font-weight:800}
@media (min-width:1101px){.scrim .sbody{padding:32px 48px}.scrim .shead{padding:16px 26px}.scrim .shead b{font-size:18px}}
.bomb{position:absolute;inset:0;pointer-events:none;border-radius:50%;box-shadow:0 0 0 0 rgba(251,146,60,.7);opacity:0}
.bomb.boom{animation:ringout 2.4s ease-out}

@keyframes up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@keyframes throb{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
@keyframes flash{0%{opacity:.9}100%{opacity:0}}
@keyframes ringout{0%{opacity:.8;box-shadow:0 0 0 0 rgba(251,146,60,.8)}70%{opacity:0}100%{opacity:0;box-shadow:0 0 0 120px rgba(251,146,60,0)}}
.overlay{position:fixed;inset:0;background:rgba(6,28,16,.5);z-index:50;display:none;backdrop-filter:blur(2px)}
.modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.94);z-index:60;display:none;opacity:0;transition:.2s;width:92%;max-width:520px;max-height:92vh;overflow:hidden;flex-direction:column}
.modal.show{display:flex;opacity:1;transform:translate(-50%,-50%) scale(1)}
.mc{background:var(--card);border-radius:var(--radius);padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.25);overflow:auto;max-height:92vh}
.mc h3{font-size:18px;font-weight:800;margin-bottom:14px;text-align:center}
@media (min-width:1101px){.mc{padding:26px 28px;border-radius:22px}}
input[type=text],input[type=password],input[type=number]{width:100%;padding:12px;border:1px solid var(--border);border-radius:11px;font-size:15px;margin-bottom:10px;outline:none;font-family:inherit;background:var(--tint)}
input:focus{border-color:var(--primary)}
.tip{font-size:12px;color:var(--text-soft);text-align:center;margin-bottom:12px}
.mrow{display:flex;gap:10px;margin-top:6px}
.mrow button{flex:1;padding:11px;border:none;border-radius:11px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit}
.mrow .gy{background:#e5e7eb;color:#374151}.mrow .pr{background:var(--primary);color:var(--card)}
.ledit{list-style:none;max-height:260px;overflow:auto}
.ledit li{display:flex;align-items:center;gap:8px;padding:9px 11px;background:var(--tint);border:1px solid var(--border);border-radius:11px;margin-bottom:7px;font-size:14px;font-weight:600}
.ledit li .lname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
.ledit li button{background:none;border:none;font-size:17px;cursor:pointer;padding:0 4px;flex:none}
.ledit li button.e{color:var(--warning)}.ledit li button.d{color:var(--danger)}.ledit li button.c{color:var(--primary)}
textarea{box-sizing:border-box}
.tinputs{display:flex;gap:16px;justify-content:center;margin-bottom:6px}
.tinputs div{display:flex;align-items:center;gap:6px}.tinputs input{width:76px;text-align:center;margin-bottom:0}
.slide{position:relative;height:54px;margin:16px 0 4px;user-select:none;-webkit-user-select:none}
.slide .track{position:absolute;inset:0;background:var(--bg);border:2px solid var(--border);border-radius:27px;overflow:hidden}
.slide .txt{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:12px;color:var(--text-soft);font-weight:700;pointer-events:none;letter-spacing:1px;white-space:nowrap}
.slide .fill{position:absolute;top:0;left:0;height:100%;width:0;background:linear-gradient(90deg,var(--fs-task),var(--ok));border-radius:27px}
.slide .thumb{position:absolute;top:5px;left:5px;width:44px;height:44px;border-radius:50%;z-index:2;
  background:linear-gradient(135deg,var(--primary),var(--primary-dark));color:var(--card);display:flex;align-items:center;justify-content:center;font-size:20px;cursor:grab;box-shadow:0 3px 10px rgba(21,128,61,.4)}
.slide .thumb.on{background:linear-gradient(135deg,var(--ok),var(--hd-2))}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:5px}

/* 主题切换器 */
.themes{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 2px}
@media (min-width:1101px){.themes{grid-template-columns:repeat(6,1fr)}}
.theme{position:relative;border-radius:14px;padding:10px 6px 9px;text-align:center;cursor:pointer;border:2px solid var(--border);background:var(--card);transition:.16s;user-select:none;overflow:hidden}
.theme:active{transform:scale(.96)}
.theme .sw{width:34px;height:34px;border-radius:50%;margin:0 auto 7px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08);
  background:radial-gradient(circle at 30% 30%,var(--t) 0 42%,transparent 42%),linear-gradient(135deg,var(--h1) 0 55%,var(--h2) 55%)}
.theme .tn{font-size:12px;font-weight:800;color:var(--text);line-height:1.1}
.theme .ts{font-size:9px;color:var(--text-soft);margin-top:2px;line-height:1.1}
.theme.on{border-color:var(--primary);box-shadow:0 0 0 3px var(--glow);transform:translateY(-1px)}
.theme.on::after{content:"✓";position:absolute;top:5px;right:6px;font-size:11px;font-weight:900;color:var(--primary)}
.theme.dark .sw{box-shadow:inset 0 0 0 1px rgba(255,255,255,.18)}
.set-block{margin-bottom:6px}
.set-block .lbl{font-size:12px;font-weight:800;color:var(--text-soft);letter-spacing:.5px;margin:14px 2px 8px;display:flex;align-items:center;gap:6px}
.set-block .lbl:first-child{margin-top:0}
.set-hint{font-size:11px;color:var(--text-soft);line-height:1.7;background:var(--tint);border-radius:11px;padding:10px 12px;margin-top:4px}
.set-hint b{color:var(--primary-dark)}

/* 分组管理弹窗 */
.grp-tabs{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap}
.grp-tab{border:1px solid var(--border);background:var(--tint);color:var(--text-soft);border-radius:20px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:6px}
.grp-tab.on{background:var(--primary);color:var(--card);border-color:var(--primary)}
.grp-tab .gc{width:9px;height:9px;border-radius:50%}
.grp-tab.add{border-style:dashed}
.grp-pane{display:none}.grp-pane.on{display:block}
.grp-meta{display:flex;gap:8px;margin-bottom:10px;align-items:center}
.grp-meta input{flex:1;margin-bottom:0}
.grp-meta .pick-c{width:44px;height:44px;border-radius:12px;border:2px solid var(--border);background:var(--tint);cursor:pointer;flex:none;position:relative;overflow:hidden}
.grp-meta .pick-c i{position:absolute;inset:0;display:block}
.mem-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin-top:6px}
.mem{flex-direction:column!important;align-items:stretch!important;gap:6px!important;padding:10px!important}
.mem .mrow2{display:flex;align-items:center;gap:8px}
.mem .mrow2 .lname{font-size:14px}
.mem .acts{display:flex;gap:4px;justify-content:flex-end}
.mem .acts button{flex:1;padding:6px 0;font-size:11px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text-soft);cursor:pointer;font-family:inherit;font-weight:700}
.mem .acts button.on{background:var(--primary);color:var(--card);border-color:var(--primary)}
.mem .acts button.d{color:var(--danger);border-color:#fecaca}
.mem .mv{display:flex;gap:4px;flex-wrap:wrap;margin-top:2px}
.mem .mv button{flex:1;min-width:46px;padding:5px 0;font-size:10px;border-radius:8px;border:1px dashed var(--border);background:var(--card);color:var(--text-soft);cursor:pointer;font-family:inherit}
.mem .mv button.on{background:var(--tint);color:var(--primary-dark);border-color:var(--primary);font-weight:800}
.color-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-top:8px}
.color-grid b{height:34px;border-radius:11px;cursor:pointer;border:3px solid transparent;font-size:0}
.color-grid b.on{border-color:var(--text);box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--text)}

@media (orientation:landscape) and (max-height:520px){
  .fs-body{flex-direction:row;gap:40px;align-items:center;padding:0 40px}
  .fs-tasks{flex:1;max-height:72vh;justify-content:flex-start}.fs-sub{display:none}.fs-title{font-size:clamp(26px,7vh,64px)}
  .scrim .sbody{padding:14px 26px}.app{max-width:min(720px,100% - 30px)}.kpis{grid-template-columns:repeat(4,1fr)}
}
@media (min-width:1101px) and (orientation:landscape){.app{max-width:min(1320px,100% - 48px)}.dash{grid-template-columns:1.2fr .8fr}}
@media (max-width:360px){.app{padding-left:12px;padding-right:12px}.hd{padding:13px 16px}
  .circle{width:42px;height:42px;font-size:10px}.big{width:66px;height:66px;font-size:15px}}
</style>
</head>
<body>
<div class="app">
  <header class="hd">
    <h1>📖 早读打卡</h1><span class="day" id="dayLabel"></span>
    <button class="icon-btn" id="btnSettings" title="设置">⚙️</button>
  </header>

  <div class="kpis">
    <div class="kpi p"><b id="kTotal">0</b><span>应到</span></div>
    <div class="kpi g"><b id="kPass">0</b><span>✔ 已背会</span></div>
    <div class="kpi r"><b id="kFail">0</b><span>✘ 未背会</span></div>
    <div class="kpi y"><b id="kStreak">0</b><span>🔥 连续达标</span></div>
  </div>

  <div class="dash">
    <section class="card">
      <div class="ch">
        <h2>🌱 今日早读任务</h2>
        <button class="btn line" id="btnDur" title="调整倒计时时长">⏱ <span id="durLabel">10:00</span></button>
        <span class="mini" id="taskCount">0 项</span>
        <button class="btn line" id="btnTaskFs" title="任务全屏展示">⛶ 投屏</button>
        <button class="btn" id="btnEditTask">编辑</button>
      </div>
      <div class="task-box">
        <div class="task-list" id="taskDisplay"><p class="empty">还没有任务,点「编辑」添加,检查前用 ⛶ 投屏</p></div>
      </div>
    </section>

    <section class="card cd">
      <div class="ch"><h2>⏱️ 倒计时</h2><button class="btn line" id="btnCdFs">⛶ 全屏</button></div>
      <div class="ring" id="ring"></div>
      <div class="clock" id="clock">05:00</div>
      <div class="cdrow">
        <button class="circle" data-a="-10">-10s</button>
        <button class="circle" data-a="-60">-1分</button>
        <button class="big" id="cdToggle">开始</button>
        <button class="circle" data-a="60">+1分</button>
        <button class="circle" data-a="10">+10s</button>
      </div>
      <div class="cdrow" style="margin-top:10px">
        <button class="btn gray" id="cdReset">重置</button>
        <button class="btn gray" id="cdCustom">自定义</button>
      </div>
    </section>
  </div>

  <!-- 分组点名 -->
  <section class="card">
    <div class="ch"><h2>👥 分组点名</h2>
      <button class="btn" id="btnPick" title="随机点名">🎲 随机点名</button>
      <button class="btn line" id="btnPickMode" title="切换范围">仅未打卡</button>
      <button class="btn" id="btnPeople">分组管理</button>
    </div>
    <div class="roster-tools">
      <span class="mini" id="scopeLabel">范围:全班</span>
      <div class="filters" id="filters"></div>
    </div>
    <div class="pbar"><i id="rosterBar" style="width:0"></i></div>
    <div class="grp-grid" id="roster"><p class="empty" style="grid-column:1/-1">还没有分组,点右上「分组管理」先建 6 个组</p></div>
    <p class="roster-hint">点学生循环:<b style="color:#9ca3af">· 未打卡</b> → <b style="color:var(--ok)">✔ 已背会</b> → <b style="color:var(--danger)">✘ 未背会</b>|点组头可折叠|👑 = 组长,随机抽人默认按当前范围</p>
  </section>

  <section class="card report">
    <div class="ch"><h2>📊 班级报表</h2><button class="btn" id="btnReport">看报表 · 周/月</button></div>
    <div class="charts" id="chartsMini"></div>
    <div class="mut">
      <button class="btn gray" id="btnHist">🕘 历史记录</button>
      <button class="btn gray" id="btnExport">⬇ 导出历史</button>
    </div>
  </section>
</div>

<!-- 任务全屏页 -->
<div class="fs-mask" id="taskFs">
  <div class="fs-flash" id="taskFlash"></div>
  <div class="fs-top">
    <span class="fs-tag">今 日 早 读 任 务</span>
    <span class="fs-tag" id="taskFsDate"></span>
    <button class="fs-close" id="taskFsClose">✕</button>
  </div>
  <div class="fs-body">
    <span class="fs-sub">MORNING READING · 全班齐读目标</span>
    <div class="fs-tasks" id="taskFsList"><span class="empty" style="color:var(--fs-tint)">暂无任务</span></div>
  </div>
  <div class="fs-foot">
    <div class="fs-prog"><i id="taskFsBar" style="width:0"></i></div>
    <div class="fs-meta"><span id="taskFsStat">应到 0 · ✔ 0 · ✘ 0</span><span class="fs-clock" id="taskFsClock">00:00</span></div>
    <div class="fs-btns"><button class="fs-btn" id="taskFsToggle">▶ 开始计时</button><button class="fs-btn" id="taskFsReset">↺ 重置</button></div>
  </div>
</div>

<!-- 随机点名页 -->
<div class="pick-mask" id="pickMask">
  <span class="pick-stat" id="pickStat">待抽 · 全班</span>
  <div class="pick-flash" id="pickFlash"></div>
  <div class="pick-box">
    <span class="pick-lbl">随 机 抽 取</span>
    <div class="pick-name blank" id="pickName">准备好了吗?</div>
    <div class="pick-who" id="pickWho"></div>
    <div class="pick-btns">
      <button class="pick-btn" id="pickStart">🎲 开始抽取</button>
      <button class="pick-btn gh" id="pickMark" disabled>先抽再标记</button>
      <button class="pick-btn gh" id="pickClose">✕ 退出</button>
    </div>
  </div>
</div>

<!-- 通用全屏层 -->
<div class="scrim" id="scrim">
  <div class="shead"><b id="scrimTitle">全屏</b><button class="fs-close" id="scrimClose" style="position:static;width:40px;height:40px;font-size:20px">✕</button></div>
  <div class="sbody" id="scrimBody"></div>
  <div class="sfoot"><div class="fs-clock" id="scrimClock">00:00</div><div class="fs-meta" style="justify-content:center;font-size:12px;margin-top:4px" id="scrimMeta"></div></div>
</div>

<div class="overlay" id="overlay"></div>

<div class="modal" id="slideModal"><div class="mc">
  <h3>🔒 管理确认</h3><p class="tip">按住滑块拖到最右,确认进入</p>
  <div class="slide" id="slide"><div class="track"><div class="txt">→ 按住滑到最右 →</div><div class="fill" id="slideFill"></div></div><div class="thumb" id="slideThumb">⇢</div></div>
  <div class="mrow"><button class="gy" id="slideCancel">取消</button></div>
</div></div>

<div class="modal" id="taskModal"><div class="mc">
  <h3>📋 任务管理</h3>
  <div class="igroup"><input type="text" id="taskInput" placeholder="例如:背诵《岳阳楼记》第2段"><button class="btn" id="taskAdd">添加</button></div>
  <ul class="ledit" id="taskList"></ul>
  <div class="mrow"><button class="gy" id="taskClose">关闭</button></div>
</div></div>

<!-- 分组管理 -->
<div class="modal" id="peopleModal"><div class="mc">
  <h3>🧩 分组管理</h3>
  <p class="tip">先建组 → 选组加人;学生可拖到别组,每组一人当组长 👑</p>
  <div class="grp-tabs" id="grpTabs"></div>
  <div id="grpPanes"></div>
  <div class="mrow" style="margin-top:12px"><button class="gy" id="peopleClose">关闭</button></div>
  <div class="set-hint" style="margin-top:12px">💡 首次进入会自动生成「第一组~第六组」,原来的名单(如有)会并进第一组,状态和组长都保留。</div>
</div></div>

<div class="modal" id="setModal"><div class="mc">
  <h3>⚙️ 设置</h3>
  <div class="set-block">
    <div class="lbl">🎨 主题配色 <span id="curThemeName" style="margin-left:auto;color:var(--primary-dark);font-weight:800"></span></div>
    <div class="themes" id="themeGrid"></div>
    <div class="set-hint">📌 选中即生效,会记住你的选择。<b>暗夜墨</b>适合夜间和投影,<b>深海蓝</b>投屏最稳。</div>
  </div>
  <div class="set-block">
    <div class="lbl">🔔 提示音</div>
    <div class="set-hint">倒计时归零 / 点名停下 / 投屏结束:响一声合成“叮~”+ 屏幕扩散闪光,静默可靠,无需任何音频文件。</div>
  </div>
  <div class="set-block">
    <div class="lbl">📏 其他</div>
    <div class="set-hint">达标判定:当日全班 100% 背会才算连续打卡 1 天。随机点名支持按组抽、只看未打卡。</div>
    <button class="btn" style="width:100%;background:var(--danger);margin-top:12px" id="clearAll">🗑️ 清空全部数据</button>
  </div>
  <div class="mrow" style="margin-top:14px"><button class="gy" id="setClose">关闭</button></div>
</div></div>

<div class="modal" id="timeModal"><div class="mc">
  <h3>⏱️ 设置倒计时</h3>
  <div class="tinputs"><div><input type="number" id="cMin" placeholder="分" min="0" max="120"><span>分</span></div><div><input type="number" id="cSec" placeholder="秒" min="0" max="59"><span>秒</span></div></div>
  <div class="mrow"><button class="gy" id="timeCancel">取消</button><button class="pr" id="timeOk">确认</button></div>
</div></div>

<script>
/* ============================================================
   早读打卡 · 分组版
   数据模型(v3):
   groups: [{id,name,color,leader,nameIdx[],members:[{n,s,t}]}]
      s: 0未打卡 1已背会 2未背会 ; t: 打卡时刻 HH:MM:SS 或 null
      nameIdx: 组内姓名唯一索引,避免同名冲突
   history: { [date]: {tasks:[], groups:[...同结构,仅保留 n/s/t/leader/name], totals:{p,f,t} } }
   旧 v1/v2 名单 {stu:[{n,s,t}]} 会在 load() 里自动迁进「第一组」
   ============================================================ */
'use strict';
const DEF_COLORS=['#22c55e','#06b6d4','#3b82f6','#f59e0b','#ec4899','#8b5cf6'];
const DEF_NAMES=['第一组','第二组','第三组','第四组','第五组','第六组'];
const COL='mr3_';

const app={
d:{tasks:[],groups:[],history:{},timer:300,left:300,run:false,tick:null,view:'today',date:'',lastDate:'',
   theme:'green',streak:0,fsTimer:null,fsLeft:0,fsRun:false,lock:false,
   pickPool:[],pickIdx:-1,pickRun:false,pickT:null,pickMode:'todo',pickGid:null,
   filterGid:null,collapsed:{}},
key:COL,
init(){
  this.el=id=>document.getElementById(id);
  this.load();this.d.date=this.today();this.applyTheme(this.d.theme,true);this.put('theme',this.d.theme);
  this.checkDay();this.dayLabel();this.renderTasks();this.renderRoster();this.renderMini();this.clockView();this.syncDurLabel();
  this.bind();
  const wake=()=>{try{const c=new (window.AudioContext||window.webkitAudioContext)();if(c.state==='suspended')c.resume()}catch(e){}};
  document.addEventListener('touchstart',wake,{once:true,passive:true});
  document.addEventListener('click',wake,{once:true,passive:true});
  this._tock=()=>this.tickAll();setInterval(this._tock,1000);
},
/* ---------- 存储 ---------- */
load(){
  const g=k=>{try{return JSON.parse(localStorage.getItem(this.key+k))}catch(e){return null}};
  this.d.tasks=g('tasks')||[];
  this.d.history=g('history')||{};
  this.d.theme=g('theme')||'green';
  this.d.lastDate=g('lastDate')||'';
  const t1=parseInt(localStorage.getItem(this.key+'timer'))||0;
  const t2=parseInt(localStorage.getItem(this.key+'timer_v2'))||0;
  this.d.timer=Math.max(t1,t2)||600;this.d.left=this.d.timer;
  this.d.streak=this.calcStreak();
  this.d.collapsed=g('collapsed')||{};
  const raw=g('groups');
  if(raw&&Array.isArray(raw)&&raw.length){
    this.d.groups=raw.map(this._normGroup.bind(this));
  }else{
    /* 旧版迁移:把 stu 列表并进第一组 */
    let stu=g('stu')||[];
    if(!stu.length){const o=g('stu_v1');if(Array.isArray(o))stu=o;}
    this.d.groups=this._seedGroups();
    if(stu.length){
      const g0=this.d.groups[0];
      stu.forEach(s=>{const nm=(s&&s.n)||'同学';g0.members.push({n:nm,s:(s&&s.s)||0,t:(s&&s.t)||null});g0.nameIdx.push(this._uniqName(g0,nm))});
      this.put('groups',this.d.groups);
    }
  }
  this._purgeLegacy();
},
_purgeLegacy(){/* 迁移完成后不再留旧键,避免两套数据打架 */
  ['stu','stu_v1'].forEach(k=>localStorage.removeItem(this.key+k));
},
_normGroup(g){
  const color=DEF_COLORS.includes(g&&g.color)?(g.color):DEF_COLORS[(this.d.groups||[]).length%6];
  const name=(g&&g.name)||this._nextGroupName();
  const members=Array.isArray(g&&g.members)?g.members.map(m=>({n:(m&&m.n)||'同学',s:(m&&m.s)||0,t:(m&&m.t)||null})):[];
  const used=(g&&Array.isArray(g.nameIdx))?g.nameIdx.slice():[];
  members.forEach(m=>{if(!used.includes(m.n))used.push(this._uniqName({nameIdx:used},m.n))});
  return {id:(g&&g.id)||(Date.now()+Math.random()+''),name,color,leader:(g&&g.leader)||'',nameIdx:used,members};
},
_seedGroups(){return DEF_NAMES.map((n,i)=>({id:'g'+(Date.now()+i),name:n,color:DEF_COLORS[i%6],leader:'',nameIdx:[],members:[]}))},
_nextGroupName(){
  const taken=this.d.groups.map(x=>x.name);
  for(let i=1;i<100;i++){const n='第'+this._cn(i)+'组';if(!taken.includes(n))return n;}
  return '新分组';
},
_cn(n){return '一二三四五六七八九十'.split('')[n-1]||n;},
put(k,v){try{localStorage.setItem(this.key+k,JSON.stringify(v))}catch(e){}},
today(){const d=new Date();return d.toISOString().slice(0,10)},
dayLabel(){const w='日一二三四五六';const d=new Date();this.el('dayLabel').textContent=`${d.getMonth()+1}月${d.getDate()}日 周${w[d.getDay()]}`},
checkDay(){
  const t=this.today();
  if(this.d.lastDate&&this.d.lastDate!==t&&this.d.groups.length){
    this.archive(this.d.lastDate);
    this.d.groups.forEach(g=>g.members.forEach(m=>{m.s=0;m.t=null}));
    this.put('groups',this.d.groups);
  }
  this.d.lastDate=t;localStorage.setItem(this.key+'lastDate',t);this.d.streak=this.calcStreak();
},
archive(date){
  const snap=this.d.groups.map(g=>({id:g.id,name:g.name,color:g.color,leader:g.leader,
    members:g.members.map(m=>({n:m.n,s:m.s,t:m.t}))}));
  const st=this.grade();
  this.d.history[date]={tasks:this.d.tasks.map(x=>x.c),groups:snap,totals:{p:st.p,f:st.f,t:st.t}};
  this.put('history',this.d.history);
},
calcStreak(){
  let n=0;const keys=Object.keys(this.d.history).sort().reverse();
  for(const k of keys){const h=this.d.history[k];if(!h||(!h.totals&&!h.rec))continue;
    const t=(h.totals&&h.totals.t)||(h.rec&&h.rec.length)||0;
    const p=(h.totals&&h.totals.p)||(h.rec&&h.rec.filter(r=>r.s===1).length)||0;
    if(t&&p===t)n++;else break;}
  const now=this.grade();if(now.t&&now.p===now.t)n=Math.max(n,1);
  return n;
},
/* ---------- 扁平成员视图(点名/统计用) ---------- */
flat(){return this.d.groups.flatMap(g=>g.members.map(m=>({g,gid:g.id,n:m.n,s:m.s,t:m.t,l:g.leader===m.n&&g.members.filter(x=>x.n===m.n)[0]===m})));},
grade(){
  const all=this.flat();const t=all.length,p=all.filter(x=>x.s===1).length,f=all.filter(x=>x.s===2).length;
  const done=p+f;return {t,p,f,rate:done?Math.round(p/done*100):0};
},
groupStat(g){const t=g.members.length,p=g.members.filter(m=>m.s===1).length,f=g.members.filter(m=>m.s===2).length;const d=p+f;return {t,p,f,rate:d?Math.round(p/d*100):0}},
colorOf(gid){const g=this.d.groups.find(x=>x.id===gid);return (g&&g.color)||DEF_COLORS[0];},

/* ---------- 渲染:任务 ---------- */
renderTasks(){
  const b=this.el('taskDisplay');this.el('taskCount').textContent=this.d.tasks.length+' 项';
  if(!this.d.tasks.length){b.innerHTML='<p class="empty">还没有任务,点「编辑」添加,检查前用 ⛶ 投屏</p>';return;}
  b.innerHTML=this.d.tasks.map((t,i)=>`<div class="task-item"><span class="n">${i+1}</span><span>${this.h(t.c)}</span></div>`).join('');
},

/* ---------- 渲染:分组点名墙 ---------- */
renderRoster(){
  const box=this.el('roster');
  if(!this.d.groups.length){box.innerHTML='<p class="empty" style="grid-column:1/-1">还没有分组,点「分组管理」先建 6 个组</p>';this.renderFilters();return;}
  const showGid=this.d.filterGid;
  const groups=showGid?this.d.groups.filter(g=>g.id===showGid):this.d.groups;
  this.renderFilters();
  const mk=['·','✔','✘'];
  box.innerHTML=groups.map(g=>{
    const st=this.groupStat(g);const col=g.color||DEF_COLORS[0];
    const members=g.members.map((m,mi)=>{
      const s=m.s,cls=s===0?'s0':s===1?'s1':'s2',isL=g.leader===m.n&&g.members.indexOf(m)===mi;
      return `<div class="stu ${cls}${isL?' leader':''}" data-g="${g.id}" data-m="${mi}" style="--g:${col}">
        <div class="mk">${mk[s]}</div><div class="nm">${this.h(m.n)}</div><div class="lv">${isL?'组长':'·'}</div></div>`;
    }).join('')||`<p class="empty" style="grid-column:1/-1;padding:8px">这组还没人,去「分组管理」加几个同学</p>`;
    const collapsed=this.d.collapsed[g.id]?'collapsed':'';
    return `<div class="grp ${collapsed}" data-g="${g.id}">
      <div class="grp-h" data-act="toggle" style="--g:${col}">
        <div class="grp-badge" style="background:${col}">${this.h(g.name.replace(/^第(.+)组$/,'$1').slice(0,2))}</div>
        <div class="grp-ttl"><div class="gn" style="color:${col}">${this.h(g.name)}</div>
          <div class="gm">${g.leader?('👑 '+this.h(g.leader)+' · '):''}${st.p}✔ ${st.f}✘ · ${st.t?Math.round(st.p/st.t*100):0}% 完成</div></div>
      </div>
      <div class="grp-bar"><i style="width:${st.rate}%;background:${col}"></i></div>
      <div class="grp-students">${members}</div>
    </div>`;
  }).join('');
  box.querySelectorAll('.grp-h[data-act="toggle"]').forEach(h=>h.onclick=()=>this.toggleGroup(h.dataset.g));
  box.querySelectorAll('.stu').forEach(el=>el.onclick=()=>this.cycle(el.dataset.g,+el.dataset.m));
  this.syncPickMode();
},
toggleGroup(gid){this.d.collapsed[gid]=!this.d.collapsed[gid];this.put('collapsed',this.d.collapsed);this.renderRoster()},
cycle(gid,mi){
  const g=this.d.groups.find(x=>x.id===gid);if(!g)return;const m=g.members[mi];const now=new Date();
  m.s=(m.s+1)%3;m.t=m.s===0?null:now.toTimeString().slice(0,8);
  this.put('groups',this.d.groups);this.renderRoster();this.renderMini();
},
/* 筛选 chip:全部 + 每组一个,按组抽人 */
renderFilters(){
  const box=this.el('filters');if(!box)return;
  const chips=[`<button class="fchip all${!this.d.filterGid?' on':''}" data-g="all"><span class="dot" style="background:var(--text)"></span>全班</button>`];
  this.d.groups.forEach(g=>chips.push(`<button class="fchip${this.d.filterGid===g.id?' on':''}" data-g="${g.id}"><span class="dot" style="background:${g.color}"></span>${this.h(g.name)}</button>`));
  box.innerHTML=chips.join('');
  box.querySelectorAll('.fchip').forEach(c=>c.onclick=()=>{this.d.filterGid=(c.dataset.g==='all')?null:c.dataset.g;this.renderRoster();this.vibrate(30)});
},
/* ---------- KPI + 报表 ---------- */
renderMini(){
  const o=this.grade();
  this.el('kTotal').textContent=o.t;this.el('kPass').textContent=o.p;this.el('kFail').textContent=o.f;this.el('kStreak').textContent=this.d.streak;
  this.renderMiniCharts();
  const bar=this.el('rosterBar');if(bar)bar.style.width=o.rate+'%';
},
renderMiniCharts(){
  const box=this.el('chartsMini');const week=this.sliceDays(7),month=this.sliceDays(this.daysInMonth());
  box.innerHTML=this.barHtml('本周',week)+this.barHtml('本月',month);
},
barHtml(title,arr){
  const max=Math.max(1,...arr.map(a=>a.total));
  const tot=arr.reduce((s,a)=>s+a.total,0)||1;const gp=arr.reduce((s,a)=>s+a.p,0),fp=arr.reduce((s,a)=>s+a.f,0);
  const rate=tot?Math.round(gp/tot*100):0;
  const cols=arr.map(a=>{
    if(!a.total)return `<div class="bar-col"><div class="bar-stack" style="height:6px"><i class="x" style="height:100%"></i></div><div class="lb">${a.lb}</div></div>`;
    const gh=Math.max(4,a.p/a.total*100),rh=Math.max(4,a.f/a.total*100),xh=Math.max(0,100-gh-rh);
    const c=DEF_COLORS[(a.gi||0)%6];
    return `<div class="bar-col" title="${a.lb} ✔${a.p} ✘${a.f}"><div class="vl">${a.p?'✔'+a.p:''}</div><div class="bar-stack" style="height:${Math.max(8,Math.round(a.total/max*100))}px">
      <i class="g" style="height:${gh}%;background:linear-gradient(180deg,${c},var(--ok))"></i><i class="r" style="height:${rh}%"></i>${xh?`<i class="x" style="height:${xh}%"></i>`:''}</div><div class="lb">${a.lb}</div></div>`;
  }).join('');
  return `<div class="chart-block"><h4><span>📅 ${title}</span><span style="color:var(--success)">✔ ${gp} · ✘ ${fp} · 达成 ${rate}%</span></h4><div class="bars">${cols}</div>
    <div class="legend"><span><em class="g1"></em>组1</span><span><em class="g2"></em>组2</span><span><em class="g3"></em>组3</span>
    <span><em class="g4"></em>组4</span><span><em class="g5"></em>组5</span><span><em class="g6"></em>组6</span><span><em style="background:var(--danger)"></em>未背会</div></div>`;
},
sliceDays(n){
  const out=[];const now=new Date(this.d.date+'T00:00:00');
  for(let i=n-1;i>=0;i--){
    const d=new Date(now.getTime()-864e5*i);const k=d.toISOString().slice(0,10);
    const h=this.d.history[k];let p=0,f=0,total=0,lb=(d.getMonth()+1)+'/'+d.getDate(),gi=0;
    if(h){total=(h.totals&&h.totals.t)||0;p=(h.totals&&h.totals.p)||0;f=(h.totals&&h.totals.f)||0;
      if(h.groups&&h.groups.length)gi=i%6;}
    else if(i===0&&this.d.date===this.today()){const s=this.grade();total=s.t;p=s.p;f=s.f;lb='今';}
    out.push({k,lb,p,f,total,gi});
  }
  return out;
},
daysInMonth(){const d=new Date(this.d.date+'T00:00:00');return new Date(d.getFullYear(),d.getMonth()+1,0).getDate()},

/* ============ 倒计时 ============ */
fmtClock(s){const m=Math.floor(s/60),r=s%60;return `${String(m).padStart(2,'0')}:${String(r).padStart(2,'0')}`},
tickAll(){
  if(this.d.run){this.d.left--;if(this.d.left<=0){this.d.left=0;this.afterFire()}}
  if(this.d.fsRun){this.d.fsLeft--;if(this.d.fsLeft<=0){this.d.fsLeft=0;this.afterFsFire()}}
  this.clockView();this.taskFsClock();this.scrimClockSync();this.pickTick();
},
clockView(){
  const el=this.el('clock');el.textContent=this.fmtClock(this.d.left);
  el.className='clock'+(this.d.left<=10&&this.d.run?' dang':(this.d.left<=60&&this.d.run?' warn':''));
  this.el('ring').style.setProperty('--p',(this.d.timer>0?this.d.left/this.d.timer*360:0)+'deg');
},
toggleTimer(){
  if(this.d.run){this.d.run=false;this.el('cdToggle').textContent='继续';this.el('cdToggle').classList.remove('on');this.scrimPlaySync();return;}
  if(this.d.left<=0)this.d.left=this.d.timer;
  this.d.run=true;this.el('cdToggle').textContent='暂停';this.el('cdToggle').classList.add('on');this.scrimPlaySync();
},
adjust(n){this.d.left=Math.max(0,this.d.left+n);this.clockView();this.scrimClockSync()},
resetTimer(){this.d.run=false;this.d.left=this.d.timer;this.el('cdToggle').textContent='开始';this.el('cdToggle').classList.remove('on');this.clockView();this.scrimClockSync()},

/* ---------- 响铃:纯合成音 + 闪光,无音频文件 ---------- */
afterFire(){
  this.d.run=false;this.el('cdToggle').textContent='开始';this.el('cdToggle').classList.remove('on');
  this._synthAlarm();this.ringFx(this.el('ring'));this.vibrate([300,200,300,200,500]);
  if(this.el('taskFs').classList.contains('show'))this.ringFx(this.el('taskFlash'));
  if(this.el('scrim').classList.contains('show'))this.ringFx(this.el('scrim').querySelector('.sbody'));
},
_synthAlarm(){try{
  const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return;const ctx=new AC();
  const beep=(f,st,d)=>{const o=ctx.createOscillator(),g=ctx.createGain();o.connect(g);g.connect(ctx.destination);o.frequency.value=f;o.type='sine';
    g.gain.setValueAtTime(.5,ctx.currentTime+st);g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+st+d);o.start(ctx.currentTime+st);o.stop(ctx.currentTime+st+d)};
  beep(880,0,.35);beep(1046.5,.45,.35);beep(880,.9,.8);beep(659.25,1.9,.9);
}catch(e){}},
vibrate(pat){if(navigator.vibrate)try{navigator.vibrate(pat)}catch(e){}},
ringFx(node){if(!node)return;const b=document.createElement('div');b.className='bomb';node.appendChild(b);requestAnimationFrame(()=>b.classList.add('boom'));setTimeout(()=>b.remove(),2600)},

/* ============ 任务全屏页 ============ */
openTaskFs(){
  const m=this.el('taskFs');m.classList.add('show');this.el('taskFsDate').textContent=this.el('dayLabel').textContent;
  this.renderTaskFs();this.fsReset();this.taskFsClock();this.tryRealFs();
},
renderTaskFs(){
  const list=this.el('taskFsList');
  if(!this.d.tasks.length){list.innerHTML='<span class="empty" style="color:var(--fs-tint)">今天还没布置任务~</span>';}
  else{list.innerHTML=this.d.tasks.map((t,i)=>`<div class="fs-task"><span class="t">${i+1}. ${this.h(t.c)}</span></div>`).join('');}
  this.taskFsStat();
},
taskFsStat(){const o=this.grade();this.el('taskFsStat').textContent=`应到 ${o.t} · ✔ ${o.p} · ✘ ${o.f} · 完成 ${o.rate}%`;this.el('taskFsBar').style.width=o.rate+'%'},
taskFsClock(){const el=this.el('taskFsClock');if(!el)return;el.textContent=this.fmtClock(this.d.fsLeft);el.className='fs-clock'+(this.d.fsLeft<=10&&this.d.fsRun?' dang':(this.d.fsLeft<=60&&this.d.fsRun?' warn':''))},
fsToggle(){
  const b=this.el('taskFsToggle');
  if(this.d.fsRun){this.d.fsRun=false;b.textContent='▶ 继续';b.classList.remove('on');return;}
  if(this.d.fsLeft<=0)this.d.fsLeft=this.d.timer;
  this.d.fsRun=true;b.textContent='⏸ 暂停';b.classList.add('on');
},
fsReset(){this.d.fsRun=false;this.d.fsLeft=this.d.timer;this.el('taskFsToggle').textContent='▶ 开始计时';this.el('taskFsToggle').classList.remove('on');this.taskFsClock()},
afterFsFire(){this.d.fsRun=false;this.d.fsLeft=0;this.el('taskFsToggle').textContent='▶ 开始';this.el('taskFsToggle').classList.remove('on');this.taskFsClock();this._synthAlarm();this.vibrate([300,200,300,200,500]);this.ringFx(this.el('taskFlash'))},
closeTaskFs(){this.fsReset();this.el('taskFs').classList.remove('show');this.exitRealFs()},

/* ============ 通用全屏层 ============ */
openScrim(title,body,meta){this.el('scrimTitle').textContent=title;this.el('scrimBody').innerHTML=body;this.el('scrimMeta').innerHTML=meta||'';this.el('scrim').classList.add('show');this.scrimClockSync();this.scrimPlaySync();this.tryRealFs()},
closeScrim(){this.el('scrim').classList.remove('show');this.exitRealFs()},
tryRealFs(){const d=document.documentElement;if(d.requestFullscreen)try{d.requestFullscreen()}catch(e){}else if(d.webkitRequestFullscreen)try{d.webkitRequestFullscreen()}catch(e){}},
exitRealFs(){const d=document||{};if(d.fullscreenElement||document.webkitFullscreenElement){try{(document.exitFullscreen||document.webkitExitFullscreen).call(document)}catch(e){}}},
buildCdFs(){const o=this.grade();
  return `<div style="text-align:center;padding-top:6vh"><div class="fs-clock" id="scrimBig" style="font-size:clamp(70px,24vw,150px)">${this.fmtClock(this.d.left)}</div>
    <div class="fs-prog" style="margin:26px auto 0;max-width:320px"><i id="scrimBigBar" style="width:${o.rate}%"></i></div>
    <div class="fs-meta" style="justify-content:center;margin-top:14px;font-size:15px">应到 ${o.t} · ✔ ${o.p} · ✘ ${o.f} · 完成 ${o.rate}%</div>
    <div style="margin-top:34px;display:flex;gap:12px;justify-content:center"><button class="fs-btn" id="scrimPlay">${this.d.run?'⏸ 暂停':'▶ 开始'}</button><button class="fs-btn" id="scrimRst">↺ 重置</button></div>
    <p class="fs-meta" style="justify-content:center;margin-top:26px;font-size:12px;opacity:.8">结束时“叮~”+ 屏幕扩散闪光</p></div>`;},
syncDurLabel(){const l=this.el('durLabel');if(l)l.textContent=this.fmtClock(this.d.timer||600)},
scrimClockSync(){
  const s=this.el('scrim');if(!s||!s.classList.contains('show'))return;
  const big=this.el('scrimBig');if(big)big.textContent=this.fmtClock(this.d.left);
  const c=this.el('scrimClock');if(c)c.textContent=this.fmtClock(this.d.left);
  const bar=this.el('scrimBigBar');if(bar&&this.d.timer>0)bar.style.width=(this.d.left/this.d.timer*100)+'%';
},
scrimPlaySync(){const b=this.el('scrimPlay');if(b)b.textContent=this.d.run?'⏸ 暂停':'▶ 开始'},
buildReport(){
  const o=this.grade(),week=this.sliceDays(7),month=this.sliceDays(this.daysInMonth());
  const sum=arr=>({p:arr.reduce((s,a)=>s+a.p,0),f:arr.reduce((s,a)=>s+a.f,0),t:arr.reduce((s,a)=>s+a.total,0)});
  const wp=sum(week),mp=sum(month);
  const rows=this.d.groups.map(g=>{const s=this.groupStat(g);return `<div class="hist-row" style="cursor:default">
      <div class="dot" style="width:16px;height:16px;border-radius:6px;background:${g.color};flex:none"></div>
      <div class="dt">${this.h(g.name)}<small>${g.leader?('👑 '+this.h(g.leader)+' · '):''}${g.members.length}人</small></div>
      <span class="rate ${s.rate===100?'g':(g.members.length&&s.p+s.f? s.rate>=50?'y':'r':'')}">✔${s.p} ✘${s.f} · ${s.rate}%</span></div>`}).join('');
  return `<div class="charts"><div style="display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:4px">
    <div class="kpi g"><b>${o.p}</b><span>✔ 已背会</span></div><div class="kpi r"><b>${o.f}</b><span>✘ 未背会</span></div>
    <div class="kpi p"><b>${o.t-o.p-o.f}</b><span>· 未打卡</span></div><div class="kpi y"><b>${this.d.streak}</b><span>🔥 连续达标</span></div>
  </div>
  ${this.barHtml('本周 · 7天',week)}${this.barHtml('本月 · '+this.daysInMonth()+'天',month)}
  <div style="font-size:12px;color:var(--text-soft);background:var(--tint);border-radius:12px;padding:11px 13px;line-height:1.8">
    <b style="color:var(--text)">🧩 各组进度</b><br>${rows||'还没有分组'}
  </div></div>`;
},
buildHist(){
  const keys=Object.keys(this.d.history).sort().reverse();
  if(!keys.length)return '<p class="empty">还没有历史记录,跨天后会自动归档今天的分组点名。</p>';
  return `<div class="hist-list">${keys.slice(0,30).map(k=>{
    const h=this.d.history[k];const t=(h.totals&&h.totals.t)||0,p=(h.totals&&h.totals.p)||0,f=(h.totals&&h.totals.f)||0;
    const rate=t?Math.round(p/t*100):0;const dot=rate===100?'🟢':(p+f? rate>=50?'🟡':'🔴':'⚪');const cls=rate===100?'g':(p+f? rate>=50?'y':'r':'');
    const gnum=(h.groups&&h.groups.length)?` · ${h.groups.length}组`:'';
    return `<div class="hist-row" data-k="${k}"><span class="dot">${dot}</span><div class="dt">${this.fmt(k)}<small>${h.tasks&&h.tasks.length?'任务 '+h.tasks.length+' 项 · ':''}${t}人${gnum}</small></div><span class="rate ${cls}">✔${p} ✘${f} · ${rate}%</span></div>`;
  }).join('')}</div><p class="tip" style="margin-top:10px">点某一天查看各组明细</p>`;
},
fmt(k){const d=new Date(k+'T00:00:00');const w='日一二三四五六';return `${d.getMonth()+1}月${d.getDate()}日 周${w[d.getDay()]}`},

/* ============ 随机点名(按组 / 仅未打卡) ============ */
pickPoolFromMode(){
  const flat=this.flat();
  let pool=flat.filter(x=>x.s===0);
  if(!pool.length)pool=flat.slice();
  if(this.d.pickGid)pool=pool.filter(x=>x.gid===this.d.pickGid);
  return pool.map(x=>({n:x.n,gid:x.gid,i:x.g.members?x.g.members.indexOf(x):x.g.members.map(m=>m.n).indexOf(x.n)}));
},
syncPickMode(){
  const todo=this.flat().filter(x=>x.s===0).length;
  const lab=this.d.pickGid?((this.d.groups.find(g=>g.id===this.d.pickGid)||{}).name||'该组')
       :(this.d.pickMode==='todo'?'仅未打卡':'全班');
  const st=this.el('pickStat');const bt=this.el('btnPickMode');
  if(st)st.textContent=`待抽 · ${lab}(${this.d.pickMode==='todo'?(todo||this.flat().length):this.flat().length}人)`;
  if(bt)bt.textContent=lab;
},
openPick(){
  if(!this.flat().length){alert('还没有学生,先点「分组管理」建组加人');return;}
  /* 每次打开:范围跟随主界面的筛选组 */
  this.d.pickGid=this.d.filterGid;
  this.d.pickMode=(this.d.pickMode==='todo')?'todo':'todo';
  this.d.pickPool=this.pickPoolFromMode();this.d.pickIdx=-1;this.d.pickRun=false;clearInterval(this.d.pickT);
  this.el('pickName').className='pick-name blank';this.el('pickName').textContent='准备好了吗?';this.el('pickWho').textContent='';
  this.el('pickStart').textContent='🎲 开始抽取';this.el('pickStart').classList.remove('on');
  this.el('pickMark').disabled=true;this.el('pickMark').textContent='先抽再标记';
  this.syncPickMode();this.el('pickMask').classList.add('show');this.tryRealFs();
},
runPick(){
  if(!this.d.pickPool.length){this.el('pickWho').textContent='当前范围没人,已放开到全班';this.d.pickGid=null;this.d.pickPool=this.pickPoolFromMode();this.syncPickMode();}
  if(this.d.pickRun){ /* 停 → 锁定结果 */
    this.d.pickRun=false;clearInterval(this.d.pickT);const hit=this.d.pickPool[this.d.pickIdx];
    const g=this.d.groups.find(x=>x.id===hit.gid);
    this.el('pickName').textContent=hit.n;this.el('pickName').className='pick-name hit';
    this.el('pickWho').textContent=`${this.h(g?g.name:'')} | 第 ${this.d.pickIdx+1} / ${this.d.pickPool.length} | 点「先抽再标记」登记`;
    this.el('pickStart').textContent='🎲 再抽一次';this.el('pickStart').classList.remove('on');
    this.el('pickMark').disabled=false;this.el('pickMark').textContent='✔ 标记已背会';
    this._synthAlarm();this.vibrate(50);
  }else{ /* 启动滚动 */
    this.d.pickRun=true;this.el('pickStart').textContent='⏹ 停!';this.el('pickStart').classList.add('on');
    this.el('pickMark').disabled=true;this.el('pickName').className='pick-name';this.el('pickWho').textContent='疯狂滚动中…';
    const pool=this.d.pickPool;let last=-1;this.d.pickT=setInterval(()=>{let r;do{r=Math.floor(Math.random()*pool.length)}while(pool.length>1&&r===last);
      last=r;this.d.pickIdx=r;const it=pool[r];const g=this.d.groups.find(x=>x.id===it.gid);
      this.el('pickName').textContent=it.n;this.el('pickWho').textContent=this.h(g?g.name:'')+' · '+(g&&g.leader===it.n?'👑 组长':'组员');},55);
  }
},
pickTick(){},
markPicked(s){
  const pool=this.d.pickPool;if(this.d.pickIdx<0||!pool.length)return;
  const it=pool[this.d.pickIdx];const g=this.d.groups.find(x=>x.id===it.gid);if(!g)return;
  const m=g.members[it.i];if(!m)return;const now=new Date();m.s=s;m.t=now.toTimeString().slice(0,8);
  /* 若标记成已背会且此人还不是组长,不自动改组长;只登记 */
  this.put('groups',this.d.groups);this.renderRoster();this.renderMini();this._synthAlarm();
  this.d.pickPool=this.pickPoolFromMode();this.el('pickMark').disabled=true;
  this.el('pickMark').textContent=s===1?'✔ 已背会':'✘ 未背会';
  this.el('pickWho').textContent=`已登记「${this.h(m.n)}」→ ${s===1?'✔ 已背会':'✘ 未背会'}(可再抽下一位)`;
  this.el('pickName').textContent=m.n;
},
closePick(){clearInterval(this.d.pickT);this.d.pickRun=false;this.el('pickMask').classList.remove('show');this.exitRealFs()},

/* ---------- 绑定 ---------- */
bind(){
  const on=(id,e,f)=>this.el(id).addEventListener(e,f);
  const guard=(cb)=>{if(this.d.lock)return;this.d.lock=true;setTimeout(()=>this.d.lock=false,400);cb()};
  const long=(id,cb)=>{let t=null;const start=e=>{if(e.type==='touchstart')e.preventDefault();this.el(id).style.opacity='.6';t=setTimeout(()=>{this.el(id).style.opacity='';this.vibrate(50);this.openSlide(cb)},700)};
    const end=()=>{clearTimeout(t);this.el(id).style.opacity=''};const el=this.el(id);
    el.addEventListener('touchstart',start,{passive:false});el.addEventListener('touchend',end);el.addEventListener('touchcancel',end);
    el.addEventListener('mousedown',start);el.addEventListener('mouseup',end);el.addEventListener('mouseleave',end);};
  long('btnEditTask',()=>this.openTaskEdit());
  long('btnPeople',()=>this.openPeople());
  long('btnSettings',()=>this.openSet());
  on('btnDur','click',()=>guard(()=>this.openSlide(()=>this.openTime())));
  on('btnTaskFs','click',()=>guard(()=>this.openSlide(()=>this.openTaskFs())));
  on('btnCdFs','click',()=>guard(()=>this.openSlide(()=>this.openCdFs())));
  on('btnReport','click',()=>guard(()=>this.openSlide(()=>this.openReport())));
  on('btnHist','click',()=>guard(()=>this.openSlide(()=>this.openHist())));
  on('btnPick','click',()=>guard(()=>this.openSlide(()=>this.openPick())));
  on('btnExport','click',()=>this.exportHist());
  on('cdToggle','click',()=>this.toggleTimer());
  on('cdReset','click',()=>this.resetTimer());
  on('cdCustom','click',()=>this.openTime());
  document.querySelectorAll('#clock+.cdrow .circle').forEach(b=>b.onclick=()=>this.adjust(+b.dataset.a));
  on('taskFsClose','click',()=>this.closeTaskFs());
  on('taskFsToggle','click',()=>this.fsToggle());
  on('taskFsReset','click',()=>this.fsReset());
  on('scrimClose','click',()=>this.closeScrim());
  on('pickStart','click',()=>this.runPick());
  on('pickClose','click',()=>this.closePick());
  on('pickMark','click',()=>{const m=this.el('pickMark').textContent.includes('未')?2:1;this.markPicked(m)});
  on('btnPickMode','click',()=>{
    if(this.el('pickMask').classList.contains('show')){
      /* 点名页内:在「全班 / 仅未打卡」间切换,组保持当前 */
      this.d.pickMode=this.d.pickMode==='todo'?'all':'todo';
      this.d.pickGid=null;this.d.pickPool=this.pickPoolFromMode();this.syncPickMode();
    }else{this.d.filterGid=null;this.renderRoster();this.vibrate(30)}
  });
  document.addEventListener('keydown',e=>{
    if(e.key==='Escape'){this.closeTaskFs();this.closeScrim();this.closePick();}
    if(this.el('pickMask').classList.contains('show')&&(e.key===' '||e.key==='Enter')){e.preventDefault();this.runPick();}
  });
  document.addEventListener('fullscreenchange',()=>{});
  on('taskAdd','click',()=>this.addTask());
  on('taskClose','click',()=>this.closeModal('taskModal'));
  on('taskInput','keypress',e=>{if(e.key==='Enter')this.addTask()});
  on('peopleClose','click',()=>this.closeModal('peopleModal'));
  on('clearAll','click',()=>this.clearAll());
  on('setClose','click',()=>this.closeModal('setModal'));
  on('timeOk','click',()=>this.confirmTime());
  on('timeCancel','click',()=>this.closeModal('timeModal'));
  on('slideCancel','click',()=>this.closeModal('slideModal'));
  this.initSlide();
},
/* ---------- 滑块验证 ---------- */
openSlide(next){this._next=next;const th=this.el('slideThumb'),fill=this.el('slideFill');
  th.style.transition='all .3s';th.style.left='5px';fill.style.width='0';th.classList.remove('on');this.showModal('slideModal')},
initSlide(){const th=this.el('slideThumb'),tr=this.el('slide').querySelector('.track');let drag=false,sx=0,max=0;
  const start=e=>{e.preventDefault();drag=true;sx=(e.touches?e.touches[0].clientX:e.clientX);max=tr.offsetWidth-th.offsetWidth-10;th.style.transition='none'};
  const move=e=>{if(!drag)return;const x=(e.touches?e.touches[0].clientX:e.clientX)-sx;let v=Math.max(0,Math.min(max,x));th.style.left=(5+v)+'px';this.el('slideFill').style.width=v+'px';
    if(v>=max*.85){drag=false;th.classList.add('on');this.el('slideFill').style.width=max+'px';th.style.transition='all .15s';th.style.left=(5+max)+'px';this.vibrate([30,20,30]);setTimeout(()=>this.passSlide(),180)}};
  const end=()=>{if(!drag)return;drag=false;th.style.transition='all .25s';th.style.left='5px';this.el('slideFill').style.width='0';th.classList.remove('on')};
  th.addEventListener('mousedown',start);th.addEventListener('touchstart',start,{passive:false});
  document.addEventListener('mousemove',move);document.addEventListener('touchmove',move,{passive:false});
  document.addEventListener('mouseup',end);document.addEventListener('touchend',end)},
passSlide(){const n=this._next;this.closeModal('slideModal');requestAnimationFrame(()=>{setTimeout(()=>{if(n)n()},16)})},
/* ---------- 弹窗 ---------- */
showModal(id){this.el('overlay').style.display='block';const m=this.el(id);m.style.display='flex';requestAnimationFrame(()=>m.classList.add('show'))},
closeModal(id){const m=this.el(id);m.classList.remove('show');setTimeout(()=>{m.style.display='none';
  if(!document.querySelector('.modal.show'))this.el('overlay').style.display='none'},180)},
closeAll(){['slideModal','taskModal','peopleModal','setModal','timeModal'].forEach(i=>this.closeModal(i))},
openTaskEdit(){this.renderTaskEdit();this.showModal('taskModal');setTimeout(()=>this.el('taskInput').focus(),120)},
renderTaskEdit(){this.el('taskList').innerHTML=this.d.tasks.map((t,i)=>`<li><span>${this.h(t.c)}</span><span><button class="e" data-i="${i}">✏️</button><button class="d" data-i="${i}">🗑️</button></span></li>`).join('');
  this.el('taskList').querySelectorAll('button.e').forEach(b=>b.onclick=()=>this.editTask(+b.dataset.i));
  this.el('taskList').querySelectorAll('button.d').forEach(b=>b.onclick=()=>this.delTask(+b.dataset.i));},
addTask(){const v=this.el('taskInput').value.trim();if(!v)return;this.d.tasks.push({c:v});this.put('tasks',this.d.tasks);this.el('taskInput').value='';this.renderTaskEdit();this.renderTasks();if(this.renderTaskFs)this.renderTaskFs()},
delTask(i){this.d.tasks.splice(i,1);this.put('tasks',this.d.tasks);this.renderTaskEdit();this.renderTasks();if(this.renderTaskFs)this.renderTaskFs()},
editTask(i){const v=prompt('修改任务:',this.d.tasks[i].c);if(v===null)return;const x=v.trim();if(!x)return;this.d.tasks[i].c=x;this.put('tasks',this.d.tasks);this.renderTaskEdit();this.renderTasks();if(this.renderTaskFs)this.renderTaskFs()},

/* ---------- 分组管理 ---------- */
openPeople(){this.renderGroups();this.showModal('peopleModal')},
renderGroups(){
  const tabs=this.el('grpTabs'),panes=this.el('grpPanes');
  tabs.innerHTML=this.d.groups.map((g,i)=>`<button class="grp-tab${i===0?' on':''}" data-g="${g.id}" style="--g:${g.color}">
      <span class="gc" style="background:${g.color}"></span>${this.h(g.name)}<span style="opacity:.6;font-weight:600">${g.members.length}</span></button>`).join('')
    +`<button class="grp-tab add" data-act="add">+ 新建组</button>`;
  panes.innerHTML=this.d.groups.map((g,i)=>`<div class="grp-pane${i===0?' on':''}" data-g="${g.id}">
      <div class="grp-meta">
        <input type="text" class="grp-name" data-g="${g.id}" value="${this.h(g.name)}" placeholder="组名,如:第一组">
        <div class="pick-c" data-act="color" data-g="${g.id}" title="换组色"><i style="background:${g.color}"></i></div>
        <button class="btn ghost" data-act="delgrp" data-g="${g.id}" style="flex:none">🗑️ 删组</button>
      </div>
      <div class="color-grid" data-g="${g.id}" style="display:${this._colorGridShown(g.id)?'grid':'none'}">
        ${DEF_COLORS.map(c=>`<b style="background:${c}" data-c="${c}" ${g.color===c?'class="on"':''}></b>`).join('')}
      </div>
      <div class="igroup" style="margin-top:10px"><input type="text" class="mem-input" data-g="${g.id}" placeholder="加一个同学,回车即可(可连续加)"><button class="btn" data-act="addmem" data-g="${g.id}">加入</button></div>
      <div class="mem-grid" data-g="${g.id}">${this._memItems(g)}</div>
    </div>`).join('');
  tabs.querySelectorAll('.grp-tab[data-g]').forEach(t=>t.onclick=()=>this._switchTab(t.dataset.g));
  tabs.querySelector('[data-act="add"]').onclick=()=>this.addGroup();
  panes.querySelectorAll('.grp-name').forEach(inp=>inp.onchange=()=>this.renameGroup(inp.dataset.g,inp.value));
  panes.querySelectorAll('[data-act="color"]').forEach(b=>b.onclick=()=>this._toggleColorGrid(b.dataset.g));
  panes.querySelectorAll('[data-act="delgrp"]').forEach(b=>b.onclick=()=>this.deleteGroup(b.dataset.g));
  panes.querySelectorAll('[data-act="addmem"]').forEach(b=>b.onclick=()=>this.addMember(b.dataset.g));
  panes.querySelectorAll('.mem-input').forEach(inp=>inp.onkeypress=e=>{if(e.key==='Enter')this.addMember(inp.dataset.g)});
  panes.querySelectorAll('.color-grid b').forEach(b=>b.onclick=()=>this.colorGroup(b.dataset.g,b.dataset.c));
  panes.querySelectorAll('.mem').forEach(li=>this._bindMem(li));
},
_colorGridShown(gid){return this._cgOpen&&this._cgOpen===gid;},
_toggleColorGrid(gid){this._cgOpen=(this._cgOpen===gid)?null:gid;this.renderGroups();this._stayOn(gid)},
_switchTab(gid){this._stayOn(gid);this.renderGroups();this._stayOn(gid)},
_stayOn(gid){const tabs=this.el('grpTabs'),panes=this.el('grpPanes');
  if(!tabs||!panes)return;
  tabs.querySelectorAll('.grp-tab').forEach(t=>t.classList.toggle('on',t.dataset.g===gid));
  panes.querySelectorAll('.grp-pane').forEach(p=>p.classList.toggle('on',p.dataset.g===gid));},
_memItems(g){
  const isL=g.leader;
  return g.members.map((m,mi)=>{const uniq=this._uniqName(g,m.n);
    const others=this.d.groups.filter(x=>x.id!==g.id).map(x=>`<button data-mv="${x.id}" title="移到${this.h(x.name)}">→${this.h(x.name.replace(/^第(.+)组$/,'$1'))}</button>`).join('');
    return `<li class="mem" data-g="${g.id}" data-m="${mi}">
      <div class="mrow2"><span class="lname">${this.h(m.n)}</span></div>
      <div class="acts">
        <button class="${isL===m.n?'on':''}" data-act="lead" ${g.members.filter(x=>x.n===m.n).indexOf(m)!==mi?'disabled style="opacity:.35"':''}>${isL===m.n?'👑 组长':'设组长'}</button>
        <button class="d" data-act="delmem">删除</button>
      </div>
      <div class="mv">${others}</div>
    </li>`;}).join('')||'<p class="empty" style="padding:8px">这组还是空的,上面输入框加人吧</p>';
},
_bindMem(li){
  const gid=li.dataset.g,mi=+li.dataset.m;
  const lead=()=>this.setLeader(gid,mi);const del=()=>this.delMember(gid,mi);
  const mv=btn=>this.moveMember(gid,mi,btn.dataset.mv);
  li.querySelector('[data-act="lead"]').onclick=lead;
  li.querySelector('[data-act="delmem"]').onclick=del;
  li.querySelectorAll('[data-mv]').forEach(b=>b.onclick=()=>mv(b));
},
addGroup(){
  if(this.d.groups.length>=12){alert('最多 12 个组就够了吧~');return;}
  const name=this._nextGroupName();
  this.d.groups.push({id:'g'+Date.now(),name,color:DEF_COLORS[this.d.groups.length%6],leader:'',nameIdx:[],members:[]});
  this.put('groups',this.d.groups);this.renderGroups();this._stayOn(this.d.groups[this.d.groups.length-1].id);this.vibrate(40);
},
renameGroup(gid,v){const g=this.d.groups.find(x=>x.id===gid);if(!g)return;const x=(v||'').trim();
  if(x&&x!==g.name){if(this.d.groups.some(o=>o.id!==gid&&o.name===x)){alert('组名重复啦');}else{g.name=x;this.put('groups',this.d.groups);this.renderGroups();this._stayOn(gid)}}},
colorGroup(gid,c){const g=this.d.groups.find(x=>x.id===gid);if(!g)return;g.color=c;this._cgOpen=null;this.put('groups',this.d.groups);this.renderGroups();this._stayOn(gid);this.vibrate(30)},
deleteGroup(gid){const g=this.d.groups.find(x=>x.id===gid);if(!g)return;
  if(g.members.length){if(!confirm(`「${g.name}」里还有 ${g.members.length} 人,删组会把他们一起删掉,确定吗?`))return;}
  else if(!confirm(`确定删除「${g.name}」?`))return;
  this.d.groups=this.d.groups.filter(x=>x.id!==gid);this._cgOpen=null;
  if(!this.d.groups.length)this.d.groups=this._seedGroups();
  this.put('groups',this.d.groups);this.renderGroups();this.vibrate(40);},
addMember(gid){
  const g=this.d.groups.find(x=>x.id===gid);if(!g)return;
  const box=this.el('peopleModal').querySelector(`.mem-input[data-g="${gid}"]`);
  const raw=box.value.split(/[\n,,、;;]+/).map(s=>s.trim()).filter(Boolean);
  const added=[];
  raw.forEach(nm=>{if(g.members.some(m=>m.n===nm)){added.push(nm+'(已存在,跳过)');return;}g.members.push({n:nm,s:0,t:null});g.nameIdx.push(this._uniqName(g,nm));added.push(nm)});
  this.put('groups',this.d.groups);box.value='';this.renderGroups();this._stayOn(gid);this.renderRoster();this.renderMini();
  if(raw.length>1)alert('批量加入:\n'+added.join('\n'));
},
_uniqName(g,nm){let c=nm,i=1;while(g.nameIdx&&g.nameIdx.includes(c)){i++;c=nm+' ('+i+')'}return c},
setLeader(gid,mi){const g=this.d.groups.find(x=>x.id===gid);if(!g)return;const m=g.members[mi];
  g.leader=(g.leader===m.n)?'':m.n;this.put('groups',this.d.groups);this.renderGroups();this._stayOn(gid);this.renderRoster();this.vibrate(40)},
delMember(gid,mi){const g=this.d.groups.find(x=>x.id===gid);if(!g)return;const m=g.members[mi];
  if(!confirm(`把「${m.n}」从「${g.name}」移除?`))return;
  g.members.splice(mi,1);if(g.leader===m.n)g.leader='';
  g.nameIdx=g.nameIdx.filter(n=>n!==this._uniqName(g,m.n));
  this.put('groups',this.d.groups);this.renderGroups();this._stayOn(gid);this.renderRoster();this.renderMini();},
moveMember(gid,mi,tgid){const from=this.d.groups.find(x=>x.id===gid),to=this.d.groups.find(x=>x.id===tgid);
  if(!from||!to||from===to)return;const m=from.members[mi];
  if(to.members.some(x=>x.n===m.n)){alert(`「${m.n}」在「${to.name}」里已经有一个了,没搬过去`);return;}
  from.members.splice(mi,1);to.members.push({n:m.n,s:m.s,t:m.t});to.nameIdx.push(this._uniqName(to,m.n));
  if(from.leader===m.n)from.leader='';
  this.put('groups',this.d.groups);this.renderGroups();this._stayOn(tgid);this.renderRoster();this.renderMini();this.vibrate(30)},

/* ---------- 设置/时间 ---------- */
openSet(){this.renderThemes();this.showModal('setModal')},
renderThemes(){const g=this.el('themeGrid');if(!g)return;
  g.innerHTML=THEMES.map(([k,n,h])=>`<div class="theme${k===this.d.theme?' on':''}" data-t="${k}"><div class="sw"></div><div class="tn">${n}</div><div class="ts">${h}</div></div>`).join('');
  g.querySelectorAll('.theme').forEach(el=>{const k=el.dataset.t;const cs=this.themeVars(k);const sw=el.querySelector('.sw');
    sw.style.setProperty('--h1',cs['--primary']);sw.style.setProperty('--h2',cs['--primary-dark']);sw.style.setProperty('--t',cs['--tint']);
    el.style.borderColor=(k===this.d.theme)?cs['--primary']:cs['--border'];el.style.background=cs['--card'];el.style.setProperty('color',cs['--text']);});
  this.el('curThemeName').textContent=THEMES.find(t=>t[0]===this.d.theme)[1];
  g.querySelectorAll('.theme').forEach(el=>el.onclick=()=>{if(this.d.theme===el.dataset.t){this.closeModal('setModal');return;}
    this.d.theme=el.dataset.t;this.put('theme',this.d.theme);this.applyTheme(this.d.theme);this.renderThemes();this.vibrate(40);});},
themeVars(k){const T={
 green :{'--primary':'#22c55e','--primary-dark':'#15803d','--tint':'#f0fdf4','--border':'#d1fae5','--card':'#ffffff','--text':'#1f2937'},
 mint  :{'--primary':'#06b6d4','--primary-dark':'#0e7498','--tint':'#ecfeff','--border':'#bae6fd','--card':'#ffffff','--text':'#0f2f33'},
 ocean :{'--primary':'#3b82f6','--primary-dark':'#1d4ed8','--tint':'#eff6ff','--border':'#c7ddf7','--card':'#ffffff','--text':'#1e293b'},
 warm  :{'--primary':'#f59e0b','--primary-dark':'#b45309','--tint':'#fffbeb','--border':'#fde1b8','--card':'#ffffff','--text':'#3a2a17'},
 cherry:{'--primary':'#ec4899','--primary-dark':'#be185d','--tint':'#fff1f8','--border':'#f5c6dd','--card':'#ffffff','--text':'#3a1f2c'},
 dark  :{'--primary':'#a78bfa','--primary-dark':'#7c3aed','--tint':'#221e33','--border':'#2e2944','--card':'#1e1b2b','--text':'#e8e6f0'}};
 return T[k]||T.green},
applyTheme(k,first){
  const cs=this.themeVars(k);const r=(document&&document.documentElement)||{};
  ['--primary','--primary-dark','--tint','--border','--card','--text'].forEach(function(n){r.style.setProperty(n,cs[n]);});
  r.setAttribute('data-theme',k);
  const TUNES={bg1:['#0f3d24','#0c2f38','#102a4d','#3a2608','#3a1226','#0d0b14'],bg2:['#0a2c19','#0a2440','#0d3a6e','#2b1c05','#2b0d1d','#100d1c'],
    bg3:['#061c10','#08222a','#0a2a55','#1c1203','#1c0814','#0a0814'],txt:['#eafff0','#e6fdff','#eaf2ff','#fff8ec','#fff0f6','#eeeaff'],
    tint:['#86efac','#67e8f9','#93c5fd','#fcd34d','#f9a8d4','#c4b5fd'],tint2:['#a7f3d0','#a5f3fc','#bfdbfe','#fde68a','#fbcfe8','#ddd6fe'],
    task:['#4ade80','#22d3ee','#60a5fa','#fbbf24','#f472b6','#a78bfa'],sh:['#166534','#0e7490','#1d4ed8','#92400e','#9d174d','#5b21b6'],
    ok:['#16a34a','#0d9488','#1d9488','#d97706','#db2777','#22c55e'],okt:['#166534','#0f4f4a','#0f3d5a','#78350f','#831843','#0e3d24']};
  const ki=THEMES.findIndex(function(t){return t[0]===k;});
  Object.keys(TUNES).forEach(function(kk){r.style.setProperty('--fs-'+kk,TUNES[kk][ki]||TUNES[kk][0]);});
  r.style.setProperty('--ok',TUNES.ok[ki]||TUNES.ok[0]);r.style.setProperty('--ok-txt',TUNES.okt[ki]||TUNES.okt[0]);
  if(!first)this.vibrate(40)},
clearAll(){if(!confirm('清空全部分组、任务和历史?不可恢复!'))return;Object.keys(localStorage).filter(k=>k.startsWith(this.key)).forEach(k=>localStorage.removeItem(k));location.reload()},
openTime(){const t=this.d.timer||600;this.el('cMin').value=Math.floor(t/60);this.el('cSec').value=t%60;this.showModal('timeModal')},
confirmTime(){const m=parseInt(this.el('cMin').value)||0,s=parseInt(this.el('cSec').value)||0;if(!m&&!s){alert('时间不能为0');return;}
  const sec=m*60+s;this.setTimer(sec);this.closeModal('timeModal')},
setTimer(sec){sec=Math.max(10,Math.floor(sec||600));this.d.timer=sec;this.d.left=sec;
  localStorage.setItem(this.key+'timer',sec);try{localStorage.setItem(this.key+'timer_v2',sec)}catch(e){}
  this.clockView();this.syncDurLabel();this.scrimClockSync();},

/* 全屏子页 */
openCdFs(){this.openScrim('⏱️ 早读计时 · 全屏',this.buildCdFs(),'手机横过来更大 · 结束时“叮~”+ 闪光');
  setTimeout(()=>{const p=this.el('scrimPlay');if(p)p.onclick=()=>this.toggleTimer();const r=this.el('scrimRst');if(r)r.onclick=()=>this.resetTimer();this.scrimClockSync();this.scrimPlaySync();},30)},
openReport(){this.openScrim('📊 班级周/月报表',this.buildReport(),'绿色=已背会 · 红色=未背会 · 下方为各组进度')},
openHist(){this.openScrim('🕘 历史记录',this.buildHist(),'最近30天 · 点某行看各组明细');
  this.el('scrimBody').querySelectorAll('.hist-row').forEach(r=>r.onclick=()=>this.openHistDetail(r.dataset.k))},
openHistDetail(k){
  const h=this.d.history[k];if(!h)return;const tot=h.totals||{p:0,f:0,t:0};
  const rate=tot.t?Math.round(tot.p/tot.t*100):0;
  let body=`<div style="font-size:13px;margin-bottom:10px;color:var(--text-soft)">${this.fmt(k)} | 应到 ${tot.t} · ✔${tot.p} ✘${tot.f} · 达成 <b style="color:var(--success)">${rate}%</b></div>`;
  if(h.tasks&&h.tasks.length)body+=`<div style="margin-bottom:12px">${h.tasks.map(x=>`<div class="fs-task" style="font-size:18px!important;padding:8px 12px">${this.h(x)}</div>`).join('')}</div>`;
  if(h.groups&&h.groups.length){
    body+='<div style="display:grid;gap:10px">'+h.groups.map(g=>{
      const t=g.members.length,p=g.members.filter(m=>m.s===1).length,f=g.members.filter(m=>m.s===2).length,r=t?Math.round(p/t*100):0;
      const rows=g.members.slice().sort((a,b)=>b.s-a.s).map(m=>{const mk=['· 未打卡','✔ 已背会','✘ 未背会'][m.s];const c=['#9ca3af','var(--ok)','var(--danger)'][m.s];
        const tag=m.n===g.leader?' 👑':'';return `<tr><td style="font-weight:700;text-align:left">${this.h(m.n)}${tag}</td><td style="color:${c};font-weight:800">${mk}</td><td>${m.t||'--'}</td></tr>`}).join('')||'<tr><td colspan="3">无明细</td></tr>';
      return `<div class="card" style="margin-top:0;border-left:5px solid ${g.color}"><div class="ch"><h2 style="font-size:14px"><span class="grp-badge" style="display:inline-flex;width:20px;height:20px;border-radius:6px;background:${g.color};color:#fff;font-size:10px;align-items:center;justify-content:center;margin-right:6px">${this.h(g.name.replace(/^第(.+)组$/,'$1').slice(0,2))}</span>${this.h(g.name)}</h2><span class="mini">✔${p} ✘${f} · ${r}%</span></div>
        <table><thead><tr><th style="text-align:left">姓名</th><th>状态</th><th>时间</th></tr></thead><tbody>${rows}</tbody></table></div>`;
    }).join('')+'</div>';
  }else{body+='<p class="empty">这天还是旧版单人记录,没有分组明细。</p>'}
  this.openScrim('🕘 '+this.fmt(k),body,'点右上 ✕ 返回列表');
},
exportHist(){
  const data={groups:this.d.groups.map(g=>({name:g.name,color:g.color,leader:g.leader,members:g.members.map(m=>({n:m.n,s:m.s,t:m.t}))})),
    history:this.d.history,tasks:this.d.tasks,exportAt:new Date().toISOString()};
  const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'});
  const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='早读打卡分组记录_'+this.today()+'.json';a.click();
},
h(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML},
vibrate(pat){if(navigator.vibrate)try{navigator.vibrate(pat)}catch(e){}}
};
const THEMES=[["green","青草绿","默认 · 清爽"],["mint","薄荷蓝","护眼 · 清透"],["ocean","深海蓝","投屏 · 沉稳"],["warm","暖阳橙","早读 · 提神"],["cherry","樱花粉","温柔 · 好看"],["dark","暗夜墨","夜间 · 投影"]];
document.addEventListener('DOMContentLoaded',()=>app.init());
document.getElementById('overlay').addEventListener('click',()=>app.closeAll());
</script>
</body>
</html>

Screenshot_2026-10-25-12-58-00-466_top.webcat.editor.x.jpg
Screenshot_2026-10-25-12-58-00-466_top.webcat.editor.x.jpg

Screenshot_2026-10-25-12-58-08-897_top.webcat.editor.x.jpg
Screenshot_2026-10-25-12-58-08-897_top.webcat.editor.x.jpg

添加新评论

文章目录