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.
- package/README.md +35 -24
- package/agents/chinese-tutor.md +67 -0
- package/agents/{homework-aide.md → homework-tutor.md} +4 -4
- package/agents/math-tutor.md +68 -0
- package/agents/reading-tutor.md +65 -0
- package/agents/scene-maker.md +41 -0
- package/dist/cards/canvas.d.ts +22 -0
- package/dist/cards/canvas.js +65 -0
- package/dist/cards/choice.d.ts +20 -0
- package/dist/cards/choice.js +76 -0
- package/dist/cards/code.d.ts +9 -0
- package/dist/cards/code.js +13 -0
- package/dist/cards/fill.d.ts +14 -0
- package/dist/cards/fill.js +64 -0
- package/dist/cards/image.d.ts +13 -0
- package/dist/cards/image.js +35 -0
- package/dist/cards/index.d.ts +48 -0
- package/dist/cards/index.js +115 -0
- package/dist/cards/kind.d.ts +27 -0
- package/dist/cards/kind.js +7 -0
- package/dist/cards/read.d.ts +8 -0
- package/dist/cards/read.js +27 -0
- package/dist/cards/scene.d.ts +24 -0
- package/dist/cards/scene.js +67 -0
- package/dist/cards/text.d.ts +18 -0
- package/dist/cards/text.js +41 -0
- package/dist/cli/cert.d.ts +7 -0
- package/dist/cli/cert.js +36 -0
- package/dist/cli/doctor.d.ts +5 -2
- package/dist/cli/doctor.js +183 -18
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +41 -39
- package/dist/cli/main.js +128 -8
- package/dist/cli/serve.d.ts +15 -4
- package/dist/cli/serve.js +40 -6
- package/dist/cli/skeleton.d.ts +23 -5
- package/dist/cli/skeleton.js +131 -29
- package/dist/cli/skills.d.ts +40 -0
- package/dist/cli/skills.js +170 -0
- package/dist/cli/tutors.d.ts +84 -0
- package/dist/cli/tutors.js +274 -0
- package/dist/cli/workspace.d.ts +9 -1
- package/dist/cli/workspace.js +25 -4
- package/dist/lib/agent-file.d.ts +1 -1
- package/dist/lib/board.d.ts +15 -0
- package/dist/lib/board.js +107 -0
- package/dist/lib/context-pack.js +10 -1
- package/dist/lib/conversation.d.ts +43 -6
- package/dist/lib/conversation.js +53 -9
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/kid-board.d.ts +148 -0
- package/dist/lib/kid-board.js +294 -0
- package/dist/lib/kid-view.d.ts +44 -7
- package/dist/lib/kid-view.js +63 -8
- package/dist/lib/ledger.d.ts +8 -0
- package/dist/lib/ledger.js +8 -0
- package/dist/lib/plan.d.ts +2 -0
- package/dist/lib/plan.js +9 -0
- package/dist/lib/run-plan.d.ts +30 -0
- package/dist/lib/run-plan.js +32 -0
- package/dist/lib/stream.d.ts +15 -0
- package/dist/lib/stream.js +69 -0
- package/dist/lib/timetable.d.ts +22 -0
- package/dist/lib/timetable.js +106 -0
- package/dist/schema/board.d.ts +59 -0
- package/dist/schema/board.js +30 -0
- package/dist/schema/config.d.ts +76 -18
- package/dist/schema/config.js +67 -37
- package/dist/schema/context-pack.d.ts +7 -0
- package/dist/schema/context-pack.js +6 -0
- package/dist/schema/conversation.d.ts +114 -4
- package/dist/schema/conversation.js +29 -5
- package/dist/schema/index.d.ts +6 -2
- package/dist/schema/index.js +6 -2
- package/dist/schema/json-schema.d.ts +2 -0
- package/dist/schema/json-schema.js +23 -0
- package/dist/schema/ledger.d.ts +1 -1
- package/dist/schema/timetable.d.ts +26 -0
- package/dist/schema/timetable.js +18 -0
- package/dist/server/app.d.ts +67 -6
- package/dist/server/app.js +399 -38
- package/dist/server/kid-page.d.ts +1 -0
- package/dist/server/kid-page.js +768 -0
- package/dist/server/mock.d.ts +58 -0
- package/dist/server/mock.js +403 -0
- package/dist/server/parent-page.d.ts +8 -0
- package/dist/server/parent-page.js +393 -0
- package/dist/server/runner.d.ts +64 -0
- package/dist/server/runner.js +277 -0
- package/dist/server/scene-props.d.ts +16 -0
- package/dist/server/scene-props.js +39 -0
- package/dist/server/stage.d.ts +15 -0
- package/dist/server/stage.js +69 -0
- package/dist/server/store.d.ts +43 -0
- package/dist/server/store.js +210 -0
- package/dist/server/tts.d.ts +51 -0
- package/dist/server/tts.js +99 -0
- package/dist/stage/Assistant-Bold-ZDZZ6JHA.woff2 +0 -0
- package/dist/stage/Assistant-Medium-DZ25RZU3.woff2 +0 -0
- package/dist/stage/Assistant-Regular-PLF2XOGW.woff2 +0 -0
- package/dist/stage/Assistant-SemiBold-CZ5MX6FK.woff2 +0 -0
- package/dist/stage/index.html +10 -0
- package/dist/stage/stage.css +1 -0
- package/dist/stage/stage.js +4383 -0
- package/package.json +13 -4
- package/agents/chinese-teacher.md +0 -19
- package/agents/math-teacher.md +0 -19
- 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());
|