cotutor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +35 -24
  2. package/agents/chinese-tutor.md +67 -0
  3. package/agents/{homework-aide.md → homework-tutor.md} +4 -4
  4. package/agents/math-tutor.md +68 -0
  5. package/agents/reading-tutor.md +65 -0
  6. package/agents/scene-maker.md +41 -0
  7. package/dist/cards/canvas.d.ts +22 -0
  8. package/dist/cards/canvas.js +65 -0
  9. package/dist/cards/choice.d.ts +20 -0
  10. package/dist/cards/choice.js +76 -0
  11. package/dist/cards/code.d.ts +9 -0
  12. package/dist/cards/code.js +13 -0
  13. package/dist/cards/fill.d.ts +14 -0
  14. package/dist/cards/fill.js +64 -0
  15. package/dist/cards/image.d.ts +13 -0
  16. package/dist/cards/image.js +35 -0
  17. package/dist/cards/index.d.ts +48 -0
  18. package/dist/cards/index.js +115 -0
  19. package/dist/cards/kind.d.ts +27 -0
  20. package/dist/cards/kind.js +7 -0
  21. package/dist/cards/read.d.ts +8 -0
  22. package/dist/cards/read.js +27 -0
  23. package/dist/cards/scene.d.ts +24 -0
  24. package/dist/cards/scene.js +67 -0
  25. package/dist/cards/text.d.ts +18 -0
  26. package/dist/cards/text.js +41 -0
  27. package/dist/cli/cert.d.ts +7 -0
  28. package/dist/cli/cert.js +36 -0
  29. package/dist/cli/doctor.d.ts +5 -2
  30. package/dist/cli/doctor.js +183 -18
  31. package/dist/cli/init.d.ts +1 -1
  32. package/dist/cli/init.js +41 -39
  33. package/dist/cli/main.js +128 -8
  34. package/dist/cli/serve.d.ts +15 -4
  35. package/dist/cli/serve.js +40 -6
  36. package/dist/cli/skeleton.d.ts +23 -5
  37. package/dist/cli/skeleton.js +131 -29
  38. package/dist/cli/skills.d.ts +40 -0
  39. package/dist/cli/skills.js +170 -0
  40. package/dist/cli/tutors.d.ts +84 -0
  41. package/dist/cli/tutors.js +274 -0
  42. package/dist/cli/workspace.d.ts +9 -1
  43. package/dist/cli/workspace.js +25 -4
  44. package/dist/lib/agent-file.d.ts +1 -1
  45. package/dist/lib/board.d.ts +15 -0
  46. package/dist/lib/board.js +107 -0
  47. package/dist/lib/context-pack.js +10 -1
  48. package/dist/lib/conversation.d.ts +43 -6
  49. package/dist/lib/conversation.js +53 -9
  50. package/dist/lib/index.d.ts +4 -0
  51. package/dist/lib/index.js +4 -0
  52. package/dist/lib/kid-board.d.ts +148 -0
  53. package/dist/lib/kid-board.js +294 -0
  54. package/dist/lib/kid-view.d.ts +44 -7
  55. package/dist/lib/kid-view.js +63 -8
  56. package/dist/lib/ledger.d.ts +8 -0
  57. package/dist/lib/ledger.js +8 -0
  58. package/dist/lib/plan.d.ts +2 -0
  59. package/dist/lib/plan.js +9 -0
  60. package/dist/lib/run-plan.d.ts +30 -0
  61. package/dist/lib/run-plan.js +32 -0
  62. package/dist/lib/stream.d.ts +15 -0
  63. package/dist/lib/stream.js +69 -0
  64. package/dist/lib/timetable.d.ts +22 -0
  65. package/dist/lib/timetable.js +106 -0
  66. package/dist/schema/board.d.ts +59 -0
  67. package/dist/schema/board.js +30 -0
  68. package/dist/schema/config.d.ts +76 -18
  69. package/dist/schema/config.js +67 -37
  70. package/dist/schema/context-pack.d.ts +7 -0
  71. package/dist/schema/context-pack.js +6 -0
  72. package/dist/schema/conversation.d.ts +114 -4
  73. package/dist/schema/conversation.js +29 -5
  74. package/dist/schema/index.d.ts +6 -2
  75. package/dist/schema/index.js +6 -2
  76. package/dist/schema/json-schema.d.ts +2 -0
  77. package/dist/schema/json-schema.js +23 -0
  78. package/dist/schema/ledger.d.ts +1 -1
  79. package/dist/schema/timetable.d.ts +26 -0
  80. package/dist/schema/timetable.js +18 -0
  81. package/dist/server/app.d.ts +67 -6
  82. package/dist/server/app.js +399 -38
  83. package/dist/server/kid-page.d.ts +1 -0
  84. package/dist/server/kid-page.js +768 -0
  85. package/dist/server/mock.d.ts +58 -0
  86. package/dist/server/mock.js +403 -0
  87. package/dist/server/parent-page.d.ts +8 -0
  88. package/dist/server/parent-page.js +393 -0
  89. package/dist/server/runner.d.ts +64 -0
  90. package/dist/server/runner.js +277 -0
  91. package/dist/server/scene-props.d.ts +16 -0
  92. package/dist/server/scene-props.js +39 -0
  93. package/dist/server/stage.d.ts +15 -0
  94. package/dist/server/stage.js +69 -0
  95. package/dist/server/store.d.ts +43 -0
  96. package/dist/server/store.js +210 -0
  97. package/dist/server/tts.d.ts +51 -0
  98. package/dist/server/tts.js +99 -0
  99. package/dist/stage/Assistant-Bold-ZDZZ6JHA.woff2 +0 -0
  100. package/dist/stage/Assistant-Medium-DZ25RZU3.woff2 +0 -0
  101. package/dist/stage/Assistant-Regular-PLF2XOGW.woff2 +0 -0
  102. package/dist/stage/Assistant-SemiBold-CZ5MX6FK.woff2 +0 -0
  103. package/dist/stage/index.html +10 -0
  104. package/dist/stage/stage.css +1 -0
  105. package/dist/stage/stage.js +4383 -0
  106. package/package.json +13 -4
  107. package/agents/chinese-teacher.md +0 -19
  108. package/agents/math-teacher.md +0 -19
  109. package/agents/reading-teacher.md +0 -19
@@ -0,0 +1,768 @@
1
+ /**
2
+ * 孩子端 `/`:首页(老师们 + 今天可以问 + 今天)与老师页(= 板书页)。零依赖内联脚本,只走 /api/kid/* 与 /api/audio。
3
+ * 铁律(《产品规划.md》):界面上永远没有错误与评判——后端不通、老师出错、识别失败,都只是「什么都不出现」或头像灰;
4
+ * 文字尽量少,语音优先。孩子设备上没有通往家长端的入口(2026-09-10 拍板)。
5
+ *
6
+ * 老师页照豆包爱学的形态(《豆包录屏分析.md》):板书是一天一份、越讲越长的文档,孩子的话不上板,只在字幕行回显一下;
7
+ * 一轮回复 = 一节:卡整块铺出,讲稿逐句播(有 mp3 放 mp3,没有用浏览器合成,再没有按字数计时),
8
+ * 播到哪句就在卡上画标注、滚到那张卡;末句是问句就停下,暂停钮换成「继续」。
9
+ * 输入条照豆包通用版:相机 | 发消息或按住说话 | 加号(相册)。平板横屏:板书两列(最宽 1040 居中),没有左栏。
10
+ *
11
+ * 交互逻辑在 ../lib/kid-board.ts(纯函数,有测试),这里把它剥掉类型内联进页面,两处一份源码。卡按 kind 渲染(text / read / choice / fill / code),
12
+ * 每种卡的紧凑态只读;点卡开舞台(盖住板书那块,顶栏是卡的名字 + 关闭,讲稿暂停):选择题在舞台里点大按钮,选了就 PUT 状态,
13
+ * 「交给老师」= 发一条 {text:'', action:'submit', focus:{card}};舞台开着时发的消息都带 focus.card。「继续」= {text:'', action:'continue'}。
14
+ * 状态存服务端,重开页面从 section.cards[n].state 读回;卡上永远不画对错。
15
+ * 重卡(scene / canvas)的舞台在 iframe 里装 /stage/?card=<id>(舞台包,src/stage/),postMessage 协议见 src/stage/protocol.ts:
16
+ * 页面发 card(props + state + 课包 URL),包回 phase / state / submit / close;场景在播时字幕行显示场景讲稿、按钮映射到播放器;
17
+ * 讲稿 [[play]] 锚到场景卡 → 念完那句把动画铺满播,done 了关舞台接着念。
18
+ * 点读段:card.assets 里有 <段号>.mp3 的放服务端配的,没有的浏览器合成;填空舞台逐空打字、「交给老师」;图片舞台双指缩放。
19
+ * 流式:老师还在说时 pending 条目带 partial 板书,卡按下标只追加不重画(先出的卡不闪),讲稿不播;整轮跑完那节换成正式的,声音从第一句起。
20
+ * __TITLE__ 由路由替换。调试:`?step=<节>.<句>` 直接停在某句(标注画齐、不出声),截图与测试用。
21
+ */
22
+ import { readFileSync } from 'node:fs';
23
+ import { stripTypeScriptTypes } from 'node:module';
24
+ /** 读 kid-board 的源码(仓库里是 .ts,npm 包里是 dist 的 .js),剥类型、去 export,变成能内联的普通脚本 */
25
+ function boardLibSource() {
26
+ let src;
27
+ try {
28
+ const ts = readFileSync(new URL('../lib/kid-board.ts', import.meta.url), 'utf8');
29
+ // stripTypeScriptTypes 会发一条 ExperimentalWarning;这里只剥自己的文件,警告对用户没有信息量,压掉
30
+ const warn = process.emitWarning;
31
+ process.emitWarning = () => { };
32
+ try {
33
+ src = stripTypeScriptTypes(ts);
34
+ }
35
+ finally {
36
+ process.emitWarning = warn;
37
+ }
38
+ }
39
+ catch {
40
+ src = readFileSync(new URL('../lib/kid-board.js', import.meta.url), 'utf8');
41
+ }
42
+ return src
43
+ .replace(/^export (?=(?:async )?(?:function|const|let|class) )/gm, '')
44
+ .replace(/^export \{[^}]*\};?[ \t]*$/gm, '')
45
+ .replace(/^import [^\n]*$/gm, '');
46
+ }
47
+ const PAGE = `<!doctype html>
48
+ <html lang="zh-CN">
49
+ <meta charset="utf-8">
50
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
51
+ <meta name="apple-mobile-web-app-capable" content="yes">
52
+ <title>__TITLE__</title>
53
+ <style>
54
+ :root { --paper:#f6f4ee; --ink:#2b2b2b; --dim:#8a8781; --line:#e2dfd6; --card:#fffdf8; --kid:#dbeeff; --tutor:#fff3d6; --accent:#e8743b; --hi:#ffe3a8; --blue:#eef4ff; --cream:#f7f1e3; --ok:#e8f6ee; --ok-ink:#1f8a4c; --purple:#efe9fb; --purple-ink:#7a4fc9; --grey:#eeece6; }
55
+ * { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
56
+ html, body { height:100%; }
57
+ body { margin:0; background:var(--paper); color:var(--ink); font:18px/1.5 -apple-system,"PingFang SC","Helvetica Neue",sans-serif; -webkit-user-select:none; user-select:none; overscroll-behavior:none; }
58
+ button { font:inherit; color:inherit; background:none; border:0; padding:0; cursor:pointer; }
59
+ .ic svg { display:block; }
60
+ /* ---- 首页 ---- */
61
+ #home { min-height:100%; padding:calc(env(safe-area-inset-top) + 16px) 20px calc(env(safe-area-inset-bottom) + 40px); max-width:960px; margin:0 auto; display:flex; flex-direction:column; gap:18px; }
62
+ h1 { font-size:20px; font-weight:600; margin:0; color:var(--dim); }
63
+ .tutors { display:flex; gap:16px; flex-wrap:wrap; justify-content:center; }
64
+ .tutor { display:flex; flex-direction:column; align-items:center; gap:8px; padding:4px; width:104px; }
65
+ .av { border-radius:50%; background:var(--card); border:3px solid var(--line); display:grid; place-items:center; font-weight:700; box-shadow:0 4px 12px #0000000f; flex:0 0 auto; overflow:hidden; }
66
+ .av img { width:100%; height:100%; object-fit:cover; }
67
+ .tutor .av { width:104px; height:104px; font-size:48px; transition:transform .15s; }
68
+ .tutor:active .av { transform:scale(.94); }
69
+ .tutor .nm { font-size:16px; font-weight:600; }
70
+ .tutor.off { pointer-events:none; }
71
+ .tutor.off .av { filter:grayscale(1); opacity:.4; box-shadow:none; }
72
+ .tutor.off .nm { color:var(--dim); }
73
+ .lbl { font-size:14px; color:var(--dim); }
74
+ .sug, .today { display:flex; flex-direction:column; gap:8px; }
75
+ .chip { display:inline-flex; align-items:center; gap:10px; align-self:flex-start; padding:10px 16px; border-radius:22px; background:var(--card); border:1px solid var(--line); font-size:17px; text-align:left; }
76
+ .chip .dot, .slot .dot { width:10px; height:10px; border-radius:50%; flex:0 0 auto; }
77
+ .slots { display:flex; gap:8px; flex-wrap:wrap; }
78
+ .slot { display:inline-flex; align-items:center; gap:8px; background:var(--card); border:1px solid var(--line); border-radius:14px; padding:6px 14px; font-size:15px; }
79
+ .slot.now { border-color:var(--accent); box-shadow:0 0 0 3px #e8743b33; }
80
+ .path { display:flex; align-items:flex-end; gap:10px; padding:6px 0 0; }
81
+ .station { display:flex; flex-direction:column; align-items:center; gap:4px; flex:1; }
82
+ .station .ring { width:30px; height:30px; border-radius:50%; border:4px solid var(--line); background:var(--card); }
83
+ .station.now .ring { width:44px; height:44px; border-width:6px; }
84
+ .station .d { font-size:12px; color:var(--dim); }
85
+ .station.now .d { color:var(--ink); font-weight:600; }
86
+ .station.past { opacity:.45; } .station.later { opacity:.7; }
87
+ .stacks { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
88
+ .stack { background:var(--card); border:2px solid var(--line); border-radius:18px; padding:14px 16px; }
89
+ .stack .name { font-weight:600; } .stack .item { font-size:15px; color:var(--dim); margin-top:4px; }
90
+ #rest { display:none; text-align:center; color:var(--dim); font-size:16px; padding:12px 0; }
91
+ body.offline #rest { display:block; }
92
+ body.offline .tutor { pointer-events:none; }
93
+ body.offline .tutor .av { filter:grayscale(1); opacity:.4; box-shadow:none; }
94
+ /* ---- 老师页 = 板书页 ---- */
95
+ #tutor { position:fixed; inset:0; background:var(--paper); display:none; z-index:10; }
96
+ #tutor.on { display:flex; }
97
+ #main { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; }
98
+ #main header { display:flex; align-items:center; gap:12px; padding:calc(env(safe-area-inset-top) + 10px) 16px 10px; background:var(--card); border-bottom:1px solid var(--line); }
99
+ #main header .av { width:44px; height:44px; font-size:20px; }
100
+ .who { flex:1; display:flex; flex-direction:column; min-width:0; }
101
+ .who .nm { font-size:18px; font-weight:600; } .who .mo { font-size:13px; color:var(--dim); }
102
+ .hb { width:40px; height:40px; display:grid; place-items:center; color:var(--dim); }
103
+ .hb.on { color:var(--accent); }
104
+ #wrap { position:relative; flex:1; min-height:0; display:flex; flex-direction:column; }
105
+ #board { flex:1; min-height:0; overflow:auto; padding:14px 16px 24px; display:flex; flex-direction:column; gap:12px; -webkit-overflow-scrolling:touch; scroll-behavior:smooth; }
106
+ /* 舞台:盖住板书那块,留 8px 边;顶栏 = 卡的名字 + 关闭;字幕行与输入条还在下面 */
107
+ #stage { position:absolute; inset:8px; border-radius:20px; background:var(--card); box-shadow:0 8px 30px #00000029; display:none; flex-direction:column; z-index:5; overflow:hidden; }
108
+ #stage.on { display:flex; }
109
+ #stage .top { display:flex; align-items:center; gap:10px; padding:12px 14px 12px 20px; border-bottom:1px solid var(--line); }
110
+ #stage .top .ttl { flex:1; min-width:0; font-size:17px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
111
+ #stage .top .kd { font-size:12px; color:var(--dim); border:1px solid var(--line); border-radius:10px; padding:1px 8px; }
112
+ #st-x { width:40px; height:40px; display:grid; place-items:center; color:var(--dim); }
113
+ #st-body { flex:1; min-height:0; overflow:auto; padding:20px; display:flex; flex-direction:column; gap:14px; -webkit-overflow-scrolling:touch; }
114
+ #st-body .c { font-size:20px; line-height:1.6; }
115
+ #st-body .c-cover { min-height:220px; } #st-body .c-cover .t { font-size:32px; } #st-body .c-cover .s { font-size:16px; }
116
+ #st-body .c-note { font-size:26px; } #st-body .c-formula { font-size:28px; } #st-body .rd { font-size:22px; }
117
+ #st-body .c-code { font-size:15px; }
118
+ .sq { font-size:22px; font-weight:600; line-height:1.5; }
119
+ .so { display:flex; align-items:center; gap:14px; padding:16px 18px; border-radius:16px; background:var(--paper); border:2px solid var(--line); font-size:20px; text-align:left; width:100%; transition:transform .1s; }
120
+ .so:active { transform:scale(.985); }
121
+ .so i { flex:0 0 auto; width:34px; height:34px; border-radius:50%; border:2px solid var(--line); background:#fff; display:grid; place-items:center; font-style:normal; font-size:15px; font-weight:700; color:var(--dim); }
122
+ .so.on { border-color:var(--purple-ink); background:var(--purple); }
123
+ .so.on i { background:var(--purple-ink); border-color:var(--purple-ink); color:#fff; }
124
+ .ch-o.on { border:2px solid var(--purple-ink); background:var(--purple); }
125
+ .ch-o.on i { background:var(--purple-ink); border-color:var(--purple-ink); color:#fff; }
126
+ #st-act { padding:12px 20px calc(16px); display:flex; justify-content:flex-end; border-top:1px solid var(--line); }
127
+ #st-act[hidden] { display:none; }
128
+ #st-go { padding:0 22px; height:48px; border-radius:24px; background:var(--accent); color:#fff; font-size:17px; font-weight:600; }
129
+ #st-go:disabled { opacity:.35; }
130
+ body.limit #st-go, body.pending #st-go { opacity:.35; pointer-events:none; }
131
+ .sec { display:contents; }
132
+ .c { border-radius:16px; padding:14px 16px; background:var(--card); font-size:16px; line-height:1.55; -webkit-user-select:text; user-select:text; }
133
+ .c-cover { position:relative; min-height:150px; background:linear-gradient(135deg,#d9cfb8,#8e9c8a); color:#fff; display:flex; flex-direction:column; justify-content:flex-end; gap:2px; text-shadow:0 1px 6px #00000066; }
134
+ .c-cover .t { font-size:24px; font-weight:700; } .c-cover .s { font-size:13px; opacity:.9; white-space:pre-line; }
135
+ .c-note { background:var(--blue); font-size:20px; font-weight:600; text-align:center; line-height:1.5; }
136
+ .c-quote { background:var(--cream); font-style:italic; text-align:center; color:#5a5650; }
137
+ .c-formula { background:none; text-align:center; font-family:"Times New Roman","Songti SC",serif; font-size:20px; }
138
+ .c-step { background:var(--ok); white-space:pre-line; } .st-t { font-size:15px; font-weight:700; color:var(--ok-ink); }
139
+ .c-text { background:var(--tutor); font-size:18px; white-space:pre-line; }
140
+ .c-read { background:none; padding:0; display:flex; flex-direction:column; gap:8px; }
141
+ .rd { padding:12px 16px; border-radius:12px; background:var(--cream); font-size:18px; line-height:1.6; cursor:pointer; }
142
+ .rd.on { background:var(--hi); }
143
+ .c-choice { background:var(--blue); display:flex; flex-direction:column; gap:8px; }
144
+ .ch-q { font-weight:600; }
145
+ .ch-o { display:flex; align-items:center; gap:10px; padding:10px 14px; border-radius:12px; background:var(--card); font-size:16px; }
146
+ .ch-o i { flex:0 0 auto; width:24px; height:24px; border-radius:50%; border:2px solid var(--line); display:grid; place-items:center; font-style:normal; font-size:12px; font-weight:700; color:var(--dim); }
147
+ .c-fill { background:var(--cream); font-size:18px; line-height:2; white-space:pre-line; }
148
+ .bl { display:inline-block; min-width:64px; border-bottom:2px solid var(--ink); margin:0 4px; height:1.2em; vertical-align:bottom; }
149
+ .c-image { padding:0; overflow:hidden; background:var(--card); }
150
+ .c-image img { display:block; width:100%; max-height:320px; object-fit:cover; }
151
+ .c-image .cap { padding:10px 16px; font-size:15px; color:#5a5650; white-space:pre-line; }
152
+ .bl.f { border-bottom-color:var(--purple-ink); color:var(--purple-ink); font-weight:600; text-align:center; padding:0 6px; }
153
+ .fq { font-size:22px; line-height:2.2; }
154
+ .fi { display:inline-block; width:120px; margin:0 4px; padding:2px 8px; font:inherit; font-size:20px; font-weight:600; color:var(--purple-ink); text-align:center; background:var(--card); border:0; border-bottom:3px solid var(--purple-ink); border-radius:6px 6px 0 0; outline:0; -webkit-user-select:text; user-select:text; }
155
+ .zoom { flex:1; min-height:0; overflow:hidden; display:grid; place-items:center; touch-action:none; }
156
+ .zoom img { max-width:100%; max-height:100%; transform-origin:center; will-change:transform; }
157
+ .c-scene { background:var(--card); border:2px solid var(--line); display:flex; flex-direction:column; gap:8px; padding:12px 14px; }
158
+ .c-scene .sp { font-weight:600; }
159
+ .c-scene .th { position:relative; border-radius:12px; overflow:hidden; background:var(--grey); min-height:110px; display:grid; place-items:center; color:var(--dim); font-size:15px; }
160
+ .c-scene .th img { display:block; width:100%; max-height:220px; object-fit:contain; background:#fff; }
161
+ .c-scene .th .pl { position:absolute; right:10px; bottom:10px; background:#000000aa; color:#fff; border-radius:14px; padding:3px 10px; font-size:13px; }
162
+ .c-scene .tx { font-size:15px; color:#5a5650; }
163
+ .c-canvas { background:var(--purple); display:flex; flex-direction:column; gap:8px; }
164
+ .c-canvas .cp { font-weight:600; } .c-canvas .cb { border:2px dashed var(--purple-ink); border-radius:12px; padding:22px 12px; text-align:center; color:var(--purple-ink); background:#fff; }
165
+ #st-frame { flex:1; min-height:0; width:100%; border:0; display:block; background:var(--card); }
166
+ #st-frame[hidden] { display:none; }
167
+ .c-code { background:#2b2b2b; color:#f6f4ee; font-family:ui-monospace,Menlo,monospace; font-size:14px; white-space:pre-wrap; }
168
+ .c-code .lg { display:block; font-size:11px; color:#aaa; margin-bottom:6px; font-family:inherit; }
169
+ /* 笔 */
170
+ .mk-marker { background:var(--hi); padding:0 3px; border-radius:3px; }
171
+ .mk-wave { text-decoration:underline wavy var(--accent); text-underline-offset:4px; }
172
+ .mk-box { border:2px solid var(--purple-ink); border-radius:4px; padding:0 3px; }
173
+ .mk-green { background:#cdeed9; padding:0 3px; border-radius:3px; }
174
+ .mk-circle { position:relative; display:inline-block; padding:0 2px; }
175
+ .mk-circle::after { content:""; position:absolute; left:-6px; right:-6px; top:-4px; bottom:-4px; border:2.5px solid var(--ok-ink); border-radius:50%; transform:rotate(-3deg); }
176
+ .mk { animation:pen .35s ease-out; }
177
+ @keyframes pen { from { opacity:0; } to { opacity:1; } }
178
+ /* 字幕行 */
179
+ #sub { display:flex; align-items:center; gap:12px; padding:8px 16px 4px; min-height:52px; }
180
+ #sub-text { flex:1; font-size:15px; line-height:1.45; color:#5a5650; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
181
+ #sub-text.echo, #sub-text.thinking { color:var(--dim); }
182
+ #sub-btn { flex:0 0 auto; height:36px; min-width:36px; border-radius:18px; background:#fff; display:inline-flex; align-items:center; justify-content:center; gap:4px; padding:0; font-size:15px; font-weight:600; }
183
+ #sub-btn.cont { padding:0 14px; border:1px solid var(--line); }
184
+ #sub-btn[hidden] { display:none; }
185
+ /* 输入条 */
186
+ #bar { padding:8px 16px calc(env(safe-area-inset-bottom) + 12px); }
187
+ #pill { display:flex; align-items:center; gap:12px; height:56px; padding:0 14px; border-radius:28px; background:#fff; border:1px solid var(--line); box-shadow:0 2px 10px #00000010; }
188
+ #pill .ic { color:var(--ink); display:grid; place-items:center; width:32px; height:44px; }
189
+ #mid { flex:1; min-width:0; min-height:44px; display:flex; align-items:center; font-size:17px; color:var(--dim); touch-action:none; }
190
+ #typed { flex:1; min-width:0; font:inherit; font-size:18px; color:var(--ink); border:0; outline:0; background:none; -webkit-user-select:text; user-select:text; }
191
+ #typed[hidden] { display:none; }
192
+ #go { width:36px; height:36px; border-radius:50%; background:var(--ink); color:#fff; display:grid; place-items:center; }
193
+ #go[hidden] { display:none; }
194
+ body.limit #pill, body.pending #pill { opacity:.45; pointer-events:none; }
195
+ #hold { position:absolute; left:0; right:0; bottom:0; height:300px; background:linear-gradient(180deg,#3b82e800 0%,#3b82e8cc 45%,#2f6fd6 100%); display:none; flex-direction:column; align-items:center; justify-content:flex-end; gap:22px; padding-bottom:calc(env(safe-area-inset-bottom) + 70px); color:#fff; pointer-events:none; z-index:20; }
196
+ #hold.on { display:flex; }
197
+ #hold .w { display:flex; align-items:center; gap:3px; height:28px; }
198
+ #hold .w i { width:3px; border-radius:2px; background:#ffffffdd; animation:wave .8s ease-in-out infinite alternate; }
199
+ @keyframes wave { from { transform:scaleY(.4); } to { transform:scaleY(1); } }
200
+ #sheet { position:absolute; inset:0; display:none; z-index:30; }
201
+ #sheet.on { display:block; }
202
+ #sheet .dimmer { position:absolute; inset:0; background:#00000033; }
203
+ #sheet .panel { position:absolute; left:0; right:0; bottom:0; background:var(--card); border-radius:22px 22px 0 0; padding:18px 20px calc(env(safe-area-inset-bottom) + 24px); display:flex; flex-direction:column; gap:14px; }
204
+ #sheet .grab { width:40px; height:4px; border-radius:2px; background:var(--line); align-self:center; }
205
+ #sheet .opts { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
206
+ #sheet label { display:flex; flex-direction:column; align-items:center; gap:8px; padding:18px 0; border-radius:16px; background:var(--paper); font-size:15px; font-weight:600; }
207
+ input[type=file] { display:none; }
208
+ /* ---- 平板横屏 ---- */
209
+ @media (min-width:900px) and (orientation:landscape) {
210
+ #home { max-width:none; display:grid; grid-template-columns:1fr 380px; gap:40px; padding:calc(env(safe-area-inset-top) + 40px) 48px 40px; align-content:start; }
211
+ #home h1 { grid-column:1 / -1; font-size:22px; }
212
+ #home .tutors { gap:40px; } #home .tutor { width:140px; } #home .tutor .av { width:120px; height:120px; font-size:56px; }
213
+ #home .sug { grid-column:1; } #home .side { grid-column:2; grid-row:2 / span 3; display:flex; flex-direction:column; gap:14px; }
214
+ #main header { padding-left:32px; padding-right:32px; }
215
+ #board { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; align-content:start; width:100%; max-width:1040px; margin:0 auto; padding:16px 32px 24px; }
216
+ .c-cover, .c-note, .c-choice, .c-read { grid-column:1 / -1; }
217
+ #stage { left:50%; transform:translateX(-50%); width:min(1040px, calc(100% - 16px)); }
218
+ #st-body { padding:28px 40px; }
219
+ #sub, #bar { width:100%; max-width:760px; margin:0 auto; }
220
+ }
221
+ </style>
222
+ <div id="home">
223
+ <h1 id="title">__TITLE__</h1>
224
+ <div class="tutors" id="tutors"></div>
225
+ <p id="rest">老师们休息中</p>
226
+ <div class="sug" id="sug"></div>
227
+ <div class="side" id="side"></div>
228
+ </div>
229
+ <section id="tutor">
230
+ <div id="main">
231
+ <header>
232
+ <button class="hb" id="back" type="button"></button>
233
+ <span class="av" id="c-av"></span>
234
+ <div class="who"><span class="nm" id="c-nm"></span><span class="mo" id="c-mo"></span></div>
235
+ <button class="hb on" id="spk" type="button"></button>
236
+ </header>
237
+ <div id="wrap">
238
+ <div id="board"></div>
239
+ <div id="stage"><div class="top"><span class="ttl" id="st-ttl"></span><span class="kd" id="st-kd"></span><button id="st-x" type="button"></button></div><div id="st-body"></div><iframe id="st-frame" hidden title="stage"></iframe><div id="st-act" hidden><button id="st-go" type="button">交给老师</button></div></div>
240
+ </div>
241
+ <div id="sub"><span id="sub-text"></span><button id="sub-btn" type="button" hidden></button></div>
242
+ <div id="bar">
243
+ <div id="pill">
244
+ <label class="ic" id="cam"><input type="file" accept="image/*" capture="environment"></label>
245
+ <div id="mid"><span id="ph">发消息或按住说话…</span><input id="typed" type="text" autocomplete="off" hidden></div>
246
+ <button id="go" type="button" hidden></button>
247
+ <button class="ic" id="plus" type="button"></button>
248
+ </div>
249
+ </div>
250
+ <div id="hold"><span>松手发送,上移取消</span><div class="w"></div></div>
251
+ </div>
252
+ <div id="sheet"><div class="dimmer"></div><div class="panel"><div class="grab"></div><div class="opts">
253
+ <label><span class="ic" id="ic-album"></span>相册<input type="file" accept="image/*"></label>
254
+ <label><span class="ic" id="ic-cam2"></span>拍照<input type="file" accept="image/*" capture="environment"></label>
255
+ </div></div></div>
256
+ </section>
257
+ <script>
258
+ (() => {
259
+ __BOARD_JS__
260
+
261
+ const $ = (s) => document.querySelector(s);
262
+ const h = (tag, attrs = {}, ...kids) => {
263
+ const el = document.createElement(tag);
264
+ for (const [k, v] of Object.entries(attrs)) {
265
+ if (k === 'class') el.className = v;
266
+ else if (k === 'html') el.innerHTML = v;
267
+ else if (k === 'on') for (const [e, f] of Object.entries(v)) el.addEventListener(e, f);
268
+ else if (v !== undefined && v !== null) el.setAttribute(k, v);
269
+ }
270
+ for (const k of kids.flat()) if (k !== null && k !== undefined) el.append(k.nodeType ? k : document.createTextNode(String(k)));
271
+ return el;
272
+ };
273
+ const SVG = (paths, size, sw) => '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="' + (sw || 2) + '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + '</svg>';
274
+ const ICON = {
275
+ back: SVG('<path d="M15 5l-7 7 7 7"></path>', 28, 2.2),
276
+ camera: SVG('<path d="M4 8h3l2-3h6l2 3h3v11H4z"></path><circle cx="12" cy="13" r="3.5"></circle>', 22),
277
+ plus: SVG('<path d="M12 5v14M5 12h14"></path>', 22, 2.2),
278
+ play: SVG('<path d="M8 5l11 7-11 7z" fill="currentColor"></path>', 16, 1.5),
279
+ pause: SVG('<path d="M8 5v14M16 5v14"></path>', 16, 2.6),
280
+ speaker: SVG('<path d="M4 10v4h4l5 4V6L8 10z"></path><path d="M16 9a4 4 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11"></path>', 24),
281
+ mute: SVG('<path d="M4 10v4h4l5 4V6L8 10z"></path><path d="M17 9l4 6M21 9l-4 6"></path>', 24),
282
+ send: SVG('<path d="M12 19V5M5 12l7-7 7 7"></path>', 20, 2.4),
283
+ image: SVG('<rect x="3" y="4" width="18" height="16" rx="2"></rect><path d="M3 16l5-5 4 4 3-3 6 6"></path><circle cx="16" cy="9" r="1.5"></circle>', 36, 1.6),
284
+ album: SVG('<rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M3 15l5-4 4 3 3-2 6 4"></path>', 28),
285
+ close: SVG('<path d="M6 6l12 12M18 6L6 18"></path>', 24, 2.2),
286
+ check: SVG('<path d="M5 12l5 5 9-10"></path>', 16, 3),
287
+ };
288
+ const api = async (method, path, body) => {
289
+ const r = await fetch(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, cache: 'no-store' });
290
+ if (!r.ok) { const e = new Error('http ' + r.status); e.status = r.status; throw e; }
291
+ return r.json();
292
+ };
293
+ const PALETTE = ['#e8743b', '#3b82e8', '#2fa36b', '#b45fd1', '#d9a520', '#e0508a'];
294
+ const FIXED = { '语文': '#e0508a', '数学': '#3b82e8', '英语': '#2fa36b' };
295
+ const color = (s) => { if (FIXED[s]) return FIXED[s]; let x = 0; for (const ch of s || '') x = (x * 31 + ch.codePointAt(0)) >>> 0; return PALETTE[x % PALETTE.length]; };
296
+ const DAYS = ['', '一', '二', '三', '四', '五', '六', '日'];
297
+ const avatarEl = (t, cls) => {
298
+ const el = h('span', { class: 'av ' + (cls || ''), style: 'border-color:' + color(t.subject || t.display) + ';color:' + color(t.subject || t.display) });
299
+ if (t.avatar && /\.(png|jpe?g|webp|svg)$/i.test(t.avatar)) el.append(h('img', { src: '/api/kid/avatar/' + t.name, alt: '' }));
300
+ else el.textContent = t.avatar || (t.subject || t.display || '?').slice(0, 1);
301
+ return el;
302
+ };
303
+ const debug = new URLSearchParams(location.search);
304
+
305
+ // ---- 状态 ----
306
+ const S = { home: null, tutor: null, day: null, sections: [], played: new Set(), state: { section: -1, line: -1, status: 'idle' }, echo: null, echoTimer: null, pending: false, limit: false, offline: false, autoplay: true, bar: 'idle', pollTimer: null, stage: null, partial: null };
307
+ try { S.autoplay = localStorage.getItem('kid-autoplay') !== '0'; } catch {}
308
+
309
+ // ---- 声音:共享 Audio,首个手势解锁(iOS);没配音退回浏览器合成;都没有按字数计时 ----
310
+ const audioEl = new Audio();
311
+ let unlocked = false;
312
+ const unlock = () => { if (unlocked) return; try { audioEl.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA='; audioEl.play().then(() => { unlocked = true; }).catch(() => {}); } catch {} };
313
+ let voiceToken = 0;
314
+ const stopVoice = () => { voiceToken++; try { audioEl.pause(); } catch {} try { if ('speechSynthesis' in window) speechSynthesis.cancel(); } catch {} };
315
+ /** 念一句;念完调 onEnd(被打断不调) */
316
+ const say = (line, onEnd) => {
317
+ const token = ++voiceToken;
318
+ const finish = () => { if (token === voiceToken) onEnd(); };
319
+ const fallback = () => { const ms = lineDurationMs(line.text); setTimeout(finish, ms); };
320
+ if (line.audio && S.tutor) {
321
+ try { if ('speechSynthesis' in window) speechSynthesis.cancel(); } catch {}
322
+ audioEl.onended = finish; audioEl.onerror = () => speak(plainLine(line.text), finish, fallback);
323
+ audioEl.src = '/api/audio/' + S.tutor.name + '/' + line.audio.split('/').map(encodeURIComponent).join('/');
324
+ audioEl.play().catch(() => speak(plainLine(line.text), finish, fallback));
325
+ } else speak(plainLine(line.text), finish, fallback);
326
+ };
327
+ const speak = (text, onEnd, onFail) => {
328
+ try {
329
+ if (!('speechSynthesis' in window)) return onFail();
330
+ speechSynthesis.cancel();
331
+ const u = new SpeechSynthesisUtterance(text); u.lang = 'zh-CN'; u.rate = 0.95;
332
+ let ended = false;
333
+ u.onend = () => { if (!ended) { ended = true; onEnd(); } };
334
+ u.onerror = () => { if (!ended) { ended = true; onFail(); } };
335
+ speechSynthesis.speak(u);
336
+ // 有的浏览器不发 onend:兜一个按字数的上限
337
+ setTimeout(() => { if (!ended) { ended = true; onEnd(); } }, lineDurationMs(text) * 2 + 1500);
338
+ } catch { onFail(); }
339
+ };
340
+
341
+ // ---- 首页 ----
342
+ const renderHome = () => {
343
+ const H = S.home;
344
+ document.title = H.title; $('#title').textContent = H.title;
345
+ $('#tutors').replaceChildren(...H.tutors.map((t) => h('button', { type: 'button', class: 'tutor' + (t.available ? '' : ' off'), on: { click: () => openTutor(t) } }, avatarEl(t), h('span', { class: 'nm' }, t.display))));
346
+ const sug = Array.isArray(H.suggestions) ? H.suggestions.filter((s) => s && s.text && H.tutors.some((t) => t.name === s.tutor && t.available)) : [];
347
+ $('#sug').replaceChildren(...(sug.length ? [h('span', { class: 'lbl' }, '今天可以问'), ...sug.map((s) => { const t = H.tutors.find((x) => x.name === s.tutor); return h('button', { type: 'button', class: 'chip', on: { click: () => openTutor(t, s.text) } }, h('span', { class: 'dot', style: 'background:' + color(t.subject || t.display) }), s.text); })] : []));
348
+ const days = [...new Set([...H.timetable.map((e) => e.day), H.day])].sort((a, b) => a - b);
349
+ const slots = H.timetable.filter((e) => e.day === H.day).map((e) => h('span', { class: 'slot' + (H.slot === e.subject + ' ' + e.start + '-' + e.end ? ' now' : '') }, h('span', { class: 'dot', style: 'background:' + color(e.subject) }), e.subject + ' ' + e.start + '–' + e.end));
350
+ const path = h('div', { class: 'path' }, ...days.map((d) => {
351
+ const subjects = [...new Set(H.timetable.filter((e) => e.day === d).map((e) => e.subject))];
352
+ const when = d === H.day ? 'now' : d < H.day ? 'past' : 'later';
353
+ return h('div', { class: 'station ' + when }, h('span', { class: 'ring', style: 'border-color:' + (subjects.length ? color(subjects[0]) : 'var(--line)') }), h('span', { class: 'd' }, d === H.day ? '今天' : '周' + DAYS[d]));
354
+ }));
355
+ const stacks = (H.stacks || []).map((s) => h('div', { class: 'stack', style: 'border-color:' + color(s.subject) }, h('div', { class: 'name' }, s.subject), ...s.items.map((a) => h('div', { class: 'item' }, a.id))));
356
+ $('#side').replaceChildren(...[h('div', { class: 'today' }, h('span', { class: 'lbl' }, '今天'), slots.length ? h('div', { class: 'slots' }, ...slots) : null, path), stacks.length ? h('div', { class: 'stacks' }, ...stacks) : null].filter(Boolean));
357
+ };
358
+ const loadHome = async () => {
359
+ try { S.home = await api('GET', '/api/kid/home'); setOffline(false); renderHome(); }
360
+ catch { setOffline(true); }
361
+ };
362
+ const setOffline = (off) => {
363
+ if (S.offline === off) return;
364
+ S.offline = off;
365
+ document.body.classList.toggle('offline', off);
366
+ if (off && S.tutor) closeTutor();
367
+ };
368
+
369
+ // ---- 老师页 ----
370
+ const openTutor = (t, sendText) => {
371
+ unlock();
372
+ S.tutor = t; S.sections = []; S.played = new Set(); S.state = { section: -1, line: -1, status: 'idle' }; S.echo = null; S.pending = false; S.limit = false; S.stage = null; S.partial = null; $('#stage').classList.remove('on');
373
+ $('#c-av').replaceWith(Object.assign(avatarEl(t), { id: 'c-av' }));
374
+ $('#c-nm').textContent = t.display;
375
+ $('#c-mo').textContent = t.motto || '';
376
+ $('#board').replaceChildren();
377
+ $('#tutor').classList.add('on');
378
+ setBar('idle');
379
+ loadDay(true).then(() => { if (sendText) send(sendText); });
380
+ };
381
+ const closeTutor = () => { clearTimeout(S.pollTimer); stopVoice(); S.tutor = null; $('#tutor').classList.remove('on'); document.body.classList.remove('pending', 'limit'); loadHome(); };
382
+ $('#back').addEventListener('click', closeTutor);
383
+ $('#back').innerHTML = ICON.back;
384
+
385
+ // 卡片:按 kind 分支(轻插件内联;不认识的 kind 把 props 里的字都显示出来)。紧凑态只读,点了开舞台;stage=true 是舞台里的画法
386
+ const renderCard = (c, idx, secIdx, stage) => {
387
+ const p = c.props || {};
388
+ const box = (cls, ...kids) => h('div', { class: 'c c-' + cls, 'data-card': idx, on: stage ? {} : { click: () => openStage(secIdx, idx) } }, ...kids);
389
+ switch (c.kind) {
390
+ case 'text': {
391
+ const st = p.style;
392
+ if (st === 'cover') return box('cover', h('span', { class: 't' }, p.title || ''), p.text ? h('span', { class: 's' }, p.text) : null);
393
+ if (st === 'note') return box('note', p.text || '');
394
+ if (st === 'quote') return box('quote', '「' + (p.text || '') + '」');
395
+ if (st === 'formula') return box('formula', p.text || '');
396
+ if (st === 'step') return box('step', h('div', { class: 'st-t' }, p.title || ''), h('div', {}, p.text || ''));
397
+ return box('text', p.text || '');
398
+ }
399
+ case 'read':
400
+ return box('read', ...(p.segments || []).map((seg, k) => h('div', { class: 'rd', on: { click: (e) => { e.stopPropagation(); readSegment(e.currentTarget, c, k, seg); } } }, seg)));
401
+ case 'image': {
402
+ const src = /^https?:\\/\\//.test(p.src || '') ? p.src : '/api/kid/image?p=' + encodeURIComponent(p.src || '');
403
+ if (stage) { const el = box('image'); el.className = 'zoom'; el.append(h('img', { src, alt: p.caption || '' })); pinch(el); return el; }
404
+ return box('image', h('img', { src, alt: '', loading: 'lazy' }), p.caption ? h('div', { class: 'cap' }, p.caption) : null);
405
+ }
406
+ case 'choice': {
407
+ const picked = (c.state && Array.isArray(c.state.picked)) ? c.state.picked : [];
408
+ if (stage) return box('choice', h('div', { class: 'sq' }, p.question || ''), ...(p.options || []).map((o, i) => h('button', { type: 'button', class: 'so' + (picked.includes(i) ? ' on' : ''), on: { click: () => pick(secIdx, idx, i) } }, h('i', {}, picked.includes(i) ? h('span', { html: ICON.check }) : 'ABCDEFGH'[i] || ''), o)));
409
+ return box('choice', h('div', { class: 'ch-q' }, p.question || ''), ...(p.options || []).map((o, i) => h('div', { class: 'ch-o' + (picked.includes(i) ? ' on' : '') }, h('i', {}, 'ABCDEFGH'[i] || ''), o)));
410
+ }
411
+ case 'fill': {
412
+ const el = box('fill');
413
+ const parts = String(p.text || '').split(/_{2,}/);
414
+ const got = filledAnswers(c);
415
+ if (stage) {
416
+ el.classList.add('fq');
417
+ parts.forEach((t, i) => { el.append(t); if (i < parts.length - 1) el.append(h('input', { class: 'fi', type: 'text', value: got[i] || '', autocomplete: 'off', enterkeyhint: 'done', on: { input: (e) => fillIn(secIdx, idx, i, e.target.value), keydown: (e) => { if (e.key === 'Enter') e.target.blur(); }, click: (e) => e.stopPropagation() } })); });
418
+ return el;
419
+ }
420
+ parts.forEach((t, i) => { el.append(t); if (i < parts.length - 1) el.append(h('span', { class: 'bl' + (got[i] ? ' f' : '') }, got[i] || '')); });
421
+ return el;
422
+ }
423
+ case 'scene': {
424
+ const ready = sceneReady(c);
425
+ const n = Array.isArray(p.steps) ? p.steps.length : 0;
426
+ const thumb = ready && p.thumb ? h('img', { src: '/api/kid/image?p=' + encodeURIComponent(p.thumb), alt: '' }) : null;
427
+ return box('scene', p.problem ? h('div', { class: 'sp' }, p.problem) : (p.title ? h('div', { class: 'sp' }, p.title) : null), h('div', { class: 'th' }, thumb || (ready ? '' : '图还在路上'), ready ? h('span', { class: 'pl' }, (n ? n + ' 步 ' : '') + '▷') : null), p.text ? h('div', { class: 'tx' }, p.text) : null);
428
+ }
429
+ case 'canvas': {
430
+ const n = inkCount(c);
431
+ return box('canvas', h('div', { class: 'cp' }, p.prompt || '画一画'), h('div', { class: 'cb' }, n ? '已经画了 ' + n + ' 笔,点开接着画' : '点开画一画 ✎'));
432
+ }
433
+ case 'code':
434
+ return box('code', p.lang ? h('span', { class: 'lg' }, p.lang) : null, p.text || '');
435
+ default:
436
+ return box('text', cardTexts(c).filter(Boolean).join('\\n'));
437
+ }
438
+ };
439
+ /** 点读:点哪段念哪段——服务端配好的段(card.assets 里有 <段号>.mp3)放 mp3,没好的用浏览器合成声;讲稿在播就先停下 */
440
+ const readSegment = (el, card, k, seg) => {
441
+ stopVoice();
442
+ if (S.state.status === 'playing') { S.state = { ...S.state, status: 'paused' }; renderSubtitle(); }
443
+ for (const x of document.querySelectorAll('.rd.on')) x.classList.remove('on');
444
+ el.classList.add('on');
445
+ const off = () => el.classList.remove('on');
446
+ say({ text: seg, audio: segmentAudio(card, k) }, off);
447
+ };
448
+ /** 填空:改一个空 → 本地状态、紧凑态重画、400ms 后 PUT(打字中不刷舞台,免得输入框失焦) */
449
+ let fillTimer = null;
450
+ const fillIn = (secIdx, idx, i, value) => {
451
+ const card = S.sections[secIdx].cards[idx];
452
+ const answers = filledAnswers(card); answers[i] = value;
453
+ card.state = { answers };
454
+ $('#st-go').disabled = !stateSummary(card).length;
455
+ repaintCard(secIdx, idx);
456
+ clearTimeout(fillTimer);
457
+ fillTimer = setTimeout(() => saveState(S.sections[secIdx].job, idx, card.state), 400);
458
+ };
459
+ /** 图片舞台:双指缩放 + 单指拖,双击复位 */
460
+ const pinch = (el) => {
461
+ const img = el.querySelector('img');
462
+ const pts = new Map(); let scale = 1, tx = 0, ty = 0, start = null, lastTap = 0;
463
+ const apply = () => { img.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')'; };
464
+ const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };
465
+ el.addEventListener('pointerdown', (e) => { pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); try { el.setPointerCapture(e.pointerId); } catch {} start = { scale, tx, ty, d: pts.size === 2 ? dist() : 0, x: e.clientX, y: e.clientY }; if (pts.size === 1) { const now = Date.now(); if (now - lastTap < 300) { scale = 1; tx = 0; ty = 0; apply(); } lastTap = now; } });
466
+ el.addEventListener('pointermove', (e) => { if (!pts.has(e.pointerId) || !start) return; pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pts.size === 2 && start.d) scale = Math.min(6, Math.max(1, start.scale * dist() / start.d)); else if (pts.size === 1 && scale > 1) { tx = start.tx + (e.clientX - start.x); ty = start.ty + (e.clientY - start.y); } apply(); });
467
+ const up = (e) => { pts.delete(e.pointerId); start = pts.size ? { scale, tx, ty, d: pts.size === 2 ? dist() : 0, x: [...pts.values()][0].x, y: [...pts.values()][0].y } : null; };
468
+ el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
469
+ };
470
+ const renderSection = (s, i) => h('div', { class: 'sec', 'data-sec': i }, ...s.cards.map((c, idx) => renderCard(c, idx, i, false)));
471
+ /** 老师还在说:这节的卡按下标只追加(下标 = 它跑完后会得到的节号,总是最后一节);讲稿不播,声音整轮跑完再从头起 */
472
+ const renderPartial = (e) => {
473
+ const idx = S.sections.length;
474
+ if (!S.partial || S.partial.job !== e.job) { if (S.partial) S.partial.el.remove(); S.partial = { job: e.job, el: h('div', { class: 'sec', 'data-sec': idx }) }; $('#board').append(S.partial.el); }
475
+ const el = S.partial.el;
476
+ for (let i = el.children.length; i < e.cards.length; i++) { const c = renderCard(e.cards[i], i, idx, false); el.append(c); c.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
477
+ };
478
+ /** 一张卡的状态变了:紧凑态原地重画(标注会掉,重画本节已播到的) */
479
+ const repaintCard = (secIdx, idx) => {
480
+ const sec = $('#board').querySelector('[data-sec="' + secIdx + '"]');
481
+ const old = sec && sec.querySelector('[data-card="' + idx + '"]');
482
+ if (!old) return;
483
+ old.replaceWith(renderCard(S.sections[secIdx].cards[idx], idx, secIdx, false));
484
+ const upTo = S.state.section === secIdx ? S.state.line : S.state.section > secIdx ? undefined : -1;
485
+ if (upTo !== -1) for (const l of S.sections[secIdx].lines.slice(0, upTo === undefined ? undefined : upTo + 1)) for (const m of l.marks) if (m.card === idx) applyMark(secIdx, m);
486
+ };
487
+
488
+ // ---- 舞台:点卡放大,交互都在这里;开着时讲稿暂停,关了字幕行出「播放」 ----
489
+ const KIND_NAME = { text: '', read: '点读', choice: '选一选', fill: '填一填', image: '看图', scene: '讲解动画', canvas: '画一画', code: '' };
490
+ const GO_LABEL = { canvas: '给老师看' };
491
+ const openStage = (secIdx, idx, opts = {}) => {
492
+ const card = S.sections[secIdx] && S.sections[secIdx].cards[idx];
493
+ if (!card) return;
494
+ if (isHeavy(card) && !sceneReady(card) && card.kind === 'scene') return; // 课包还没到:紧凑态写着「图还在路上」,不开
495
+ if (S.state.status === 'playing' && !opts.delegate) { stopVoice(); S.state = { ...S.state, status: 'paused' }; renderSubtitle(); }
496
+ S.stage = { section: secIdx, card: idx, id: S.sections[secIdx].job + '/' + idx, scene: null, delegate: Boolean(opts.delegate), autoplay: Boolean(opts.autoplay) };
497
+ $('#st-ttl').textContent = cardTitle(card);
498
+ $('#st-kd').textContent = KIND_NAME[card.kind] || card.kind;
499
+ $('#st-kd').hidden = !(KIND_NAME[card.kind] || card.kind);
500
+ renderStage();
501
+ $('#stage').classList.add('on');
502
+ };
503
+ const STAGE_SOURCE = 'cotutor-stage';
504
+ const frame = $('#st-frame');
505
+ const postStage = (m) => { try { frame.contentWindow.postMessage({ source: STAGE_SOURCE, ...m }, '*'); } catch {} };
506
+ const renderStage = () => {
507
+ if (!S.stage) return;
508
+ const card = S.sections[S.stage.section].cards[S.stage.card];
509
+ const heavy = isHeavy(card);
510
+ $('#st-body').hidden = heavy;
511
+ frame.hidden = !heavy;
512
+ if (heavy) { $('#st-body').replaceChildren(); frame.src = '/stage/?card=' + encodeURIComponent(S.stage.id); }
513
+ else { frame.src = 'about:blank'; $('#st-body').replaceChildren(renderCard(card, S.stage.card, S.stage.section, true)); }
514
+ const act = $('#st-act'); act.hidden = !hasState(card);
515
+ $('#st-go').textContent = GO_LABEL[card.kind] || '交给老师';
516
+ $('#st-go').disabled = !stateSummary(card).length;
517
+ };
518
+ /** 舞台包说话:ready → 把卡发过去;phase → 字幕行;state → 存;done 且是讲稿委托的 → 关舞台接着念 */
519
+ window.addEventListener('message', (e) => {
520
+ const m = e.data;
521
+ if (!m || m.source !== STAGE_SOURCE || !S.stage) return;
522
+ const card = S.sections[S.stage.section].cards[S.stage.card];
523
+ if (m.type === 'ready') { const b = card.kind === 'scene' ? card.props.bundle : card.kind === 'canvas' && card.props.base && card.props.base.bundle ? card.props.base.bundle : null; postStage({ type: 'card', id: S.stage.id, kind: card.kind, props: card.props, state: card.state === undefined ? null : card.state, bundleUrl: b ? '/api/bundles/' + encodeURIComponent(b) + '/' : undefined, autoplay: S.stage.autoplay }); }
524
+ else if (m.type === 'phase') { S.stage.scene = { phase: m.phase, line: m.line, step: m.step, total: m.total }; renderSubtitle(); if (m.phase === 'done' && S.stage.delegate) { const d = S.stage; closeStage(); resumeAfter(d); } }
525
+ else if (m.type === 'state') { card.state = m.state; $('#st-go').disabled = !stateSummary(card).length; repaintCard(S.stage.section, S.stage.card); saveState(S.sections[S.stage.section].job, S.stage.card, m.state); }
526
+ else if (m.type === 'submit') { card.state = m.state; const id = S.stage.id; const job = S.sections[S.stage.section].job; const idx = S.stage.card; closeStage(); api('PUT', '/api/kid/conversations/' + S.tutor.name + '/cards/' + job + '/' + idx, m.image ? { ...m.state, image: m.image } : m.state).catch(() => {}).then(() => send('', { action: 'submit', focus: { card: id }, echoText: '你:' + (stateSummary(card).join('、') || '给老师看') })); }
527
+ else if (m.type === 'close' || m.type === 'error') { const d = S.stage; closeStage(); if (d.delegate) resumeAfter(d); }
528
+ });
529
+ /** 讲稿委托给场景播完(或孩子关了)→ 接着念下一句 */
530
+ const resumeAfter = (d) => { if (S.state.status !== 'stage') return; S.state = advance({ ...S.state, status: 'playing' }, S.sections); if (S.state.status === 'playing') playLine(); else renderSubtitle(); };
531
+ const closeStage = () => { S.stage = null; frame.src = 'about:blank'; $('#stage').classList.remove('on'); renderSubtitle(); };
532
+ $('#st-x').innerHTML = ICON.close;
533
+ $('#st-x').addEventListener('click', closeStage);
534
+ /** 选择题:点一项 → 本地改状态、重画、PUT 到服务端(失败不响,下次再点再存) */
535
+ const pick = (secIdx, idx, i) => {
536
+ const card = S.sections[secIdx].cards[idx];
537
+ const cur = (card.state && Array.isArray(card.state.picked)) ? card.state.picked : [];
538
+ card.state = { picked: togglePick(cur, i, Boolean(card.props.multi)) };
539
+ renderStage(); repaintCard(secIdx, idx);
540
+ saveState(S.sections[secIdx].job, idx, card.state);
541
+ };
542
+ const saveState = (job, idx, state) => { if (!S.tutor) return; api('PUT', '/api/kid/conversations/' + S.tutor.name + '/cards/' + job + '/' + idx, state).catch(() => {}); };
543
+ $('#st-go').addEventListener('click', () => {
544
+ if (!S.stage) return;
545
+ const card = S.sections[S.stage.section].cards[S.stage.card];
546
+ if (isHeavy(card)) { postStage({ type: 'control', action: 'submit' }); return; } // 画板:让舞台包导出 png 再交
547
+ const labels = stateSummary(card);
548
+ if (!labels.length) return;
549
+ const id = S.stage.id;
550
+ clearTimeout(fillTimer); if (card.kind === 'fill') saveState(S.sections[S.stage.section].job, S.stage.card, card.state);
551
+ closeStage();
552
+ send('', { action: 'submit', focus: { card: id }, echoText: '你:' + labels.join('、') });
553
+ });
554
+ const applyMark = (secIdx, mark) => {
555
+ const sec = $('#board').querySelector('[data-sec="' + secIdx + '"]');
556
+ const card = sec && sec.querySelector('[data-card="' + mark.card + '"]');
557
+ if (!card) return null;
558
+ const cardData = S.sections[secIdx].cards[mark.card];
559
+ const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
560
+ let n;
561
+ while ((n = walker.nextNode())) {
562
+ const i = n.nodeValue.indexOf(mark.phrase);
563
+ if (i < 0 || n.parentElement.classList.contains('mk')) continue;
564
+ const range = document.createRange(); range.setStart(n, i); range.setEnd(n, i + mark.phrase.length);
565
+ const span = document.createElement('span'); span.className = 'mk mk-' + markStyle(cardData);
566
+ range.surroundContents(span);
567
+ return card;
568
+ }
569
+ return card;
570
+ };
571
+ const renderSubtitle = () => {
572
+ let v = subtitleFor({ state: S.state, sections: S.sections, echo: S.echo, pending: S.pending, thinking: (S.tutor && S.tutor.thinking) || '让我想想…', limit: S.limit });
573
+ if (S.stage && S.stage.scene && !S.echo && !S.limit) v = sceneSubtitle(S.stage.scene.phase, S.stage.scene.line, S.stage.scene.step, S.stage.scene.total);
574
+ const t = $('#sub-text'); t.textContent = v.text; t.className = v.kind;
575
+ const b = $('#sub-btn');
576
+ b.hidden = v.right === 'none';
577
+ b.className = v.right === 'continue' ? 'cont' : '';
578
+ b.innerHTML = v.right === 'pause' ? ICON.pause : v.right === 'play' ? ICON.play : v.right === 'continue' ? ICON.play + '<span>继续</span>' : '';
579
+ document.body.classList.toggle('pending', S.pending);
580
+ document.body.classList.toggle('limit', S.limit);
581
+ };
582
+ $('#sub-btn').addEventListener('click', () => {
583
+ if (S.stage && S.stage.scene) { postStage({ type: 'control', action: 'toggle' }); return; }
584
+ if (S.state.status === 'playing') { stopVoice(); S.state = { ...S.state, status: 'paused' }; renderSubtitle(); }
585
+ else if (S.state.status === 'paused') { S.state = { ...S.state, status: 'playing' }; playLine(); }
586
+ else if (S.state.status === 'waiting') send('', { action: 'continue', echo: false });
587
+ });
588
+
589
+ // 播放:一句 = 字幕 + 标注 + 滚到那张卡 + 声音;播完往下走
590
+ const playLine = () => {
591
+ const s = S.sections[S.state.section]; const line = s && s.lines[S.state.line];
592
+ if (!line) { S.state = { ...S.state, status: 'done' }; renderSubtitle(); return; }
593
+ renderSubtitle();
594
+ let target = null;
595
+ for (const m of line.marks) target = applyMark(S.state.section, m) || target;
596
+ if (!target) { const at = lineTarget(line); target = $('#board').querySelector('[data-sec="' + S.state.section + '"] ' + (at === null ? '.c' : '[data-card="' + at + '"]')); }
597
+ if (target) target.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
598
+ say(line, () => {
599
+ if (S.state.status !== 'playing') return;
600
+ // [[play]]:念完这句把场景铺满播,播完(done)再接着念
601
+ const play = line.cues.find((c) => c.name === 'play');
602
+ const target = play && S.sections[S.state.section].cards[play.card];
603
+ if (target && target.kind === 'scene' && sceneReady(target)) { S.state = { ...S.state, status: 'stage' }; openStage(S.state.section, play.card, { delegate: true, autoplay: true }); return; }
604
+ S.state = advance(S.state, S.sections);
605
+ if (S.state.status === 'playing') playLine();
606
+ else { renderSubtitle(); if (S.state.status === 'waiting') openAskCard(S.state.section, line); }
607
+ });
608
+ };
609
+ /** 末句问句停下时,锚点卡有交互(选择题)就把它推到舞台等答 */
610
+ const openAskCard = (secIdx, line) => {
611
+ const at = lineTarget(line);
612
+ const card = at !== null && S.sections[secIdx] && S.sections[secIdx].cards[at];
613
+ if (card && hasState(card) && !S.stage) openStage(secIdx, at);
614
+ };
615
+ /** 把一节的标注一次画齐(打开页面、不出声时) */
616
+ const paintAll = (secIdx, upTo) => { const s = S.sections[secIdx]; if (!s) return; for (const l of s.lines.slice(0, upTo === undefined ? s.lines.length : upTo + 1)) for (const m of l.marks) applyMark(secIdx, m); };
617
+
618
+ const loadDay = async (silent) => {
619
+ if (!S.tutor) return;
620
+ try {
621
+ const d = await api('GET', '/api/kid/conversations/' + S.tutor.name + '/today');
622
+ setOffline(false);
623
+ S.day = d;
624
+ S.limit = d.remaining <= 0;
625
+ const entries = sectionsFromMessages(d.messages);
626
+ const fresh = [];
627
+ for (const e of entries) {
628
+ if (S.played.has(e.job)) continue;
629
+ if (e.partial) { renderPartial(e); continue; }
630
+ S.played.add(e.job); S.sections.push(e); fresh.push(S.sections.length - 1);
631
+ const idx = S.sections.length - 1;
632
+ if (S.partial && S.partial.job === e.job) {
633
+ // 流式时先铺的卡留着,只补后面的;节的编号就是它现在的位置
634
+ const el = S.partial.el; S.partial = null;
635
+ for (let i = el.children.length; i < e.cards.length; i++) el.append(renderCard(e.cards[i], i, idx, false));
636
+ el.setAttribute('data-sec', idx);
637
+ } else $('#board').append(renderSection(e, idx));
638
+ }
639
+ if (S.partial && !entries.some((e) => e.partial && e.job === S.partial.job)) { S.partial.el.remove(); S.partial = null; }
640
+ // 服务端的状态是真相(别的设备上选的、重开页面):没在舞台里改着的卡照它画
641
+ entries.forEach((e) => { const i = S.sections.findIndex((x) => x.job === e.job); if (i < 0 || fresh.includes(i)) return; e.cards.forEach((c, idx) => { const mine = S.sections[i].cards[idx]; if (JSON.stringify(mine.state) !== JSON.stringify(c.state) && !(S.stage && S.stage.section === i && S.stage.card === idx)) { mine.state = c.state; repaintCard(i, idx); } }); });
642
+ if (fresh.length && S.stage) closeStage();
643
+ const stillPending = Boolean(d.pending) || d.messages.some((m) => m.pending);
644
+ S.pending = stillPending;
645
+ if (fresh.length) {
646
+ if (silent || !S.autoplay) { for (const i of fresh) paintAll(i); S.state = playerAtEnd(S.sections); renderSubtitle(); const last = $('#board').querySelector('[data-sec="' + (S.sections.length - 1) + '"] .c'); if (last) last.scrollIntoView({ block: 'start' }); }
647
+ else { stopVoice(); S.state = startSection(fresh[0], S.sections); if (S.state.status === 'playing') playLine(); else renderSubtitle(); }
648
+ } else renderSubtitle();
649
+ clearTimeout(S.pollTimer);
650
+ if (stillPending) S.pollTimer = setTimeout(() => loadDay(false), 1000);
651
+ } catch (e) {
652
+ if (e && e.status === 404) return closeTutor();
653
+ setOffline(true);
654
+ }
655
+ };
656
+ const showEcho = (text) => { clearTimeout(S.echoTimer); S.echo = text; renderSubtitle(); S.echoTimer = setTimeout(() => { S.echo = null; renderSubtitle(); }, 2500); };
657
+ const send = async (text, opts = {}) => {
658
+ text = (text || '').trim();
659
+ if ((!text && !opts.action) || !S.tutor || (S.limit && opts.action !== 'continue')) return;
660
+ unlock();
661
+ stopVoice();
662
+ if (S.state.status === 'playing' || S.state.status === 'paused' || S.state.status === 'stage') S.state = { ...S.state, status: 'done' };
663
+ if (opts.echo !== false) showEcho(opts.echoText || ('你:' + text));
664
+ S.pending = true; renderSubtitle();
665
+ const body = { text };
666
+ if (opts.action) body.action = opts.action;
667
+ const focus = opts.focus || (S.stage ? { card: S.stage.id } : null);
668
+ if (focus) body.focus = focus;
669
+ try {
670
+ await api('POST', '/api/kid/conversations/' + S.tutor.name + '/messages', body);
671
+ clearTimeout(S.pollTimer); S.pollTimer = setTimeout(() => loadDay(false), 1200);
672
+ } catch (e) {
673
+ // 忙 / 上限 / 不通:什么都不报;刷新一下让状态说话
674
+ S.pending = false;
675
+ if (e && e.status === 429) { S.limit = true; renderSubtitle(); } else if (e && e.status === 409) loadDay(true); else setOffline(true);
676
+ }
677
+ };
678
+
679
+ // ---- 喇叭:自动朗读开关 ----
680
+ const spk = $('#spk');
681
+ const renderSpk = () => { spk.innerHTML = S.autoplay ? ICON.speaker : ICON.mute; spk.classList.toggle('on', S.autoplay); };
682
+ spk.addEventListener('click', () => { S.autoplay = !S.autoplay; try { localStorage.setItem('kid-autoplay', S.autoplay ? '1' : '0'); } catch {} renderSpk(); if (!S.autoplay && S.state.status === 'playing') { stopVoice(); paintAll(S.state.section); S.state = playerAtEnd(S.sections); renderSubtitle(); } });
683
+ renderSpk();
684
+
685
+ // ---- 输入条:相机 | 发消息或按住说话 | 加号 ----
686
+ $('#cam').insertAdjacentHTML('afterbegin', ICON.camera);
687
+ $('#plus').innerHTML = ICON.plus;
688
+ $('#go').innerHTML = ICON.send;
689
+ $('#ic-album').innerHTML = ICON.album; $('#ic-cam2').innerHTML = ICON.camera;
690
+ const setBar = (mode) => {
691
+ S.bar = mode;
692
+ $('#ph').hidden = mode === 'typing'; $('#typed').hidden = mode !== 'typing'; $('#go').hidden = mode !== 'typing';
693
+ $('#hold').classList.toggle('on', mode === 'holding');
694
+ if (mode === 'typing') $('#typed').focus();
695
+ };
696
+ const mid = $('#mid'), typed = $('#typed');
697
+ $('#go').addEventListener('click', () => { const t = typed.value; typed.value = ''; setBar(barNext(S.bar, 'sent')); send(t); });
698
+ typed.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $('#go').click(); } });
699
+ typed.addEventListener('blur', () => { if (!typed.value.trim()) setBar(barNext(S.bar, 'blur')); });
700
+ $('#plus').addEventListener('click', () => $('#sheet').classList.add('on'));
701
+ $('#sheet .dimmer').addEventListener('click', () => $('#sheet').classList.remove('on'));
702
+ for (const f of document.querySelectorAll('input[type=file]')) f.addEventListener('change', () => { $('#sheet').classList.remove('on'); f.value = ''; /* 照片走 R5 作业线,现在只收下不发 */ });
703
+ $('#hold .w').replaceChildren(...[6, 10, 16, 22, 12, 26, 18, 8, 14, 24, 20, 10, 16, 28, 12, 8, 18, 22, 10, 14, 6, 12, 20, 16, 8].map((v, i) => h('i', { style: 'height:' + v + 'px;animation-delay:' + (i * 37 % 400) + 'ms' })));
704
+
705
+ // 中间那段:点 = 打字;按住 150ms = 说话(浏览器识别),松手发,上滑 60px 取消;没有识别就只有打字
706
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
707
+ let press = null, rec = null, finalText = '';
708
+ const startRec = () => {
709
+ finalText = '';
710
+ try {
711
+ rec = new SR(); rec.lang = 'zh-CN'; rec.interimResults = true; rec.continuous = false; rec.maxAlternatives = 1;
712
+ rec.onresult = (ev) => { let s = ''; for (const r of ev.results) s += r[0].transcript; finalText = s; };
713
+ rec.onerror = () => {};
714
+ rec.onend = () => { const cancelled = press && press.cancelled; const t = finalText; rec = null; if (!cancelled && t.trim()) send(t); };
715
+ rec.start();
716
+ } catch { rec = null; }
717
+ };
718
+ mid.addEventListener('pointerdown', (e) => {
719
+ if (S.bar === 'typing') return;
720
+ e.preventDefault(); unlock();
721
+ press = { y: e.clientY, cancelled: false, held: false, timer: setTimeout(() => { if (!press || !SR) return; press.held = true; setBar(barNext(S.bar, 'holdStart')); stopVoice(); startRec(); }, 150) };
722
+ try { mid.setPointerCapture(e.pointerId); } catch {}
723
+ });
724
+ mid.addEventListener('pointermove', (e) => { if (press && press.held) { const up = press.y - e.clientY > 60; if (up !== press.cancelled) { press.cancelled = up; $('#hold span').textContent = up ? '松手取消' : '松手发送,上移取消'; } } });
725
+ const release = () => {
726
+ if (!press) return;
727
+ clearTimeout(press.timer);
728
+ if (press.held) { setBar(barNext(S.bar, press.cancelled ? 'holdCancel' : 'holdEnd')); if (rec) { try { rec.stop(); } catch {} } }
729
+ else setBar(barNext(S.bar, 'tap'));
730
+ const p = press; press = null;
731
+ if (rec && p.cancelled) { const r = rec; rec = null; try { r.onend = null; r.abort(); } catch {} }
732
+ };
733
+ mid.addEventListener('pointerup', release);
734
+ mid.addEventListener('pointercancel', release);
735
+ mid.addEventListener('contextmenu', (e) => e.preventDefault());
736
+
737
+ // ---- 调试:?step=<节>.<句> 停在某句(截图 / 测试用,不出声) ----
738
+ const jumpTo = () => {
739
+ const step = debug.get('step');
740
+ if ((!step && !debug.get('stage')) || !S.sections.length) return;
741
+ const [a, b] = (step || '0.0').split('.').map(Number);
742
+ stopVoice();
743
+ const sec = Math.min(Math.max(a || 0, 0), S.sections.length - 1);
744
+ const line = Math.min(Math.max(b || 0, 0), Math.max(S.sections[sec].lines.length - 1, 0));
745
+ S.state = { section: sec, line, status: 'paused' };
746
+ for (let i = 0; i < sec; i++) paintAll(i);
747
+ paintAll(sec, line);
748
+ if (S.sections[sec].lines[line] && S.sections[sec].lines[line].ask && line === S.sections[sec].lines.length - 1 && sec === S.sections.length - 1) S.state.status = 'waiting';
749
+ renderSubtitle();
750
+ const tgt = S.sections[sec].lines[line] ? lineTarget(S.sections[sec].lines[line]) : null;
751
+ const at = $('#board').querySelector('[data-sec="' + sec + '"] ' + (tgt === null ? '.c' : '[data-card="' + tgt + '"]'));
752
+ if (at) at.scrollIntoView({ block: 'center' });
753
+ const st = debug.get('stage');
754
+ if (st) { const [x, y] = st.split('.').map(Number); openStage(x || 0, y || 0); }
755
+ };
756
+
757
+ // ---- 启动与心跳:不通就头像灰,什么都不报 ----
758
+ loadHome().then(() => {
759
+ const open = debug.get('tutor');
760
+ if (open && S.home) { const t = S.home.tutors.find((x) => x.name === open); if (t) { openTutor(t); setTimeout(jumpTo, 400); } }
761
+ });
762
+ setInterval(() => { if (S.tutor) { if (!S.pending) loadDay(true); } else loadHome(); }, 5000);
763
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) (S.tutor ? loadDay(true) : loadHome()); });
764
+ })();
765
+ </script>
766
+ </html>
767
+ `;
768
+ export const KID_PAGE = PAGE.replace('__BOARD_JS__', boardLibSource());