cotutor 0.2.0 → 0.3.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/LICENSE +247 -21
- package/NOTICE +42 -0
- package/README.md +92 -20
- package/bin/cotutor.js +4 -4
- package/cards/canvas/card.css +8 -0
- package/cards/canvas/card.md +68 -0
- package/cards/choice/card.css +18 -0
- package/cards/choice/card.md +55 -0
- package/cards/code/card.css +5 -0
- package/cards/code/card.md +46 -0
- package/cards/dictation/card.css +10 -0
- package/cards/dictation/card.md +97 -0
- package/cards/image/card.css +10 -0
- package/cards/image/card.md +52 -0
- package/cards/lecture/card.css +9 -0
- package/cards/lecture/card.md +69 -0
- package/cards/paint/card.css +20 -0
- package/cards/paint/card.md +77 -0
- package/cards/photo/card.css +15 -0
- package/cards/photo/card.md +61 -0
- package/cards/read/card.css +6 -0
- package/cards/read/card.md +51 -0
- package/cards/text/card.css +3 -0
- package/cards/text/card.md +69 -0
- package/cards/tianzige/card.css +17 -0
- package/cards/tianzige/card.md +78 -0
- package/cards/tutor/card.css +66 -0
- package/cards/tutor/card.md +148 -0
- package/cards/word/card.css +24 -0
- package/cards/word/card.md +91 -0
- package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +159 -0
- package/dist/app/activity-eval-rules.d.ts +62 -0
- package/dist/app/activity-eval-rules.js +276 -0
- package/dist/app/activity-eval.d.ts +65 -0
- package/dist/app/activity-eval.js +158 -0
- package/dist/app/build.d.ts +11 -0
- package/dist/app/build.js +45 -0
- package/dist/app/cli/activities.d.ts +63 -0
- package/dist/app/cli/activities.js +158 -0
- package/dist/{cli → app/cli}/cert.js +3 -3
- package/dist/app/cli/chalk/bake.d.ts +10 -0
- package/dist/app/cli/chalk/bake.js +58 -0
- package/dist/app/cli/chalk/browser.d.ts +16 -0
- package/dist/app/cli/chalk/browser.js +142 -0
- package/dist/app/cli/chalk/build.d.ts +14 -0
- package/dist/app/cli/chalk/build.js +100 -0
- package/dist/app/cli/chalk/dub.d.ts +51 -0
- package/dist/app/cli/chalk/dub.js +106 -0
- package/dist/app/cli/chalk/load-scene.d.ts +34 -0
- package/dist/app/cli/chalk/load-scene.js +117 -0
- package/dist/app/cli/chalk/main.d.ts +7 -0
- package/dist/app/cli/chalk/main.js +198 -0
- package/dist/app/cli/chalk/serve.d.ts +4 -0
- package/dist/app/cli/chalk/serve.js +28 -0
- package/dist/app/cli/chalk/snap.d.ts +65 -0
- package/dist/app/cli/chalk/snap.js +103 -0
- package/dist/app/cli/chalk/typecheck.d.ts +7 -0
- package/dist/app/cli/chalk/typecheck.js +51 -0
- package/dist/{cli → app/cli}/doctor.d.ts +4 -3
- package/dist/app/cli/doctor.js +617 -0
- package/dist/app/cli/grades.d.ts +14 -0
- package/dist/app/cli/grades.js +44 -0
- package/dist/{cli → app/cli}/init.d.ts +3 -1
- package/dist/app/cli/init.js +155 -0
- package/dist/app/cli/main.js +715 -0
- package/dist/app/cli/migrate.d.ts +58 -0
- package/dist/app/cli/migrate.js +253 -0
- package/dist/{cli → app/cli}/serve.d.ts +12 -5
- package/dist/app/cli/serve.js +98 -0
- package/dist/app/cli/shipped.d.ts +30 -0
- package/dist/app/cli/shipped.js +47 -0
- package/dist/app/cli/skeleton.d.ts +38 -0
- package/dist/app/cli/skeleton.js +161 -0
- package/dist/app/cli/skills.d.ts +55 -0
- package/dist/app/cli/skills.js +189 -0
- package/dist/app/cli/themes.d.ts +52 -0
- package/dist/app/cli/themes.js +148 -0
- package/dist/{cli → app/cli}/tutors.d.ts +15 -26
- package/dist/{cli → app/cli}/tutors.js +86 -110
- package/dist/app/cli/usage.d.ts +2 -0
- package/dist/app/cli/usage.js +30 -0
- package/dist/app/dictation/recognize.d.ts +18 -0
- package/dist/app/dictation/recognize.js +6 -0
- package/dist/app/dictation/route.d.ts +52 -0
- package/dist/app/dictation/route.js +510 -0
- package/dist/app/diff.d.ts +11 -0
- package/dist/app/diff.js +71 -0
- package/dist/app/http/access.d.ts +62 -0
- package/dist/app/http/access.js +180 -0
- package/dist/app/http/app.d.ts +246 -0
- package/dist/app/http/app.js +1212 -0
- package/dist/app/http/dev-activities.d.ts +115 -0
- package/dist/app/http/dev-activities.js +193 -0
- package/dist/app/http/dev-overview.d.ts +108 -0
- package/dist/app/http/dev-overview.js +209 -0
- package/dist/app/http/dev-tutor-files.d.ts +80 -0
- package/dist/app/http/dev-tutor-files.js +151 -0
- package/dist/app/http/parent-home.d.ts +37 -0
- package/dist/app/http/parent-home.js +203 -0
- package/dist/app/http/range.d.ts +9 -0
- package/dist/app/http/range.js +19 -0
- package/dist/app/http/raw-view.d.ts +117 -0
- package/dist/app/http/raw-view.js +149 -0
- package/dist/app/http/send-file.d.ts +7 -0
- package/dist/app/http/send-file.js +29 -0
- package/dist/{server → app/http}/stage.d.ts +9 -0
- package/dist/{server → app/http}/stage.js +34 -6
- package/dist/app/http/theme.d.ts +13 -0
- package/dist/app/http/theme.js +41 -0
- package/dist/app/machine/bailian.d.ts +28 -0
- package/dist/app/machine/bailian.js +88 -0
- package/dist/app/mock/mock-live.d.ts +16 -0
- package/dist/app/mock/mock-live.js +140 -0
- package/dist/{server → app/mock}/mock.d.ts +36 -5
- package/dist/app/mock/mock.js +1117 -0
- package/dist/app/replay.d.ts +94 -0
- package/dist/app/replay.js +170 -0
- package/dist/app/skills/analyze-doc.d.ts +3 -0
- package/dist/app/skills/analyze-doc.js +47 -0
- package/dist/app/skills/home-doc.d.ts +5 -0
- package/dist/app/skills/home-doc.js +112 -0
- package/dist/app/skills/tune-doc.d.ts +3 -0
- package/dist/app/skills/tune-doc.js +80 -0
- package/dist/app/talk/list.d.ts +11 -0
- package/dist/app/talk/list.js +9 -0
- package/dist/app/talk/route.d.ts +16 -0
- package/dist/app/talk/route.js +142 -0
- package/dist/app/workspace.d.ts +58 -0
- package/dist/{cli → app}/workspace.js +77 -52
- package/dist/app/ws/route.d.ts +25 -0
- package/dist/app/ws/route.js +29 -0
- package/dist/app/ws/ws.d.ts +31 -0
- package/dist/app/ws/ws.js +169 -0
- package/dist/board/canvas-chat.d.ts +97 -0
- package/dist/board/canvas-chat.js +175 -0
- package/dist/board/card-guards.d.ts +15 -0
- package/dist/board/card-guards.js +38 -0
- package/dist/board/cards/canvas.d.ts +41 -0
- package/dist/board/cards/canvas.js +83 -0
- package/dist/{cards → board/cards}/choice.js +0 -12
- package/dist/{cards → board/cards}/code.js +0 -3
- package/dist/board/cards/dictation.d.ts +42 -0
- package/dist/board/cards/dictation.js +105 -0
- package/dist/board/cards/docs.d.ts +47 -0
- package/dist/board/cards/docs.js +152 -0
- package/dist/{cards → board/cards}/image.js +1 -10
- package/dist/board/cards/index.d.ts +62 -0
- package/dist/board/cards/index.js +144 -0
- package/dist/{cards → board/cards}/kind.d.ts +10 -4
- package/dist/board/cards/kind.js +9 -0
- package/dist/board/cards/lecture.d.ts +26 -0
- package/dist/board/cards/lecture.js +62 -0
- package/dist/board/cards/paint.d.ts +38 -0
- package/dist/board/cards/paint.js +85 -0
- package/dist/board/cards/photo.d.ts +15 -0
- package/dist/board/cards/photo.js +29 -0
- package/dist/{cards → board/cards}/read.js +1 -10
- package/dist/board/cards/text.d.ts +20 -0
- package/dist/board/cards/text.js +59 -0
- package/dist/board/cards/tianzige.d.ts +16 -0
- package/dist/board/cards/tianzige.js +64 -0
- package/dist/board/cards/tutor.d.ts +72 -0
- package/dist/board/cards/tutor.js +134 -0
- package/dist/board/cards/word.d.ts +20 -0
- package/dist/board/cards/word.js +76 -0
- package/dist/board/lecture.d.ts +100 -0
- package/dist/board/lecture.js +204 -0
- package/dist/board/paper.d.ts +20 -0
- package/dist/board/paper.js +76 -0
- package/dist/board/parse.d.ts +120 -0
- package/dist/board/parse.js +330 -0
- package/dist/board/presentation.d.ts +295 -0
- package/dist/board/presentation.js +615 -0
- package/dist/board/sections.d.ts +16 -0
- package/dist/board/sections.js +130 -0
- package/dist/brain/api-tools.d.ts +8 -0
- package/dist/brain/api-tools.js +190 -0
- package/dist/brain/api.d.ts +62 -0
- package/dist/brain/api.js +411 -0
- package/dist/brain/cli/adapters.d.ts +60 -0
- package/dist/brain/cli/adapters.js +91 -0
- package/dist/brain/cli/qwen.d.ts +11 -0
- package/dist/brain/cli/qwen.js +141 -0
- package/dist/brain/cli/run-plan.d.ts +59 -0
- package/dist/brain/cli/run-plan.js +71 -0
- package/dist/brain/cli/run.d.ts +35 -0
- package/dist/brain/cli/run.js +256 -0
- package/dist/brain/cli/stream-json.d.ts +18 -0
- package/dist/brain/cli/stream-json.js +86 -0
- package/dist/brain/cli/types.d.ts +144 -0
- package/dist/brain/cli/types.js +3 -0
- package/dist/brain/cli/warm.d.ts +53 -0
- package/dist/brain/cli/warm.js +103 -0
- package/dist/brain/index.d.ts +145 -0
- package/dist/brain/index.js +74 -0
- package/dist/brain/stream.d.ts +20 -0
- package/dist/brain/stream.js +70 -0
- package/dist/brain/transcript.d.ts +45 -0
- package/dist/brain/transcript.js +132 -0
- package/dist/chalk/actions.d.ts +37 -0
- package/dist/chalk/actions.js +69 -0
- package/dist/chalk/bundle.d.ts +26 -0
- package/dist/chalk/bundle.js +69 -0
- package/dist/chalk/check.d.ts +28 -0
- package/dist/chalk/check.js +291 -0
- package/dist/chalk/circle.d.ts +64 -0
- package/dist/chalk/circle.js +193 -0
- package/dist/chalk/clock.d.ts +84 -0
- package/dist/chalk/clock.js +84 -0
- package/dist/chalk/geometry.d.ts +44 -0
- package/dist/chalk/geometry.js +139 -0
- package/dist/chalk/glyphs/capitals.d.ts +12 -0
- package/dist/chalk/glyphs/capitals.js +264 -0
- package/dist/chalk/glyphs/index.d.ts +21 -0
- package/dist/chalk/glyphs/index.js +18 -0
- package/dist/chalk/glyphs/letters.d.ts +13 -0
- package/dist/chalk/glyphs/letters.js +280 -0
- package/dist/chalk/glyphs/metrics.d.ts +33 -0
- package/dist/chalk/glyphs/metrics.js +28 -0
- package/dist/chalk/glyphs/registry.d.ts +7 -0
- package/dist/chalk/glyphs/registry.js +8 -0
- package/dist/chalk/glyphs/sample.d.ts +32 -0
- package/dist/chalk/glyphs/sample.js +102 -0
- package/dist/chalk/glyphs/skeleton.d.ts +64 -0
- package/dist/chalk/glyphs/skeleton.js +126 -0
- package/dist/chalk/glyphs/types.d.ts +58 -0
- package/dist/chalk/glyphs/types.js +17 -0
- package/dist/chalk/index.d.ts +23 -0
- package/dist/chalk/index.js +15 -0
- package/dist/chalk/manifest.d.ts +41 -0
- package/dist/chalk/manifest.js +6 -0
- package/dist/chalk/paint.d.ts +180 -0
- package/dist/chalk/paint.js +210 -0
- package/dist/chalk/pointer.d.ts +8 -0
- package/dist/chalk/pointer.js +21 -0
- package/dist/chalk/prepare.d.ts +11 -0
- package/dist/chalk/prepare.js +15 -0
- package/dist/chalk/scene-schema.d.ts +199 -0
- package/dist/chalk/scene-schema.js +91 -0
- package/dist/chalk/seed.d.ts +3 -0
- package/dist/chalk/seed.js +10 -0
- package/dist/chalk/text-metrics.d.ts +8 -0
- package/dist/chalk/text-metrics.js +156 -0
- package/dist/chalk/timeline.d.ts +47 -0
- package/dist/chalk/timeline.js +84 -0
- package/dist/chalk/types.d.ts +150 -0
- package/dist/chalk/types.js +8 -0
- package/dist/lib/index.d.ts +14 -12
- package/dist/lib/index.js +14 -12
- package/dist/media/draw.d.ts +23 -0
- package/dist/media/draw.js +44 -0
- package/dist/media/mp3.d.ts +6 -0
- package/dist/media/mp3.js +58 -0
- package/dist/media/mp4.d.ts +1 -0
- package/dist/media/mp4.js +58 -0
- package/dist/media/tts-voices.d.ts +18 -0
- package/dist/media/tts-voices.js +606 -0
- package/dist/media/tts.d.ts +56 -0
- package/dist/media/tts.js +174 -0
- package/dist/media/vision.d.ts +25 -0
- package/dist/media/vision.js +71 -0
- package/dist/memory/commit.d.ts +67 -0
- package/dist/memory/commit.js +130 -0
- package/dist/memory/context.d.ts +86 -0
- package/dist/memory/context.js +97 -0
- package/dist/memory/diary.d.ts +84 -0
- package/dist/memory/diary.js +183 -0
- package/dist/memory/grade.d.ts +2 -0
- package/dist/memory/grade.js +7 -0
- package/dist/{lib → memory}/plan.d.ts +1 -1
- package/dist/{lib → memory}/plan.js +1 -1
- package/dist/memory/proposals.d.ts +11 -0
- package/dist/memory/proposals.js +43 -0
- package/dist/memory/retrieval.d.ts +44 -0
- package/dist/memory/retrieval.js +157 -0
- package/dist/{lib → memory}/timetable.d.ts +0 -3
- package/dist/{lib → memory}/timetable.js +0 -11
- package/dist/memory/vault-notes.d.ts +165 -0
- package/dist/memory/vault-notes.js +359 -0
- package/dist/memory/vault.d.ts +70 -0
- package/dist/memory/vault.js +169 -0
- package/dist/realtime/bailian.d.ts +120 -0
- package/dist/realtime/bailian.js +288 -0
- package/dist/realtime/cues.d.ts +51 -0
- package/dist/realtime/cues.js +94 -0
- package/dist/realtime/listen.d.ts +32 -0
- package/dist/realtime/listen.js +53 -0
- package/dist/realtime/preview.d.ts +23 -0
- package/dist/realtime/preview.js +38 -0
- package/dist/realtime/prompt.d.ts +8 -0
- package/dist/realtime/prompt.js +13 -0
- package/dist/realtime/voices.d.ts +19 -0
- package/dist/realtime/voices.js +71 -0
- package/dist/runtime/activities.d.ts +17 -0
- package/dist/runtime/activities.js +46 -0
- package/dist/runtime/activity-state.d.ts +22 -0
- package/dist/runtime/activity-state.js +83 -0
- package/dist/runtime/activity.d.ts +63 -0
- package/dist/runtime/activity.js +428 -0
- package/dist/runtime/agent-file.d.ts +24 -0
- package/dist/runtime/agent-file.js +59 -0
- package/dist/runtime/auto-bookkeep.d.ts +30 -0
- package/dist/runtime/auto-bookkeep.js +84 -0
- package/dist/{lib → runtime/context}/context-pack.d.ts +3 -3
- package/dist/runtime/context/context-pack.js +182 -0
- package/dist/runtime/context/thread-digest.d.ts +4 -0
- package/dist/runtime/context/thread-digest.js +35 -0
- package/dist/runtime/context/tutor-rules.d.ts +25 -0
- package/dist/runtime/context/tutor-rules.js +47 -0
- package/dist/runtime/delivery/delivery.d.ts +94 -0
- package/dist/runtime/delivery/delivery.js +145 -0
- package/dist/runtime/delivery/playback.d.ts +25 -0
- package/dist/runtime/delivery/playback.js +29 -0
- package/dist/runtime/delivery/player.d.ts +145 -0
- package/dist/runtime/delivery/player.js +221 -0
- package/dist/runtime/delivery/queue.d.ts +89 -0
- package/dist/runtime/delivery/queue.js +59 -0
- package/dist/runtime/home/edit.d.ts +39 -0
- package/dist/runtime/home/edit.js +132 -0
- package/dist/runtime/home/home.d.ts +180 -0
- package/dist/runtime/home/home.js +311 -0
- package/dist/runtime/home/store.d.ts +142 -0
- package/dist/runtime/home/store.js +440 -0
- package/dist/runtime/journal/conversation.d.ts +46 -0
- package/dist/runtime/journal/conversation.js +89 -0
- package/dist/runtime/journal/events.d.ts +27 -0
- package/dist/runtime/journal/events.js +136 -0
- package/dist/runtime/journal/kid-log.d.ts +105 -0
- package/dist/runtime/journal/kid-log.js +133 -0
- package/dist/runtime/journal/ledger.d.ts +17 -0
- package/dist/{lib → runtime/journal}/ledger.js +7 -40
- package/dist/runtime/journal/mix.d.ts +33 -0
- package/dist/runtime/journal/mix.js +88 -0
- package/dist/runtime/journal/mouth-index.d.ts +26 -0
- package/dist/runtime/journal/mouth-index.js +34 -0
- package/dist/runtime/journal/mouth.d.ts +54 -0
- package/dist/runtime/journal/mouth.js +102 -0
- package/dist/runtime/journal/recorder.d.ts +46 -0
- package/dist/runtime/journal/recorder.js +58 -0
- package/dist/runtime/journal/store.d.ts +123 -0
- package/dist/runtime/journal/store.js +316 -0
- package/dist/runtime/kid-input.d.ts +16 -0
- package/dist/runtime/kid-input.js +195 -0
- package/dist/runtime/kid-view.d.ts +124 -0
- package/dist/runtime/kid-view.js +157 -0
- package/dist/runtime/lecture.d.ts +86 -0
- package/dist/runtime/lecture.js +237 -0
- package/dist/runtime/live/call.d.ts +219 -0
- package/dist/runtime/live/call.js +1211 -0
- package/dist/runtime/live/turns.d.ts +48 -0
- package/dist/runtime/live/turns.js +102 -0
- package/dist/runtime/runner.d.ts +289 -0
- package/dist/runtime/runner.js +898 -0
- package/dist/runtime/talk-run.d.ts +21 -0
- package/dist/runtime/talk-run.js +34 -0
- package/dist/scenes/dictation/match.d.ts +57 -0
- package/dist/scenes/dictation/match.js +255 -0
- package/dist/scenes/dictation/recognize.d.ts +86 -0
- package/dist/scenes/dictation/recognize.js +163 -0
- package/dist/scenes/dictation/store.d.ts +112 -0
- package/dist/scenes/dictation/store.js +99 -0
- package/dist/scenes/look.d.ts +26 -0
- package/dist/scenes/look.js +1 -0
- package/dist/scenes/paint/painter.d.ts +84 -0
- package/dist/scenes/paint/painter.js +170 -0
- package/dist/scenes/paint/prompt.d.ts +23 -0
- package/dist/scenes/paint/prompt.js +36 -0
- package/dist/scenes/talk/en-words.d.ts +8 -0
- package/dist/scenes/talk/en-words.js +44 -0
- package/dist/scenes/talk/list.d.ts +44 -0
- package/dist/scenes/talk/list.js +88 -0
- package/dist/scenes/talk/store.d.ts +27 -0
- package/dist/scenes/talk/store.js +61 -0
- package/dist/scenes/talk/warmup.d.ts +16 -0
- package/dist/scenes/talk/warmup.js +39 -0
- package/dist/schema/activity.d.ts +149 -0
- package/dist/schema/activity.js +113 -0
- package/dist/schema/board.d.ts +143 -2
- package/dist/schema/board.js +15 -3
- package/dist/schema/config.d.ts +309 -94
- package/dist/schema/config.js +259 -72
- package/dist/schema/context-pack.d.ts +76 -14
- package/dist/schema/context-pack.js +91 -10
- package/dist/{lib/conversation.d.ts → schema/conversation-files.d.ts} +11 -33
- package/dist/schema/conversation-files.js +26 -0
- package/dist/schema/conversation.d.ts +385 -49
- package/dist/schema/conversation.js +135 -19
- package/dist/schema/events.d.ts +138 -0
- package/dist/schema/events.js +1 -0
- package/dist/schema/home.d.ts +46 -0
- package/dist/schema/home.js +30 -0
- package/dist/schema/index.d.ts +14 -3
- package/dist/schema/index.js +14 -3
- package/dist/schema/kid-device.d.ts +20 -0
- package/dist/schema/kid-device.js +32 -0
- package/dist/schema/ledger.d.ts +8 -63
- package/dist/schema/ledger.js +7 -31
- package/dist/schema/memory.d.ts +73 -0
- package/dist/schema/memory.js +47 -0
- package/dist/schema/plan.d.ts +2 -2
- package/dist/schema/plan.js +2 -2
- package/dist/schema/sections.d.ts +32 -25
- package/dist/schema/sections.js +21 -22
- package/dist/schema/theme.d.ts +25 -0
- package/dist/schema/theme.js +26 -0
- package/dist/schema/timetable.d.ts +2 -2
- package/dist/schema/timetable.js +2 -2
- package/dist/schema/workspace.d.ts +41 -0
- package/dist/schema/workspace.js +9 -0
- package/dist/shared/errors.d.ts +8 -0
- package/dist/shared/errors.js +13 -0
- package/dist/shared/home.d.ts +2 -0
- package/dist/shared/home.js +16 -0
- package/dist/shared/human-notes.d.ts +5 -0
- package/dist/shared/human-notes.js +7 -0
- package/dist/shared/qianwen.d.ts +50 -0
- package/dist/shared/qianwen.js +109 -0
- package/dist/shared/time.d.ts +9 -0
- package/dist/shared/time.js +22 -0
- package/dist/shared/tokens.d.ts +18 -0
- package/dist/shared/tokens.js +11 -0
- package/dist/shared/wav.d.ts +10 -0
- package/dist/shared/wav.js +50 -0
- package/dist/stage/chunk-226TNLQA.js +129 -0
- package/dist/stage/chunk-24LGQEKE.js +1 -0
- package/dist/stage/chunk-25XV6UY3.js +10 -0
- package/dist/stage/chunk-2AZI3NHJ.js +1 -0
- package/dist/stage/chunk-2BQZTTBE.js +1 -0
- package/dist/stage/chunk-2M7CGY3Y.js +11 -0
- package/dist/stage/chunk-2VR4WFQ7.js +10 -0
- package/dist/stage/chunk-2ZYXBUWG.js +9 -0
- package/dist/stage/chunk-32LAWMGY.js +1 -0
- package/dist/stage/chunk-3LEMTSOO.js +11 -0
- package/dist/stage/chunk-3QCKGZYE.js +32 -0
- package/dist/stage/chunk-44P66WFA.js +1 -0
- package/dist/stage/chunk-4AM6FRR6.js +7 -0
- package/dist/stage/chunk-4QDE4SUK.js +1 -0
- package/dist/stage/chunk-4QESHV3U.js +1 -0
- package/dist/stage/chunk-4SG5QPHB.js +2 -0
- package/dist/stage/chunk-4T4UVCOC.js +1 -0
- package/dist/stage/chunk-4WUEPPG3.js +34 -0
- package/dist/stage/chunk-4YCN6UZ5.js +99 -0
- package/dist/stage/chunk-5V7ZY2AP.js +1 -0
- package/dist/stage/chunk-5VS5Y2VJ.js +6 -0
- package/dist/stage/chunk-5W4NO2ZD.js +62 -0
- package/dist/stage/chunk-65CRNICM.js +1 -0
- package/dist/stage/chunk-66MULO7F.js +5 -0
- package/dist/stage/chunk-6COFQTBS.js +162 -0
- package/dist/stage/chunk-6VZG3M3I.js +8 -0
- package/dist/stage/chunk-7HVDH43T.js +1 -0
- package/dist/stage/chunk-7JLJN4EZ.js +8 -0
- package/dist/stage/chunk-7QDSP4LN.js +1 -0
- package/dist/stage/chunk-7R6OQAO3.js +1 -0
- package/dist/stage/chunk-7VTTHZKW.js +7 -0
- package/dist/stage/chunk-ATHR5KEN.js +10 -0
- package/dist/stage/chunk-B6R5LTP2.js +36 -0
- package/dist/stage/chunk-BD4WE264.js +1 -0
- package/dist/stage/chunk-BONANU47.js +206 -0
- package/dist/stage/chunk-BTVW7OFK.js +89 -0
- package/dist/stage/chunk-BUTJIDHG.js +8 -0
- package/dist/stage/chunk-C2IPYMML.js +84 -0
- package/dist/stage/chunk-C54FTPPV.js +9 -0
- package/dist/stage/chunk-C6LOUF2Z.js +90 -0
- package/dist/stage/chunk-CFF3PILO.js +339 -0
- package/dist/stage/chunk-CI2FQ2JY.js +9 -0
- package/dist/stage/chunk-CW5O4RV7.js +12 -0
- package/dist/stage/chunk-CYABNQYD.js +1 -0
- package/dist/stage/chunk-D7FFA4NS.js +15 -0
- package/dist/stage/chunk-DUNNXU6Z.js +8 -0
- package/dist/stage/chunk-E3E4Q6JF.js +6 -0
- package/dist/stage/chunk-EB3U3LH7.js +9 -0
- package/dist/stage/chunk-EGSZ736B.js +8 -0
- package/dist/stage/chunk-EOBXW5VQ.js +2 -0
- package/dist/stage/chunk-ERZC3B7Y.js +11 -0
- package/dist/stage/chunk-ESDOOBIH.js +11 -0
- package/dist/stage/chunk-EVU4QAL4.js +178 -0
- package/dist/stage/chunk-EVVZI2NY.js +39 -0
- package/dist/stage/chunk-FBKAZUKD.js +39 -0
- package/dist/stage/chunk-FDVKYQJN.js +1 -0
- package/dist/stage/chunk-FGPVYHVR.js +5 -0
- package/dist/stage/chunk-FHUD4AZC.js +1 -0
- package/dist/stage/chunk-FKUJ2DMY.js +96 -0
- package/dist/stage/chunk-FNZGVIJV.js +1 -0
- package/dist/stage/chunk-GBYSEGYM.js +78 -0
- package/dist/stage/chunk-GCO4TDHX.js +1 -0
- package/dist/stage/chunk-GKTML2LG.js +10 -0
- package/dist/stage/chunk-GMDIHHXQ.js +11 -0
- package/dist/stage/chunk-GOUNACI3.js +10 -0
- package/dist/stage/chunk-GXAYATPF.js +3 -0
- package/dist/stage/chunk-H2K2DDXV.js +8 -0
- package/dist/stage/chunk-H3UJI6ZY.js +13 -0
- package/dist/stage/chunk-H6PUGCME.js +7 -0
- package/dist/stage/chunk-HNBUIMIG.js +1 -0
- package/dist/stage/chunk-HRZY2BJQ.js +10 -0
- package/dist/stage/chunk-HSPJGALW.js +30 -0
- package/dist/stage/chunk-IBYORHIP.js +25 -0
- package/dist/stage/chunk-IHF4EWTY.js +1 -0
- package/dist/stage/chunk-ISBRPNKI.js +1 -0
- package/dist/stage/chunk-IZ3OI3C2.js +2 -0
- package/dist/stage/chunk-JEFZAC2R.js +1 -0
- package/dist/stage/chunk-JFIGRDPO.js +1 -0
- package/dist/stage/chunk-JFVYBOOC.js +9 -0
- package/dist/stage/chunk-JGD4TUTQ.js +1 -0
- package/dist/stage/chunk-JO4BEHDH.js +4 -0
- package/dist/stage/chunk-JOWBS42C.js +1 -0
- package/dist/stage/chunk-JP3TM2TG.js +1 -0
- package/dist/stage/chunk-JVK2C52O.js +1 -0
- package/dist/stage/chunk-K4I76XBX.js +14 -0
- package/dist/stage/chunk-KA6CZZ4M.js +1 -0
- package/dist/stage/chunk-KESSJ36M.js +1 -0
- package/dist/stage/chunk-LKATEVRU.js +1 -0
- package/dist/stage/chunk-LS4EJ2EI.js +1 -0
- package/dist/stage/chunk-LUKJQC2X.js +70 -0
- package/dist/stage/chunk-LWULR3SM.js +1 -0
- package/dist/stage/chunk-LWVRFMKX.js +2 -0
- package/dist/stage/chunk-LXC4ZBPZ.js +1 -0
- package/dist/stage/chunk-M2UP255H.js +1 -0
- package/dist/stage/chunk-MAPPXUZQ.js +231 -0
- package/dist/stage/chunk-MBMPW7Z5.js +1 -0
- package/dist/stage/chunk-MD3HMMTK.js +1 -0
- package/dist/stage/chunk-MLPCUIKR.js +17 -0
- package/dist/stage/chunk-MNQIRPDQ.js +8 -0
- package/dist/stage/chunk-MRG7MJVS.js +1 -0
- package/dist/stage/chunk-MU6EXW72.js +10 -0
- package/dist/stage/chunk-MVU2X6LU.js +4 -0
- package/dist/stage/chunk-N6UA5CZV.js +36 -0
- package/dist/stage/chunk-NLZIT2TJ.js +7 -0
- package/dist/stage/chunk-NOJR3J7M.js +1 -0
- package/dist/stage/chunk-NT7ARDOL.js +1 -0
- package/dist/stage/chunk-NWOGMPZH.js +1 -0
- package/dist/stage/chunk-NXE4KMG3.js +40 -0
- package/dist/stage/chunk-O4YCZXD2.js +257 -0
- package/dist/stage/chunk-OAJZ7HLK.js +1 -0
- package/dist/stage/chunk-OCFCFASO.js +1 -0
- package/dist/stage/chunk-ODGPG5RY.js +7 -0
- package/dist/stage/chunk-ODUEA52Q.js +3 -0
- package/dist/stage/chunk-OGDJ3FCI.js +1 -0
- package/dist/stage/chunk-OLDJ55TT.js +1 -0
- package/dist/stage/chunk-OOCL4ATI.js +2 -0
- package/dist/stage/chunk-OQEFDUDV.js +1 -0
- package/dist/stage/chunk-OQWVEWG7.js +4 -0
- package/dist/stage/chunk-OUIX6BGK.js +1 -0
- package/dist/stage/chunk-OV7OZYUO.js +7 -0
- package/dist/stage/chunk-PFGDY33O.js +1 -0
- package/dist/stage/chunk-PFJDUJB3.js +139 -0
- package/dist/stage/chunk-PGEW76ER.js +1 -0
- package/dist/stage/chunk-PP76TAQX.js +32 -0
- package/dist/stage/chunk-PTBOP4OT.js +2 -0
- package/dist/stage/chunk-PX2RVHCK.js +1 -0
- package/dist/stage/chunk-Q7K3PNAD.js +1 -0
- package/dist/stage/chunk-QDDPLZDB.js +1 -0
- package/dist/stage/chunk-QLGLZ77F.js +1 -0
- package/dist/stage/chunk-QOPFDFHI.js +1 -0
- package/dist/stage/chunk-QXT4KAEJ.js +1 -0
- package/dist/stage/chunk-R4YVZCIT.js +1 -0
- package/dist/stage/chunk-R6P6EHWA.js +62 -0
- package/dist/stage/chunk-RCH3OXBI.js +9 -0
- package/dist/stage/chunk-RHPE44I2.js +1 -0
- package/dist/stage/chunk-RJ2R4FVC.js +1 -0
- package/dist/stage/chunk-RJH2YVTD.js +1 -0
- package/dist/stage/chunk-RLWWW5OB.js +6 -0
- package/dist/stage/chunk-S3WO35Z7.js +120 -0
- package/dist/stage/chunk-S7ZGX6MW.js +1 -0
- package/dist/stage/chunk-SIAWX27B.js +1 -0
- package/dist/stage/chunk-SLG4X622.js +1 -0
- package/dist/stage/chunk-SLJRBC77.js +168 -0
- package/dist/stage/chunk-SRORWQJZ.js +1 -0
- package/dist/stage/chunk-STZTUIIF.js +2 -0
- package/dist/stage/chunk-SVQ6MWNV.js +9 -0
- package/dist/stage/chunk-TL3HLL2S.js +132 -0
- package/dist/stage/chunk-U3DOE6SF.js +24 -0
- package/dist/stage/chunk-UABQIZKW.js +6 -0
- package/dist/stage/chunk-UDK3R764.js +1 -0
- package/dist/stage/chunk-UFF4E54B.js +41 -0
- package/dist/stage/chunk-UFP5Q4AY.js +1 -0
- package/dist/stage/chunk-UMH2TSW2.js +72 -0
- package/dist/stage/chunk-UTASVNF5.js +10 -0
- package/dist/stage/chunk-UW7ML5ML.js +88 -0
- package/dist/stage/chunk-V3J7F6OP.js +1 -0
- package/dist/stage/chunk-VEHP5B5Q.js +5 -0
- package/dist/stage/chunk-VRXWB733.js +9 -0
- package/dist/stage/chunk-VVDHBBS5.js +5 -0
- package/dist/stage/chunk-VYFOH6FU.js +106 -0
- package/dist/stage/chunk-W3VZTLQS.js +3 -0
- package/dist/stage/chunk-W7WNRWTZ.js +8 -0
- package/dist/stage/chunk-WHUCV53C.js +1 -0
- package/dist/stage/chunk-WULMYHIF.js +1 -0
- package/dist/stage/chunk-X54EX7AW.js +9 -0
- package/dist/stage/chunk-X565ISZM.js +1 -0
- package/dist/stage/chunk-XGZMDECB.js +1 -0
- package/dist/stage/chunk-XHONC4RF.js +172 -0
- package/dist/stage/chunk-XIK4XQ6E.js +24 -0
- package/dist/stage/chunk-XJQQWFTE.js +1 -0
- package/dist/stage/chunk-XM4WOU3I.js +13 -0
- package/dist/stage/chunk-XM7Y5HP5.js +38 -0
- package/dist/stage/chunk-XNYYRGD5.js +292 -0
- package/dist/stage/chunk-XOYVR3SV.js +1 -0
- package/dist/stage/chunk-XQIW5HCE.js +1 -0
- package/dist/stage/chunk-YEWL7FUW.js +8 -0
- package/dist/stage/chunk-YI55PY3V.js +1 -0
- package/dist/stage/chunk-YM63HI6W.js +1 -0
- package/dist/stage/chunk-YNLGLU4P.js +9 -0
- package/dist/stage/chunk-YTNMNOWP.js +1 -0
- package/dist/stage/chunk-YV62XKTC.js +1 -0
- package/dist/stage/chunk-YVBA3QW6.js +1 -0
- package/dist/stage/chunk-Z2SY2HOC.js +1 -0
- package/dist/stage/chunk-ZBTCQEBK.js +10 -0
- package/dist/stage/chunk-ZCUFN74D.js +1 -0
- package/dist/stage/chunk-ZHOULXEJ.js +11 -0
- package/dist/stage/chunk-ZTRZIET2.js +1 -0
- package/dist/stage/index.html +1 -0
- package/dist/stage/stage.css +1 -1
- package/dist/stage/stage.js +1 -4383
- package/dist/ui/dev/dev-page.d.ts +1 -0
- package/dist/ui/dev/dev-page.js +2248 -0
- package/dist/ui/dev/dev-theme.d.ts +6 -0
- package/dist/ui/dev/dev-theme.js +67 -0
- package/dist/ui/dev/qr-page.d.ts +8 -0
- package/dist/ui/dev/qr-page.js +61 -0
- package/dist/ui/dev/voice-gallery-page.d.ts +1 -0
- package/dist/ui/dev/voice-gallery-page.js +121 -0
- package/dist/ui/dev/voice-scenes.d.ts +21 -0
- package/dist/ui/dev/voice-scenes.js +30 -0
- package/dist/ui/dictation/en-box.d.ts +2 -0
- package/dist/ui/dictation/en-box.js +86 -0
- package/dist/ui/dictation/en-page.d.ts +1 -0
- package/dist/ui/dictation/en-page.js +484 -0
- package/dist/ui/dictation/page.d.ts +1 -0
- package/dist/ui/dictation/page.js +1084 -0
- package/dist/ui/dictation/parent-page.d.ts +1 -0
- package/dist/ui/dictation/parent-page.js +234 -0
- package/dist/ui/dictation/reel-mode.d.ts +1 -0
- package/dist/ui/dictation/reel-mode.js +87 -0
- package/dist/ui/dictation/reel-page.d.ts +1 -0
- package/dist/ui/dictation/reel-page.js +62 -0
- package/dist/ui/dictation/reel.d.ts +163 -0
- package/dist/ui/dictation/reel.js +436 -0
- package/dist/ui/dictation/ui.d.ts +44 -0
- package/dist/ui/dictation/ui.js +80 -0
- package/dist/ui/dictation/unlock.d.ts +7 -0
- package/dist/ui/dictation/unlock.js +16 -0
- package/dist/ui/kid/audio-client.d.ts +11 -0
- package/dist/ui/kid/audio-client.js +74 -0
- package/dist/ui/kid/board-mark-art.d.ts +37 -0
- package/dist/ui/kid/board-mark-art.js +5 -0
- package/dist/ui/kid/board-mark.d.ts +24 -0
- package/dist/ui/kid/board-mark.js +58 -0
- package/dist/ui/kid/kid-log.d.ts +15 -0
- package/dist/ui/kid/kid-log.js +87 -0
- package/dist/ui/kid/kid-nav.d.ts +11 -0
- package/dist/ui/kid/kid-nav.js +22 -0
- package/dist/ui/kid/kid-page.d.ts +9 -0
- package/dist/ui/kid/kid-page.js +3719 -0
- package/dist/ui/kid/letters.d.ts +26 -0
- package/dist/ui/kid/letters.js +51 -0
- package/dist/ui/kid/photo-edit.d.ts +83 -0
- package/dist/ui/kid/photo-edit.js +106 -0
- package/dist/ui/kid/photo-ui.d.ts +23 -0
- package/dist/ui/kid/photo-ui.js +60 -0
- package/dist/ui/kid/reel.d.ts +419 -0
- package/dist/ui/kid/reel.js +648 -0
- package/dist/ui/kid/tianzige.d.ts +8 -0
- package/dist/ui/kid/tianzige.js +45 -0
- package/dist/ui/kid/tutor-face.d.ts +35 -0
- package/dist/ui/kid/tutor-face.js +84 -0
- package/dist/ui/kid/voice-ui.d.ts +70 -0
- package/dist/ui/kid/voice-ui.js +316 -0
- package/dist/ui/kid/voice-view.d.ts +71 -0
- package/dist/ui/kid/voice-view.js +75 -0
- package/dist/ui/kid/word-draw.d.ts +2 -0
- package/dist/ui/kid/word-draw.js +107 -0
- package/dist/ui/kid/word-emoji.d.ts +10 -0
- package/dist/ui/kid/word-emoji.js +66 -0
- package/dist/ui/kid/word-layout.d.ts +25 -0
- package/dist/ui/kid/word-layout.js +29 -0
- package/dist/ui/parent/parent-home-page.d.ts +1 -0
- package/dist/ui/parent/parent-home-page.js +414 -0
- package/dist/ui/parent/parent-page.d.ts +2 -0
- package/dist/ui/parent/parent-page.js +507 -0
- package/dist/ui/shared/brand.d.ts +36 -0
- package/dist/ui/shared/brand.js +51 -0
- package/dist/ui/shared/favicon.d.ts +12 -0
- package/dist/ui/shared/favicon.js +96 -0
- package/dist/ui/shared/icon.d.ts +9 -0
- package/dist/ui/shared/icon.js +35 -0
- package/dist/ui/shared/inline-lib.d.ts +1 -0
- package/dist/ui/shared/inline-lib.js +28 -0
- package/dist/ui/shared/logo.d.ts +31 -0
- package/dist/ui/shared/logo.js +43 -0
- package/dist/ui/shared/page-dom.d.ts +12 -0
- package/dist/ui/shared/page-dom.js +36 -0
- package/dist/ui/shared/png.d.ts +1 -0
- package/dist/ui/shared/png.js +45 -0
- package/dist/ui/shared/qr-svg.d.ts +3 -0
- package/dist/ui/shared/qr-svg.js +32 -0
- package/dist/ui/shared/qr.d.ts +14 -0
- package/dist/ui/shared/qr.js +281 -0
- package/dist/ui/shared/reel-dock.d.ts +22 -0
- package/dist/ui/shared/reel-dock.js +365 -0
- package/dist/ui/shared/tutor-color.d.ts +15 -0
- package/dist/ui/shared/tutor-color.js +47 -0
- package/dist/ui/talk/page.d.ts +6 -0
- package/dist/ui/talk/page.js +294 -0
- package/grades/grade-2/README.md +11 -0
- package/grades/grade-2/VALIDATION.md +41 -0
- package/grades/grade-2/agents/chinese-tutor/activities//344/275/240/350/257/264/346/210/221/347/224/273.md +157 -0
- package/grades/grade-2/agents/chinese-tutor/activities//344/275/240/350/257/264/346/210/221/347/224/273./344/276/213/345/255/220.md +78 -0
- package/grades/grade-2/agents/chinese-tutor/activities//344/276/213/345/255/220/345/233/276//345/200/237/344/274/236.jpg +0 -0
- package/grades/grade-2/agents/chinese-tutor/activities//344/276/213/345/255/220/345/233/276//345/260/217/347/214/264/346/221/230/346/241/203.jpg +0 -0
- package/grades/grade-2/agents/chinese-tutor/activities//345/206/231/350/257/235.md +64 -0
- package/grades/grade-2/agents/chinese-tutor/activities//345/206/231/350/257/235./344/276/213/345/255/220.md +31 -0
- package/grades/grade-2/agents/chinese-tutor/activities//345/220/254/345/206/231.md +48 -0
- package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +79 -0
- package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/chinese-tutor/activities//345/260/217/350/257/276/345/240/202.md +53 -0
- package/grades/grade-2/agents/chinese-tutor/activities//346/211/271/346/224/271.md +81 -0
- package/grades/grade-2/agents/chinese-tutor/activities//346/211/271/346/224/271./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/chinese-tutor/activities//347/234/213/345/233/276/350/257/264/350/257/235.md +94 -0
- package/grades/grade-2/agents/chinese-tutor/activities//347/234/213/345/233/276/350/257/264/350/257/235./344/276/213/345/255/220.md +63 -0
- package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +82 -0
- package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/chinese-tutor.md +59 -0
- package/grades/grade-2/agents/english-tutor/activities//345/217/243/350/257/255/350/257/276.md +149 -0
- package/grades/grade-2/agents/english-tutor/activities//345/217/243/350/257/255/350/257/276./344/276/213/345/255/220.md +13 -0
- package/grades/grade-2/agents/english-tutor/activities//345/220/254/345/206/231.md +47 -0
- package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +78 -0
- package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/english-tutor/activities//345/260/217/350/257/276/345/240/202.md +53 -0
- package/grades/grade-2/agents/english-tutor/activities//346/211/271/346/224/271.md +80 -0
- package/grades/grade-2/agents/english-tutor/activities//346/211/271/346/224/271./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +86 -0
- package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/english-tutor.md +57 -0
- package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +78 -0
- package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/math-tutor/activities//345/260/217/350/257/276/345/240/202.md +56 -0
- package/grades/grade-2/agents/math-tutor/activities//345/260/217/350/257/276/345/240/202./344/276/213/345/255/220.md +52 -0
- package/grades/grade-2/agents/math-tutor/activities//346/211/271/346/224/271.md +80 -0
- package/grades/grade-2/agents/math-tutor/activities//346/211/271/346/224/271./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +81 -0
- package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240./344/276/213/345/255/220.md +35 -0
- package/grades/grade-2/agents/math-tutor.md +51 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/audio/index.json +44 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/audio/step-1.mp3 +0 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/audio/step-2.mp3 +0 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/audio/step-3.mp3 +0 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/audio/step-4.mp3 +0 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/audio/step-5.mp3 +0 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/bake.json +1 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/manifest.json +63 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen/scene.json +247 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen.md +40 -0
- package/grades/grade-2/examples/2026-10-27-pingjunfen.ts +26 -0
- package/grades/grade-2/examples//345/271/263/345/235/207/345/210/206./344/276/213/345/255/220.md +24 -0
- package/grades/grade-2/examples//346/225/260/345/255/246/345/271/263/345/235/207/345/210/206.md +42 -0
- package/grades/grade-2/validation/2026-10-27.json +4410 -0
- package/package.json +26 -15
- package/skills/chalk-cli/SKILL.md +62 -0
- package/skills/chalk-scene/SKILL.md +91 -0
- package/skills/chalk-teaching/SKILL.md +82 -0
- package/skills/chalk-teaching/models//344/272/244/351/224/231/346/225/260/345/210/227/345/210/206/350/241/214.md +8 -0
- package/skills/chalk-teaching/models//345/207/221/345/215/201.md +7 -0
- package/skills/chalk-teaching/models//345/221/250/346/234/237/350/247/204/345/276/213.md +6 -0
- package/skills/chalk-teaching/models//345/256/236/347/211/251/345/233/276-/345/242/236/345/207/217.md +6 -0
- package/skills/chalk-teaching/models//345/256/236/347/211/251/345/233/276-/346/257/224/345/244/232/345/260/221-/346/261/202/345/244/247/346/225/260/346/261/202/345/260/217/346/225/260.md +7 -0
- package/skills/chalk-teaching/models//345/256/236/347/211/251/345/233/276-/346/257/224/345/244/232/345/260/221-/346/261/202/345/267/256.md +6 -0
- package/skills/chalk-teaching/models//345/260/217/346/243/222/350/277/233/344/275/215/345/212/240.md +7 -0
- package/skills/chalk-teaching/models//345/260/217/346/243/222/351/200/200/344/275/215/345/207/217.md +6 -0
- package/skills/chalk-teaching/models//346/216/222/351/230/237/347/202/271/351/230/265.md +7 -0
- package/skills/chalk-teaching/models//346/227/266/351/227/264/346/225/260/350/275/264.md +6 -0
- package/skills/chalk-teaching/models//346/245/274/345/261/202/351/227/264/351/232/224/345/233/276.md +7 -0
- package/skills/chalk-teaching/models//347/240/264/345/215/201.md +7 -0
- package/skills/chalk-teaching/models//347/253/226/345/274/217/351/200/200/344/275/215.md +6 -0
- package/skills/chalk-teaching/models//347/272/277/346/256/265/345/233/276.md +7 -0
- package/skills/chalk-teaching/models-index.md +24 -0
- package/skills/chalk-teaching/references//350/256/262/350/247/243/346/250/241/346/235/277/344/270/211/346/241/243.md +32 -0
- package/skills/chalk-teaching/references//350/257/264/346/230/216/346/226/207/344/273/266.md +102 -0
- package/skills/chalk-verify/SKILL.md +57 -0
- package/skills/chalk-verify/references//350/257/204/345/210/244/346/240/207/345/207/206.md +69 -0
- package/skills/cotutor-analyze/SKILL.md +106 -0
- package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +31 -0
- package/skills/cotutor-board/SKILL.md +373 -0
- package/skills/cotutor-board/references/README.md +38 -0
- package/skills/cotutor-board/references/canvas.md +68 -0
- package/skills/cotutor-board/references/choice.md +55 -0
- package/skills/cotutor-board/references/code.md +46 -0
- package/skills/cotutor-board/references/image.md +52 -0
- package/skills/cotutor-board/references/lecture.md +69 -0
- package/skills/cotutor-board/references/paint.md +77 -0
- package/skills/cotutor-board/references/photo.md +61 -0
- package/skills/cotutor-board/references/read.md +51 -0
- package/skills/cotutor-board/references/text.md +69 -0
- package/skills/cotutor-board/references/tianzige.md +78 -0
- package/skills/cotutor-board/references/word.md +91 -0
- package/skills/cotutor-home/SKILL.md +169 -0
- package/skills/cotutor-home/references/dictation.md +97 -0
- package/skills/cotutor-home/references/tutor.md +148 -0
- package/skills/cotutor-home/references//345/221/275/344/273/244.md +21 -0
- package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +142 -0
- package/skills/cotutor-tune/SKILL.md +147 -0
- package/skills/cotutor-tune/references//345/255/227/346/256/265.md +50 -0
- package/skills/cotutor-tutor/SKILL.md +261 -0
- package/skills/cotutor-tutor/references//344/270/212/344/270/213/346/226/207/345/214/205.md +78 -0
- package/themes/default/kid.css +86 -0
- package/themes/default/theme.json +26 -0
- package/agents/chinese-tutor.md +0 -67
- package/agents/homework-tutor.md +0 -17
- package/agents/math-tutor.md +0 -68
- package/agents/planner.md +0 -18
- package/agents/reading-tutor.md +0 -65
- package/agents/scene-maker.md +0 -41
- package/dist/cards/canvas.d.ts +0 -22
- package/dist/cards/canvas.js +0 -65
- package/dist/cards/fill.d.ts +0 -14
- package/dist/cards/fill.js +0 -64
- package/dist/cards/index.d.ts +0 -48
- package/dist/cards/index.js +0 -115
- package/dist/cards/kind.js +0 -7
- package/dist/cards/scene.d.ts +0 -24
- package/dist/cards/scene.js +0 -67
- package/dist/cards/text.d.ts +0 -18
- package/dist/cards/text.js +0 -41
- package/dist/cli/doctor.js +0 -355
- package/dist/cli/init.js +0 -116
- package/dist/cli/main.js +0 -243
- package/dist/cli/serve.js +0 -56
- package/dist/cli/skeleton.d.ts +0 -39
- package/dist/cli/skeleton.js +0 -184
- package/dist/cli/skills.d.ts +0 -40
- package/dist/cli/skills.js +0 -170
- package/dist/cli/workspace.d.ts +0 -69
- package/dist/lib/agent-file.d.ts +0 -10
- package/dist/lib/agent-file.js +0 -16
- package/dist/lib/board.d.ts +0 -15
- package/dist/lib/board.js +0 -107
- package/dist/lib/context-pack.js +0 -55
- package/dist/lib/conversation.js +0 -88
- package/dist/lib/kid-board.d.ts +0 -148
- package/dist/lib/kid-board.js +0 -294
- package/dist/lib/kid-view.d.ts +0 -63
- package/dist/lib/kid-view.js +0 -93
- package/dist/lib/ledger.d.ts +0 -29
- package/dist/lib/run-plan.d.ts +0 -30
- package/dist/lib/run-plan.js +0 -32
- package/dist/lib/sections.d.ts +0 -12
- package/dist/lib/sections.js +0 -151
- package/dist/lib/stream.d.ts +0 -15
- package/dist/lib/stream.js +0 -69
- package/dist/lib/transcript.d.ts +0 -36
- package/dist/lib/transcript.js +0 -95
- package/dist/server/app.d.ts +0 -74
- package/dist/server/app.js +0 -406
- package/dist/server/kid-page.d.ts +0 -1
- package/dist/server/kid-page.js +0 -768
- package/dist/server/mock.js +0 -403
- package/dist/server/parent-page.d.ts +0 -8
- package/dist/server/parent-page.js +0 -393
- package/dist/server/runner.d.ts +0 -64
- package/dist/server/runner.js +0 -277
- package/dist/server/scene-props.d.ts +0 -16
- package/dist/server/scene-props.js +0 -39
- package/dist/server/store.d.ts +0 -43
- package/dist/server/store.js +0 -210
- package/dist/server/tts.d.ts +0 -51
- package/dist/server/tts.js +0 -99
- /package/dist/{cli → app/cli}/cert.d.ts +0 -0
- /package/dist/{cli → app/cli}/main.d.ts +0 -0
- /package/dist/{cards → board/cards}/choice.d.ts +0 -0
- /package/dist/{cards → board/cards}/code.d.ts +0 -0
- /package/dist/{cards → board/cards}/image.d.ts +0 -0
- /package/dist/{cards → board/cards}/read.d.ts +0 -0
|
@@ -0,0 +1,3719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 孩子端 `/`:首页(老师卡 + 家长发布的首页卡)与老师页(= 板书页)。零依赖内联脚本,只走 /api/kid/* 与 /api/audio。
|
|
3
|
+
* 首页的老师卡上按钮决定进老师页之后的话题:新话题 / 接着某个话题 / 开场(按钮上的字立刻发出去);老师页不再自己猜话题。
|
|
4
|
+
* 家长端「记录」(/parent)2026-10-28 拆成自己的一页(parent-page.ts),这里没有家长的分支了。
|
|
5
|
+
* 录像里孩子的屏幕(/reel)是同一个页面:__MODE__ 里 reel = true——孩子端的样子,数据是 /reel 给的一个话题,
|
|
6
|
+
* S.readonly 照样是真(不开麦克风、不记实录),由录像的时钟驱动。REEL 的分支只准出现在:声音(用家长页那个 Audio)、chrome(输入条露着、点不动)、
|
|
7
|
+
* 答案栏的「等你说…」、启动与重载,以及录像那一块;别处看到 REEL 就是写错了。
|
|
8
|
+
* 家长端「首页」里的预览(/preview)也是同一个页面:__MODE__ 里 preview = true——孩子端首页本身,在家长页的 iframe 里;点牌上的按钮不跳走,
|
|
9
|
+
* 经 window.parent.cotutorPreview 告诉家长页点了哪颗,家长页经 window.cotutorPreviewSet 换最前那张、塞改着的字;不记「看过了」。PREVIEW 的分支只准出现在首页那几处与启动。
|
|
10
|
+
* 铁律:界面上永远没有错误与评判——后端不通、老师出错、识别失败,都只是「什么都不出现」或头像灰;
|
|
11
|
+
* 文字尽量少,语音优先。孩子设备上没有通往家长端的入口。
|
|
12
|
+
*
|
|
13
|
+
* 老师页照豆包爱学的形态:板书是一天一份、越讲越长的文档。一轮回复 = 一节。
|
|
14
|
+
* 今天能说话的话题都走实时(body.live):嘴照念脑的讲稿、声音从 WebSocket 下来,孩子端不念;卡闭合了就铺,落下时标这张卡的标注一起画上;
|
|
15
|
+
* 节头是脑写的那张卡,孩子的话不上板(在左栏);横屏左栏大头像 + 字幕、板在右边一块白板,卡角没有钮;
|
|
16
|
+
* 谁出声谁停走 ../../runtime/delivery/player.ts 的 liveStep(同样剥类型内联);连不上、断了回到「正在叫老师」再连,不退回别的样子。
|
|
17
|
+
* 不走实时的(以前的话题只读回放、家长端试发与 cotutor send 的轮):整节铺上、标注画齐、不念;孩子的话进问题卡的答案栏或节开头一行。
|
|
18
|
+
* 输入条照豆包通用版:相机 | 发消息或按住说话 | 加号(相册)。
|
|
19
|
+
*
|
|
20
|
+
* 交互逻辑在 ../../board/presentation.ts(纯函数,有测试),这里把它剥掉类型内联进页面,两处一份源码。卡按 kind 渲染(text / read / choice / fill / code),
|
|
21
|
+
* 每种卡的紧凑态只读;点卡开舞台(盖住板书那块,顶栏是卡的名字 + 关闭,讲稿暂停):选择题在舞台里点大按钮,选了就 PUT 状态,
|
|
22
|
+
* 「交给老师」= 发一条 {text:'', action:'submit', focus:{card}};舞台开着时发的消息都带 focus.card。「继续」2026-10-08 去掉了。
|
|
23
|
+
* 状态存服务端,重开页面从 section.cards[n].state 读回;卡上永远不画对错。
|
|
24
|
+
* 重卡(小课堂 / 画板)的舞台在 iframe 里装 /stage/?card=<id>(舞台包,src/ui/stage/),postMessage 协议见 src/ui/stage/protocol.ts:
|
|
25
|
+
* 页面发 card(props + state + 课包 URL),包回 phase / state / submit / close;小课堂那一段在舞台上放(课里的字与播放按钮在舞台里),放完接着念;
|
|
26
|
+
* 讲稿 [[play]] 锚到小课堂卡 → 念完那句铺满放那一段,放完停在末帧、接着念。
|
|
27
|
+
* 点读段:card.assets 里有 <段号>.mp3 的放服务端配的,还没配好的等它到(拉今天会补上);填空舞台逐空打字、「交给老师」;图片舞台双指缩放。
|
|
28
|
+
* 作业照片:相机 / 相册先进发照片屏(裁剪、圈画、转 90°、配一句话,坐标在 src/ui/kid/photo-edit.ts),再一条 {text, photos};节头小图点开看大图。
|
|
29
|
+
* 流式:老师还在写时 pending 条目带 partial 板书,卡按下标只追加不重画(先出的卡不闪);整轮跑完那节换成正式的,不重播。
|
|
30
|
+
* __TITLE__ / __SHORT__(主屏幕图标下的名字)由路由替换。调试参数(只管这一次打开,见文末启动):`?tutor=` 进哪位老师,
|
|
31
|
+
* `&stage=<节>.<卡>` 直接开某张卡的舞台,`&new=1` / `&hist=<日期>.<话题>` / `&panel=1` 见启动那段;`?device=` 一直留着。
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { wordDrawJs } from "./word-draw.js";
|
|
35
|
+
import { NEUTRAL_COLOR } from "../shared/tutor-color.js";
|
|
36
|
+
import { faviconLinks } from "../shared/favicon.js";
|
|
37
|
+
import { REEL_DOCK_CSS, REEL_DOCK_HTML, REEL_DOCK_JS } from "../shared/reel-dock.js";
|
|
38
|
+
import { inlineScript } from "../shared/inline-lib.js";
|
|
39
|
+
import { BACK_LOGO, BRAND_CSS, BRAND_JS, titleLogo } from "../shared/brand.js";
|
|
40
|
+
import { albumSvg, cameraSvg } from "./photo-ui.js";
|
|
41
|
+
import { AUDIO_CLIENT_JS } from "./audio-client.js";
|
|
42
|
+
import { VOICE_CSS, VOICE_JS } from "./voice-ui.js";
|
|
43
|
+
import { KID_NAV_JS } from "./kid-nav.js";
|
|
44
|
+
import { pageDomJs } from "../shared/page-dom.js";
|
|
45
|
+
import { KID_LOG_JS } from "./kid-log.js";
|
|
46
|
+
import { BACK_TINT_CSS, BACK_TINT_JS } from "./tutor-face.js";
|
|
47
|
+
import { BOARD_MARK_CSS, BOARD_MARK_HAND_LEFT_CSS, BOARD_MARK_JS } from "./board-mark.js";
|
|
48
|
+
/** 内联进页面的那几份纯文件(仓库里是 .ts,npm 包里是 dist 的 .js;inline-lib 剥类型、去 export,变成能内联的普通脚本) */
|
|
49
|
+
const INLINE = {
|
|
50
|
+
'kid-board': new URL('../../board/presentation.ts', import.meta.url),
|
|
51
|
+
'photo-edit': new URL('./photo-edit.ts', import.meta.url),
|
|
52
|
+
reel: new URL('./reel.ts', import.meta.url),
|
|
53
|
+
'live-board': new URL('../../runtime/delivery/player.ts', import.meta.url),
|
|
54
|
+
'voice-view': new URL('./voice-view.ts', import.meta.url),
|
|
55
|
+
'canvas-chat': new URL('../../board/canvas-chat.ts', import.meta.url),
|
|
56
|
+
};
|
|
57
|
+
function libSource(name) {
|
|
58
|
+
return inlineScript(INLINE[name]);
|
|
59
|
+
}
|
|
60
|
+
const PAGE = `<!doctype html>
|
|
61
|
+
<html lang="zh-CN">
|
|
62
|
+
<meta charset="utf-8">
|
|
63
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
|
|
64
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
65
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
66
|
+
<meta name="apple-mobile-web-app-title" content="__SHORT__">
|
|
67
|
+
<meta name="theme-color" content="#faf9f4">
|
|
68
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
69
|
+
${faviconLinks()}
|
|
70
|
+
<link rel="stylesheet" href="/kid/theme.css">
|
|
71
|
+
<title>__TITLE__</title>
|
|
72
|
+
<style>
|
|
73
|
+
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
|
74
|
+
html, body { height:100%; }
|
|
75
|
+
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; }
|
|
76
|
+
button { font:inherit; color:inherit; background:none; border:0; padding:0; cursor:pointer; }
|
|
77
|
+
.ic svg { display:block; }
|
|
78
|
+
/* ---- 首页 ---- */
|
|
79
|
+
#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; overflow-x:clip; }
|
|
80
|
+
h1 { font-size:20px; font-weight:600; margin:0; color:var(--dim); }
|
|
81
|
+
/* 标题前那颗 logo(src/ui/shared/brand.ts titleLogo):不在标题里面(#title 的字会整段换掉);孩子端跟标题同色(家长端各页是彩色的) */
|
|
82
|
+
#ttl { display:flex; align-items:center; gap:10px; }
|
|
83
|
+
#ct-mark { display:flex; color:var(--dim); -webkit-tap-highlight-color:transparent; }
|
|
84
|
+
#ct-mark .ct-go, #ct-mark .ct-gi { transition:transform .45s cubic-bezier(.3,1.6,.5,1); }
|
|
85
|
+
/* 老师头像 = C 形开口环(Cotutor 的 C,开口朝右、朝着老师说出来的话):环是 r=44 的圆(周长 276.46),
|
|
86
|
+
描 80% 留 20%,转 36° 让缺口居中在右;环是学科色(currentColor),里面一个白底圆放图或字。会动的只改 dasharray 与转角 */
|
|
87
|
+
.av { position:relative; display:grid; place-items:center; font-weight:700; flex:0 0 auto; }
|
|
88
|
+
.av .ring { position:absolute; left:0; top:0; width:100%; height:100%; overflow:visible; pointer-events:none; }
|
|
89
|
+
.av .ring circle { fill:none; stroke:currentColor; stroke-width:7; stroke-linecap:round; stroke-dasharray:221.17 55.29; transform-origin:50px 50px; transform:rotate(36deg); transition:transform .45s cubic-bezier(.3,1.6,.5,1); }
|
|
90
|
+
.av .face { position:absolute; left:15%; top:15%; width:70%; height:70%; border-radius:50%; background:var(--card); overflow:hidden; display:grid; place-items:center; font-size:.76em; line-height:1; }
|
|
91
|
+
.av img { width:100%; height:100%; object-fit:cover; }
|
|
92
|
+
/* 画出来:从右上起笔逆时针一笔写出 C,写完头像冒出来(首页打开、回到首页、空白板;--d 是首页几位老师错开的那一下) */
|
|
93
|
+
.av.draw .ring circle { animation:av-draw .9s cubic-bezier(.6,0,.3,1) var(--d, 0s) backwards; }
|
|
94
|
+
.av.draw .face { animation:av-pop .5s ease-out calc(var(--d, 0s) + .35s) backwards; }
|
|
95
|
+
@keyframes av-draw { from { stroke-dasharray:0 276.46; stroke-dashoffset:-221.17; } to { stroke-dasharray:221.17 276.46; stroke-dashoffset:0; } }
|
|
96
|
+
@keyframes av-pop { from { opacity:0; transform:scale(.7); } to { opacity:1; transform:scale(1); } }
|
|
97
|
+
@media (prefers-reduced-motion:reduce) { .av .ring circle, .av .face { animation:none !important; transition:none; } }
|
|
98
|
+
.hcards { display:grid; grid-template-columns:minmax(0,1fr); gap:12px; align-items:start; }
|
|
99
|
+
.hcards:empty { display:none; }
|
|
100
|
+
.lbl { font-size:14px; color:var(--dim); }
|
|
101
|
+
#toast { position:fixed; left:50%; bottom:calc(env(safe-area-inset-bottom) + 24px); transform:translateX(-50%); max-width:min(560px,calc(100% - 32px)); padding:12px 18px; border-radius:16px; background:#2b2b2bee; color:#fff; font-size:15px; line-height:1.5; white-space:pre-line; z-index:50; display:none; }
|
|
102
|
+
#toast.on { display:block; }
|
|
103
|
+
/* 小课堂:首页按钮点进来,舞台包铺满整页放;看完关掉,板书顶上一张小课堂卡,输入条才亮 */
|
|
104
|
+
#lc-frame { position:fixed; inset:0; width:100%; height:100%; border:0; z-index:90; background:#faf9f4; }
|
|
105
|
+
#lc-frame[hidden] { display:none; }
|
|
106
|
+
.c.c-lc { flex-direction:row; align-items:center; gap:14px; background:var(--tint-sand-bg); border-color:var(--tint-sand-line); }
|
|
107
|
+
.c-lc .lt { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
|
|
108
|
+
.c-lc .lt small { font-size:13px; font-weight:700; color:var(--tint-sand-head); letter-spacing:1px; }
|
|
109
|
+
.c-lc .lt b { font-size:20px; }
|
|
110
|
+
.c-lc .lt span { font-size:14px; color:var(--dim); }
|
|
111
|
+
.c-lc .lc-th { width:168px; height:100px; flex:none; padding:0; border:1px solid var(--tint-sand-line); border-radius:10px; overflow:hidden; display:grid; place-items:center; background:#fff; }
|
|
112
|
+
.c-lc .lc-th .th, .c-lc .lc-th .mk-svg, .c-lc .lc-th video { width:100%; height:100%; display:block; object-fit:contain; }
|
|
113
|
+
@media (max-width:560px) { .c-lc .lc-th { width:112px; height:68px; } }
|
|
114
|
+
.c-lc .re { flex:none; height:44px; padding:0 16px; border-radius:22px; border:1.5px solid var(--tint-sand-line); background:#fff; font-size:16px; }
|
|
115
|
+
.lc-ask { display:flex; flex-direction:column; align-items:center; gap:8px; padding:36px 0 12px; text-align:center; }
|
|
116
|
+
.lc-ask b { font-size:26px; }
|
|
117
|
+
.lc-ask span { font-size:16px; color:var(--dim); }
|
|
118
|
+
.lc-ask.sm { padding:8px 0 4px; }
|
|
119
|
+
/* 画的卡:孩子做的卡;缩略图是那张截图(画面叠上孩子画的几笔;没截图的课包现画那一刻再叠上) */
|
|
120
|
+
.mk-row { display:flex; flex-wrap:wrap; gap:12px; margin:10px 0; }
|
|
121
|
+
.c.c-mark { position:relative; flex-direction:column; gap:8px; padding:10px 12px; border:2px solid #2f6fd6; border-radius:16px; background:#fff; max-width:100%; }
|
|
122
|
+
.c-mark .mk-main { display:flex; align-items:center; gap:12px; cursor:pointer; padding-right:34px; }
|
|
123
|
+
.c-mark .mk-th { width:156px; height:80px; flex:none; border:1px solid var(--line); border-radius:10px; overflow:hidden; display:grid; place-items:center; background:#fff; }
|
|
124
|
+
.c-mark .mk-svg { width:100%; height:100%; display:block; }
|
|
125
|
+
.c-mark img.mk-svg { object-fit:contain; }
|
|
126
|
+
.c-mark .mk-t { display:flex; flex-direction:column; gap:2px; }
|
|
127
|
+
.c-mark .mk-t small { font-size:13px; font-weight:700; color:#2f6fd6; }
|
|
128
|
+
.c-mark .mk-t b { font-size:22px; font-variant-numeric:tabular-nums; }
|
|
129
|
+
.c-mark .x { position:absolute; right:4px; top:4px; width:40px; height:40px; border-radius:50%; border:none; background:transparent; font-size:22px; line-height:1; color:var(--dim); }
|
|
130
|
+
/* 孩子的话:答问题卡的进卡下沿的答案栏,其余的就是那一节的节头(.sh.said);语音前面一个蓝底小话筒,一行截断,点字看原话 */
|
|
131
|
+
.kid-ans { margin:8px -16px -14px; padding:10px 16px 12px; border:0; border-top:1.5px dashed var(--tint-paper-line); border-radius:0 0 14px 14px; background:transparent; display:flex; align-items:center; gap:9px; font:inherit; font-size:18px; font-weight:500; line-height:1.5; color:var(--ink); text-align:left; cursor:pointer; }
|
|
132
|
+
.kid-ans.wait { display:none; font-weight:400; color:var(--dim); cursor:default; }
|
|
133
|
+
body.waiting .kid-ans.wait { display:flex; }
|
|
134
|
+
.sh.said { gap:10px; padding-top:14px; }
|
|
135
|
+
.sh.said::after { display:none; }
|
|
136
|
+
.sh.said .ln { flex:1 1 12px; min-width:12px; height:1px; margin-left:6px; background:var(--line); }
|
|
137
|
+
.sh.said .ka-t { font-size:22px; font-weight:600; letter-spacing:.5px; line-height:1.4; color:var(--ink); cursor:pointer; }
|
|
138
|
+
.kid-ans .ka-t, .sh.said .ka-t { flex:0 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
139
|
+
.kid-ans .ka-t { flex-grow:1; }
|
|
140
|
+
.kid-ans.open .ka-t, .sh.said.open .ka-t { white-space:normal; }
|
|
141
|
+
.lc-still { width:100%; height:100%; }
|
|
142
|
+
.lc-still img { width:100%; height:100%; display:block; object-fit:contain; }
|
|
143
|
+
#rest { display:none; text-align:center; color:var(--dim); font-size:16px; padding:12px 0; }
|
|
144
|
+
/* 百炼没配:老师们灰着、点不进去,下面一行叫爸爸妈妈;不说为什么 */
|
|
145
|
+
#gate { display:none; text-align:center; color:var(--dim); font-size:18px; padding:16px 0; line-height:1.6; }
|
|
146
|
+
#gate small { display:block; font-size:15px; }
|
|
147
|
+
body.gated #gate { display:block; }
|
|
148
|
+
body.gated #rest { display:none; }
|
|
149
|
+
/* 首页牌堆:一位老师一张牌(老师卡),平着叠,最前那张在最上层。手机竖着叠:后面的牌在上面,每张往上露出头那一行;
|
|
150
|
+
平板横着叠:后面的牌在左边,每张往左露出一格(左上角的头像)。位置全在 transform 里(JS 按层算,换牌用 Web Animations);--tf 是同一个值,
|
|
151
|
+
给「减弱动态效果」下的 *:active { transform:none !important } 兜底。牌堆往上伸进上面的空当(换牌时抽出、浮起要地方),空的地方不收触摸 */
|
|
152
|
+
#deck { position:relative; z-index:1; flex:none; pointer-events:none; margin-top:calc(6px - var(--head, 0px)); }
|
|
153
|
+
#deck[hidden] { display:none; }
|
|
154
|
+
#deck .pc { position:absolute; left:0; top:var(--head); width:var(--w); height:var(--h); transform-origin:var(--org); pointer-events:auto; padding:14px 14px 44px; gap:12px; border-width:2px; }
|
|
155
|
+
#deck .pc:not(.front) { cursor:pointer; }
|
|
156
|
+
#deck .pc:not(.front) * { pointer-events:none; }
|
|
157
|
+
#deck .pc .veil { position:absolute; inset:0; border-radius:inherit; background:var(--paper); pointer-events:none; z-index:2; }
|
|
158
|
+
#deck .pc-hd { position:relative; display:flex; align-items:center; gap:10px; min-width:0; }
|
|
159
|
+
#deck .pc-hd .av { position:relative; z-index:3; width:var(--av); height:var(--av); font-size:calc(var(--av) * .4); }
|
|
160
|
+
#deck .pc-hd .nm { font-size:var(--nmz); font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
|
161
|
+
#deck .pc-hd .dot { position:absolute; z-index:4; top:0; left:calc(var(--av) * .12); width:14px; height:14px; border-radius:50%; background:var(--accent); border:3px solid var(--card); }
|
|
162
|
+
#deck .pc-ix { position:absolute; right:12px; bottom:10px; width:28px; height:28px; transform:rotate(180deg); }
|
|
163
|
+
#deck .pc-ix .av { width:100%; height:100%; font-size:11px; }
|
|
164
|
+
#deck .pc .kc { display:grid; grid-template-columns:minmax(0,1fr); gap:12px; align-items:start; }
|
|
165
|
+
#deck .pc .kcards { margin-top:4px; }
|
|
166
|
+
/* 家长端预览里正在改的那颗 */
|
|
167
|
+
#deck .pc .bt.pv-sel { outline:4px solid var(--ink); outline-offset:3px; }
|
|
168
|
+
body.offline #deck .pc, body.gated #deck .pc { pointer-events:none; }
|
|
169
|
+
@media (prefers-reduced-motion:reduce) { #deck .pc:active { transform:var(--tf) !important; } }
|
|
170
|
+
/* 给老师换样子(figshot):不是首页发布的卡,是 cotutor.json 配了 figshot 就有的固定入口;figshot 没开着就不出现 */
|
|
171
|
+
.c.c-figshot { flex-direction:row; align-items:center; gap:16px; text-decoration:none; color:var(--ink); background:#fff3e8; border-color:var(--accent); }
|
|
172
|
+
.c-figshot[hidden] { display:none; }
|
|
173
|
+
.c-figshot .avs { display:flex; flex:none; }
|
|
174
|
+
.c-figshot .avs .av { width:52px; height:52px; font-size:24px; }
|
|
175
|
+
.c-figshot .avs .av + .av { margin-left:-14px; }
|
|
176
|
+
.c-figshot .tx { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
|
|
177
|
+
.c-figshot .tx b { font-size:19px; }
|
|
178
|
+
.c-figshot .tx span { font-size:15px; color:var(--dim); }
|
|
179
|
+
.c-figshot .go { flex:none; height:48px; padding:0 22px; border-radius:24px; background:#c95a22; color:#fff; font-size:17px; font-weight:600; display:flex; align-items:center; }
|
|
180
|
+
body.offline #rest { display:block; }
|
|
181
|
+
body.offline .c-tutor, body.gated .c-tutor, body.gated .c, body.gated .hcards { pointer-events:none; }
|
|
182
|
+
body.offline .c-tutor .av, body.gated .c-tutor .av { filter:grayscale(1); opacity:.4; }
|
|
183
|
+
body.offline .c-tutor .av .ring circle, body.gated .c-tutor .av .ring circle { transform:rotate(126deg); }
|
|
184
|
+
body.offline #ct-mark, body.gated #ct-mark { filter:grayscale(1); opacity:.4; }
|
|
185
|
+
body.offline #ct-mark .ct-go, body.offline #ct-mark .ct-gi, body.gated #ct-mark .ct-go, body.gated #ct-mark .ct-gi { transform:rotate(90deg); }
|
|
186
|
+
body.offline .c-tutor .nm, body.offline .c-tutor .bt, body.gated .c-tutor .nm, body.gated .c-tutor .bt { color:var(--dim); }
|
|
187
|
+
body.offline .c-tutor .bt-new, body.gated .c-tutor .bt-new { background:var(--grey); }
|
|
188
|
+
/* ---- 老师页 = 板书页 ---- */
|
|
189
|
+
#tutor { position:fixed; inset:0; background:var(--paper); display:none; z-index:10; }
|
|
190
|
+
#tutor.on { display:flex; }
|
|
191
|
+
/* 三门主科在板右边竖着写一个很淡的学科名(board-mark.ts) */
|
|
192
|
+
${BOARD_MARK_CSS}
|
|
193
|
+
#main { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; }
|
|
194
|
+
/* 没有顶栏:左上角一颗胶囊「‹ logo」(整颗点了回首页,放 logo,老师的脸在实时的左栏 / 手机的字幕行),右上角「更多」;浮在板书上面、舞台遮罩(z 4)下面,舞台开了就被压暗盖住 */
|
|
195
|
+
.tb { position:absolute; top:calc(env(safe-area-inset-top) + 10px); z-index:3; display:flex; align-items:center; gap:10px; }
|
|
196
|
+
.tb.l { left:calc(env(safe-area-inset-left) + 12px); } .tb.r { right:calc(env(safe-area-inset-right) + 12px); }
|
|
197
|
+
#back { display:flex; align-items:center; gap:2px; height:48px; padding:0 4px 0 8px; border-radius:24px; background:#fffffff0; border:1px solid var(--line); box-shadow:0 2px 10px #00000018; }
|
|
198
|
+
#back .ct-logo { color:var(--ink); margin:0 8px 0 4px; }
|
|
199
|
+
/* 胶囊带上老师:填这位老师的颜色、logo 后面接首字(同听写、口语课一份,tutor-face.ts) */
|
|
200
|
+
${BACK_TINT_CSS}
|
|
201
|
+
#back-ic { width:24px; height:24px; color:var(--dim); display:grid; place-items:center; }
|
|
202
|
+
#c-mo { font-size:13px; color:var(--dim); background:#fffffff0; border:1px solid var(--line); border-radius:14px; padding:4px 12px; box-shadow:0 2px 10px #00000010; white-space:nowrap; }
|
|
203
|
+
#c-mo[hidden] { display:none; }
|
|
204
|
+
.tb .hb { width:44px; height:44px; border-radius:50%; background:#fffffff0; border:1px solid var(--line); box-shadow:0 2px 10px #00000018; }
|
|
205
|
+
#menu { position:absolute; inset:0; display:none; z-index:30; }
|
|
206
|
+
#menu.on { display:block; }
|
|
207
|
+
#menu .dimmer { position:absolute; inset:0; background:#00000073; }
|
|
208
|
+
#menu .panel { position:absolute; top:calc(env(safe-area-inset-top) + 62px); right:calc(env(safe-area-inset-right) + 12px); width:min(320px, calc(100% - 24px)); background:var(--card); border-radius:20px; padding:8px; display:flex; flex-direction:column; gap:4px; box-shadow:0 8px 30px #00000033; }
|
|
209
|
+
#menu .panel button { display:flex; align-items:center; gap:14px; padding:14px; border-radius:14px; text-align:left; width:100%; }
|
|
210
|
+
#menu .panel button[hidden] { display:none; }
|
|
211
|
+
#menu .panel button.dim { opacity:.35; pointer-events:none; }
|
|
212
|
+
#menu .ic { flex:0 0 auto; width:44px; height:44px; border-radius:50%; background:var(--paper); color:var(--dim); display:grid; place-items:center; }
|
|
213
|
+
#menu .tx { display:flex; flex-direction:column; gap:2px; min-width:0; }
|
|
214
|
+
#menu .tx b { font-size:17px; font-weight:600; color:var(--ink); } #menu .tx small { font-size:13px; color:var(--dim); }
|
|
215
|
+
.hb { width:40px; height:40px; display:grid; place-items:center; color:var(--dim); }
|
|
216
|
+
.hb.on { color:var(--accent); }
|
|
217
|
+
.hb.dim { opacity:.35; pointer-events:none; }
|
|
218
|
+
.hb[hidden] { display:none; }
|
|
219
|
+
.blank { flex:1; grid-column:1 / -1; min-height:50vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; text-align:center; padding:40px 20px; color:var(--dim); }
|
|
220
|
+
.blank .av { width:96px; height:96px; font-size:44px; margin-bottom:4px; }
|
|
221
|
+
.blank .nm { font-size:26px; font-weight:700; color:var(--ink); }
|
|
222
|
+
.blank b { font-size:22px; font-weight:600; color:var(--ink); }
|
|
223
|
+
.blank .resume { margin-top:24px; min-height:48px; padding:0 20px; border-radius:24px; border:1.5px solid; background:#fff; display:inline-flex; align-items:center; gap:10px; font:inherit; font-size:17px; font-weight:500; box-shadow:0 2px 10px #0000000f; cursor:pointer; max-width:100%; }
|
|
224
|
+
.blank .resume .ic { display:grid; } .blank .resume small { font-size:14px; color:var(--dim); }
|
|
225
|
+
.blank.ask b { margin-top:14px; }
|
|
226
|
+
.ask-row { display:flex; flex-wrap:wrap; justify-content:center; gap:16px; margin-top:20px; width:100%; }
|
|
227
|
+
.ask-go { flex:0 1 300px; min-height:120px; border-radius:24px; padding:18px 22px; display:flex; flex-direction:column; align-items:flex-start; gap:6px; text-align:left; font:inherit; cursor:pointer; box-shadow:0 4px 16px #0000000f; }
|
|
228
|
+
.ask-go.back { background:#fff; border:2px solid; } .ask-go.fresh { border:0; color:#fff; }
|
|
229
|
+
.ask-go .t { display:flex; align-items:center; gap:8px; font-size:18px; font-weight:700; } .ask-go .ic { display:grid; }
|
|
230
|
+
.ask-go .s { font-size:17px; color:var(--ink); } .ask-go.fresh .s { color:#fff; } .ask-go small { font-size:14px; color:var(--dim); }
|
|
231
|
+
.blank small { font-size:15px; }
|
|
232
|
+
#hist { position:absolute; inset:0; display:none; z-index:30; }
|
|
233
|
+
#hist.on { display:block; }
|
|
234
|
+
#hist .dimmer { position:absolute; inset:0; background:#00000033; }
|
|
235
|
+
#hist .panel { position:absolute; top:0; right:0; bottom:0; width:100%; max-width:380px; background:var(--card); display:flex; flex-direction:column; box-shadow:-4px 0 20px #00000018; }
|
|
236
|
+
#hist .hd { display:flex; align-items:center; justify-content:space-between; padding:calc(env(safe-area-inset-top) + 12px) 12px 12px 20px; font-size:18px; font-weight:600; border-bottom:1px solid var(--line); }
|
|
237
|
+
#hist .ls { flex:1; overflow:auto; padding:8px 12px calc(env(safe-area-inset-bottom) + 24px); }
|
|
238
|
+
#hist .dt { font-size:13px; color:var(--dim); padding:14px 6px 6px; }
|
|
239
|
+
#hist .tr { display:flex; flex-direction:column; gap:4px; padding:12px 14px; border-radius:14px; background:var(--paper); margin-bottom:8px; cursor:pointer; border:2px solid transparent; }
|
|
240
|
+
#hist .tr.on { border-color:var(--accent); }
|
|
241
|
+
#hist .tr b { font-size:16px; font-weight:600; }
|
|
242
|
+
#hist .tr small { font-size:13px; color:var(--dim); }
|
|
243
|
+
#hist .empty { padding:40px 16px; text-align:center; color:var(--dim); }
|
|
244
|
+
#pill[hidden] { display:none; }
|
|
245
|
+
#back-today { width:100%; height:56px; border-radius:28px; background:#fff; border:1px solid var(--line); box-shadow:0 2px 10px #00000010; font-size:17px; font-weight:600; color:var(--ink); }
|
|
246
|
+
#back-today[hidden] { display:none; }
|
|
247
|
+
#pill .ic[hidden] { display:none; }
|
|
248
|
+
#wrap { position:relative; flex:1; min-height:0; display:flex; flex-direction:column; }
|
|
249
|
+
/* 板书顶上一条渐隐:滚上去的内容在两角按钮那一带淡成纸色,不和胶囊、话题标签叠字。只是盖一层颜色,不占位置、不收触摸;
|
|
250
|
+
夹在板书(卡上的「再听」z 1)与两角按钮(z 3)之间。滚到顶时第一节在 68px 以下,基本碰不到它 */
|
|
251
|
+
#wrap::before { content:""; position:absolute; left:0; right:0; top:0; height:calc(env(safe-area-inset-top) + 72px); z-index:2; pointer-events:none; background:linear-gradient(var(--paper) calc(env(safe-area-inset-top) + 16px), transparent); }
|
|
252
|
+
/* 舞台的遮罩:板书与顶栏压暗,点一下 = 点 ×;字幕行与输入条不盖(舞台开着孩子照样能说话) */
|
|
253
|
+
#st-dim { position:absolute; left:0; right:0; bottom:0; top:-100vh; z-index:4; background:#00000073; display:none; }
|
|
254
|
+
#stage.on ~ #st-dim { display:block; }
|
|
255
|
+
/* ---- 画板铺满:画板卡与左栏的纸都铺满整屏、盖住左栏;顶栏 × · 题目一行(点开看全)· 给老师看(轻点只交画,按住说 = 画和这句话一起给老师);
|
|
256
|
+
工具一列在舞台里,最底下浮着老师的小头像(按住说话 = 边画边聊,给嘴);嘴说的话在画布顶上浮一条,说完几秒淡掉 ---- */
|
|
257
|
+
#stage.cv, body.live #stage.cv, body.live.hand-left #stage.cv { position:fixed; inset:0; width:auto; transform:none; border-radius:0; box-shadow:none; z-index:20; }
|
|
258
|
+
#stage.cv .top { min-height:56px; box-sizing:border-box; padding:calc(env(safe-area-inset-top) + 6px) calc(env(safe-area-inset-right) + 220px) 6px calc(env(safe-area-inset-left) + 12px); gap:12px; }
|
|
259
|
+
#stage.cv #st-x { order:-1; }
|
|
260
|
+
#stage.cv .top .ttl { cursor:pointer; font-size:18px; }
|
|
261
|
+
#stage.cv .top .ttl.open { white-space:normal; }
|
|
262
|
+
#stage.cv #st-act { position:absolute; top:env(safe-area-inset-top); right:env(safe-area-inset-right); height:56px; box-sizing:border-box; padding:0 12px; border-top:0; gap:10px; }
|
|
263
|
+
#stage.cv #st-note { font-size:14px; }
|
|
264
|
+
#st-go.holding { background:#3d7fd9; border-color:#3d7fd9; color:#fff; }
|
|
265
|
+
#cv-hold, #cv-cap { display:none !important; }
|
|
266
|
+
body.live #stage.cv #cv-hold { display:flex !important; position:absolute; z-index:2; left:calc(env(safe-area-inset-left) + 4px); bottom:calc(env(safe-area-inset-bottom) + 20px); }
|
|
267
|
+
body.live.hand-left #stage.cv #cv-hold { left:auto; right:calc(env(safe-area-inset-right) + 4px); }
|
|
268
|
+
#stage.cv #cv-cap { display:block !important; position:absolute; z-index:2; top:calc(env(safe-area-inset-top) + 68px); left:50%; translate:-50% 0; width:max-content; max-width:min(720px, calc(100% - 200px)); box-sizing:border-box; padding:10px 18px; border-radius:16px; background:#fffffff2; box-shadow:0 4px 18px #0000001f; font-size:20px; line-height:1.45; font-weight:600; color:var(--ink); opacity:0; pointer-events:none; transition:opacity .3s; }
|
|
269
|
+
#stage.cv #cv-cap.on { opacity:1; }
|
|
270
|
+
/* 孩子从纸上交的画:节前一张——图(点开看大图)、几笔、接着画 */
|
|
271
|
+
.c.c-draw { flex-direction:row; align-items:center; gap:14px; padding:10px 12px; border:2px solid #7a4fc9; border-radius:16px; background:#fff; max-width:100%; align-self:flex-start; }
|
|
272
|
+
.c-draw .dw-th { width:156px; height:104px; flex:none; border:1px solid var(--line); border-radius:10px; object-fit:contain; background:#fffdf8; cursor:zoom-in; }
|
|
273
|
+
.c-draw .dw-t { display:flex; flex-direction:column; gap:2px; }
|
|
274
|
+
.c-draw .dw-t small { font-size:13px; font-weight:700; color:#7a4fc9; }
|
|
275
|
+
.c-draw .dw-t b { font-size:20px; }
|
|
276
|
+
.c-draw .dw-go { margin-left:8px; height:44px; padding:0 16px; border-radius:22px; border:1.5px solid #7a4fc9; background:#fff; color:#7a4fc9; font:inherit; font-size:16px; font-weight:600; }
|
|
277
|
+
#board { flex:1; min-height:0; overflow:auto; padding:calc(env(safe-area-inset-top) + 68px) 16px 24px; display:flex; flex-direction:column; gap:12px; -webkit-overflow-scrolling:touch; scroll-behavior:smooth; }
|
|
278
|
+
.sh .ph { display:inline-block; height:34px; width:auto; max-width:120px; border-radius:6px; object-fit:cover; border:1px solid var(--line); background:#fff; vertical-align:middle; }
|
|
279
|
+
/* 输入条 */
|
|
280
|
+
#bar { padding:8px 16px calc(env(safe-area-inset-bottom) + 12px); }
|
|
281
|
+
#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; }
|
|
282
|
+
#pill .ic { color:var(--ink); display:grid; place-items:center; width:32px; height:44px; }
|
|
283
|
+
/* 一直听(口语课与 live.listen):输入条里的麦克风(点它关麦 / 开麦,外圈跟着音量) */
|
|
284
|
+
#pill #lv-mic { display:none; }
|
|
285
|
+
/* 交给老师了(选择、画板):锁住,不能改、不能再交 */
|
|
286
|
+
.c > .lk { display:inline-flex; align-items:center; gap:4px; align-self:flex-end; font-size:14px; color:var(--dim); }
|
|
287
|
+
body.live.lv-open #pill #lv-mic { display:grid; width:44px; border-radius:50%; color:#2a5ea8; box-shadow:0 0 0 calc(var(--lv, 0) * 10px) #3d7fd933; transition:box-shadow .1s; }
|
|
288
|
+
body.live.lv-open #pill #lv-mic.hear { background:#3d7fd9; color:#fff; }
|
|
289
|
+
body.live.lv-open #pill #lv-mic.off { color:var(--dim); box-shadow:none; }
|
|
290
|
+
#mid { flex:1; min-width:0; min-height:44px; display:flex; align-items:center; font-size:17px; color:var(--dim); touch-action:none; }
|
|
291
|
+
#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; }
|
|
292
|
+
#typed[hidden] { display:none; }
|
|
293
|
+
#go { width:36px; height:36px; border-radius:50%; background:var(--ink); color:#fff; display:grid; place-items:center; }
|
|
294
|
+
#go[hidden] { display:none; }
|
|
295
|
+
/* 录像栏(reel-dock.ts,话题与听写共用):直接打开 /reel(不在家长页的 iframe 里)时在孩子的屏幕底下 */
|
|
296
|
+
__REEL_DOCK_CSS__
|
|
297
|
+
__BRAND_CSS__
|
|
298
|
+
/* 孩子的屏幕(iframe 里):孩子端原样,只是点不动;板书能上下翻(点在 reelBoot 里挡掉) */
|
|
299
|
+
body.rlk #tutor .tb, body.rlk #bar, body.rlk #lc-frame { pointer-events:none; }
|
|
300
|
+
/* 直接打开孩子的屏幕(调试)时录像栏在它自己底下:压在按住浮层、小课堂上面 */
|
|
301
|
+
body.rlk #reel { position:relative; z-index:25; background:var(--paper); }
|
|
302
|
+
/* 录像里孩子在打字:底下垫一块示意的键盘,老师页挪到它上面 */
|
|
303
|
+
#rl-kbd { position:fixed; left:0; right:0; bottom:0; height:var(--rl-kb, 0); background:#d1d4db; display:none; flex-direction:column; justify-content:center; gap:8px; padding:8px; box-sizing:border-box; z-index:30; }
|
|
304
|
+
body.rlk.kbd #rl-kbd { display:flex; }
|
|
305
|
+
body.rlk.kbd #tutor { bottom:var(--rl-kb, 0); }
|
|
306
|
+
#rl-kbd .r { display:flex; gap:6px; justify-content:center; flex:1; max-height:56px; }
|
|
307
|
+
#rl-kbd .r span { flex:0 1 72px; border-radius:7px; background:#fdfdfd; box-shadow:0 1px 0 #8e9196; display:grid; place-items:center; font-size:18px; color:#333; }
|
|
308
|
+
#rl-kbd .r span.sp { flex:0 1 420px; font-size:14px; color:#777; }
|
|
309
|
+
#rl-ps-img { position:absolute; inset:0; margin:auto; max-width:100%; max-height:100%; object-fit:contain; }
|
|
310
|
+
body.rlk #ps-cv { visibility:hidden; }
|
|
311
|
+
body.rlk #ps { pointer-events:none; }
|
|
312
|
+
body.pending #pill { opacity:.45; }
|
|
313
|
+
/* 老师在想时中间那段照旧接按(按了说「老师在想,等一下再说」,busyHint),相机、加号不接 */
|
|
314
|
+
body.pending #pill > :not(#mid) { pointer-events:none; }
|
|
315
|
+
#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; }
|
|
316
|
+
#hold.on { display:flex; }
|
|
317
|
+
#hold .w { display:flex; align-items:center; gap:3px; height:28px; }
|
|
318
|
+
#hold .t { max-width:min(640px, 88%); font-size:20px; line-height:1.45; text-align:center; text-shadow:0 1px 2px #0003; }
|
|
319
|
+
#hold .t:empty { display:none; }
|
|
320
|
+
#hold.real .w i { animation:none; }
|
|
321
|
+
#hold.wait .w { opacity:.3; }
|
|
322
|
+
#hold.wait .w i { animation-play-state:paused; }
|
|
323
|
+
#hold .w i { width:3px; border-radius:2px; background:#ffffffdd; animation:wave .8s ease-in-out infinite alternate; }
|
|
324
|
+
@keyframes wave { from { transform:scaleY(.4); } to { transform:scaleY(1); } }
|
|
325
|
+
#sheet { position:absolute; inset:0; display:none; z-index:30; }
|
|
326
|
+
#sheet.on { display:block; }
|
|
327
|
+
#sheet .dimmer { position:absolute; inset:0; background:#00000033; }
|
|
328
|
+
#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; }
|
|
329
|
+
#sheet .grab { width:40px; height:4px; border-radius:2px; background:var(--line); align-self:center; }
|
|
330
|
+
#sheet .opts { display:grid; grid-auto-flow:column; grid-auto-columns:minmax(0,1fr); gap:12px; }
|
|
331
|
+
#sheet .opts > [hidden] { display:none; }
|
|
332
|
+
#sheet label, #sheet .opts button { 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; }
|
|
333
|
+
#sheet .opts button { border:0; color:inherit; font-family:inherit; line-height:inherit; }
|
|
334
|
+
input[type=file] { display:none; }
|
|
335
|
+
/* 发照片屏:看一眼、裁剪 / 圈画 / 转 90°、配一句话,再发 */
|
|
336
|
+
#ps { position:absolute; inset:0; z-index:40; background:#1d1c1a; color:#fff; display:none; flex-direction:column; }
|
|
337
|
+
#ps.on { display:flex; }
|
|
338
|
+
/* 发给谁(设计稿 https://claude.ai/artifact/SPtjRhH9aLoLQoGfCNJaVz):头像和名字;「发给」按钮用这位老师的颜色,和拍照听写拍完的「开始听写」分得开 */
|
|
339
|
+
#ps-top { display:flex; align-items:center; padding:calc(env(safe-area-inset-top) + 12px) 16px 0; }
|
|
340
|
+
#ps-to { display:inline-flex; align-items:center; gap:10px; height:50px; padding:0 18px 0 6px; border-radius:25px; background:#ffffff14; border:1px solid #ffffff26; font-size:17px; }
|
|
341
|
+
#ps-to:empty { display:none; }
|
|
342
|
+
#ps-to small { font-size:17px; color:#ffffffb3; }
|
|
343
|
+
#ps-to .av { width:38px; height:38px; font-size:18px; }
|
|
344
|
+
#ps-to .av .ring circle { stroke-width:8; }
|
|
345
|
+
#ps .view { position:relative; flex:1; min-height:0; display:flex; align-items:center; justify-content:center; padding:8px 16px; }
|
|
346
|
+
#ps .cv { position:relative; }
|
|
347
|
+
#ps canvas { display:block; touch-action:none; }
|
|
348
|
+
#ps.ps-drawing canvas { cursor:crosshair; }
|
|
349
|
+
#ps-crop { position:absolute; inset:0; touch-action:none; }
|
|
350
|
+
#ps-crop[hidden] { display:none; }
|
|
351
|
+
#ps-crop .dim { position:absolute; background:#000000a0; pointer-events:none; }
|
|
352
|
+
#ps-crop .fr { position:absolute; border:2px solid #fff; box-shadow:0 0 0 1px #0006; }
|
|
353
|
+
#ps-crop .g { position:absolute; width:44px; height:44px; margin:-22px 0 0 -22px; }
|
|
354
|
+
#ps-crop .g::after { content:""; position:absolute; left:13px; top:13px; width:18px; height:18px; border:0 solid #fff; }
|
|
355
|
+
#ps-crop .nw { left:0; top:0; } #ps-crop .nw::after { border-width:4px 0 0 4px; left:20px; top:20px; }
|
|
356
|
+
#ps-crop .ne { left:100%; top:0; } #ps-crop .ne::after { border-width:4px 4px 0 0; left:6px; top:20px; }
|
|
357
|
+
#ps-crop .sw { left:0; top:100%; } #ps-crop .sw::after { border-width:0 0 4px 4px; left:20px; top:6px; }
|
|
358
|
+
#ps-crop .se { left:100%; top:100%; } #ps-crop .se::after { border-width:0 4px 4px 0; left:6px; top:6px; }
|
|
359
|
+
#ps-strip { display:flex; gap:10px; justify-content:center; padding:6px 16px; }
|
|
360
|
+
#ps-strip:empty { display:none; }
|
|
361
|
+
#ps-strip .t { position:relative; width:56px; height:56px; border-radius:10px; border:2px solid transparent; }
|
|
362
|
+
#ps-strip .t.on { border-color:#fff; }
|
|
363
|
+
#ps-strip .t img { width:100%; height:100%; object-fit:cover; border-radius:8px; display:block; }
|
|
364
|
+
#ps-strip .x { position:absolute; top:-8px; right:-8px; width:24px; height:24px; border-radius:12px; background:#fff; color:#1d1c1a; display:grid; place-items:center; }
|
|
365
|
+
#ps-strip .x::before { content:""; position:absolute; inset:-8px; }
|
|
366
|
+
#ps .tools { display:flex; gap:10px; justify-content:center; padding:8px 16px; }
|
|
367
|
+
#ps .tools[hidden] { display:none; }
|
|
368
|
+
#ps .tools button { min-width:76px; height:48px; padding:0 14px; border-radius:24px; background:#ffffff1f; color:#fff; font-size:16px; font-weight:600; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
|
369
|
+
#ps .tools button.ok { background:#fff; color:#1d1c1a; }
|
|
370
|
+
#ps .tools button:disabled { opacity:.35; }
|
|
371
|
+
#ps .foot { display:flex; gap:12px; padding:12px 16px calc(env(safe-area-inset-bottom) + 14px); }
|
|
372
|
+
#ps .foot[hidden] { display:none; }
|
|
373
|
+
#ps .foot button { flex:1; height:54px; border-radius:27px; font-size:17px; font-weight:600; }
|
|
374
|
+
#ps-cancel { background:#ffffff1f; color:#fff; }
|
|
375
|
+
#ps-go { background:var(--accent); color:#fff; }
|
|
376
|
+
#ps-go:disabled { opacity:.35; }
|
|
377
|
+
/* 看大图:节头的小图点开;双指缩放、双击复位 */
|
|
378
|
+
#lb { position:absolute; inset:0; z-index:40; background:#111; display:none; }
|
|
379
|
+
#lb.on { display:block; }
|
|
380
|
+
#lb .zoom { position:absolute; inset:0; overflow:hidden; display:flex; align-items:center; justify-content:center; touch-action:none; }
|
|
381
|
+
#lb .zoom img { max-width:100%; max-height:100%; transform-origin:center; }
|
|
382
|
+
#lb .x, #lb .nav { position:absolute; width:48px; height:48px; border-radius:24px; background:#ffffff26; color:#fff; display:grid; place-items:center; z-index:2; }
|
|
383
|
+
#lb .x { top:calc(env(safe-area-inset-top) + 12px); right:16px; }
|
|
384
|
+
#lb .nav { top:50%; margin-top:-24px; font-size:26px; line-height:1; }
|
|
385
|
+
#lb .nav.l { left:12px; } #lb .nav.r { right:12px; }
|
|
386
|
+
#lb .nav[hidden] { display:none; }
|
|
387
|
+
#lb .n { position:absolute; left:0; right:0; bottom:calc(env(safe-area-inset-bottom) + 18px); text-align:center; color:#fffc; font-size:15px; z-index:2; pointer-events:none; }
|
|
388
|
+
.sh .ph { cursor:zoom-in; }
|
|
389
|
+
/* ---- 按压反馈(全局关了系统的点击高亮,这里统一补):三种手感,按下快(60ms)、松开慢(200ms)。
|
|
390
|
+
圆钮与胶囊:缩到 94% + 暗一档;深色屏上的钮:缩 + 变淡;大块面:缩到 98.5% + 暗一点;行内文字:变淡。
|
|
391
|
+
板书里的卡在滚动容器里,按下延迟 80ms 才显(不然一滑整屏都在闪);卡里的小钮按着时卡自己不动。交互不是皮肤,写在页面里,主题不用跟 ---- */
|
|
392
|
+
#back, .tb .hb, #hist-x, #st-x, #st-go, #pill .ic, #go, #back-today, .c-tutor .bt,
|
|
393
|
+
#ps .tools button, #ps .foot button, #ps-strip .x, #lb .x, #lb .nav,
|
|
394
|
+
#menu .panel button, #sheet label, #hist .tr, .so, .rd, .hz, .c-tutor .tt, #ps-strip .t,
|
|
395
|
+
#lv-tools button, #lv-tools .ic, #lv-replay, .lv-go { transition:transform .2s ease-out, filter .2s ease-out, opacity .2s ease-out, background-color .2s ease-out; }
|
|
396
|
+
#board .pend { display:none; }
|
|
397
|
+
#lay-probe { position:fixed; left:-10000px; top:0; visibility:hidden; pointer-events:none; }
|
|
398
|
+
#board .c { transition:transform .2s ease-out, filter .2s ease-out, border-color .2s, box-shadow .2s; }
|
|
399
|
+
/* ---- 弹层进出场:遮罩淡入,面板按来的方向动(菜单从右上角放大、相册面板从底下滑上来、以前的从右边滑进、卡的弹窗与看大图轻轻放大、发照片屏上浮)。
|
|
400
|
+
进场靠 @starting-style,退场靠 display 的 allow-discrete 过渡(Safari 18 起);不认的浏览器就是瞬间出现 / 消失,和以前一样。
|
|
401
|
+
用 scale / translate 独立属性,不碰 transform(横屏的舞台已经用 transform 居中了)。关着的时候不收触摸,退场那 200ms 里点得到后面 ---- */
|
|
402
|
+
#menu, #sheet, #hist, #ps, #lb, #stage, #st-dim { transition:opacity .2s ease-out, scale .24s cubic-bezier(.2,.8,.2,1), translate .24s cubic-bezier(.2,.8,.2,1), display .24s allow-discrete; }
|
|
403
|
+
#menu:not(.on), #sheet:not(.on), #hist:not(.on), #ps:not(.on), #lb:not(.on), #stage:not(.on), #stage:not(.on) ~ #st-dim { pointer-events:none; }
|
|
404
|
+
#menu .dimmer, #sheet .dimmer, #hist .dimmer { opacity:0; transition:opacity .2s ease-out; }
|
|
405
|
+
#menu.on .dimmer, #sheet.on .dimmer, #hist.on .dimmer { opacity:1; }
|
|
406
|
+
#menu .panel { opacity:0; scale:.9; transform-origin:top right; transition:opacity .16s ease-out, scale .24s cubic-bezier(.2,.8,.2,1); }
|
|
407
|
+
#menu.on .panel { opacity:1; scale:1; }
|
|
408
|
+
#sheet .panel { translate:0 100%; transition:translate .28s cubic-bezier(.2,.8,.2,1); }
|
|
409
|
+
#sheet.on .panel { translate:0 0; }
|
|
410
|
+
#hist .panel { translate:100% 0; transition:translate .28s cubic-bezier(.2,.8,.2,1); }
|
|
411
|
+
#hist.on .panel { translate:0 0; }
|
|
412
|
+
#stage, #lb { opacity:0; scale:.96; }
|
|
413
|
+
#stage.on, #lb.on { opacity:1; scale:1; }
|
|
414
|
+
#ps { opacity:0; translate:0 24px; }
|
|
415
|
+
#ps.on { opacity:1; translate:0 0; }
|
|
416
|
+
#st-dim { opacity:0; }
|
|
417
|
+
#stage.on ~ #st-dim { opacity:1; }
|
|
418
|
+
@starting-style {
|
|
419
|
+
#menu.on .dimmer, #sheet.on .dimmer, #hist.on .dimmer, #stage.on ~ #st-dim { opacity:0; }
|
|
420
|
+
#menu.on .panel { opacity:0; scale:.9; }
|
|
421
|
+
#sheet.on .panel { translate:0 100%; }
|
|
422
|
+
#hist.on .panel { translate:100% 0; }
|
|
423
|
+
#stage.on, #lb.on { opacity:0; scale:.96; }
|
|
424
|
+
#ps.on { opacity:0; translate:0 24px; }
|
|
425
|
+
}
|
|
426
|
+
@media (prefers-reduced-motion:reduce) { #menu, #sheet, #hist, #ps, #lb, #stage, #st-dim, #menu .dimmer, #sheet .dimmer, #hist .dimmer, #menu .panel, #sheet .panel, #hist .panel { transition:none; } }
|
|
427
|
+
@media (hover:hover) {
|
|
428
|
+
#back:hover, .tb .hb:hover, #hist-x:hover, #st-x:hover, #pill .ic:hover, #back-today:hover, #menu .panel button:hover, #sheet label:hover, #hist .tr:hover, .so:hover, .rd:hover, .c-tutor .bt:hover, #board .c:not(.c-tianzige):hover, #lv-tools button:hover .ic, #lv-replay:hover, .lv-go:hover { filter:brightness(.97); }
|
|
429
|
+
}
|
|
430
|
+
#back:active, .tb .hb:active, #hist-x:active, #st-x:active, #st-go:active, #pill .ic:active, #go:active, #back-today:active, .c-tutor .bt:active, .blank .resume:active, .ask-go:active { transform:scale(.94); filter:brightness(.92); transition-duration:.06s; }
|
|
431
|
+
#ps .tools button:active, #ps .foot button:active, #ps-strip .x:active, #lb .x:active, #lb .nav:active { transform:scale(.94); opacity:.65; transition-duration:.06s; }
|
|
432
|
+
/* 左栏(实时老师页白纸)最底下那排:圆钮同输入条上的圆钮(缩到 94% + 暗一档),字跟着深一档;「再听一遍」是行内文字,变淡。合成的那颗按钮在 voice-ui.ts */
|
|
433
|
+
#lv-tools button:active .ic { transform:scale(.94); filter:brightness(.92); transition-duration:.06s; }
|
|
434
|
+
#lv-tools button:active { color:var(--ink); transition-duration:.06s; }
|
|
435
|
+
#lv-replay:active { opacity:.55; transition-duration:.06s; }
|
|
436
|
+
#lv-today:active { opacity:.55; transition-duration:.06s; }
|
|
437
|
+
#menu .panel button:active, #sheet label:active, #hist .tr:active, .so:active, .rd:active, .hz:active, .c-tutor .tt:active, #ps-strip .t:active, .lv-go:active { transform:scale(.985); filter:brightness(.95); transition-duration:.06s; }
|
|
438
|
+
#board .c:not(.c-tianzige):active:not(:has(button:active, .hz:active)) { transform:scale(.985); filter:brightness(.96); transition-duration:.06s; transition-delay:.08s; }
|
|
439
|
+
button:disabled, .hb.dim { opacity:.35; }
|
|
440
|
+
button:disabled:active { transform:none; filter:none; }
|
|
441
|
+
@media (prefers-reduced-motion:reduce) { #back, .tb .hb, .so, .rd, .hz, #board .c, #hist .tr, #sheet label, #menu .panel button, #ps .tools button, #ps .foot button, .c-tutor .bt, .c-tutor .tt { transition:none; } *:active { transform:none !important; } }
|
|
442
|
+
__VOICE_CSS__
|
|
443
|
+
/* ---- 实时的老师:
|
|
444
|
+
字幕行换成 #lv(类名带 lv-:卡是 .c,撞名会套上卡的边框)——竖屏、手机在输入条上方一行(小头像 + 在说的那句),横屏是左栏(大头像 + 字幕,在说的大、上一句淡),板是右边一块白板;
|
|
445
|
+
平板横屏是左栏一整张白纸 + 头像和按住说话合成的一颗按钮(src/ui/kid/voice-ui.ts);卡角没有再听的喇叭,也没有别的钮(想问某张卡,点开它再按住说) ---- */
|
|
446
|
+
#lv { display:none; }
|
|
447
|
+
body.live #lv { display:flex; align-items:center; gap:14px; padding:8px 20px 4px; min-height:60px; }
|
|
448
|
+
#lv-av { position:relative; flex:0 0 auto; width:44px; height:44px; }
|
|
449
|
+
/* 口语课:热身那拍念完,那张卡一圈慢慢呼吸的光 = 轮到你;孩子开口光变实。打电话前头像晃着响铃 */
|
|
450
|
+
#board .c.lv-turn { animation:lv-turn 2.4s ease-in-out infinite; }
|
|
451
|
+
#board .c.lv-turn.hear { animation:none; box-shadow:0 0 0 4px #3d7fd9, 0 0 0 10px #3d7fd933; }
|
|
452
|
+
@keyframes lv-turn { 0%,100% { box-shadow:0 0 0 3px #3d7fd966, 0 0 0 10px #3d7fd914; } 50% { box-shadow:0 0 0 6px #3d7fd999, 0 0 0 20px #3d7fd90f; } }
|
|
453
|
+
@media (prefers-reduced-motion: reduce) { #board .c.lv-turn { animation:none; box-shadow:0 0 0 4px #3d7fd966; } }
|
|
454
|
+
#lv-av .av { width:100%; height:100%; font-size:20px; }
|
|
455
|
+
/* 手机字幕行那颗头像的动作(在说 / 在听 / 在想 / 响铃)同按钮里那颗,走 voice-ui.ts 的 voiceFace(.vb-halo 是外圈) */
|
|
456
|
+
#lv-cap { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
|
|
457
|
+
#lv-cap .lv-prev { display:none; }
|
|
458
|
+
#lv-cap .lv-cur { font-size:18px; line-height:1.45; font-weight:600; color:var(--ink); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
|
459
|
+
#lv-cap .lv-cur.nm { font-weight:500; color:var(--dim); }
|
|
460
|
+
#lv-cap.done .lv-cur, #lv-cap .lv-cur.cut { color:#a39f96; font-weight:500; }
|
|
461
|
+
/* 孩子刚说的那句、按住说话那颗按钮、拍照 / 相册 / 打字:只在平板横屏的左栏里(见下面「平板横屏」);竖屏、手机照旧输入条。
|
|
462
|
+
一页一张脸:实时的脸是按钮里那颗(手机是 #lv-av),空板中间那颗不露 */
|
|
463
|
+
#lv-kid, #lv-hold, #lv-tools, #lv-today { display:none; }
|
|
464
|
+
/* 字幕重放:家长提醒着用的,不做大按钮——手机是字幕行右边一颗小钮,平板横屏是字幕下面一行小字「↺ 再听一遍」;放着时是「■ 停」 */
|
|
465
|
+
#lv-replay { flex:none; width:40px; height:40px; border-radius:50%; display:grid; place-items:center; color:var(--dim); }
|
|
466
|
+
#lv-replay[hidden] { display:none !important; }
|
|
467
|
+
#lv-replay .tx { display:none; }
|
|
468
|
+
#lv-cap .lv-cur .rp { display:inline-flex; vertical-align:-2px; margin-right:6px; color:var(--dim); }
|
|
469
|
+
#lv-cap.rp .lv-cur { color:#4d4a44; font-weight:600; }
|
|
470
|
+
/* 空板(原型 F 排):老师对孩子说的话都在左栏(手机是字幕行),板只放板书——板上的 .blank 收着,
|
|
471
|
+
开场那句(+「比如」)在 #lv-ask、老师给的选择(接着刚才的、接着那个 / 重新开始、先热身)在 #lv-acts;这时字幕、孩子那句、再听一遍让开。
|
|
472
|
+
手机(F5):开场那句在小头像旁(大一点、粗),选择在它上面一条 */
|
|
473
|
+
#lv-ask, #lv-acts { display:none; }
|
|
474
|
+
body.live #board > .blank { display:none; }
|
|
475
|
+
body.live.lv-opening #lv-cap, body.live.lv-opening #lv-kid, body.live.lv-opening #lv-replay { display:none; }
|
|
476
|
+
body.live.lv-opening #lv { flex-wrap:wrap; }
|
|
477
|
+
body.live.lv-opening #lv-ask { display:flex; flex:1; min-width:0; flex-direction:column; gap:2px; }
|
|
478
|
+
#lv-ask b { font-size:20px; line-height:1.4; font-weight:700; color:var(--ink); }
|
|
479
|
+
#lv-ask small { display:none; font-size:16px; line-height:1.5; color:#8a8781; }
|
|
480
|
+
body.live.lv-opening #lv-acts:not(:empty) { display:flex; order:-1; flex:0 0 100%; gap:10px; }
|
|
481
|
+
.lv-go { flex:1 1 0; min-width:0; display:flex; align-items:center; gap:12px; padding:12px 16px; border-radius:20px; border:1.5px solid var(--tc, var(--line)); background:#fff; color:var(--ink); font:inherit; text-align:left; }
|
|
482
|
+
.lv-go .ic { flex:none; display:grid; color:var(--tc, var(--dim)); }
|
|
483
|
+
.lv-go .ic svg { width:22px; height:22px; }
|
|
484
|
+
.lv-go .tx { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
|
|
485
|
+
.lv-go .k { font-size:13px; color:var(--tc, var(--dim)); }
|
|
486
|
+
.lv-go .s { font-size:18px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
487
|
+
.lv-go .tm { flex:none; font-size:14px; color:#8a8781; }
|
|
488
|
+
.lv-go.fresh { border-color:transparent; background:var(--tc, var(--ink)); color:#fff; }
|
|
489
|
+
.lv-go.fresh .ic, .lv-go.fresh .k { color:#ffffffd9; }
|
|
490
|
+
.lv-go.talk { padding-top:14px; padding-bottom:14px; }
|
|
491
|
+
/* 实时的板是脑写的:节头是脑写的那张「# 标题」文字卡(一行摘要 —— 线 —— 板上第一节带时间);没写节头的一轮接着上一节画,不留节间空 */
|
|
492
|
+
.sh.lh { gap:12px; padding:14px 4px 2px; white-space:normal; }
|
|
493
|
+
.sh.lh::after { display:none; }
|
|
494
|
+
/* 不出卡、没写节头的一轮:板上什么都不留(节是空的,不占节间空) */
|
|
495
|
+
.sec.cont:empty { display:none; }
|
|
496
|
+
.sh.lh .t { flex:0 1 auto; min-width:0; font-size:22px; font-weight:700; line-height:1.4; color:var(--ink); }
|
|
497
|
+
.sh.lh .ln { flex:1 1 12px; min-width:12px; height:1px; background:var(--line); }
|
|
498
|
+
.sh.lh .tm { flex:none; font-size:13px; color:var(--dim); }
|
|
499
|
+
.sec + .sec.cont { margin-top:0; }
|
|
500
|
+
/* ---- 平板横屏 ---- */
|
|
501
|
+
@media (min-width:900px) and (orientation:landscape) {
|
|
502
|
+
#home { max-width:1040px; padding:calc(env(safe-area-inset-top) + 40px) 48px 40px; gap:24px; }
|
|
503
|
+
#home h1 { font-size:22px; }
|
|
504
|
+
#home .hcards { grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); }
|
|
505
|
+
.tb.l { left:calc(env(safe-area-inset-left) + 20px); } .tb.r { right:calc(env(safe-area-inset-right) + 20px); }
|
|
506
|
+
#menu .panel { right:calc(env(safe-area-inset-right) + 20px); }
|
|
507
|
+
#board { width:100%; max-width:1040px; margin:0 auto; padding:calc(env(safe-area-inset-top) + 68px) 32px 24px; }
|
|
508
|
+
#bar, #reel { width:100%; max-width:1040px; margin:0 auto; padding-left:32px; padding-right:32px; }
|
|
509
|
+
/* 实时的老师(原型 https://claude.ai/artifact/RNjBdhuvRmnsBjKn521fkE 的 D 排):
|
|
510
|
+
板是主体——占满整屏、纸色,内容从左栏右边排起,往下翻时从浮着的「更多」底下过去;
|
|
511
|
+
左栏是贴边的一整张白纸(上、左、下不留边,右边一道细线加很淡的影子),宽 360:顶上回首页,字幕占回首页下面到按钮上面
|
|
512
|
+
(孩子刚说的那句、上一句、在说那句、再听一遍;跟着视口高走——iPad Safari 有地址栏时只有 ~760 高,10-21 真机上按 820 写死的把字幕挤没了);
|
|
513
|
+
头像和按住说话是一颗按钮(src/ui/kid/voice-ui.ts),中心约在屏高三分之二、离底边约 200(平板横放在桌上,底边太贴桌、手指往下会碰 iPadOS 底部手势);
|
|
514
|
+
拍照、相册、打字在最底下。左栏在舞台遮罩(z 4)上面:舞台开着孩子照样能说话 */
|
|
515
|
+
body.live #board { max-width:none; padding:calc(env(safe-area-inset-top) + 68px) calc(env(safe-area-inset-right) + 48px) 28px calc(env(safe-area-inset-left) + 400px); }
|
|
516
|
+
body.live #stage { left:calc(env(safe-area-inset-left) + 376px); right:calc(env(safe-area-inset-right) + 16px); width:auto; transform:none; }
|
|
517
|
+
body.live .tb.l { left:calc(env(safe-area-inset-left) + 24px); top:calc(env(safe-area-inset-top) + 16px); z-index:6; }
|
|
518
|
+
body.live #back { box-shadow:none; } body.live #back:not(.tinted) { background:#f4f2ec; }
|
|
519
|
+
body.live #lv { position:absolute; z-index:5; left:0; top:0; bottom:0; width:calc(env(safe-area-inset-left) + 360px); box-sizing:border-box; flex-direction:column; justify-content:flex-start; align-items:stretch; gap:12px; padding:calc(env(safe-area-inset-top) + 96px) 24px calc(env(safe-area-inset-bottom) + 20px) calc(env(safe-area-inset-left) + 24px); min-height:0; overflow:hidden; background:#fff; box-shadow:1px 0 0 #efece4, 6px 0 24px #46371e0d; }
|
|
520
|
+
body.live #lv-av { display:none; }
|
|
521
|
+
body.live #lv-kid { display:flex; flex:none; align-items:flex-start; gap:8px; font-size:20px; line-height:1.4; font-weight:600; color:#2a5ea8; }
|
|
522
|
+
body.live #lv-kid:empty { display:none; }
|
|
523
|
+
#lv-kid .tx { min-width:0; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
|
|
524
|
+
body.live #lv-kid.old { font-size:17px; font-weight:400; color:#6f93c4; }
|
|
525
|
+
body.live #lv-cap { flex:0 1 auto; min-height:0; overflow:hidden; gap:12px; text-align:left; }
|
|
526
|
+
body.live #lv-cap .lv-prev { font-size:18px; line-height:1.55; color:#a39f96; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
|
|
527
|
+
body.live #lv-cap .lv-prev:empty { display:none; }
|
|
528
|
+
body.live #lv-cap .lv-cur { font-size:26px; line-height:1.5; -webkit-line-clamp:8; }
|
|
529
|
+
body.live #lv-cap.rp .lv-prev { font-size:15px; color:#8a8781; }
|
|
530
|
+
body.live #lv-replay { display:flex; flex:none; align-self:flex-start; width:auto; height:44px; padding:0 12px 0 0; border-radius:22px; align-items:center; gap:6px; font-size:15px; color:#6f6b63; }
|
|
531
|
+
body.live #lv-replay .tx { display:inline; }
|
|
532
|
+
body.live #lv-replay.stop { height:40px; padding:0 14px; border:1px solid var(--line); background:#fff; color:var(--ink); }
|
|
533
|
+
/* 空板:开场那句是左栏字幕位的第一句,选择一个个跟在下面、占满白纸宽 */
|
|
534
|
+
body.live.lv-opening #lv-ask { flex:none; margin-top:8px; gap:10px; }
|
|
535
|
+
body.live #lv-ask b { font-size:30px; }
|
|
536
|
+
body.live #lv-ask[data-kind=ask] b { font-size:27px; }
|
|
537
|
+
body.live #lv-ask small:not(:empty) { display:block; }
|
|
538
|
+
body.live.lv-opening #lv-acts:not(:empty) { order:0; flex:none; flex-direction:column; margin-top:12px; }
|
|
539
|
+
body.live .lv-go { flex:none; }
|
|
540
|
+
body.live .lv-go .s { font-size:19px; }
|
|
541
|
+
/* 按住说话那颗按钮(头像 + 声音,样子在 voice-ui.ts):挨着字幕往下顶到底下那排上面;下沿离屏底 = 底下那排 + 13vh 的空 */
|
|
542
|
+
body.live #lv-hold { display:flex; flex:none; align-self:center; margin-top:auto; }
|
|
543
|
+
body.live #lv-tools { display:flex; flex:none; justify-content:center; gap:24px; margin-top:clamp(36px, 13vh, 150px); }
|
|
544
|
+
#lv-tools button { display:flex; flex-direction:column; align-items:center; gap:6px; font-size:13px; color:#6f6b63; }
|
|
545
|
+
#lv-tools .ic { width:56px; height:56px; box-sizing:border-box; border-radius:50%; border:1px solid var(--line); background:#fff; display:grid; place-items:center; color:#6f6b63; }
|
|
546
|
+
/* 老师在想:按钮自己画灰(voice-view 的 think),照样接按(按了晃一下说为什么);底下那排不接 */
|
|
547
|
+
body.live.pending #lv-tools { opacity:.45; pointer-events:none; }
|
|
548
|
+
body.live.nosend:not(.choose) #lv-hold, body.live.nosend:not(.choose) #lv-tools { display:none; }
|
|
549
|
+
/* 看以前的话题(只能看):按钮灰着、按了晃一下(voice-view 的 readonly),底下一行「回到今天 ›」;底下那排不露 */
|
|
550
|
+
body.live.nosend.ro:not(.choose) #lv-hold { display:flex; }
|
|
551
|
+
body.live.ro #lv-today { display:block; flex:none; align-self:center; margin:12px 0 clamp(36px, 13vh, 150px); padding:8px 16px; font-size:17px; color:var(--ink); }
|
|
552
|
+
/* 先点上面一个(口语单拍好、今天点过同一个按钮):按钮灰着写「点上面开始」(voice-view 的 choose),底下那排淡着不接 */
|
|
553
|
+
body.live.choose #lv-tools { opacity:.45; pointer-events:none; }
|
|
554
|
+
body.live #bar { display:none; }
|
|
555
|
+
body.live.typing #bar { display:block; position:fixed; left:0; right:0; bottom:var(--kb, 0px); z-index:25; max-width:none; padding:10px calc(env(safe-area-inset-right) + 40px) 10px calc(env(safe-area-inset-left) + 40px); background:#faf9f4f5; }
|
|
556
|
+
body.live #cam, body.live #plus, body.live #lv-mic { display:none !important; }
|
|
557
|
+
/* 按着时按钮自己变蓝(voice-view 的 hold),不再盖一块 #hold 上去 */
|
|
558
|
+
body.live #hold { display:none !important; }
|
|
559
|
+
/* 左撇子镜像(cotutor.json 的 kid.hand: left,工作台切):白纸挪到右边、板在左边、按钮里头像在右头;回首页、「更多」的位置同右手(回首页浮在板的左上) */
|
|
560
|
+
body.live.hand-left #board { padding-left:calc(env(safe-area-inset-left) + 48px); padding-right:calc(env(safe-area-inset-right) + 400px); }
|
|
561
|
+
body.live.hand-left #stage { left:calc(env(safe-area-inset-left) + 16px); right:calc(env(safe-area-inset-right) + 376px); }
|
|
562
|
+
body.live.hand-left #lv { left:auto; right:0; width:calc(env(safe-area-inset-right) + 360px); padding-top:calc(env(safe-area-inset-top) + 80px); padding-left:24px; padding-right:calc(env(safe-area-inset-right) + 24px); box-shadow:-1px 0 0 #efece4, -6px 0 24px #46371e0d; }
|
|
563
|
+
body.live.hand-left #back { box-shadow:0 2px 10px #46371e2e; } body.live.hand-left #back:not(.tinted) { background:#fffffff0; }
|
|
564
|
+
body.live.hand-left .tb.r { z-index:6; }
|
|
565
|
+
${BOARD_MARK_HAND_LEFT_CSS}
|
|
566
|
+
}
|
|
567
|
+
</style>
|
|
568
|
+
<div id="home">
|
|
569
|
+
<div id="ttl"><span id="ct-mark" class="ct-draw">${titleLogo(false)}</span><h1 id="title">__TITLE__</h1></div>
|
|
570
|
+
<div class="hcards" id="hcards"></div>
|
|
571
|
+
<div class="deck" id="deck" hidden></div>
|
|
572
|
+
<p id="rest">老师们休息中</p>
|
|
573
|
+
<p id="gate">老师们还没到<small>叫爸爸妈妈来看一下</small></p>
|
|
574
|
+
<a class="c c-figshot" id="figshot" hidden><span class="avs"></span><span class="tx"><b>给老师们换个样子</b><span>帽子、脸、衣服,都由你来挑</span></span><span class="go">去搭</span></a>
|
|
575
|
+
</div>
|
|
576
|
+
<div id="toast"></div>
|
|
577
|
+
<iframe id="lc-frame" hidden title="小课堂"></iframe>
|
|
578
|
+
<section id="tutor">
|
|
579
|
+
<div id="main">
|
|
580
|
+
<div class="tb l"><button id="back" type="button" aria-label="回首页"><i id="back-ic"></i>__BACK_LOGO__</button><span id="c-mo" hidden></span></div>
|
|
581
|
+
<div class="tb r"><button class="hb" id="more-btn" type="button" aria-label="更多"></button></div>
|
|
582
|
+
<div id="wrap">
|
|
583
|
+
<div id="bmark" hidden></div>
|
|
584
|
+
<div id="board"></div>
|
|
585
|
+
<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><span class="note" id="st-note"></span><button id="st-go" type="button">交给老师</button></div><button id="cv-hold" class="vb-mini" type="button"></button><div id="cv-cap"></div></div>
|
|
586
|
+
<div id="st-dim"></div>
|
|
587
|
+
</div>
|
|
588
|
+
<div id="lv"><div id="lv-av"></div><div id="lv-kid"></div><div id="lv-cap"><div class="lv-prev"></div><div class="lv-cur"></div></div><div id="lv-ask"><b></b><small></small></div><div id="lv-acts"></div><button id="lv-replay" type="button" hidden><span class="ic"></span><span class="tx"></span></button><button id="lv-hold" type="button"></button><button id="lv-today" type="button">回到今天 ›</button><div id="lv-tools"><button type="button" id="lv-paper"><span class="ic"></span>画板</button><button type="button" id="lv-cam"><span class="ic"></span>拍照</button><button type="button" id="lv-album"><span class="ic"></span>相册</button><button type="button" id="lv-type"><span class="ic"></span>打字</button></div></div>
|
|
589
|
+
<div id="bar">
|
|
590
|
+
<div id="pill">
|
|
591
|
+
<label class="ic" id="cam"><input type="file" accept="image/*" capture="environment"></label>
|
|
592
|
+
<div id="mid"><span id="ph">发消息或按住说话…</span><input id="typed" type="text" autocomplete="off" hidden></div>
|
|
593
|
+
<button class="ic" id="lv-mic" type="button" aria-label="关麦"></button>
|
|
594
|
+
<button id="go" type="button" hidden></button>
|
|
595
|
+
<button class="ic" id="plus" type="button"></button>
|
|
596
|
+
</div>
|
|
597
|
+
<button id="back-today" type="button" hidden>回到今天</button>
|
|
598
|
+
</div>
|
|
599
|
+
__REEL_DOCK_HTML__
|
|
600
|
+
<div id="hold"><div class="t"></div><span>松手发送,上移取消</span><div class="w"></div></div>
|
|
601
|
+
</div>
|
|
602
|
+
<div id="hist"><div class="dimmer"></div><div class="panel"><div class="hd"><span>以前的</span><button class="hb" id="hist-x" type="button"></button></div><div class="ls"></div></div></div>
|
|
603
|
+
<div id="menu"><div class="dimmer"></div><div class="panel">
|
|
604
|
+
<button type="button" id="hist-btn"><span class="ic"></span><span class="tx"><b>以前的</b><small>看看以前聊过的话题</small></span></button>
|
|
605
|
+
<button type="button" id="new-btn" hidden><span class="ic"></span><span class="tx"><b>新话题</b><small>这个聊完了,换一个问</small></span></button>
|
|
606
|
+
</div></div>
|
|
607
|
+
<div id="sheet"><div class="dimmer"></div><div class="panel"><div class="grab"></div><div class="opts">
|
|
608
|
+
<label><span class="ic" id="ic-album"></span>相册<input type="file" accept="image/*" multiple></label>
|
|
609
|
+
<label><span class="ic" id="ic-cam2"></span>拍照<input type="file" accept="image/*" capture="environment"></label>
|
|
610
|
+
<button type="button" id="sheet-paper"><span class="ic" id="ic-paper"></span>画板</button>
|
|
611
|
+
</div></div></div>
|
|
612
|
+
<div id="ps">
|
|
613
|
+
<div id="ps-top"><span id="ps-to"></span></div>
|
|
614
|
+
<div class="view"><div class="cv"><canvas id="ps-cv"></canvas><div id="ps-crop" hidden><div class="dim"></div><div class="dim"></div><div class="dim"></div><div class="dim"></div><div class="fr" data-g="move"><span class="g nw" data-g="nw"></span><span class="g ne" data-g="ne"></span><span class="g sw" data-g="sw"></span><span class="g se" data-g="se"></span></div></div></div></div>
|
|
615
|
+
<div id="ps-strip"></div>
|
|
616
|
+
<div class="tools" id="ps-tools"><button type="button" id="ps-t-crop">裁剪</button><button type="button" id="ps-t-pen">圈画</button><button type="button" id="ps-t-rot">转 90°</button></div>
|
|
617
|
+
<div class="tools" id="ps-crop-bar" hidden><button type="button" id="ps-c-full">整张</button><button type="button" class="ok" id="ps-c-ok">好了</button></div>
|
|
618
|
+
<div class="tools" id="ps-pen-bar" hidden><button type="button" id="ps-p-undo">撤销</button><button type="button" id="ps-p-clear">清空</button><button type="button" class="ok" id="ps-p-ok">好了</button></div>
|
|
619
|
+
<div class="foot" id="ps-foot"><button type="button" id="ps-cancel">取消</button><button type="button" id="ps-go">发给老师</button></div>
|
|
620
|
+
</div>
|
|
621
|
+
<div id="lb"><div class="zoom"><img alt=""></div><button type="button" class="x" id="lb-x"></button><button type="button" class="nav l" id="lb-l">‹</button><button type="button" class="nav r" id="lb-r">›</button><div class="n" id="lb-n"></div></div>
|
|
622
|
+
</section>
|
|
623
|
+
<script>
|
|
624
|
+
// 孩子端的流水(kid-log.ts):最先装上出错的监听;打开那一下在下面 kidLog.start
|
|
625
|
+
${KID_LOG_JS}
|
|
626
|
+
(() => {
|
|
627
|
+
__BOARD_JS__
|
|
628
|
+
__PHOTO_JS__
|
|
629
|
+
__REEL_JS__
|
|
630
|
+
__LIVE_JS__
|
|
631
|
+
__CANVAS_CHAT_JS__
|
|
632
|
+
__AUDIO_JS__
|
|
633
|
+
__VOICE_VIEW_JS__
|
|
634
|
+
__VOICE_JS__
|
|
635
|
+
__BRAND_JS__
|
|
636
|
+
|
|
637
|
+
/** 页面的模式:{} = 孩子端;reel = 录像里孩子的屏幕;preview = 家长端「首页」里的预览 */
|
|
638
|
+
const MODE = __MODE__;
|
|
639
|
+
/** 舞台包的版本(src/app/http/stage.ts stageVersion):iframe 的地址带上它,打包过一次就换一个地址,不用浏览器缓存里的旧舞台 */
|
|
640
|
+
const STAGE_V = '__STAGE_V__';
|
|
641
|
+
/** 这份页面的版本(源码的 hash):孩子端记进实录,录像对不上时标「画法已更新」 */
|
|
642
|
+
const PAGE_V = '__PAGE_V__';
|
|
643
|
+
const stageUrl = (card) => '/stage/?v=' + STAGE_V + '&card=' + encodeURIComponent(card);
|
|
644
|
+
/** 录像里孩子的屏幕(/reel):孩子端的样子,数据是 /reel 给的一个话题;只读、不记、点不动,由录像的时钟驱动 */
|
|
645
|
+
const REEL = MODE.reel === true;
|
|
646
|
+
const PREVIEW = MODE.preview === true;
|
|
647
|
+
// 孩子端不往浏览器历史里加东西(kid-nav.ts):去听写、口语课的链接都是替换当前页;家长端、录像、预览照常
|
|
648
|
+
if (!REEL && !PREVIEW) { ${KID_NAV_JS} }
|
|
649
|
+
// 只有孩子端报「打开」、登记设备;家长端、录像、预览开在家长自己的设备上,只记出错。ctx 出错时才取(S、LV 在下面)
|
|
650
|
+
kidLog.start({ page: 'kid', mode: REEL ? 'reel' : PREVIEW ? 'preview' : 'kid', v: PAGE_V, ctx: () => ({ tutor: S.tutor ? S.tutor.name : null, thread: S.thread || null, live: Boolean(LV.on) }) });
|
|
651
|
+
/** 孩子端对话接口的前缀(messages / photos / cards / history);家长端只看,一天的板书走自己的路 */
|
|
652
|
+
const CONV = '/api/kid/conversations/';
|
|
653
|
+
const AUDIO = '/api/audio/';
|
|
654
|
+
// $ / h / toast / api(page-dom.ts,几页共用)
|
|
655
|
+
${pageDomJs(4000)}
|
|
656
|
+
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>';
|
|
657
|
+
const ICON = {
|
|
658
|
+
back: SVG('<path d="M15 5l-7 7 7 7"></path>', 28, 2.2),
|
|
659
|
+
// 相机、相册同拍照听写那几屏(photo-ui.ts);加号面板里两个一样大
|
|
660
|
+
camera: __CAMERA_22__,
|
|
661
|
+
cameraLg: __CAMERA_28__,
|
|
662
|
+
plus: SVG('<path d="M12 5v14M5 12h14"></path>', 22, 2.2),
|
|
663
|
+
play: SVG('<path d="M8 5l11 7-11 7z" fill="currentColor"></path>', 16, 1.5),
|
|
664
|
+
pause: SVG('<path d="M8 5v14M16 5v14"></path>', 16, 2.6),
|
|
665
|
+
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),
|
|
666
|
+
replay: 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>', 16, 2),
|
|
667
|
+
stop: SVG('<rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor"></rect>', 16, 1.5),
|
|
668
|
+
send: SVG('<path d="M12 19V5M5 12l7-7 7 7"></path>', 20, 2.4),
|
|
669
|
+
micMd: SVG('<rect x="9" y="3" width="6" height="11" rx="3"></rect><path d="M5 11a7 7 0 0 0 14 0M12 18v3"></path>', 22, 2),
|
|
670
|
+
keyboard: SVG('<rect x="3" y="6" width="18" height="13" rx="2"></rect><path d="M7 10h1M11 10h1M15 10h1M8 15h8"></path>', 26, 1.8),
|
|
671
|
+
paper: SVG('<path d="M15 4l5 5-9 9H6v-5z"></path><path d="M13 6l5 5"></path><path d="M3 21c2-1.5 4-1.5 6 0s4 1.5 6 0"></path>', 26, 1.8),
|
|
672
|
+
micOff: SVG('<rect x="9" y="3" width="6" height="11" rx="3"></rect><path d="M5 11a7 7 0 0 0 14 0M12 18v3M4 4l16 16"></path>', 22, 2),
|
|
673
|
+
album: __ALBUM_28__,
|
|
674
|
+
close: SVG('<path d="M6 6l12 12M18 6L6 18"></path>', 24, 2.2),
|
|
675
|
+
closeSm: SVG('<path d="M7 7l10 10M17 7L7 17"></path>', 14, 2.8),
|
|
676
|
+
check: SVG('<path d="M5 12l5 5 9-10"></path>', 16, 3),
|
|
677
|
+
lock: SVG('<rect x="5" y="11" width="14" height="9" rx="2"></rect><path d="M8 11V8a4 4 0 0 1 8 0v3"></path>', 14, 2.2),
|
|
678
|
+
more: SVG('<circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle>', 24),
|
|
679
|
+
history: SVG('<circle cx="12" cy="12" r="8.5"></circle><path d="M12 7.5V12l3 2"></path>', 24),
|
|
680
|
+
again: SVG('<path d="M4 12a8 8 0 1 0 2.4-5.7"></path><path d="M4 4v4.5h4.5"></path>', 20, 2),
|
|
681
|
+
start: SVG('<path d="M8 5l11 7-11 7z" fill="currentColor"></path>', 18, 1.5),
|
|
682
|
+
spark: SVG('<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"></path><path d="M19 15.5l.7 1.8 1.8.7-1.8.7-.7 1.8-.7-1.8-1.8-.7 1.8-.7z"></path>', 24, 1.8),
|
|
683
|
+
};
|
|
684
|
+
// 服务重起过(启动号变了)→ 记下,空下来自己重载(见 staleReload)
|
|
685
|
+
apiSeen = (r) => {
|
|
686
|
+
const boot = r.headers.get('x-cotutor-boot');
|
|
687
|
+
if (boot) { if (!bootId) bootId = boot; else if (boot !== bootId) stale = true; }
|
|
688
|
+
};
|
|
689
|
+
let bootId = null, stale = false;
|
|
690
|
+
// 学科色:服务端定好随老师下发(t.color,src/ui/shared/tutor-color.ts);不归哪位老师的块用中性的灰
|
|
691
|
+
const colorOf = (t) => (t && t.color) || __NEUTRAL_COLOR__;
|
|
692
|
+
// 回首页胶囊带上老师(tutorInitial 同首页牌上的首字,backTint 填颜色)
|
|
693
|
+
${BACK_TINT_JS}
|
|
694
|
+
const tintBack = (t) => backTint($('#back'), t ? { color: colorOf(t), display: t.display } : null);
|
|
695
|
+
// 板书底纹的学科字(三门主科):老师换了跟着换,别的老师不写字
|
|
696
|
+
${BOARD_MARK_JS}
|
|
697
|
+
/** 老师头像:学科色的 C 形开口环 + 白底圆里的图或字(样子见 .av 的样式);cls 'draw' = 一笔画出来,delay 是错开的秒数 */
|
|
698
|
+
const AV_RING = '<svg class="ring" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="44"></circle></svg>';
|
|
699
|
+
const avatarEl = (t, cls, delay) => {
|
|
700
|
+
const el = h('span', { class: 'av ' + (cls || ''), style: 'color:' + colorOf(t) + (delay ? ';--d:' + delay + 's' : '') });
|
|
701
|
+
el.innerHTML = AV_RING;
|
|
702
|
+
const face = h('span', { class: 'face' });
|
|
703
|
+
if (t.avatar && /\.(png|jpe?g|webp|svg)$/i.test(t.avatar)) face.append(h('img', { src: '/api/kid/avatar/' + t.name, alt: '' }));
|
|
704
|
+
else face.textContent = t.avatar || (t.subject || t.display || '?').slice(0, 1);
|
|
705
|
+
el.append(face);
|
|
706
|
+
return el;
|
|
707
|
+
};
|
|
708
|
+
const debug = new URLSearchParams(location.search);
|
|
709
|
+
|
|
710
|
+
// ---- 状态 ----
|
|
711
|
+
const S = { home: null, tutor: null, day: null, sections: [], played: new Set(), state: { section: -1, line: -1, status: 'idle' }, pending: false, waitSince: null, offline: false, bar: 'idle', pollTimer: null, stage: null, partial: null, thread: null, threadAt: null, hist: null, readonly: false, newThread: false, device: 'phone', via: null, cont: null, ask: null, talk: null, talkWait: false, reel: null, pendSaid: null, homeDraw: true };
|
|
712
|
+
// 实时的老师这一次(连、发、收、画在下面「实时的老师」那一块):on = 这位老师这一次走实时(左栏的样子);ready = 嘴连上了;
|
|
713
|
+
// model = liveStep 的状态(只在 liveDispatch 里写);cap = 字幕(上一句、在说的、被打断的那句);heard = 每一轮(job)念到第几句
|
|
714
|
+
const LV = { on: false, ready: false, ws: null, audio: null, model: initialLive(), cap: { prev: '', cur: '', cut: '', catching: false }, heard: new Map(), jobs: new Set(), drain: 0, was: 'quiet', holdAt: 0, diag: null, queued: null, playLine: null, endLine: null, ring: null, ringEnd: 0, lcPending: false, gateOn: false, retries: 0, retryTimer: 0 };
|
|
715
|
+
/** 按住说话那颗按钮(平板横屏左栏;头像 + 声音):样子是 voice-view.ts 的 voiceView 给的、画法在 voice-ui.ts,
|
|
716
|
+
* 这里只在一处(voiceSync)把状态凑好交过去、把音量喂进去。hint = 刚才那一下的提示(留 2 秒);photo = 这一轮发的是照片;loudAt = 按着时最近一次有声音;
|
|
717
|
+
* reelHold / reelSpeak = 录像里孩子的屏幕这一刻(按着没有、嘴在不在出声) */
|
|
718
|
+
/** 两颗按住说话的按钮一起变:左栏那颗、画板铺满时工具列底下那颗小的;探针、录像读的是大的那颗 */
|
|
719
|
+
const vbPair = (a, b) => ({ el: a.el, set: (v) => { a.set(v); b.set(v); }, shake: () => { a.shake(); b.shake(); }, nod: () => { a.nod(); b.nod(); }, avatar: (n) => { a.avatar(n); b.avatar(n.cloneNode(true)); }, side: (x) => { a.side(x); b.side(x); }, color: (c) => { a.color(c); b.color(c); }, level: (w, v) => { a.level(w, v); b.level(w, v); }, tutorSource: (fn) => { a.tutorSource(fn); b.tutorSource(fn); }, drawn: a.drawn });
|
|
720
|
+
const VB = { btn: vbPair(voiceButton($('#lv-hold'), { side: 'left' }), voiceButton($('#cv-hold'), { side: 'left' })), hint: '', hintT: 0, loudAt: 0, photo: false, silentT: 0, side: 'left', reelHold: null, reelSpeak: false };
|
|
721
|
+
VB.btn.tutorSource(() => (LV.audio && LV.audio.outLevel ? LV.audio.outLevel() : 0));
|
|
722
|
+
$('#lv-av').classList.add('vb-halo');
|
|
723
|
+
|
|
724
|
+
// ---- 声音:共享 Audio,首个手势解锁(iOS);卡没配好音就不出声(不用浏览器合成声:老师配了音色,机械声孩子不习惯) ----
|
|
725
|
+
// 录像里孩子的屏幕在家长页的 iframe 里:用家长页那个 Audio(家长点「看录像」时在手势里解锁过),声音才放得出来
|
|
726
|
+
const audioEl = (() => { if (REEL) { try { if (window.parent !== window && window.parent.__cotutorAudio) return window.parent.__cotutorAudio; } catch {} } return new Audio(); })();
|
|
727
|
+
let unlocked = false;
|
|
728
|
+
// 老师的声已经放过(元素早解锁了)就不再放静音:换 src 会抢走正在念的那句,触发它的 onerror 退成浏览器的声重念(2026-10-04 真机:点输入框,当前那句被浏览器重念一遍)。
|
|
729
|
+
// 静音这一下被下一句的 src 打断(AbortError)也算解锁:手势里调过 play() 就够了;原来不算,之后每点一下输入框都会再抢一次
|
|
730
|
+
const unlock = () => {
|
|
731
|
+
if (unlocked) return;
|
|
732
|
+
if (audioEl.src && !audioEl.src.startsWith('data:')) { unlocked = true; return; }
|
|
733
|
+
try { audioEl.onended = audioEl.onerror = audioEl.onplaying = null; audioEl.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA='; audioEl.play().then(() => { unlocked = true; }, (e) => { if (e && e.name === 'AbortError') unlocked = true; }); } catch {}
|
|
734
|
+
};
|
|
735
|
+
let voiceToken = 0;
|
|
736
|
+
/** 配音文件(相对 conversations/<老师>/)的地址 */
|
|
737
|
+
const audioUrl = (rel) => AUDIO + S.tutor.name + '/' + rel.split('/').map(encodeURIComponent).join('/');
|
|
738
|
+
/** 卡上第 k 段的配音(点读段、单词):这一轮跑完才在后台配,孩子刚看到卡就点时可能还没好。
|
|
739
|
+
* 好了就 play(相对路径);没好就每秒拉一次今天(会把 card.assets 补上),到了再放,最多 CARD_CLIP_WAIT_MS;等的时候被别的声音打断(token 变了)就算了。
|
|
740
|
+
* 等不到就 miss()(不出声)。2026-10-08 之前直接退合成声:刚出来的单词卡「听」是机械声,iPad 上开着麦克风时还可能把话筒弄哑;10-28 合成声整个去掉(mock 也配静音的段) */
|
|
741
|
+
const CARD_CLIP_WAIT_MS = 12000;
|
|
742
|
+
const cardClip = (card, k, token, play, miss) => {
|
|
743
|
+
const a = S.tutor ? segmentAudio(card, k) : null;
|
|
744
|
+
if (a) return play(a);
|
|
745
|
+
if (!S.tutor) return miss();
|
|
746
|
+
const until = Date.now() + CARD_CLIP_WAIT_MS;
|
|
747
|
+
const tick = async () => {
|
|
748
|
+
try { await loadDay(true); } catch {}
|
|
749
|
+
if (token !== voiceToken || !S.tutor) return miss(); // miss 里的收尾自己查 token:这里只是把按钮的「在念」撤掉
|
|
750
|
+
const b = segmentAudio(card, k);
|
|
751
|
+
if (b) play(b);
|
|
752
|
+
else if (Date.now() < until) setTimeout(tick, 1000);
|
|
753
|
+
else miss();
|
|
754
|
+
};
|
|
755
|
+
setTimeout(tick, 300);
|
|
756
|
+
};
|
|
757
|
+
/** 换了音源后定倍速(单词卡舞台慢放;看录像另有倍速):换 src 会把 playbackRate 重置成 defaultPlaybackRate,两个一起设;音高不变 */
|
|
758
|
+
const audioRate = (r) => { audioEl.defaultPlaybackRate = r; audioEl.playbackRate = r; try { audioEl.preservesPitch = true; audioEl.webkitPreservesPitch = true; } catch {} };
|
|
759
|
+
/** 只停声音。播放状态不在这里改——那走 dispatch(见下面「播放器」) */
|
|
760
|
+
const silence = () => { voiceToken++; try { audioEl.pause(); } catch {} };
|
|
761
|
+
|
|
762
|
+
// ---- 首页:老师卡在上,其余卡照发布的顺序;按钮决定进老师页之后的话题 ----
|
|
763
|
+
const BUTTON_ICON = { new: 'spark', start: 'start', continue: 'again', lecture: 'play' };
|
|
764
|
+
const pickButton = (t, b) => {
|
|
765
|
+
const via = { home: S.home.home, button: b.id };
|
|
766
|
+
// 今天同一个入口聊过的(b.resume):新话题在空白板上放一个胶囊;activity、小课堂先问接着那个还是重新开始
|
|
767
|
+
if (b.kind === 'new') openTutor(t, { kind: 'new', via, resume: b.resume });
|
|
768
|
+
else if (b.kind === 'start') openTutor(t, b.resume ? { kind: 'new', via, ask: { label: b.label, resume: b.resume, send: b.label } } : { kind: 'new', via, send: b.label });
|
|
769
|
+
// 小课堂:新话题,先铺满放课;看完才能开口,开口那条带上 via 与看的情况
|
|
770
|
+
else if (b.kind === 'lecture') { const lecture = { bundle: b.bundle, title: b.title || b.label, ...(b.video ? { video: true } : {}) }; openTutor(t, b.resume ? { kind: 'new', via, ask: { label: b.label, resume: b.resume, lecture } } : { kind: 'new', via, lecture }); }
|
|
771
|
+
else if (b.date === S.home.date) openTutor(t, { kind: 'thread', thread: b.thread, via, send: b.label });
|
|
772
|
+
else openTutor(t, { kind: 'new', via, send: b.label, cont: b.date });
|
|
773
|
+
};
|
|
774
|
+
/** 小课堂的课长:「1 分 40 秒」 */
|
|
775
|
+
const lcLen = (ms) => { const s = Math.floor(ms / 1000); return s >= 60 ? Math.floor(s / 60) + ' 分' + (s % 60 ? ' ' + (s % 60) + ' 秒' : '') : s + ' 秒'; };
|
|
776
|
+
// ---- 常驻入口:听写、拍照听写、口语课一直在那位老师的卡上,不经首页发布 ----
|
|
777
|
+
const EXTRA_ICON = { dictation: SVG('<path d="M4 20l4-1 11-11-3-3L5 16z"></path><path d="M14 7l3 3"></path>', 20), photo: __CAMERA_20__ };
|
|
778
|
+
/** 听写(原型 B,https://claude.ai/artifact/HhpuLJXvNr94E9PqSNv6tj):一整条,上面一排空格子,几个词几格(最多 6,英文是四线三格),下面「听写 · 几个词」和「去写」 */
|
|
779
|
+
const dictationBtn = (label, sub, n, en, href) => h('a', { class: 'bt bt-extra bt-x-dictation' + (en ? ' en' : ''), href },
|
|
780
|
+
h('span', { class: 'dg', 'aria-hidden': 'true' }, ...Array.from({ length: Math.max(1, Math.min(n, en ? 4 : 6)) }, () => h('i'))),
|
|
781
|
+
h('span', { class: 'dr' }, h('span', { class: 'lb' }, h('span', { class: 'btx' }, label), h('small', { class: 'ln' }, sub)), h('span', { class: 'go' }, '去写')));
|
|
782
|
+
/** 拍照听写:图标是一小格——语文的田字格写个「字」、英语的四线格写个「Aa」,右下角学科色的相机;孩子看格子就分得出是写汉字还是写英文 */
|
|
783
|
+
const photoBtn = (t) => {
|
|
784
|
+
const en = t.lang === 'en';
|
|
785
|
+
return h('a', { class: 'bt bt-extra bt-x-photo' + (en ? ' en' : ''), href: '/dictation?tutor=' + encodeURIComponent(t.name) + (en ? '&lang=en' : ''), style: '--sc:' + colorOf(t) },
|
|
786
|
+
h('span', { class: 'pg', 'aria-hidden': 'true' }, h('i', {}, en ? 'Aa' : '字'), h('span', { class: 'cam', html: EXTRA_ICON.photo })),
|
|
787
|
+
h('span', { class: 'lb' }, h('span', { class: 'btx' }, '拍照听写'), h('small', { class: 'ln' }, en ? '拍英语书上要写的单词' : '拍语文书上要写的词')));
|
|
788
|
+
};
|
|
789
|
+
/** 口语课:开着就一直在,点进去先拍照;有上次的口语单,小字写「接着上 · 几月几日的」。/api/talk 30 秒读一次 */
|
|
790
|
+
const talkInfo = () => {
|
|
791
|
+
if (S.talkAt && Date.now() - S.talkAt < 30000) return S.talkLast;
|
|
792
|
+
S.talkAt = Date.now();
|
|
793
|
+
api('GET', '/api/talk').then((j) => {
|
|
794
|
+
const last = j && j.enabled ? (j.talks || []).find((x) => (x.status === 'ready' || x.status === 'done') && x.list.words.length + x.list.sentences.length > 0) : null;
|
|
795
|
+
const next = j && j.enabled ? { last: last || null } : null;
|
|
796
|
+
const sig = (x) => (x ? 'on:' + (x.last ? x.last.id : '') : '');
|
|
797
|
+
const was = sig(S.talkLast); S.talkLast = next;
|
|
798
|
+
if (was !== sig(next) && S.home && !S.tutor) renderHome();
|
|
799
|
+
}, () => {});
|
|
800
|
+
return S.talkLast;
|
|
801
|
+
};
|
|
802
|
+
/** 口语课的按钮:用途图是对话气泡「Hi」带相机角标,和拍照听写的四线格「Aa」挨着、分得开 */
|
|
803
|
+
const talkBtn = (t, info) => {
|
|
804
|
+
const d = info.last ? new Date(info.last.createdAt) : null;
|
|
805
|
+
return h('a', { class: 'bt bt-extra bt-x-talk', href: '/talk?tutor=' + encodeURIComponent(t.name), style: '--sc:' + colorOf(t) },
|
|
806
|
+
h('span', { class: 'pg', 'aria-hidden': 'true' }, h('i', {}, 'Hi'), h('span', { class: 'cam', html: EXTRA_ICON.photo })),
|
|
807
|
+
h('span', { class: 'lb' }, h('span', { class: 'btx' }, '口语课'), h('small', { class: 'ln' }, d ? '接着上 · ' + (d.getMonth() + 1) + ' 月 ' + d.getDate() + ' 日的,或者拍新的' : '拍今天学的那页,先跟读再聊')));
|
|
808
|
+
};
|
|
809
|
+
/** 这位老师卡上「每天都在」那栏:归他的听写卡、常驻的拍照听写与口语课 */
|
|
810
|
+
const extrasFor = (t, H) => {
|
|
811
|
+
const out = [];
|
|
812
|
+
const extras = t.extras || [];
|
|
813
|
+
const all = (Array.isArray(H.cards) ? H.cards : []).filter((x) => x.kind === 'dictation');
|
|
814
|
+
// 英文单词听写:卡上的词是英文单词,或者英语老师卡上的拍照听写 → /dictation?lang=en
|
|
815
|
+
for (const c of all) if (c.owner === t.name) { const en = c.props.lang === 'en', n = c.props.count || 0; out.push(dictationBtn(en ? '单词听写' : '听写', n + (en ? ' 个单词' : ' 个词') + ' · 老师念,你来写', n, en, '/dictation?home=' + encodeURIComponent(H.home || '') + '&n=' + all.indexOf(c) + (en ? '&lang=en' : ''))); }
|
|
816
|
+
if (extras.includes('dictation')) out.push(photoBtn(t));
|
|
817
|
+
if (extras.includes('talk')) { const info = talkInfo(); if (info) out.push(talkBtn(t, info)); }
|
|
818
|
+
return out;
|
|
819
|
+
};
|
|
820
|
+
// 牌上的按钮(原型 B,https://claude.ai/artifact/HhpuLJXvNr94E9PqSNv6tj):新话题是头像下面一个学科色大气泡(不带尖角);小课堂是带预览图的海报;其余是大胶囊。
|
|
821
|
+
// 孩子看到的字都在 .btx 里(探针按它读)。新话题打头印名字的首字(.fc):平板横着叠时后面的牌只露左边一格,露出来的那截气泡就是这个字;手机也印,两处一样
|
|
822
|
+
const tutorButton = (t, b) => {
|
|
823
|
+
const on = { click: () => pickButton(t, b) };
|
|
824
|
+
const fc = tutorInitial(t);
|
|
825
|
+
if (b.kind === 'new') return h('button', { type: 'button', class: 'bt bt-new', 'data-bid': String(b.id), style: '--sc:' + colorOf(t), on }, fc ? h('span', { class: 'fc', 'aria-hidden': 'true' }, fc) : null, h('span', { class: 'lb' }, h('span', { class: 'btx' }, b.label), h('small', { class: 'ln' }, '想问我什么?')), h('span', { class: 'bi', html: ICON.spark }));
|
|
826
|
+
if (b.kind === 'lecture') return h('button', { type: 'button', class: 'bt bt-lecture', 'data-bid': String(b.id), on }, lecturePic(b),
|
|
827
|
+
h('span', { class: 'lm' }, h('small', { class: 'tg' }, '小课堂'), h('span', { class: 'btx' }, b.label), b.ms ? h('small', { class: 'ln' }, lcLen(b.ms)) : null));
|
|
828
|
+
return h('button', { type: 'button', class: 'bt bt-' + b.kind, 'data-bid': String(b.id), on }, h('span', { class: 'bi', html: ICON[BUTTON_ICON[b.kind]] || ICON.start }), h('span', { class: 'btx' }, b.label));
|
|
829
|
+
};
|
|
830
|
+
/** 挨着的几个小课堂收进一格(平板上两张海报并排,手机上一张一行) */
|
|
831
|
+
const buttonRuns = (t, buttons) => {
|
|
832
|
+
const out = [];
|
|
833
|
+
for (const b of buttons) {
|
|
834
|
+
const el = tutorButton(t, b), last = out[out.length - 1];
|
|
835
|
+
if (b.kind !== 'lecture') out.push(el);
|
|
836
|
+
else if (last && last.classList.contains('lcs')) last.append(el);
|
|
837
|
+
else out.push(h('div', { class: 'lcs' }, el));
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
};
|
|
841
|
+
/** 小课堂海报的图:课包画最后一帧(frame.svg,没烤好时 404 就只留底色和播放键),视频停在末尾前一点 */
|
|
842
|
+
const lecturePic = (b) => {
|
|
843
|
+
const box = h('span', { class: 'lp' });
|
|
844
|
+
if (b.video) { if (b.ms) box.append(lcVideoStill(b.bundle, b.ms)); }
|
|
845
|
+
else box.append(h('img', { alt: '', src: lcBundleUrl(b.bundle) + 'frame.svg?svg=end', on: { error: () => box.classList.add('nopic') } }));
|
|
846
|
+
box.append(h('span', { class: 'pl', html: ICON.play }));
|
|
847
|
+
return box;
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// ---- 首页牌堆(原型 https://claude.ai/artifact/BAUVUj9TgCKpiQkRyZtvLy):一位老师一张牌,平着叠,最前那张在最上层 ----
|
|
851
|
+
// 手机竖着叠:后面的牌在上面,每张往上露出头那一行(头像 + 名字);平板横着叠:后面的牌在左边,每张往左露出一格(左上角的头像)。
|
|
852
|
+
// 叠的顺序 K.order:最后一个在最前。进首页时家长排第一的在最前、排第二的紧挨在后面;点过谁谁到最前(最近点过的在前);
|
|
853
|
+
// 从老师页回来不变,刷新页面回到家长排的。牌一样高(取最高那张)。牌元素常驻,5 秒一轮的刷新只在这张牌的内容变了时重画它、不放动画
|
|
854
|
+
const K = { order: null, els: new Map(), busy: false, geo: null };
|
|
855
|
+
// 从牌上去了别的页(听写、拍照听写、口语课),回来是整页重开、K.order 没了:走之前记下是哪位的牌,下一次进首页读一次就删(「回来停在刚才那位」;刷新照旧回家长排的)
|
|
856
|
+
const FRONT_KEY = 'kid-home-front';
|
|
857
|
+
const stashFront = (el) => { if (PREVIEW) return; const pc = el && el.closest && el.closest('[data-tutor]'); try { if (pc) sessionStorage.setItem(FRONT_KEY, pc.dataset.tutor); } catch {} };
|
|
858
|
+
const takeFront = () => { if (PREVIEW) return null; let f = null; try { f = sessionStorage.getItem(FRONT_KEY); sessionStorage.removeItem(FRONT_KEY); } catch {} return f; };
|
|
859
|
+
const reduced = () => Boolean(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
860
|
+
// 橙点「看过了」记在这台设备上(换了一份首页就清):家长排了东西、孩子还没找过这位老师时亮,换到最前就灭
|
|
861
|
+
const SEEN_KEY = 'kid-home-seen';
|
|
862
|
+
const seenOf = (home) => { try { const j = JSON.parse(localStorage.getItem(SEEN_KEY) || '{}'); return j.home === home && Array.isArray(j.tutors) ? j.tutors : []; } catch { return []; } };
|
|
863
|
+
const markSeen = (home, name) => { if (PREVIEW) return; try { const t = seenOf(home); if (!t.includes(name)) localStorage.setItem(SEEN_KEY, JSON.stringify({ home, tutors: [...t, name] })); } catch {} };
|
|
864
|
+
const homeTutorsOf = (H) => {
|
|
865
|
+
const byName = new Map(H.tutors.map((t) => [t.name, t]));
|
|
866
|
+
return (Array.isArray(H.cards) ? H.cards : []).filter((c) => c.kind === 'tutor' && byName.has(c.props.tutor)).map((c) => ({ t: byName.get(c.props.tutor), card: c }));
|
|
867
|
+
};
|
|
868
|
+
// 平板的牌最宽 640(原型 B):两张小课堂海报并排要这么宽
|
|
869
|
+
const DECK = { V: { av: 56, nm: 21, pull: 44, head: 48 }, H: { av: 64, nm: 24, pull: 120, head: 40, wMax: 640, wMin: 320, step: 84, stepMin: 48 } };
|
|
870
|
+
const DECK_SCALE = [1, 0.97, 0.94, 0.91, 0.88], DECK_VEIL = [0, 0.16, 0.26, 0.34, 0.4];
|
|
871
|
+
// 影子:竖着叠往上投、横着叠往左投,落在后面那张上;第二个是拿起来时的
|
|
872
|
+
const DECK_SHADOW = { V: ['0 -4px 14px rgba(70,50,30,.14)', '0 24px 34px rgba(70,50,30,.24)'], H: ['-6px 4px 14px rgba(70,50,30,.16)', '-18px 26px 34px rgba(70,50,30,.26)'] };
|
|
873
|
+
// 换一张的时间表(ms):按下 → 往盖着它的牌的反方向抽出(还在原层)→ 230 换到最上层、浮起 → 飘到最前、略冲过 → 落下回弹、张嘴;
|
|
874
|
+
// 其余几张 230 起各退一层,挨着缺口的先走
|
|
875
|
+
const DT = { press: 70, pull: 230, lift: 320, glide: 580, land: 700, shift: [230, 580], stagger: 30, bite: 580, lock: 900 };
|
|
876
|
+
const deckAxis = () => ((debug.get('device') || deviceFor(innerWidth, innerHeight)) === 'phone' ? 'V' : 'H');
|
|
877
|
+
const deckBase = (slot, n) => { const g = K.geo, d = Math.min(n - 1 - slot, 4); return { x: g.axis === 'H' ? slot * g.step : 0, y: g.axis === 'V' ? slot * g.strip : 0, r: 0, s: DECK_SCALE[d], v: DECK_VEIL[d] }; };
|
|
878
|
+
const deckTf = (b) => 'translate(' + b.x.toFixed(1) + 'px,' + b.y.toFixed(1) + 'px) rotate(' + b.r + 'deg) scale(' + b.s + ')';
|
|
879
|
+
const deckPlace = (el, slot, n) => {
|
|
880
|
+
const b = deckBase(slot, n), tf = deckTf(b);
|
|
881
|
+
el.style.transform = tf; el.style.setProperty('--tf', tf); el.style.boxShadow = DECK_SHADOW[K.geo.axis][0]; el.style.zIndex = String(slot + 1);
|
|
882
|
+
el.querySelector('.veil').style.opacity = String(b.v);
|
|
883
|
+
};
|
|
884
|
+
/** 量牌宽、牌高、竖着叠露出的那条,摆好每张;换牌时不动 */
|
|
885
|
+
const layoutDeck = () => {
|
|
886
|
+
const box = $('#deck'), n = K.order ? K.order.length : 0;
|
|
887
|
+
if (K.busy || !n || box.hidden) return;
|
|
888
|
+
const axis = deckAxis(), d = DECK[axis], home = box.parentElement, cs = getComputedStyle(home);
|
|
889
|
+
const avail = home.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
890
|
+
const g = { axis, ...d, step: d.step || 0 };
|
|
891
|
+
if (axis === 'V') g.w = avail;
|
|
892
|
+
else {
|
|
893
|
+
g.w = Math.min(d.wMax, avail - (n - 1) * d.step);
|
|
894
|
+
if (g.w < d.wMin) { g.step = Math.max(d.stepMin, (avail - d.wMin) / Math.max(1, n - 1)); g.w = Math.max(240, avail - (n - 1) * g.step); }
|
|
895
|
+
}
|
|
896
|
+
box.style.setProperty('--w', g.w + 'px'); box.style.setProperty('--av', d.av + 'px'); box.style.setProperty('--nmz', d.nm + 'px');
|
|
897
|
+
box.style.setProperty('--org', axis === 'V' ? '50% 0%' : '0% 100%'); box.style.setProperty('--head', d.head + 'px');
|
|
898
|
+
// 一样高:取最高那张;竖着叠时露出的那条 = 头那一行实际的高度 + 8
|
|
899
|
+
const cards = [...K.els.values()];
|
|
900
|
+
let tall = 0, strip = 0;
|
|
901
|
+
for (const el of cards) { el.style.height = 'auto'; tall = Math.max(tall, el.offsetHeight); const hd = el.querySelector('.pc-hd'); strip = Math.max(strip, hd.offsetTop + hd.offsetHeight + 8); }
|
|
902
|
+
for (const el of cards) el.style.height = '';
|
|
903
|
+
g.strip = strip; K.geo = g;
|
|
904
|
+
box.style.setProperty('--h', tall + 'px');
|
|
905
|
+
box.style.width = (axis === 'H' ? g.w + (n - 1) * g.step : g.w) + 'px';
|
|
906
|
+
box.style.height = (d.head + (axis === 'V' ? (n - 1) * strip : 0) + tall + 12) + 'px';
|
|
907
|
+
box.style.marginLeft = box.style.marginRight = axis === 'H' ? 'auto' : '';
|
|
908
|
+
K.order.forEach((name, slot) => deckPlace(K.els.get(name), slot, n));
|
|
909
|
+
};
|
|
910
|
+
// ---- 家长端「首页」里的预览:S.pv = {tutor, sel, edit: {k, kind, label, until} | null, add: 字 | null},家长页塞进来的,只在预览里 ----
|
|
911
|
+
const pvHost = () => { try { return window.parent !== window && typeof window.parent.cotutorPreview === 'function' ? window.parent : null; } catch { return null; } };
|
|
912
|
+
const pvTell = (ev) => { const host = pvHost(); if (host) host.cotutorPreview(ev); };
|
|
913
|
+
/** 牌上的按钮换上家长正改着的那颗(字、留到),新加的那颗(id draft)排在最后;没在改就是孩子此刻拿到的 */
|
|
914
|
+
const pvButtons = (n, list) => {
|
|
915
|
+
const P = PREVIEW && S.pv && S.pv.tutor === n ? S.pv : null;
|
|
916
|
+
if (!P || (!P.edit && !P.add)) return list;
|
|
917
|
+
const today = S.home && S.home.date;
|
|
918
|
+
const E = P.edit, gone = E && E.until && today && E.until < today;
|
|
919
|
+
let out = list.map((b) => (E && b.id === E.k ? { ...b, label: E.label } : b)).filter((b) => !(gone && E && b.id === E.k));
|
|
920
|
+
if (E && !gone && !out.some((b) => b.id === E.k)) out.push({ kind: E.kind === 'continue' ? 'continue' : 'start', label: E.label, id: E.k });
|
|
921
|
+
if (P.add) out.push({ kind: 'start', label: P.add, id: 'draft' });
|
|
922
|
+
return out;
|
|
923
|
+
};
|
|
924
|
+
if (PREVIEW) {
|
|
925
|
+
window.cotutorPreviewSet = (o) => {
|
|
926
|
+
if (!o) return;
|
|
927
|
+
if ('pv' in o) S.pv = o.pv;
|
|
928
|
+
const toFront = () => { if (o.front && K.order && !K.busy && K.order.includes(o.front) && K.order[K.order.length - 1] !== o.front) K.order = K.order.filter((x) => x !== o.front).concat([o.front]); };
|
|
929
|
+
// 发布了(按钮、先后、开关):重读孩子的首页,牌堆照家长新排的先后重叠一遍(孩子端只在老师换了人时才重叠),再把在看的那位抽到最前
|
|
930
|
+
if (o.reload) { K.order = null; loadHome().then(() => { toFront(); if (S.home && !S.tutor) renderHome(); }); return; }
|
|
931
|
+
toFront();
|
|
932
|
+
if (S.home && !S.tutor) renderHome();
|
|
933
|
+
};
|
|
934
|
+
// 点牌上的按钮不跳走,告诉家长页点了哪颗;后面那张照孩子端换到最前(换完 pickTutor 里告诉家长页);牌外的东西点了什么都不做
|
|
935
|
+
document.addEventListener('click', (e) => {
|
|
936
|
+
const pc = e.target.closest ? e.target.closest('#deck .pc') : null;
|
|
937
|
+
if (pc && !pc.classList.contains('front')) return;
|
|
938
|
+
e.preventDefault(); e.stopPropagation();
|
|
939
|
+
const bt = pc ? e.target.closest('.bt') : null;
|
|
940
|
+
if (!bt) return;
|
|
941
|
+
const bid = bt.dataset.bid;
|
|
942
|
+
if (bid !== undefined) pvTell({ act: 'button', tutor: pc.dataset.tutor, id: bid === 'new' || bid === 'draft' ? bid : Number(bid) });
|
|
943
|
+
else pvTell({ act: 'extra', tutor: pc.dataset.tutor, kind: bt.classList.contains('bt-x-talk') ? 'talk' : 'dictation' });
|
|
944
|
+
}, true);
|
|
945
|
+
}
|
|
946
|
+
/** 每位老师一张牌:头像 + 名字、按钮、「每天都在」、归他的卡,右下角倒着一个小头像;deal = 打开首页、从老师页回来,发牌 */
|
|
947
|
+
const renderDeck = (H, list, deal) => {
|
|
948
|
+
const box = $('#deck');
|
|
949
|
+
const front = K.order[K.order.length - 1];
|
|
950
|
+
const seen = seenOf(H.home);
|
|
951
|
+
for (const [n, el] of K.els) if (!K.order.includes(n)) { el.remove(); K.els.delete(n); }
|
|
952
|
+
K.order.forEach((n, slot) => {
|
|
953
|
+
const { t, card } = list.find((x) => x.t.name === n);
|
|
954
|
+
const buttons = pvButtons(n, Array.isArray(card.props.buttons) ? card.props.buttons : []);
|
|
955
|
+
const owned = (Array.isArray(H.cards) ? H.cards : []).filter((c) => c.kind !== 'tutor' && c.owner === n);
|
|
956
|
+
const sig = JSON.stringify([t.avatar, t.subject, t.display, buttons, owned, t.extras, S.talkLast && (S.talkLast.last ? S.talkLast.last.id : 'on'), H.home]);
|
|
957
|
+
let el = K.els.get(n);
|
|
958
|
+
if (!el) {
|
|
959
|
+
const pc = h('div', { class: 'c c-tutor pc', 'data-tutor': n, on: { click: (e) => { if (e.target.closest && e.target.closest('a[href]')) stashFront(pc); if (!pc.classList.contains('front')) pickTutor(n); } } });
|
|
960
|
+
box.append(pc); K.els.set(n, pc); el = pc;
|
|
961
|
+
}
|
|
962
|
+
if (deal || el.dataset.sig !== sig) {
|
|
963
|
+
el.dataset.sig = sig;
|
|
964
|
+
el.style.borderColor = colorOf(t);
|
|
965
|
+
const extras = extrasFor(t, H);
|
|
966
|
+
const others = owned.filter((c) => c.kind !== 'dictation');
|
|
967
|
+
el.replaceChildren(...[
|
|
968
|
+
h('div', { class: 'pc-hd' }, deal ? avatarEl(t, 'draw', 0.1 + slot * 0.12) : avatarEl(t), h('span', { class: 'nm' }, t.display)),
|
|
969
|
+
h('div', { class: 'kc' }, h('div', { class: 'bts' }, ...buttonRuns(t, buttons)), extras.length ? h('div', { class: 'bts extras' }, ...extras) : null),
|
|
970
|
+
others.length ? h('div', { class: 'hcards kcards' }, ...others.map((c, i) => renderCard(c, i, null, false))) : null,
|
|
971
|
+
h('span', { class: 'pc-ix' }, avatarEl(t)),
|
|
972
|
+
h('span', { class: 'veil' }),
|
|
973
|
+
].filter(Boolean));
|
|
974
|
+
// 归他的卡里有图:图到了牌会变高,重量一次
|
|
975
|
+
for (const img of el.querySelectorAll('img')) if (!img.complete) img.addEventListener('load', layoutDeck, { once: true });
|
|
976
|
+
} else el.querySelector('.pc-hd .av').classList.remove('draw'); // 之后的刷新摘掉(动画早放完了,摘了也不会重放)
|
|
977
|
+
el.classList.toggle('front', n === front);
|
|
978
|
+
if (PREVIEW) for (const b of el.querySelectorAll('.bt')) b.classList.toggle('pv-sel', Boolean(S.pv && S.pv.tutor === n && S.pv.sel !== null && S.pv.sel !== undefined && String(S.pv.sel) === b.dataset.bid));
|
|
979
|
+
const dot = Boolean(card.props.news) && n !== front && !seen.includes(n);
|
|
980
|
+
const hd = el.querySelector('.pc-hd'), had = hd.querySelector('.dot');
|
|
981
|
+
if (dot && !had) hd.append(h('span', { class: 'dot' })); else if (!dot && had) had.remove();
|
|
982
|
+
});
|
|
983
|
+
layoutDeck();
|
|
984
|
+
// 发牌:从最底下那张起,一张张从下面滑上来,最前那张最后
|
|
985
|
+
if (deal && !reduced()) K.order.forEach((n, slot) => { const b = deckBase(slot, K.order.length); K.els.get(n).animate([{ transform: deckTf({ ...b, y: b.y + 60 }), opacity: 0 }, { transform: deckTf(b), opacity: 1 }], { duration: 420, delay: slot * 120, easing: 'cubic-bezier(.2,.9,.3,1.15)', fill: 'backwards' }); });
|
|
986
|
+
};
|
|
987
|
+
/** 一段动画:跑完把末帧写进 style 再取消,不留 fill */
|
|
988
|
+
const deckRun = (el, kf, dur, delay, easing) => {
|
|
989
|
+
const a = el.animate(kf, { duration: dur, delay: delay || 0, easing: easing || 'linear', fill: 'both' });
|
|
990
|
+
a.finished.then(() => { const last = kf[kf.length - 1]; for (const k in last) if (k !== 'offset' && k !== 'easing') el.style[k] = last[k]; a.cancel(); }, () => {});
|
|
991
|
+
};
|
|
992
|
+
/** 点后面那张:先往盖着它的牌的反方向抽出(平板往左、手机往上),230 ms 换到最上层、浮起,再飘到最前(平板往右、手机往下)、落下、张嘴;
|
|
993
|
+
* 其余几张各退一层。牌太大抽不干净,换层那一下会盖过前面那张,靠它同时放大、影子拉长、前面几张同时变暗读成「拿起来了」。点最前那张不换 */
|
|
994
|
+
const pickTutor = (n) => {
|
|
995
|
+
const H = S.home, N = K.order ? K.order.length : 0, i = N ? K.order.indexOf(n) : -1;
|
|
996
|
+
if (!H || K.busy || !K.geo || i < 0 || i === N - 1) return;
|
|
997
|
+
const next = K.order.filter((x) => x !== n).concat([n]);
|
|
998
|
+
const el = K.els.get(n);
|
|
999
|
+
const done = () => { K.busy = false; K.order = next; markSeen(H.home, n); renderHome(); if (PREVIEW) pvTell({ act: 'front', tutor: n }); };
|
|
1000
|
+
if (reduced()) { done(); el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 120, easing: 'ease-out' }); return; }
|
|
1001
|
+
K.busy = true;
|
|
1002
|
+
const g = K.geo, b = deckBase(i, N), f = deckBase(N - 1, N), [SH, UP] = DECK_SHADOW[g.axis];
|
|
1003
|
+
let way;
|
|
1004
|
+
if (g.axis === 'H') { const ax = b.x - g.pull, mid = (ax + 10 + f.x + 8) / 2; way = [{ x: ax, y: -14, r: -2, s: b.s }, { x: ax + 10, y: -30, r: -1, s: 1.03 }, { x: mid, y: -34, r: 0, s: 1.03 }, { x: f.x + 8, y: -8, r: 1, s: 1.01 }]; }
|
|
1005
|
+
else { const ay = b.y - g.pull, mid = (ay - 10 + f.y + 8) / 2; way = [{ x: -6, y: ay, r: 0, s: b.s }, { x: -12, y: ay - 10, r: 0, s: 1.03 }, { x: -18, y: mid, r: 0, s: 1.03 }, { x: -4, y: f.y + 8, r: 0, s: 1.01 }]; }
|
|
1006
|
+
const o = (x) => x / DT.land;
|
|
1007
|
+
for (const x of K.els.values()) x.classList.remove('front');
|
|
1008
|
+
deckRun(el, [
|
|
1009
|
+
{ offset: 0, transform: deckTf(b), boxShadow: SH, easing: 'ease-out' },
|
|
1010
|
+
{ offset: o(DT.press), transform: deckTf({ ...b, s: b.s * 0.97 }), boxShadow: SH, easing: 'cubic-bezier(.3,0,.2,1)' },
|
|
1011
|
+
{ offset: o(DT.pull), transform: deckTf(way[0]), boxShadow: SH, easing: 'ease-out' },
|
|
1012
|
+
{ offset: o(DT.lift), transform: deckTf(way[1]), boxShadow: UP, easing: 'ease-in' },
|
|
1013
|
+
{ offset: o((DT.lift + DT.glide) / 2), transform: deckTf(way[2]), boxShadow: UP, easing: 'ease-out' },
|
|
1014
|
+
{ offset: o(DT.glide), transform: deckTf(way[3]), boxShadow: UP, easing: 'cubic-bezier(.3,1.5,.6,1)' },
|
|
1015
|
+
{ offset: 1, transform: deckTf(f), boxShadow: SH },
|
|
1016
|
+
], DT.land);
|
|
1017
|
+
deckRun(el.querySelector('.veil'), [{ opacity: String(b.v) }, { opacity: '0' }], DT.lift - DT.press, DT.press, 'ease-out');
|
|
1018
|
+
setTimeout(() => { el.style.zIndex = '50'; K.order.forEach((x) => { if (x !== n) K.els.get(x).style.zIndex = String(next.indexOf(x) + 1); }); }, DT.pull);
|
|
1019
|
+
K.order.forEach((x, s) => {
|
|
1020
|
+
if (x === n) return;
|
|
1021
|
+
const from = deckBase(s, N), to = deckBase(next.indexOf(x), N), q = K.els.get(x);
|
|
1022
|
+
const delay = DT.shift[0] + (s > i ? (s - i - 1) * DT.stagger : 0);
|
|
1023
|
+
deckRun(q, [{ transform: deckTf(from) }, { transform: deckTf(to) }], DT.shift[1] - DT.shift[0], delay, 'cubic-bezier(.3,1.2,.5,1)');
|
|
1024
|
+
deckRun(q.querySelector('.veil'), [{ opacity: String(from.v) }, { opacity: String(to.v) }], 200, delay, 'ease-out');
|
|
1025
|
+
});
|
|
1026
|
+
// 张嘴:开口一张一合一次(和老师页「说」同一套 dasharray)
|
|
1027
|
+
const ring = el.querySelector('.pc-hd .ring circle');
|
|
1028
|
+
if (ring) ring.animate([{ strokeDasharray: '221.17 55.29', transform: 'rotate(36deg)' }, { offset: 0.45, strokeDasharray: '193.52 82.94', transform: 'rotate(54deg)' }, { strokeDasharray: '221.17 55.29', transform: 'rotate(36deg)' }], { duration: 300, delay: DT.bite, easing: 'ease-in-out' });
|
|
1029
|
+
setTimeout(done, DT.lock);
|
|
1030
|
+
};
|
|
1031
|
+
let deckResize = 0;
|
|
1032
|
+
window.addEventListener('resize', () => { clearTimeout(deckResize); deckResize = setTimeout(() => { if (!S.tutor && S.home) layoutDeck(); }, 150); });
|
|
1033
|
+
const renderHome = () => {
|
|
1034
|
+
const H = S.home;
|
|
1035
|
+
document.title = H.title; $('#title').textContent = H.title;
|
|
1036
|
+
const cards = Array.isArray(H.cards) ? H.cards : [];
|
|
1037
|
+
const list = homeTutorsOf(H);
|
|
1038
|
+
const names = list.map((x) => x.t.name);
|
|
1039
|
+
$('#deck').hidden = !names.length;
|
|
1040
|
+
// 换牌的那 900 ms 里不动牌堆(下一轮刷新补上);发牌只在打开首页、从老师页回来时发一次
|
|
1041
|
+
if (names.length && !K.busy) {
|
|
1042
|
+
const deal = S.homeDraw; S.homeDraw = false;
|
|
1043
|
+
if (deal) ctReplay(ctMark, 'ct-draw'); // 发牌之前先把 logo 画出来
|
|
1044
|
+
// 老师变了(开了、关了、换了一份首页的排法)就按家长排的重来:排第一的在最前
|
|
1045
|
+
if (!K.order || K.order.length !== names.length || names.some((x) => !K.order.includes(x))) {
|
|
1046
|
+
const f = K.order ? null : takeFront();
|
|
1047
|
+
K.order = [...names].reverse();
|
|
1048
|
+
if (f && K.order.includes(f)) K.order = K.order.filter((x) => x !== f).concat([f]);
|
|
1049
|
+
}
|
|
1050
|
+
renderDeck(H, list, deal);
|
|
1051
|
+
if (deal) markSeen(H.home, K.order[K.order.length - 1]);
|
|
1052
|
+
}
|
|
1053
|
+
// 公共的卡(写在第一张老师卡前面的)在牌堆上面;归了老师的在那位老师的牌里
|
|
1054
|
+
$('#hcards').replaceChildren(...cards.filter((c) => c.kind !== 'tutor' && !c.owner).map((c, i) => renderCard(c, i, null, false)));
|
|
1055
|
+
renderFigshot(H);
|
|
1056
|
+
};
|
|
1057
|
+
// 给老师换样子(figshot pick):同一台电脑、同一张证书,所以协议和主机跟着本页走,只换端口。
|
|
1058
|
+
// 先探一下它开没开 —— 没开时点进去是 Safari 的「无法连接」,孩子不该看到;no-cors 只看连不连得上
|
|
1059
|
+
const renderFigshot = (H) => {
|
|
1060
|
+
const el = $('#figshot');
|
|
1061
|
+
if (!H.figshot) { el.hidden = true; return; }
|
|
1062
|
+
const url = location.protocol + '//' + location.hostname + ':' + H.figshot.port + '/';
|
|
1063
|
+
el.href = url;
|
|
1064
|
+
el.querySelector('.avs').replaceChildren(...H.tutors.slice(0, 3).map((t) => avatarEl(t)));
|
|
1065
|
+
fetch(url + 'api/health', { mode: 'no-cors', cache: 'no-store' }).then(() => { el.hidden = false; }, () => { el.hidden = true; });
|
|
1066
|
+
};
|
|
1067
|
+
const loadHome = async () => {
|
|
1068
|
+
try { S.home = await api('GET', '/api/kid/home'); setOffline(false); document.body.classList.toggle('hand-left', S.home.hand === 'left'); setGated(!REEL && S.home.ready === false); renderHome(); phIdle(); }
|
|
1069
|
+
catch { setOffline(true); }
|
|
1070
|
+
};
|
|
1071
|
+
/** 百炼没配:首页的老师灰着点不进去;在老师页上就回首页。家长配好了下一次读首页自己就好 */
|
|
1072
|
+
const setGated = (on) => {
|
|
1073
|
+
if (S.gated === on) return;
|
|
1074
|
+
S.gated = on;
|
|
1075
|
+
document.body.classList.toggle('gated', on);
|
|
1076
|
+
if (on && S.tutor) closeTutor();
|
|
1077
|
+
};
|
|
1078
|
+
const setOffline = (off) => {
|
|
1079
|
+
if (S.offline === off) return;
|
|
1080
|
+
S.offline = off;
|
|
1081
|
+
// 断了多久记一笔流水(kid-log.ts):断着发不出去,通了再报
|
|
1082
|
+
kidLog.offline(off);
|
|
1083
|
+
document.body.classList.toggle('offline', off);
|
|
1084
|
+
if (off && S.tutor) closeTutor();
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// ---- 老师页 ----
|
|
1088
|
+
/**
|
|
1089
|
+
* intent(首页的按钮定的):new = 空白新话题;thread = 今天的那个话题;today = 今天的当前话题(调试入口 ?tutor=)。
|
|
1090
|
+
* via = 哪个按钮(下一条消息带上);send = 打开就发出去的字(开场 / 接着按钮);cont = 接着的是哪天(头部写「接着 9 月 16 日」)
|
|
1091
|
+
*/
|
|
1092
|
+
const openTutor = (t, intent) => {
|
|
1093
|
+
unlock();
|
|
1094
|
+
intent = intent || { kind: 'new' };
|
|
1095
|
+
S.tutor = t; S.sections = []; S.played = new Set(); dispatch({ type: 'reset' }); S.pending = false; S.stage = null; S.paper = null; S.partial = null; $('#stage').classList.remove('on', 'cv');
|
|
1096
|
+
S.thread = intent.kind === 'thread' ? intent.thread : null; S.threadAt = null; S.hist = null; S.readonly = false; S.newThread = intent.kind === 'new';
|
|
1097
|
+
S.via = intent.via || null; S.cont = intent.cont || null; S.lecture = intent.lecture ? { ...intent.lecture, watch: null, sent: false, marks: [], flying: [], log: [] } : null; S.ask = intent.ask || null;
|
|
1098
|
+
// 口语课页拍好了口语单:先等孩子点「先热身,再打电话」(那一下起声音),发出去的第一条带 talk
|
|
1099
|
+
S.talk = intent.talk || null; S.talkWait = Boolean(intent.talk);
|
|
1100
|
+
// 先定走不走实时,再热麦克风:实时时麦克风的自检要开在实时那个 AudioContext 里(liveOpen)
|
|
1101
|
+
if (liveWanted(t)) liveOpen(intent); else liveClose();
|
|
1102
|
+
$('#hist').classList.remove('on'); $('#menu').classList.remove('on'); renderBar(); renderHeader();
|
|
1103
|
+
liveFace(t); tintBack(t); markBoard($('#bmark'), t);
|
|
1104
|
+
$('#board').replaceChildren(); pin.on = false;
|
|
1105
|
+
if (S.ask) $('#board').append(askBoard(S.ask));
|
|
1106
|
+
else if (S.talkWait) $('#board').append(talkBoard());
|
|
1107
|
+
else if (S.newThread && !intent.send && !S.lecture) $('#board').append(blankBoard('想问什么?', intent.resume));
|
|
1108
|
+
$('#tutor').classList.add('on');
|
|
1109
|
+
setBar('idle');
|
|
1110
|
+
loadDay(true).then(() => { if (intent.send) send(intent.send); });
|
|
1111
|
+
if (S.lecture) openLecture();
|
|
1112
|
+
viewKey = ''; viewNote();
|
|
1113
|
+
};
|
|
1114
|
+
/** 空板:没有顶栏了,老师是谁写在这里(头像、名字、口头禅),下面才是「想问什么」。
|
|
1115
|
+
* 三种空板(这个、askBoard、talkBoard)都把老师要说的挂在 _open 上:实时时板上这块收着,左栏照它画(openSync) */
|
|
1116
|
+
const blankBoard = (title, resume) => withOpen(h('div', { class: 'blank' }, S.tutor ? avatarEl(S.tutor, 'draw') : null, S.tutor ? h('span', { class: 'nm' }, S.tutor.display) : null, h('b', {}, title), S.tutor && S.tutor.firstQuestion ? h('small', {}, '比如:' + S.tutor.firstQuestion) : null, resume && S.tutor ? resumeChip(resume) : null),
|
|
1117
|
+
{ kind: 'new', big: title, small: S.tutor && S.tutor.firstQuestion ? '比如:' + S.tutor.firstQuestion : '', acts: resume && S.tutor ? [{ cls: 'back', icon: 'again', k: resume.title ? '接着刚才的' : '', s: resume.title || '接着刚才的', tm: clock(resume.at), go: () => resumeThread(resume.thread) }] : [] });
|
|
1118
|
+
/** 空板挂上老师要说的(开场那句 big、下面一行 small、选择 acts:{cls, icon, k 上面的小字, s 大字, sub 下面的小字, tm 时刻, id, go}) */
|
|
1119
|
+
const withOpen = (el, o) => { el._open = o; return el; };
|
|
1120
|
+
// ---- 接着今天的(设计稿 https://claude.ai/artifact/QkJBzLVbL7tgfEGKcDjaRm):首页不再有「接着刚才的」,进来都是新话题 ----
|
|
1121
|
+
const tutorColor = () => colorOf(S.tutor);
|
|
1122
|
+
/** 回到今天的那个话题:照旧铺开、不重播(同「以前的」里点今天的一行) */
|
|
1123
|
+
const resumeThread = (thread) => { S.ask = null; switchThread(null, thread); };
|
|
1124
|
+
/** 新话题空白板上的胶囊:今天最后一个自由话题;孩子一开口(发出去)空白板连它一起收起 */
|
|
1125
|
+
const resumeChip = (r) => h('button', { type: 'button', class: 'resume', style: 'color:' + tutorColor() + ';border-color:' + tutorColor(), on: { click: () => resumeThread(r.thread) } }, h('span', { class: 'ic', html: ICON.again }), h('span', {}, r.title ? '接着刚才的:' + r.title : '接着刚才的'), h('small', {}, clock(r.at)));
|
|
1126
|
+
/** activity、小课堂今天点过同一个:先问接着那个,还是重新开始(不点就不开讲、不算一条) */
|
|
1127
|
+
const askBoard = (ask) => withOpen(h('div', { class: 'blank ask' }, avatarEl(S.tutor, 'draw'), h('span', { class: 'nm' }, S.tutor.display), h('b', {}, '今天点过「' + ask.label + '」啦'),
|
|
1128
|
+
h('div', { class: 'ask-row' },
|
|
1129
|
+
h('button', { type: 'button', class: 'ask-go back', style: 'color:' + tutorColor() + ';border-color:' + tutorColor(), on: { click: () => resumeThread(ask.resume.thread) } },
|
|
1130
|
+
h('span', { class: 't' }, h('span', { class: 'ic', html: ICON.again }), '接着刚才那个'), h('span', { class: 's' }, ask.resume.title || '刚开了个头'), h('small', {}, clock(ask.resume.at))),
|
|
1131
|
+
h('button', { type: 'button', class: 'ask-go fresh', style: 'background:' + tutorColor(), on: { click: () => startFresh(ask) } },
|
|
1132
|
+
h('span', { class: 't' }, h('span', { class: 'ic', html: ICON.spark }), ask.lecture ? '从头看一遍' : '重新开始'), h('span', { class: 's' }, ask.lecture ? ask.lecture.title : ask.label)))),
|
|
1133
|
+
{ kind: 'ask', big: '今天点过「' + ask.label + '」啦', small: '', acts: [
|
|
1134
|
+
{ cls: 'back', icon: 'again', k: '接着刚才那个', s: ask.resume.title || '刚开了个头', tm: clock(ask.resume.at), go: () => resumeThread(ask.resume.thread) },
|
|
1135
|
+
{ cls: 'fresh', icon: 'spark', k: ask.lecture ? '从头看一遍' : '重新开始', s: ask.lecture ? ask.lecture.title : ask.label, go: () => startFresh(ask) }] });
|
|
1136
|
+
/** 口语课页过来的:口语单拍好了,点一下开始(这一下点击起声音、开麦;不点不开讲) */
|
|
1137
|
+
const talkGo = () => {
|
|
1138
|
+
unlock(); if (LV.audio) LV.audio.wake().catch(() => {}); liveMicOn();
|
|
1139
|
+
S.talkWait = false; renderBar();
|
|
1140
|
+
const b = $('#board .blank'); if (b) b.remove();
|
|
1141
|
+
send('口语课');
|
|
1142
|
+
};
|
|
1143
|
+
const talkBoard = () => {
|
|
1144
|
+
const el = withOpen(h('div', { class: 'blank ask' }, avatarEl(S.tutor, 'draw'), h('span', { class: 'nm' }, S.tutor.display), h('b', {}, '口语单拍好啦'),
|
|
1145
|
+
h('div', { class: 'ask-row' },
|
|
1146
|
+
h('button', { type: 'button', class: 'ask-go fresh', style: 'background:' + tutorColor(), on: { click: talkGo } },
|
|
1147
|
+
h('span', { class: 't' }, h('span', { class: 'ic', html: ICON.spark }), '先热身,再打电话'), h('span', { class: 's' }, '先跟老师热热身,再打个电话聊一聊')))),
|
|
1148
|
+
{ kind: 'talk', big: '口语单拍好啦', small: '', acts: [{ cls: 'fresh talk', icon: 'play', s: '先热身,再打电话', sub: '点一下开始', id: 'talk-go', go: talkGo }] });
|
|
1149
|
+
// 左栏那句下面写几个单词、几个句子(读不到就不写)
|
|
1150
|
+
if (S.talk) api('GET', '/api/talk/' + encodeURIComponent(S.talk)).then((j) => {
|
|
1151
|
+
const l = j && j.meta && j.meta.list; if (!l) return;
|
|
1152
|
+
el._open.small = [l.words.length ? l.words.length + ' 个单词' : '', l.sentences.length ? l.sentences.length + ' 个句子' : ''].filter(Boolean).join('、');
|
|
1153
|
+
if (LO.el === el) { LO.el = undefined; openSync(); }
|
|
1154
|
+
}, () => {});
|
|
1155
|
+
return el;
|
|
1156
|
+
};
|
|
1157
|
+
const startFresh = (ask) => {
|
|
1158
|
+
S.ask = null;
|
|
1159
|
+
const b = $('#board .blank'); if (b) b.remove();
|
|
1160
|
+
if (ask.lecture) { S.lecture = { ...ask.lecture, watch: null, sent: false, marks: [], flying: [], log: [] }; renderBar(); renderHeader(); openLecture(); }
|
|
1161
|
+
else { renderBar(); send(ask.send); }
|
|
1162
|
+
};
|
|
1163
|
+
/** 空板上老师对孩子说的话挪进左栏(实时,原型 F 排):照板上那块 .blank 的 _open 画 #lv-ask、#lv-acts。
|
|
1164
|
+
* 板上那块一换、一收(进老师页、换话题、孩子开口、点了选择……十来处)都是 #board 的子节点在变:盯着它,一处跟上 */
|
|
1165
|
+
const LO = { el: undefined };
|
|
1166
|
+
const lvGo = (a) => h('button', { type: 'button', class: 'lv-go ' + a.cls, id: a.id || null, on: { click: a.go } }, h('span', { class: 'ic', html: ICON[a.icon] }),
|
|
1167
|
+
h('span', { class: 'tx' }, a.k ? h('span', { class: 'k' }, a.k) : null, h('span', { class: 's' }, a.s), a.sub ? h('span', { class: 'k' }, a.sub) : null), a.tm ? h('span', { class: 'tm' }, a.tm) : null);
|
|
1168
|
+
function openSync() {
|
|
1169
|
+
const b = $('#board > .blank'), o = b && b._open ? b._open : null;
|
|
1170
|
+
if (LO.el === b) return;
|
|
1171
|
+
LO.el = b;
|
|
1172
|
+
document.body.classList.toggle('lv-opening', Boolean(o));
|
|
1173
|
+
const ask = $('#lv-ask');
|
|
1174
|
+
ask.dataset.kind = o ? o.kind : '';
|
|
1175
|
+
ask.querySelector('b').textContent = o ? o.big : '';
|
|
1176
|
+
ask.querySelector('small').textContent = o ? o.small : '';
|
|
1177
|
+
$('#lv-acts').replaceChildren(...(o ? o.acts.map(lvGo) : []));
|
|
1178
|
+
}
|
|
1179
|
+
new MutationObserver(openSync).observe($('#board'), { childList: true });
|
|
1180
|
+
const closeTutor = () => { liveDispatch({ type: 'reset' }); liveClose(); hideLecture(); S.lecture = null; reelStop(); clearTimeout(S.pollTimer); dispatch({ type: 'halt' }); psClose(); $('#menu').classList.remove('on'); $('#lb').classList.remove('on'); S.tutor = null; S.via = null; S.cont = null; S.ask = null; $('#tutor').classList.remove('on'); document.body.classList.remove('pending', 'speaking'); S.homeDraw = true; loadHome(); };
|
|
1181
|
+
$('#back').addEventListener('click', closeTutor);
|
|
1182
|
+
$('#back-ic').innerHTML = ICON.back;
|
|
1183
|
+
|
|
1184
|
+
// 卡片:按 kind 分支(轻插件内联;不认识的 kind 把 props 里的字都显示出来)。紧凑态只读,点了开舞台;stage=true 是舞台里的画法
|
|
1185
|
+
const renderCard = (c, idx, secIdx, stage) => {
|
|
1186
|
+
const p = c.props || {};
|
|
1187
|
+
// 每张卡带底色槽与字形槽(围栏行上写的 look,没有就机械规则);名字对不上主题的,CSS 落回 paper / plain
|
|
1188
|
+
const board = !stage && secIdx !== null;
|
|
1189
|
+
const lk = board && c.submitted ? h('span', { class: 'lk', html: ICON.lock + '交给老师了' }) : null;
|
|
1190
|
+
const box = (cls, ...kids) => h('div', { class: 'c c-' + cls, 'data-card': idx, 'data-tint': tintFor(c), 'data-look': lookFor(c), on: board ? { click: () => openStage(secIdx, idx) } : {} }, ...kids, lk);
|
|
1191
|
+
switch (c.kind) {
|
|
1192
|
+
case 'text': {
|
|
1193
|
+
if (isHeading(c)) return board && liveSec(secIdx) ? liveHeadEl(p.title || '', secIdx, idx) : h('div', { class: 'heading', 'data-card': idx }, p.title || '');
|
|
1194
|
+
const emoji = c.look && c.look.emoji ? c.look.emoji + ' ' : '';
|
|
1195
|
+
const title = p.title ? h('div', { class: 'ct' }, emoji + p.title) : null;
|
|
1196
|
+
const body = p.text ? h('div', { class: 'cb' }, p.text) : null;
|
|
1197
|
+
// 提问卡:末句那一问(解析器补的),和选择题的问题一个字重;问题卡下沿是孩子的答案栏
|
|
1198
|
+
const a = board ? ansFor(secIdx, idx) : null;
|
|
1199
|
+
const el = box(isAskCard(c) ? 'text c-ask' : 'text', title, body, a ? ansEl(a) : null);
|
|
1200
|
+
if (a) el.dataset.ans = ansKey(a);
|
|
1201
|
+
return el;
|
|
1202
|
+
}
|
|
1203
|
+
case 'read':
|
|
1204
|
+
return box('read', ...(p.segments || []).map((seg, k) => h('div', { class: 'rd', on: { click: (e) => { e.stopPropagation(); readSegment(e.currentTarget, c, k); } } }, seg)));
|
|
1205
|
+
case 'image': {
|
|
1206
|
+
const src = /^https?:\\/\\//.test(p.src || '') ? p.src : '/api/kid/image?p=' + encodeURIComponent(p.src || '');
|
|
1207
|
+
if (stage) { const el = box('image'); el.className = 'zoom'; el.append(h('img', { src, alt: p.caption || '' })); pinch(el); return el; }
|
|
1208
|
+
return box('image', h('img', { src, alt: '', loading: 'lazy' }), p.caption ? h('div', { class: 'cap' }, p.caption) : null);
|
|
1209
|
+
}
|
|
1210
|
+
case 'paint': {
|
|
1211
|
+
// 画图卡(你说我画):题干、画好的图(没画好是分好的几幅)、词语宝库;舞台里放大看图。没画成也不说,停在分好的几幅;角色不显示
|
|
1212
|
+
const pic = p.pic ? '/api/kid/image?p=' + encodeURIComponent(p.pic) : null;
|
|
1213
|
+
if (stage && pic) { const el = box('paint'); el.className = 'zoom'; el.append(h('img', { src: pic, alt: '画家画的' })); pinch(el); return el; }
|
|
1214
|
+
const panels = Array.isArray(p.panels) ? p.panels : [];
|
|
1215
|
+
const grid = h('div', { class: 'pt-grid' + (panels.length === 1 ? ' one' : '') }, ...panels.map((t, i) => h('div', { class: 'pt-cell' }, h('span', { class: 'pt-n' }, String(i + 1)), h('div', {}, t))));
|
|
1216
|
+
const words = Array.isArray(p.words) && p.words.length ? h('div', { class: 'pt-words' }, h('b', {}, '词语宝库'), h('span', {}, p.words.join('、'))) : null;
|
|
1217
|
+
const art = h('div', { class: 'pt-art' }, pic ? h('img', { src: pic, alt: '画家画的' }) : grid, !pic && p.drawn === 'drawing' ? h('div', { class: 'pt-wait' }, '画家在画') : null);
|
|
1218
|
+
const el = box('paint', p.ask ? h('div', { class: 'pt-ask' }, p.ask) : null, h('div', { class: 'pt-body' }, art, words));
|
|
1219
|
+
el.dataset.drawn = p.drawn || '';
|
|
1220
|
+
return el;
|
|
1221
|
+
}
|
|
1222
|
+
case 'choice': {
|
|
1223
|
+
const picked = (c.state && Array.isArray(c.state.picked)) ? c.state.picked : [];
|
|
1224
|
+
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)));
|
|
1225
|
+
// 选项都短(≤ 4 个字)就横排成一行胶囊,不然三个一位数的答案各占一整行(2026-09-11 截图)
|
|
1226
|
+
const short = (p.options || []).length > 0 && (p.options || []).every((o) => Array.from(String(o)).length <= 4);
|
|
1227
|
+
return box('choice', h('div', { class: 'ch-q' }, p.question || ''), h('div', { class: 'ch-os' + (short ? ' short' : '') }, ...(p.options || []).map((o, i) => h('div', { class: 'ch-o' + (picked.includes(i) ? ' on' : '') }, h('i', {}, 'ABCDEFGH'[i] || ''), o))));
|
|
1228
|
+
}
|
|
1229
|
+
case 'lecture': {
|
|
1230
|
+
// 小课堂卡:老师放课里的一段;紧凑态是那一段末帧的画面、「0:19–0:30 ▷」;舞台是舞台包里的小课堂播放器
|
|
1231
|
+
const ready = lectureReady(c);
|
|
1232
|
+
return box('lecture', h('div', { class: 'sp' }, p.title || p.bundle || ''), p.text ? h('div', { class: 'tx' }, p.text) : null, h('div', { class: 'th' }, ready ? (p.video ? lcVideoStill(p.bundle, p.end) : lcStill(p.bundle, p.still || 0, null, 'lc-still')) : '课还没放进来', ready ? h('span', { class: 'pl' }, lcClock(p.start) + '–' + lcClock(p.end) + ' ▷') : null));
|
|
1233
|
+
}
|
|
1234
|
+
case 'canvas': {
|
|
1235
|
+
const n = inkCount(c);
|
|
1236
|
+
// 交过了:紧凑态是孩子画的那张图(真服务给 png 的相对路径,mock 存的是 data URL)
|
|
1237
|
+
const img = c.state && typeof c.state.image === 'string' ? c.state.image : null;
|
|
1238
|
+
// 照片做底、还没交过:卡上先是那张照片(淡一档)
|
|
1239
|
+
const base = !img && p.base && typeof p.base.image === 'string' ? p.base.image : null;
|
|
1240
|
+
const src = img ? (img.startsWith('data:') ? img : '/api/kid/image?p=' + encodeURIComponent(img)) : base ? '/api/kid/image?p=' + encodeURIComponent(base) : null;
|
|
1241
|
+
return box('canvas', h('div', { class: 'cp' }, p.prompt || '画一画'), src ? h('div', { class: 'th' + (base ? ' base' : '') }, h('img', { src, alt: '', loading: 'lazy' }), h('span', { class: 'pl' }, n ? '画了 ' + n + ' 笔' : '点开画一画 ✎')) : h('div', { class: 'cb' }, n ? '已经画了 ' + n + ' 笔,点开接着画' : '点开画一画 ✎'));
|
|
1242
|
+
}
|
|
1243
|
+
case 'photo': {
|
|
1244
|
+
// 拍给我看(cards/photo/card.md):相机 + 要拍什么;下沿是答案栏:等你拍(拍照、相册)或拍来的照片。点卡不开舞台,点下沿的按钮拍
|
|
1245
|
+
const a = board ? ansFor(secIdx, idx) : null;
|
|
1246
|
+
const el = h('div', { class: 'c c-photo', 'data-card': idx, 'data-tint': tintFor(c), 'data-look': lookFor(c) },
|
|
1247
|
+
h('div', { class: 'pc-row' }, h('span', { class: 'pc-ic', html: ICON.camera }), h('div', { class: 'pc-t' }, p.text || '')), a ? ansEl(a) : null);
|
|
1248
|
+
if (a) el.dataset.ans = ansKey(a);
|
|
1249
|
+
return el;
|
|
1250
|
+
}
|
|
1251
|
+
case 'code':
|
|
1252
|
+
return box('code', p.lang ? h('span', { class: 'lg' }, p.lang) : null, h('div', { class: 'cb' }, p.text || ''));
|
|
1253
|
+
case 'tianzige': {
|
|
1254
|
+
// 轻卡,没有舞台:点字就写,不开舞台;讲到它时 setNow 写一遍
|
|
1255
|
+
const el = h('div', { class: 'c c-tianzige', 'data-card': idx, 'data-tint': tintFor(c), 'data-look': lookFor(c), 'data-ch': String(p.chars || '') });
|
|
1256
|
+
// 带拼音的:每个字一列,拼音在格子上方(像课本);老师写的是一字一个,对不上解析时就没要
|
|
1257
|
+
const chs = Array.from(String(p.chars || ''));
|
|
1258
|
+
const py = Array.isArray(p.pinyin) && p.pinyin.length === chs.length ? p.pinyin : null;
|
|
1259
|
+
for (const [i, ch] of chs.entries()) el.append(py ? h('div', { class: 'hzc' }, h('span', { class: 'py' }, String(py[i])), tianzigeBox(ch)) : tianzigeBox(ch));
|
|
1260
|
+
return el;
|
|
1261
|
+
}
|
|
1262
|
+
case 'dictation': {
|
|
1263
|
+
// 听写卡(只在首页,cards/dictation):点了去 /dictation 听写页;卡上没有字,只有几个词。n = 首页里第几张听写卡
|
|
1264
|
+
const all = (S.home && Array.isArray(S.home.cards) ? S.home.cards : []).filter((x) => x.kind === 'dictation');
|
|
1265
|
+
const en = p.lang === 'en';
|
|
1266
|
+
const href = '/dictation?home=' + encodeURIComponent((S.home && S.home.home) || '') + '&n=' + Math.max(0, all.indexOf(c)) + (en ? '&lang=en' : '');
|
|
1267
|
+
return h('a', { class: 'c c-dictation', 'data-card': idx, href }, h('span', { class: 'tg' }, h('i', {}), h('i', {})), h('span', { class: 'tx' }, h('b', {}, en ? '单词听写' : '听写'), h('span', {}, (p.count || 0) + (en ? ' 个单词' : ' 个词') + ' · 老师念,你来写')), h('span', { class: 'go' }, '去写'));
|
|
1268
|
+
}
|
|
1269
|
+
case 'word': {
|
|
1270
|
+
// 单词卡:四线三格里写这个词,卡上没有中文;点卡开舞台(慢念、分段慢写),「听」按正常速度念;讲到它时 setNow 写一遍
|
|
1271
|
+
if (stage) return wordStage(c, idx);
|
|
1272
|
+
const em = p.emoji || (c.look && c.look.emoji) || '';
|
|
1273
|
+
const el = box('word', em ? h('span', { class: 'we' }, em) : null, wordGrid(String(p.word || ''), null), h('div', { class: 'wd-row' }, soundPill(ICON.speaker, '听', (e) => wordSay(c, e.currentTarget, 1))));
|
|
1274
|
+
el.dataset.w = String(p.word || '');
|
|
1275
|
+
return el;
|
|
1276
|
+
}
|
|
1277
|
+
default:
|
|
1278
|
+
return box('text', h('div', { class: 'cb' }, cardTexts(c).filter(Boolean).join('\\n')));
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
// ---- 田字格卡:每字一个格,笔顺数据 /api/kid/tianzige/<字>(1024 见方、y 向上,组上 scale(1,-1) translate(0,-900) 摆正);
|
|
1282
|
+
// 写 = 每一笔的中线用粗线沿着描、外面套这一笔的轮廓做 clipPath,stroke-dashoffset 从头长到尾,和线条笔一个手法;数据没有的字只显示字形不动 ----
|
|
1283
|
+
const HZ = new Map();
|
|
1284
|
+
const tianzigeData = (ch) => { if (!HZ.has(ch)) HZ.set(ch, fetch('/api/kid/tianzige/' + encodeURIComponent(ch)).then((r) => (r.ok ? r.json() : null)).catch(() => null)); return HZ.get(ch); };
|
|
1285
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
1286
|
+
const sv = (tag, attrs) => { const el = document.createElementNS(SVG_NS, tag); for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, String(v)); return el; };
|
|
1287
|
+
const polyLen = (pts) => { let n = 0; for (let i = 1; i < pts.length; i++) n += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return n; };
|
|
1288
|
+
const HZ_T = 'scale(1,-1) translate(0,-900)';
|
|
1289
|
+
/** 一个格:田字格 + 字形(数据到了是淡灰的轮廓,写完变墨色;没数据是字体的字,不动)+ 右下角几画 */
|
|
1290
|
+
const tianzigeBox = (ch) => {
|
|
1291
|
+
const svg = sv('svg', { viewBox: '0 0 1024 1024' });
|
|
1292
|
+
const grid = sv('g', { class: 'grid' });
|
|
1293
|
+
grid.append(sv('rect', { x: 8, y: 8, width: 1008, height: 1008, rx: 36 }), sv('line', { class: 'mid', x1: 512, y1: 8, x2: 512, y2: 1016 }), sv('line', { class: 'mid', x1: 8, y1: 512, x2: 1016, y2: 512 }));
|
|
1294
|
+
svg.append(grid);
|
|
1295
|
+
const el = h('div', { class: 'hz', 'data-ch': ch, on: { click: (e) => { e.stopPropagation(); tianzigePlay(el.closest('.c'), el); } } }, svg);
|
|
1296
|
+
tianzigeData(ch).then((d) => {
|
|
1297
|
+
if (!d) { const t = sv('text', { x: 512, y: 512, 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': 720, class: 'ghost done' }); t.textContent = ch; svg.append(t); return; }
|
|
1298
|
+
const g = sv('g', { class: 'ghost' + (el._want || el._queued ? '' : ' done'), transform: HZ_T }); // 排着队等写的,数据到了也先淡着
|
|
1299
|
+
for (const st of d.strokes) g.append(sv('path', { d: st }));
|
|
1300
|
+
svg.append(g);
|
|
1301
|
+
el.append(h('span', { class: 'n' }, d.strokes.length + ' 画'));
|
|
1302
|
+
el._hz = d;
|
|
1303
|
+
if (el._want) tianzigeWrite(el);
|
|
1304
|
+
});
|
|
1305
|
+
return el;
|
|
1306
|
+
};
|
|
1307
|
+
/** 回到写完的样子(打断正在写的) */
|
|
1308
|
+
const tianzigeReset = (el) => {
|
|
1309
|
+
if (el._run) el._run.stop();
|
|
1310
|
+
el._run = null; el._then = null; el._want = false; el._queued = false;
|
|
1311
|
+
for (const x of el.querySelectorAll('.ink, .clip')) x.remove();
|
|
1312
|
+
const ghost = el.querySelector('.ghost'); if (ghost) ghost.classList.add('done');
|
|
1313
|
+
};
|
|
1314
|
+
/** 写一个字:一笔接一笔,写完调 el._then;数据还没到就记着,到了再写 */
|
|
1315
|
+
const tianzigeWrite = (el) => {
|
|
1316
|
+
const d = el._hz;
|
|
1317
|
+
if (!d) { el._want = true; return; }
|
|
1318
|
+
const then = el._then; tianzigeReset(el); el._then = then;
|
|
1319
|
+
const svg = el.querySelector('svg');
|
|
1320
|
+
const ghost = svg.querySelector('.ghost'); ghost.classList.remove('done');
|
|
1321
|
+
const id = 'hz' + Math.random().toString(36).slice(2, 8);
|
|
1322
|
+
const defs = sv('defs', { class: 'clip' });
|
|
1323
|
+
const ink = sv('g', { class: 'ink', transform: HZ_T });
|
|
1324
|
+
const strokes = d.strokes.map((st, i) => {
|
|
1325
|
+
const cp = sv('clipPath', { id: id + '-' + i }); cp.append(sv('path', { d: st })); defs.append(cp);
|
|
1326
|
+
const pts = d.medians[i] || [];
|
|
1327
|
+
const len = Math.round(polyLen(pts)) + 200; // 圆头两端各多出半个笔宽
|
|
1328
|
+
const path = sv('path', { d: 'M' + pts.map((q) => q[0] + ' ' + q[1]).join(' L'), 'clip-path': 'url(#' + id + '-' + i + ')' });
|
|
1329
|
+
path.style.strokeDasharray = String(len); path.style.strokeDashoffset = String(len);
|
|
1330
|
+
ink.append(path);
|
|
1331
|
+
return { path, len };
|
|
1332
|
+
});
|
|
1333
|
+
svg.append(defs, ink);
|
|
1334
|
+
let i = 0, timer = null, alive = true;
|
|
1335
|
+
el._run = { stop: () => { alive = false; clearTimeout(timer); } };
|
|
1336
|
+
const step = () => {
|
|
1337
|
+
if (!alive) return;
|
|
1338
|
+
if (i >= strokes.length) { ghost.classList.add('done'); ink.remove(); defs.remove(); el._run = null; el._queued = false; const f = el._then; el._then = null; if (f) f(); return; }
|
|
1339
|
+
const { path, len } = strokes[i++];
|
|
1340
|
+
const ms = Math.max(240, Math.round(len * 0.8)); // 一横约 600 单位 → 半秒多
|
|
1341
|
+
void path.getBoundingClientRect();
|
|
1342
|
+
path.style.transition = 'stroke-dashoffset ' + ms + 'ms linear';
|
|
1343
|
+
path.style.strokeDashoffset = '0';
|
|
1344
|
+
timer = setTimeout(step, ms + 200);
|
|
1345
|
+
};
|
|
1346
|
+
step();
|
|
1347
|
+
};
|
|
1348
|
+
/** 写一张卡:按顺序一个字接一个字(鼓写完再写励);给了 only 就只写那个字,别的字回到写完的样子 */
|
|
1349
|
+
const tianzigePlay = (cardEl, only) => {
|
|
1350
|
+
if (!cardEl) return;
|
|
1351
|
+
const all = [...cardEl.querySelectorAll('.hz')];
|
|
1352
|
+
for (const b of all) tianzigeReset(b);
|
|
1353
|
+
const boxes = only ? [only] : all;
|
|
1354
|
+
// 排队要写的字先退成淡灰(鼓在写的时候励不该已经是墨色),轮到谁谁再写出来
|
|
1355
|
+
for (const b of boxes) { b._queued = true; const g = b.querySelector('.ghost'); if (g && b._hz) g.classList.remove('done'); }
|
|
1356
|
+
const go = (k) => { if (k >= boxes.length) return; const b = boxes[k]; b._then = () => setTimeout(() => go(k + 1), 320); tianzigeWrite(b); };
|
|
1357
|
+
go(0);
|
|
1358
|
+
};
|
|
1359
|
+
// ---- 单词卡的画法(wordGrid / wordWrite / wordMerge、wordLayout)在 word-draw.ts,两页共用 ----
|
|
1360
|
+
__WORD_DRAW_JS__
|
|
1361
|
+
/** 念这个词:服务端配好的 1.mp3(老师的音色)按 rate 倍速放(音高不变),没好就等它到(cardClip);念完调 then(被打断不调) */
|
|
1362
|
+
const wordSay = (card, btn, rate, then) => {
|
|
1363
|
+
if (!cardSoundOk()) return;
|
|
1364
|
+
const token = ++voiceToken;
|
|
1365
|
+
for (const x of document.querySelectorAll('.c-word .wd-pl.on')) x.classList.remove('on');
|
|
1366
|
+
if (btn) btn.classList.add('on');
|
|
1367
|
+
const done = () => { if (btn) btn.classList.remove('on'); if (token === voiceToken && then) then(); };
|
|
1368
|
+
const miss = () => { if (token === voiceToken) done(); };
|
|
1369
|
+
cardClip(card, 0, token, (a) => {
|
|
1370
|
+
audioEl.onplaying = null;
|
|
1371
|
+
audioEl.onended = miss;
|
|
1372
|
+
audioEl.onerror = miss;
|
|
1373
|
+
audioEl.src = audioUrl(a);
|
|
1374
|
+
audioRate(rate);
|
|
1375
|
+
audioEl.play().catch(miss);
|
|
1376
|
+
}, done);
|
|
1377
|
+
};
|
|
1378
|
+
/** 舞台里的单词卡:emoji、分段两色的四线三格、「再听一遍」(慢);打开就走一遍:慢念与一段一段慢写同时开始 → 写完合拢 → 再慢念。点一段,那段慢写一遍 */
|
|
1379
|
+
let wordRun = 0;
|
|
1380
|
+
const wordStage = (card, idx) => {
|
|
1381
|
+
const p = card.props || {};
|
|
1382
|
+
const em = p.emoji || (card.look && card.look.emoji) || '';
|
|
1383
|
+
const chunks = Array.isArray(p.chunks) && p.chunks.join('') === p.word ? p.chunks : null;
|
|
1384
|
+
const grid = wordGrid(String(p.word || ''), chunks, { stage: true, fresh: !S.reel });
|
|
1385
|
+
// 再听只念、不打断写;点一段打断自动那一遍,写完那段照样合拢(不然字母一直分开摆着)
|
|
1386
|
+
const btn = soundPill(ICON.speaker, '再听一遍', (e) => wordSay(card, e.currentTarget, WORD_SLOW.rate));
|
|
1387
|
+
grid.addEventListener('click', (e) => {
|
|
1388
|
+
const l = e.target.closest && e.target.closest('.lt');
|
|
1389
|
+
if (!l || S.reel) return;
|
|
1390
|
+
e.stopPropagation(); wordRun++;
|
|
1391
|
+
wordWrite(grid, true, chunks ? Number(l.dataset.k) : null, () => wordMerge(grid, true));
|
|
1392
|
+
});
|
|
1393
|
+
const el = h('div', { class: 'c c-word', 'data-card': idx, 'data-tint': tintFor(card), 'data-look': lookFor(card) }, em ? h('span', { class: 'we' }, em) : null, grid, h('div', { class: 'wd-row' }, btn));
|
|
1394
|
+
// 看录像时只读地开:不出声、不写,停在写完的样子
|
|
1395
|
+
if (!S.reel) {
|
|
1396
|
+
const run = ++wordRun;
|
|
1397
|
+
const live = () => run === wordRun && S.stage;
|
|
1398
|
+
// 念和写同时开始(2026-10-01:念完再写,孩子听的时候格子是空的,对不上)
|
|
1399
|
+
// 实时时卡的声音不自己响:只写,点「再听一遍」才念
|
|
1400
|
+
if (!LV.on) wordSay(card, btn, WORD_SLOW.rate);
|
|
1401
|
+
wordWrite(grid, true, null, () => {
|
|
1402
|
+
if (!live()) return;
|
|
1403
|
+
const again = () => { if (live() && !LV.on) wordSay(card, btn, WORD_SLOW.rate); };
|
|
1404
|
+
if (chunks) { wordMerge(grid, true); setTimeout(again, 600); } else setTimeout(again, 300);
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
return el;
|
|
1408
|
+
};
|
|
1409
|
+
/** 点读:点哪段念哪段——服务端配好的段(card.assets 里有 <段号>.mp3)放 mp3,没好的等它到(cardClip) */
|
|
1410
|
+
const readSegment = (el, card, k) => {
|
|
1411
|
+
if (!cardSoundOk()) return;
|
|
1412
|
+
for (const x of document.querySelectorAll('.rd.on')) x.classList.remove('on');
|
|
1413
|
+
el.classList.add('on');
|
|
1414
|
+
const off = () => el.classList.remove('on');
|
|
1415
|
+
cardClip(card, k, ++voiceToken, (a) => playClip(audioUrl(a), el), off);
|
|
1416
|
+
};
|
|
1417
|
+
// ---- 卡上的声音钮(单词卡的「听」「再听一遍」)与放一段 ----
|
|
1418
|
+
const soundPill = (icon, label, onClick) => h('button', { type: 'button', class: 'wd-pl', html: icon, on: { click: (e) => { e.stopPropagation(); onClick(e); } } }, label);
|
|
1419
|
+
/** 放卡上的一段声音(点读段、单词);被别的声音打断不回调 */
|
|
1420
|
+
const playClip = (src, btn) => {
|
|
1421
|
+
if (!cardSoundOk()) return;
|
|
1422
|
+
const token = ++voiceToken;
|
|
1423
|
+
const off = () => { if (btn) btn.classList.remove('on'); };
|
|
1424
|
+
if (btn) btn.classList.add('on');
|
|
1425
|
+
if (!src) { off(); return; }
|
|
1426
|
+
audioEl.onplaying = null;
|
|
1427
|
+
audioEl.onended = () => { if (token === voiceToken) off(); };
|
|
1428
|
+
audioEl.onerror = () => { if (token === voiceToken) off(); };
|
|
1429
|
+
audioEl.src = src;
|
|
1430
|
+
audioRate(1);
|
|
1431
|
+
audioEl.play().catch(() => { if (token === voiceToken) off(); });
|
|
1432
|
+
};
|
|
1433
|
+
/** 图片舞台:双指缩放 + 单指拖,双击复位 */
|
|
1434
|
+
const pinch = (el) => {
|
|
1435
|
+
const img = el.querySelector('img');
|
|
1436
|
+
const pts = new Map(); let scale = 1, tx = 0, ty = 0, start = null, lastTap = 0;
|
|
1437
|
+
const apply = () => { img.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')'; };
|
|
1438
|
+
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };
|
|
1439
|
+
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; } });
|
|
1440
|
+
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(); });
|
|
1441
|
+
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; };
|
|
1442
|
+
el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
|
|
1443
|
+
};
|
|
1444
|
+
// 节头:时间 · 节名;孩子问这节时拍的照片(R5)缩略图跟在后面,点小图看大图(不是再听)
|
|
1445
|
+
const headPhotos = (s) => (s.photos || []).map((p, k) => h('img', { class: 'ph', src: '/api/kid/image?p=' + encodeURIComponent(p), alt: '', loading: 'lazy', on: { click: (e) => { e.stopPropagation(); openLb(s.photos, k); } } }));
|
|
1446
|
+
const sectionHead = (s) => h('div', { class: 'sh' }, (s.at ? clock(s.at) + ' · ' : '') + sectionTitle(s), ...headPhotos(s));
|
|
1447
|
+
const rowEl = (n, ...kids) => h('div', { class: 'row', style: 'grid-template-columns:repeat(' + n + ',minmax(0,1fr))' }, ...kids);
|
|
1448
|
+
// ---- 排版:每张卡放进量具量一次,半宽放得下(fitsHalf)就半宽,相邻两张半宽的并一行(rowsFor) ----
|
|
1449
|
+
// 量具在 body 下、屏幕外:板书藏着(首页开着)时也量得出;按 端 + 半宽像素 + 卡的内容缓存,看录像每次重画不再量
|
|
1450
|
+
const fitCache = new Map();
|
|
1451
|
+
const ROW_GAP = 12;
|
|
1452
|
+
const boardWidth = () => {
|
|
1453
|
+
const b = $('#board');
|
|
1454
|
+
if (b && b.clientWidth > 0) { const cs = getComputedStyle(b); return b.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); }
|
|
1455
|
+
return S.device === 'tablet-landscape' ? Math.min(innerWidth, 1040) - 64 : innerWidth - 32;
|
|
1456
|
+
};
|
|
1457
|
+
const halfWidth = () => Math.floor((boardWidth() - ROW_GAP) / 2);
|
|
1458
|
+
const linesOf = (el) => {
|
|
1459
|
+
const cs = getComputedStyle(el);
|
|
1460
|
+
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.4;
|
|
1461
|
+
return Math.round((el.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)) / lh);
|
|
1462
|
+
};
|
|
1463
|
+
/** 这张卡能不能半宽;el 是画好的卡(没给就现画一张不带交互的) */
|
|
1464
|
+
const halfOf = (card, el) => {
|
|
1465
|
+
if (!card || !canHalf(card)) return false;
|
|
1466
|
+
const W = boardWidth(), hw = Math.floor((W - ROW_GAP) / 2);
|
|
1467
|
+
if (!(hw > 80)) return isShortCard(card);
|
|
1468
|
+
const key = S.device + '|' + hw + '|' + card.kind + '|' + JSON.stringify(card.props || {}) + '|' + JSON.stringify(card.look || null);
|
|
1469
|
+
if (fitCache.has(key)) return fitCache.get(key);
|
|
1470
|
+
let probe = document.getElementById('lay-probe');
|
|
1471
|
+
if (!probe) { probe = h('div', { id: 'lay-probe', 'aria-hidden': 'true' }); document.body.append(probe); }
|
|
1472
|
+
const c = el || renderCard(card, 0, null, false);
|
|
1473
|
+
const home = c.parentNode, next = c.nextSibling;
|
|
1474
|
+
probe.style.width = hw + 'px'; probe.append(c);
|
|
1475
|
+
const half = c.offsetHeight, box = c.getBoundingClientRect();
|
|
1476
|
+
// 横着溢出:卡里哪块内容伸出卡外(右上角的「再听」钮本来就伸出去,不算)
|
|
1477
|
+
const overflow = [...c.children].some((x) => !x.classList.contains('again') && (x.scrollWidth > x.clientWidth + 1 || x.getBoundingClientRect().right > box.right + 1));
|
|
1478
|
+
const lines = [...c.querySelectorAll(':scope > .ct, :scope > .cb, :scope > .rd')].reduce((n, x) => n + linesOf(x), 0);
|
|
1479
|
+
probe.style.width = W + 'px';
|
|
1480
|
+
const full = c.offsetHeight;
|
|
1481
|
+
if (home) home.insertBefore(c, next); else c.remove();
|
|
1482
|
+
const ok = fitsHalf(card, S.device, { half, full, lines, overflow });
|
|
1483
|
+
fitCache.set(key, ok);
|
|
1484
|
+
return ok;
|
|
1485
|
+
};
|
|
1486
|
+
/** 一节的行元素:els[k] 是画好的第 k 张卡 */
|
|
1487
|
+
const rowEls = (s, els) => {
|
|
1488
|
+
const half = (k) => halfOf(s.cards[k], els[k]);
|
|
1489
|
+
return rowsFor(s, S.device, half).map((row) => ({ row, el: rowEl(rowCols(s, row, half), ...row.map((k) => els[k])) }));
|
|
1490
|
+
};
|
|
1491
|
+
/** 一节 = 头一行(时间 · 节名)+ 若干行;行按卡量出来的宽度排(rowsFor) */
|
|
1492
|
+
// ---- 小课堂:首页按钮 → 舞台包铺满整页放(#lc-frame);看完 → 板书顶上一张小课堂卡 + 「看完了!有什么想问老师的?」,输入条亮;
|
|
1493
|
+
// 孩子第一句带上看的情况(body.lecture),那条的节前画小课堂卡;「再看一遍」再铺开,关了回板书 ----
|
|
1494
|
+
// 画:播放器里停住了能画,停在同一刻画的几笔算一处,整张单子发回来(S.lecture.marks,页面是画的主人,带着截图);
|
|
1495
|
+
// 看完那排画的卡能删,孩子开口时一起带上;问过以后「再看一遍」又画了,下一条带上(again)。画的卡的缩略图是截图 ----
|
|
1496
|
+
const lcFrame = $('#lc-frame');
|
|
1497
|
+
const lcPost = (m) => { try { lcFrame.contentWindow.postMessage({ source: STAGE_SOURCE, ...m }, '*'); } catch {} };
|
|
1498
|
+
/** 小课堂在哪:课包 /api/bundles/<id>/;视频 /api/kid/lectures/<id>/(lecture.json + video.mp4) */
|
|
1499
|
+
const lcBundleUrl = (bundle, video) => (video ? '/api/kid/lectures/' : '/api/bundles/') + encodeURIComponent(bundle) + '/';
|
|
1500
|
+
let lcShowing = null;
|
|
1501
|
+
/** 正在放的就是孩子手上这一课(画记到 S.lecture) */
|
|
1502
|
+
const lcMine = () => Boolean(S.lecture && lcShowing && lcShowing.bundle === S.lecture.bundle && !S.readonly);
|
|
1503
|
+
const openLecture = (l) => {
|
|
1504
|
+
const L = l || S.lecture; if (!L) return;
|
|
1505
|
+
// 问过以后(刷新过、或从别处回来,手上没有 S.lecture)从节前的小课堂卡再看一遍:这个话题接着收画的,下一条带上
|
|
1506
|
+
if (l && !S.readonly && !S.newThread && (!S.lecture || S.lecture.bundle !== l.bundle)) {
|
|
1507
|
+
const own = S.sections.find((x) => x.lecture && !x.lecture.again && x.lecture.bundle === l.bundle);
|
|
1508
|
+
if (own) S.lecture = { bundle: l.bundle, title: l.title, ...(own.lecture.video ? { video: true } : {}), watch: { watchedMs: 0, finished: true, pauses: 0 }, sent: true, firstJob: own.job, job: own.job, marks: [], flying: [], log: [] };
|
|
1509
|
+
}
|
|
1510
|
+
lcShowing = L; dispatch({ type: 'halt' });
|
|
1511
|
+
lcFrame.hidden = false; lcFrame.src = stageUrl('lecture');
|
|
1512
|
+
};
|
|
1513
|
+
const hideLecture = () => { lcShowing = null; lcFrame.hidden = true; lcFrame.src = 'about:blank'; };
|
|
1514
|
+
/** 「分:秒」(同 src/board/lecture.ts 的 clockLabel) */
|
|
1515
|
+
const lcClock = (ms) => { const t = Math.max(0, Math.floor(ms / 1000)); const hh = Math.floor(t / 3600), mm = Math.floor((t % 3600) / 60), ss = String(t % 60).padStart(2, '0'); return hh ? hh + ':' + String(mm).padStart(2, '0') + ':' + ss : mm + ':' + ss; };
|
|
1516
|
+
/** 课包那一刻的画面(没截图的画的卡、小课堂卡的缩略图):服务端用烤好的画面现画(frame.svg,SVG 时刻 + 几笔;没烤过的服务端先烤),一张 <img>。
|
|
1517
|
+
* 几笔写进地址(笔间「~」),缩略图用不着那么细:一共抽到 400 个点以内,地址不会太长 */
|
|
1518
|
+
const lcStill = (bundle, svgMs, strokes, cls) => {
|
|
1519
|
+
const box = h('div', { class: cls });
|
|
1520
|
+
const all = (strokes || []).filter((st) => st && st.length >= 2), n = all.reduce((a, st) => a + st.length, 0), step = Math.max(1, Math.ceil(n / 400));
|
|
1521
|
+
const ring = all.length ? '&ring=' + all.map((st) => st.filter((_, i) => i % step === 0 || i === st.length - 1).map((p) => Math.round(p[0]) + ',' + Math.round(p[1])).join(';')).join('~') : '';
|
|
1522
|
+
const img = h('img', { class: 'mk-svg', alt: '', src: lcBundleUrl(bundle) + 'frame.svg?svg=' + (svgMs === 'end' ? 'end' : Math.round((svgMs || 0) * 10) / 10) + ring });
|
|
1523
|
+
box.append(img);
|
|
1524
|
+
return box;
|
|
1525
|
+
};
|
|
1526
|
+
/** 画的卡的缩略图:截下来的那张(还没发的是 data URL,发了是 captures/ 路径);没截图的课包现画 */
|
|
1527
|
+
/** 视频那一段末尾的一帧(小课堂卡的缩略图):<video> 停在 end 前一点,不出声 */
|
|
1528
|
+
const lcVideoStill = (bundle, end) => {
|
|
1529
|
+
const v = h('video', { class: 'lc-still', src: lcBundleUrl(bundle, true) + 'video.mp4#t=' + Math.max(0, (end - 200) / 1000).toFixed(2), playsinline: '', preload: 'metadata' });
|
|
1530
|
+
v.muted = true;
|
|
1531
|
+
return v;
|
|
1532
|
+
};
|
|
1533
|
+
const markThumb = (l, mk) => mk.image ? h('div', { class: 'mk-th' }, h('img', { class: 'mk-svg', alt: '', src: mk.image.startsWith('data:') ? mk.image : '/api/kid/image?p=' + encodeURIComponent(mk.image) })) : lcStill(l.bundle, mk.svgMs, mk.strokes, 'mk-th');
|
|
1534
|
+
/** 一张画的卡:缩略图 + 「你画的」+ 时刻;点了从那一刻停着打开小课堂;del = 看完还没交出去的能删 */
|
|
1535
|
+
const markCard = (l, mk, del) => h('div', { class: 'c c-mark', 'data-at': String(mk.atMs) },
|
|
1536
|
+
h('div', { class: 'mk-main', on: { click: (e) => { e.stopPropagation(); openLecture({ bundle: l.bundle, title: l.title, at: mk.atMs, ...(l.video ? { video: true } : {}) }); } } }, markThumb(l, mk), h('div', { class: 'mk-t' }, h('small', {}, '你画的'), h('b', {}, lcClock(mk.atMs)))),
|
|
1537
|
+
del ? h('button', { type: 'button', class: 'x', 'aria-label': '删掉这一处', on: { click: (e) => { e.stopPropagation(); del(); } } }, '×') : null);
|
|
1538
|
+
const markRow = (l, marks, del) => h('div', { class: 'mk-row' }, ...marks.map((k) => markCard(l, k, del ? () => del(k) : null)));
|
|
1539
|
+
/** 板书顶上的小课堂卡:最后一帧的缩略图、课名、看完了、画了几处、「再看一遍」(done = 孩子已经问过,卡画在那一节前面);缩略图点了也是再看一遍 */
|
|
1540
|
+
const lectureCard = (l, done) => {
|
|
1541
|
+
const n = (l.marks || []).length;
|
|
1542
|
+
const again = (e) => { e.stopPropagation(); openLecture({ bundle: l.bundle, title: l.title, ...(l.video ? { video: true } : {}) }); };
|
|
1543
|
+
// 课包画最后一帧(frame.svg?svg=end);视频停在看到的最后一刻,不知道就不画
|
|
1544
|
+
const still = l.video ? (l.endMs ? lcVideoStill(l.bundle, l.endMs) : null) : lcStill(l.bundle, 'end', null, 'th');
|
|
1545
|
+
return h('div', { class: 'c c-lc', 'data-lecture': l.bundle },
|
|
1546
|
+
still ? h('button', { type: 'button', class: 'lc-th', 'aria-label': '再看一遍', on: { click: again } }, still) : null,
|
|
1547
|
+
h('div', { class: 'lt' }, h('small', {}, '小课堂'), h('b', {}, l.title || ''), h('span', {}, '看完了' + (n ? ' · 画了 ' + n + ' 处' : done ? '' : ' · 有不懂的就问老师'))),
|
|
1548
|
+
h('button', { type: 'button', class: 're', on: { click: again } }, '再看一遍'));
|
|
1549
|
+
};
|
|
1550
|
+
/** 一节前面的小课堂:第一问那节是小课堂卡 + 画的卡;再看一遍又画的那节只有画的卡 */
|
|
1551
|
+
const lectureHead = (s) => !s.lecture ? [] : [s.lecture.again ? null : lectureCard(s.lecture, true), s.lecture.marks && s.lecture.marks.length ? markRow(s.lecture, s.lecture.marks, null) : null];
|
|
1552
|
+
/**
|
|
1553
|
+
* 板书上还没落进哪一节的小课堂(S.lecture):看完、还没开口 → 顶上小课堂卡 + 一排画的卡(能删)+「看完了!有什么想问老师的?」;
|
|
1554
|
+
* 问了、那一节还没回来 → 卡与发出去的画留着(不能删了),提示撤掉;那一节回来了(节前画着)→ 撤掉。
|
|
1555
|
+
* 问过以后再看一遍又画了 → 板书末尾一排画的卡(能删)+ 一句「下次说话会一起带给老师」
|
|
1556
|
+
*/
|
|
1557
|
+
const renderLecturePending = () => {
|
|
1558
|
+
const old = $('#board .lc-pending');
|
|
1559
|
+
const L = S.lecture;
|
|
1560
|
+
const waiting = Boolean(L && L.job && !$('#board .sec[data-job="' + L.job + '"]'));
|
|
1561
|
+
const showCard = Boolean(L && L.watch && (!L.sent || (waiting && L.job === L.firstJob)));
|
|
1562
|
+
const flying = L && waiting ? (L.flying || []) : [];
|
|
1563
|
+
const marks = L && L.watch ? (L.marks || []) : [];
|
|
1564
|
+
if (!L || (!showCard && !flying.length && !marks.length)) { if (old) old.remove(); return; }
|
|
1565
|
+
const key = JSON.stringify([showCard, L.sent, flying.map((k) => k.atMs), marks.map((k) => k.atMs + ':' + k.strokes.length + (k.image ? ':i' : ''))]);
|
|
1566
|
+
if (old && old.getAttribute('data-key') === key) return;
|
|
1567
|
+
const del = (k) => { L.marks = L.marks.filter((x) => x !== k); renderLecturePending(); };
|
|
1568
|
+
const ask = !L.sent ? h('div', { class: 'lc-ask' }, h('b', {}, '看完了!有什么想问老师的?'), h('span', {}, marks.length ? '画的 ' + marks.length + ' 处,问的时候会一起带给老师;不想带的点 × 去掉' : '按住说话,或者打字'))
|
|
1569
|
+
: marks.length ? h('div', { class: 'lc-ask sm' }, h('span', {}, '又画了 ' + marks.length + ' 处,下次说话会一起带给老师;不想带的点 × 去掉')) : null;
|
|
1570
|
+
const el = h('div', { class: 'lc-pending', 'data-key': key }, showCard ? lectureCard({ bundle: L.bundle, title: L.title, video: L.video, endMs: L.watch && L.watch.watchedMs, marks: L.sent ? flying : marks }, false) : null,
|
|
1571
|
+
flying.length || marks.length ? h('div', { class: 'mk-row' }, ...flying.map((k) => markCard(L, k, null)), ...marks.map((k) => markCard(L, k, () => del(k)))) : null, ask);
|
|
1572
|
+
if (old) old.remove();
|
|
1573
|
+
if (showCard) $('#board').prepend(el); else $('#board').append(el);
|
|
1574
|
+
};
|
|
1575
|
+
window.addEventListener('message', (e) => {
|
|
1576
|
+
const m = e.data;
|
|
1577
|
+
if (!m || m.source !== STAGE_SOURCE || e.source !== lcFrame.contentWindow || !lcShowing) return;
|
|
1578
|
+
if (m.type === 'ready') {
|
|
1579
|
+
lcPost({ type: 'card', id: 'lecture', kind: 'lecture', props: { title: lcShowing.title, back: S.tutor ? { color: colorOf(S.tutor), initial: tutorInitial(S.tutor) } : null, marks: lcMine() ? S.lecture.marks : [], ...(lcShowing.at !== undefined ? { at: lcShowing.at } : {}), ...(lcMine() ? {} : { view: true }), ...(lcShowing.video ? { video: true } : {}), ...(lcShowing.follow ? { follow: true } : {}) }, state: null, bundleUrl: lcBundleUrl(lcShowing.bundle, lcShowing.video) });
|
|
1580
|
+
// 录像(follow):播放器这时才在听,录像停着不会再发,把最近那条 follow 再发一次(之前那条它没收到;2026-10-19 停着点「圈了一处」(现在叫「画了一处」)一直停在 0:00)
|
|
1581
|
+
if (lcShowing.follow && lcShowing.followMsg) lcPost(lcShowing.followMsg);
|
|
1582
|
+
}
|
|
1583
|
+
else if (m.type === 'marks') { if (lcMine()) S.lecture.marks = m.marks; }
|
|
1584
|
+
// 看的过程(录像用):孩子开口时一起带上(没开口之前哪里都不记)
|
|
1585
|
+
else if (m.type === 'watch') { if (lcMine() && S.lecture.log.length < 500) S.lecture.log.push({ at: m.at, pos: m.pos, play: m.play }); }
|
|
1586
|
+
else if (m.type === 'lecture') {
|
|
1587
|
+
const first = lcMine() && !S.lecture.watch;
|
|
1588
|
+
if (m.event === 'finished' && first) S.lecture.watch = { watchedMs: m.watchedMs, finished: true, pauses: m.pauses };
|
|
1589
|
+
else if (lcMine()) S.lecture.rewatch = { watchedMs: m.watchedMs, finished: m.finished, pauses: m.pauses };
|
|
1590
|
+
// 第一遍没看完就回去 = 回首页(这一课不记);看完了点「去问老师」或回去、「再看一遍」关掉 = 回板书。
|
|
1591
|
+
// 放完(finished)只记下来,停在最后一帧,孩子点了才回板书
|
|
1592
|
+
if (m.event === 'close' && first) { closeTutor(); return; }
|
|
1593
|
+
if (m.event === 'finished') return;
|
|
1594
|
+
hideLecture(); renderLecturePending(); renderBar(); renderSubtitle();
|
|
1595
|
+
} else if (m.type === 'error') { const first = S.lecture && !S.lecture.watch; hideLecture(); if (first) closeTutor(); }
|
|
1596
|
+
});
|
|
1597
|
+
// ---- 一个话题一节:孩子端节头只留板上第一节、带照片的节与孩子单独说了话的节(他的话就是节头);孩子的话答问题卡的进卡的答案栏 ----
|
|
1598
|
+
const headOf = (s, i) => (i === 0 || (s.photos && s.photos.length) ? sectionHead(s) : null);
|
|
1599
|
+
const saidBody = (sd, on = {}) => [
|
|
1600
|
+
// 答拍给我看卡拍来的照片(kidSaid 的 photos):缩在答案栏里,点开看大图;只发了照片没说话就没有字
|
|
1601
|
+
...(sd.photos || []).map((p, k) => h('img', { class: 'kp', src: '/api/kid/image?p=' + encodeURIComponent(p), alt: '', loading: 'lazy', on: { click: (e) => { e.stopPropagation(); openLb(sd.photos, k); } } })),
|
|
1602
|
+
sd.text ? h('span', { class: 'ka-t', on }, sd.text) : null];
|
|
1603
|
+
/** 点开看原话(语音的显示是去了口头禅的),再点收起 */
|
|
1604
|
+
const toggleSaid = (sd) => (e) => { e.stopPropagation(); const el = e.currentTarget.closest('.kid-ans, .sh'); const t = el.querySelector('.ka-t'); if (!t) return; const open = el.classList.toggle('open'); t.textContent = open ? sd.full : sd.text; };
|
|
1605
|
+
/** 孩子单独说的那句当节头:话 —— 线 —— [时间] [照片] 再听;meta = false 只要话和线(刚发出去挂在板尾的、小课堂卡下面的) */
|
|
1606
|
+
const saidHead = (s, sd, meta, first) => h('div', { class: 'sh said' }, ...saidBody(sd, { click: toggleSaid(sd) }), h('span', { class: 'ln' }), ...(meta ? [first && s.at ? clock(s.at) : null, ...headPhotos(s)] : []));
|
|
1607
|
+
/** 一节开头:孩子没单独说话 → 原来的节头(只留第一节与带照片的);说了 → 他的话就是节头;看完小课堂后的第一问写在小课堂卡下面 */
|
|
1608
|
+
/** 孩子从纸上交的画:节前一张——图(点开看大图)、几笔、「接着画」打开这个话题的纸(只读、家长端、录像里不出) */
|
|
1609
|
+
const drawHead = (s) => !s.drawing ? null : h('div', { class: 'c c-draw' },
|
|
1610
|
+
h('img', { class: 'dw-th', src: '/api/kid/image?p=' + encodeURIComponent(s.drawing.image), alt: '', loading: 'lazy', on: { click: (e) => { e.stopPropagation(); openLb([s.drawing.image], 0); } } }),
|
|
1611
|
+
h('div', { class: 'dw-t' }, h('small', {}, '我画的'), h('b', {}, '画了 ' + s.drawing.strokes + ' 笔')),
|
|
1612
|
+
!S.readonly && !REEL ? h('button', { class: 'dw-go', type: 'button', on: { click: (e) => { e.stopPropagation(); openPaper(); } } }, '接着画') : null);
|
|
1613
|
+
const headEls = (s, i) => [...headEls0(s, i), drawHead(s)];
|
|
1614
|
+
const headEls0 = (s, i) => {
|
|
1615
|
+
// 实时:节头是脑写的那张卡(liveHeadEl,随卡画);板上第一节没写节头,只挂时间。孩子的话不当节头(在左栏 #lv-kid)
|
|
1616
|
+
if (s.live) return [i === 0 && !s.head && s.at ? h('div', { class: 'sh lh' }, h('span', { class: 'ln' }), h('span', { class: 'tm' }, clock(s.at))) : null, ...lectureHead(s)];
|
|
1617
|
+
const sd = s.said && !s.saidOn ? s.said : null;
|
|
1618
|
+
const lec = lectureHead(s);
|
|
1619
|
+
if (!sd) return [headOf(s, i), ...lec];
|
|
1620
|
+
if (!lec.some(Boolean)) return [saidHead(s, sd, true, i === 0)];
|
|
1621
|
+
return [headOf(s, i), ...lec, saidHead(s, sd, false)];
|
|
1622
|
+
};
|
|
1623
|
+
/** 实时的一节:节头脑写、孩子的话不进答案栏、卡和讲稿不同步 */
|
|
1624
|
+
const liveSec = (i) => Boolean(i !== null && S.sections[i] && S.sections[i].live);
|
|
1625
|
+
/** 脑写的节头(只有一行「# 标题」的文字卡):一行摘要 —— 线 —— 板上第一节带时间;一轮中间的也照这样断成新一节 */
|
|
1626
|
+
const liveHeadEl = (title, i, k) => { const s = S.sections[i]; return h('div', { class: 'sh lh', 'data-card': k }, h('span', { class: 't' }, title), h('span', { class: 'ln' }), i === 0 && k === 0 && s && s.at ? h('span', { class: 'tm' }, clock(s.at)) : null); };
|
|
1627
|
+
/** 实时的卡落下:讲稿里标这张卡的 [词] 都画上(不等念到),田字格、单词卡写一遍(这一页只写一次);animate = false 是重画、回来看,只画不写 */
|
|
1628
|
+
const liveLand = (i, k, animate) => {
|
|
1629
|
+
const s = S.sections[i]; if (!s || !s.live) return;
|
|
1630
|
+
for (const l of s.lines) for (const m of l.marks) if (m.card === k) applyMark(i, m, animate);
|
|
1631
|
+
const el = animate ? $('#board').querySelector('[data-sec="' + i + '"] [data-card="' + k + '"]') : null;
|
|
1632
|
+
if (el && (el.classList.contains('c-tianzige') || el.classList.contains('c-word')) && !el.dataset.wrote) { el.dataset.wrote = '1'; if (el.classList.contains('c-word')) wordWrite(el.querySelector('.wg'), false, null); else tianzigePlay(el); }
|
|
1633
|
+
};
|
|
1634
|
+
/** 第 j 节第 k 张卡的答案栏:孩子答了就是他的话;最后一节的问题卡还没人答、老师在等,写「等你说…」(body.waiting 时才露) */
|
|
1635
|
+
const ansFor = (j, k) => {
|
|
1636
|
+
const sec = S.sections[j];
|
|
1637
|
+
if (!sec || sec.live) return null;
|
|
1638
|
+
for (const s of S.sections) if (s.said && s.saidOn && s.saidOn.job === sec.job && s.saidOn.card === k) return s.said;
|
|
1639
|
+
if (S.pendSaid && S.pendSaid.on && S.pendSaid.on.job === sec.job && S.pendSaid.on.card === k) return S.pendSaid.said;
|
|
1640
|
+
if ((!S.readonly || REEL) && !S.pendSaid && j === S.sections.length - 1 && askCard(sec) === k) return sec.cards[k].kind === 'photo' ? { wait: true, snap: true } : { wait: true };
|
|
1641
|
+
return null;
|
|
1642
|
+
};
|
|
1643
|
+
/** 答案栏画的是什么(data-ans):变了才重画那张卡 */
|
|
1644
|
+
const ansKey = (a) => (a.snap ? 'snap' : a.wait ? 'wait' : (a.text || '') + '|' + (a.photos || []).join(','));
|
|
1645
|
+
/** 拍给我看卡上的拍照 / 相册:一个藏起来的 file input,和输入条的相机同一套(wireFile) */
|
|
1646
|
+
const snapBtn = (camera) => {
|
|
1647
|
+
const f = h('input', camera ? { type: 'file', accept: 'image/*', capture: 'environment' } : { type: 'file', accept: 'image/*', multiple: '' });
|
|
1648
|
+
wireFile(f);
|
|
1649
|
+
return h('label', { class: 'pc-go' + (camera ? '' : ' alt'), on: { click: (e) => e.stopPropagation() } }, h('span', { html: camera ? ICON.camera : ICON.album }), camera ? '拍照' : '相册', f);
|
|
1650
|
+
};
|
|
1651
|
+
const ansEl = (a) => (a.snap ? h('div', { class: 'kid-ans snap' }, h('span', { class: 'ka-w' }, '等你拍…'), snapBtn(true), snapBtn(false))
|
|
1652
|
+
: a.wait ? h('div', { class: 'kid-ans wait' }, '等你说…') : h('button', { type: 'button', class: 'kid-ans', on: { click: toggleSaid(a) } }, ...saidBody(a)));
|
|
1653
|
+
/** 拿起相机老师就停(同点卡开舞台);拍完 / 选完进发照片屏。相册最多 4 张,多的只取前 4 张。输入条、加号面板、拍给我看卡都走这里 */
|
|
1654
|
+
const wireFile = (f) => {
|
|
1655
|
+
f.addEventListener('change', () => { $('#sheet').classList.remove('on'); const files = Array.from(f.files || []).slice(0, f.multiple ? PHOTO_ALBUM_MAX : 1); f.value = ''; psOpen(files); });
|
|
1656
|
+
};
|
|
1657
|
+
/** 答案栏跟着数据走:哪张卡该有的和画着的(data-ans)不一样就重画那张卡 */
|
|
1658
|
+
const syncAnswers = () => {
|
|
1659
|
+
const want = new Set();
|
|
1660
|
+
const at = (job, k) => { const j = S.sections.findIndex((s) => s.job === job); if (j >= 0) want.add(j + ':' + k); };
|
|
1661
|
+
for (const s of S.sections) if (s.said && s.saidOn) at(s.saidOn.job, s.saidOn.card);
|
|
1662
|
+
if (S.pendSaid && S.pendSaid.on) at(S.pendSaid.on.job, S.pendSaid.on.card);
|
|
1663
|
+
const li = S.sections.length - 1;
|
|
1664
|
+
if (li >= 0) { const lk = askCard(S.sections[li]); if (lk !== null) want.add(li + ':' + lk); }
|
|
1665
|
+
for (const el of document.querySelectorAll('#board .c[data-ans]')) { const sec = el.closest('.sec'); if (sec) want.add(sec.dataset.sec + ':' + el.dataset.card); }
|
|
1666
|
+
for (const key of want) {
|
|
1667
|
+
const [j, k] = key.split(':').map(Number);
|
|
1668
|
+
const el = $('#board').querySelector('[data-sec="' + j + '"] .c[data-card="' + k + '"]');
|
|
1669
|
+
if (!el) continue;
|
|
1670
|
+
const a = ansFor(j, k);
|
|
1671
|
+
if ((el.dataset.ans || '') !== (a ? ansKey(a) : '')) repaintCard(j, k);
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
/** 刚发出去、老师还没写出这一节:不答卡的那句先挂在板尾 */
|
|
1675
|
+
const syncPendSay = () => {
|
|
1676
|
+
const old = $('#board > .sh.said.pend');
|
|
1677
|
+
const p = S.pendSaid && !S.pendSaid.on && !S.pendSaid.live && !LV.on ? S.pendSaid : null;
|
|
1678
|
+
if (old && (!p || old.dataset.said !== p.job)) old.remove();
|
|
1679
|
+
if (!p || (old && old.dataset.said === p.job)) return;
|
|
1680
|
+
const el = saidHead(null, p.said, false); el.classList.add('pend'); el.dataset.said = p.job;
|
|
1681
|
+
$('#board').append(el);
|
|
1682
|
+
};
|
|
1683
|
+
const renderSection = (s, i) => h('div', { class: 'sec' + (s.live && s.cont ? ' cont' : ''), 'data-sec': i, 'data-job': s.job }, ...headEls(s, i), ...rowEls(s, s.cards.map((c, k) => renderCard(c, k, i, false))).map((r) => r.el));
|
|
1684
|
+
/**
|
|
1685
|
+
* 老师还在说(2026-09-13,一拍一就绪):第一拍就绪前板上什么都不出(头像在想,见 renderSubtitle);就绪了就把这条转成 S.sections 里的一节(live)开播,
|
|
1686
|
+
* 之后每次 poll 只铺新就绪的拍的卡(一行一张,没有排版)、把 lines 换成最新的(配音名填进来),播到头等着(thinking)的就接上。
|
|
1687
|
+
* 铺卡在拍就绪时而不是围栏闭合时:铺的时候声音已齐,不会先素后彩地闪
|
|
1688
|
+
*/
|
|
1689
|
+
const liveCardsOf = (e) => { if (e.live) return e.cards.length; const bs = beatsOf(e).slice(0, e.ready || 0); let n = 0; for (const b of bs) if (b.card !== null) n = Math.max(n, b.card + 1); return n; };
|
|
1690
|
+
/** 一张卡落到行里:和前面的卡并一行(半宽 + 半宽,或明写的并排)就进那一行,否则新起一行;只看这张和前面的,后面的卡不影响前面的行 */
|
|
1691
|
+
// ---- 跟到底:新露出来的卡在板的最后 → 板滚到底(连底下留的空一起露);之后这一节再长高(问题卡的「等你说…」、图片载完、排版量完),
|
|
1692
|
+
// 只要孩子没自己往上翻,就再滚到底。原来是 scrollIntoView nearest:只把卡的下沿贴到可视区下沿,之后长高的那截就在屏幕外
|
|
1693
|
+
// (2026-10-08 真机:新卡要手动往上滑才看全;mock 量出来每轮停下等答时最后一张卡被截 45px,其余 4–7px) ----
|
|
1694
|
+
const pin = { on: false, touched: 0 };
|
|
1695
|
+
const avListen = () => document.body.classList.toggle('listening', S.bar === 'holding' || (LV.on && LV.model.open && LV.model.hearing));
|
|
1696
|
+
/** 实时:老师说完这一轮点一下头——按钮里那颗、手机那颗(动作在 voice-ui.ts) */
|
|
1697
|
+
const liveNod = () => { VB.btn.nod(); const a = $('#lv-av'); a.classList.remove('vb-nod'); void a.offsetWidth; a.classList.add('vb-nod'); setTimeout(() => a.classList.remove('vb-nod'), 950); };
|
|
1698
|
+
/** 让孩子看到 el:它是板上最后看得见的东西就滚到底并跟着(pin);不是(念到上面另一张卡)就照旧 nearest,不跟;比可视区还高的露上沿 */
|
|
1699
|
+
const follow = (el) => {
|
|
1700
|
+
const b = $('#board'); if (!el || !b.contains(el)) return;
|
|
1701
|
+
const er = el.getBoundingClientRect();
|
|
1702
|
+
const padB = parseFloat(getComputedStyle(b).paddingBottom) || 0;
|
|
1703
|
+
// 板上最后看得见的东西的下沿(不用 scrollHeight:内容还没板高时它就是板高,短板上的卡永远「不是最后」,之后上面的卡长高把它挤出去也不跟)
|
|
1704
|
+
let tail = b.lastElementChild; while (tail && !tail.getClientRects().length) tail = tail.previousElementSibling;
|
|
1705
|
+
const gap = tail ? tail.getBoundingClientRect().bottom - er.bottom : 0;
|
|
1706
|
+
if (er.height > b.clientHeight - padB) { pin.on = false; el.scrollIntoView({ block: 'start', behavior: 'smooth' }); return; }
|
|
1707
|
+
if (gap > 32) {
|
|
1708
|
+
// 已经整张看得见就不动:再滚一次 nearest 会打断正在往底下滚的那一下(新卡刚铺、这句又念到上面那张时,新卡停在半截)
|
|
1709
|
+
const br = b.getBoundingClientRect();
|
|
1710
|
+
if (er.top >= br.top && er.bottom <= br.bottom) return;
|
|
1711
|
+
pin.on = false; el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return;
|
|
1712
|
+
}
|
|
1713
|
+
pin.on = true;
|
|
1714
|
+
b.scrollTo({ top: b.scrollHeight, behavior: 'smooth' });
|
|
1715
|
+
};
|
|
1716
|
+
{
|
|
1717
|
+
const b = $('#board');
|
|
1718
|
+
// 孩子自己翻了就不跟(点一下不算:只有拖动、滚轮)
|
|
1719
|
+
for (const ev of ['touchmove', 'wheel']) b.addEventListener(ev, () => { pin.on = false; pin.touched = Date.now(); }, { passive: true });
|
|
1720
|
+
if (window.ResizeObserver) {
|
|
1721
|
+
const ro = new ResizeObserver(() => { if (pin.on && b.scrollHeight - b.scrollTop - b.clientHeight > 1) b.scrollTo({ top: b.scrollHeight, behavior: 'smooth' }); });
|
|
1722
|
+
new MutationObserver((ms) => { for (const m of ms) for (const n of m.addedNodes) if (n.nodeType === 1) ro.observe(n); }).observe(b, { childList: true });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
const placeCard = (P, sec, k, idx) => {
|
|
1726
|
+
const c = renderCard(sec.cards[k], k, idx, false);
|
|
1727
|
+
const half = (j) => j <= k && halfOf(sec.cards[j], j === k ? c : null);
|
|
1728
|
+
const row = rowsFor(sec, S.device, half).find((r) => r.includes(k)) || [k];
|
|
1729
|
+
const first = row[0] !== k ? P.el.querySelector('[data-card="' + row[0] + '"]') : null;
|
|
1730
|
+
const el = first && first.parentNode && first.parentNode.classList.contains('row') ? first.parentNode : null;
|
|
1731
|
+
if (el) { el.style.gridTemplateColumns = 'repeat(' + rowCols(sec, row, half) + ',minmax(0,1fr))'; el.append(c); }
|
|
1732
|
+
else P.el.append(rowEl(rowCols(sec, row, half), c));
|
|
1733
|
+
return c;
|
|
1734
|
+
};
|
|
1735
|
+
const renderLive = (e) => {
|
|
1736
|
+
if (!S.partial || S.partial.job !== e.job) { if (S.partial) S.partial.el.remove(); const idx = S.sections.length; S.partial = { job: e.job, idx, live: false, shown: 0, el: h('div', { class: 'sec', 'data-sec': idx, 'data-job': e.job }, ...headEls(e, idx)) }; $('#board').append(S.partial.el); }
|
|
1737
|
+
const P = S.partial, idx = P.idx;
|
|
1738
|
+
P.el.classList.toggle('cont', Boolean(e.live && e.cont));
|
|
1739
|
+
// 不是实时的一轮:一拍就绪才铺;实时的一轮:卡闭合了就铺、有句就出节(讲稿嘴在念,不等拍)
|
|
1740
|
+
if (!(e.ready > 0) && !(e.live && (e.cards.length || e.lines.length))) return;
|
|
1741
|
+
const sec = { ...e, partial: true };
|
|
1742
|
+
const wasLive = P.live;
|
|
1743
|
+
if (!wasLive) { P.live = true; S.played.add(e.job); S.sections.push(sec); }
|
|
1744
|
+
else S.sections[idx] = sec;
|
|
1745
|
+
const n = liveCardsOf(sec);
|
|
1746
|
+
const placed = n > P.shown;
|
|
1747
|
+
for (; P.shown < n; P.shown++) { follow(placeCard(P, sec, P.shown, idx)); liveLand(idx, P.shown, true); }
|
|
1748
|
+
dispatch({ type: wasLive ? 'liveBeat' : 'liveStart', section: idx });
|
|
1749
|
+
if (LV.on && placed) liveDispatch({ type: 'cards' });
|
|
1750
|
+
};
|
|
1751
|
+
/** 老师写完了:live 的那节换成正式的(带标注 / 样子),不重播——没铺的卡补上,铺过的换样子并把标注补画 */
|
|
1752
|
+
const finalizeLive = (e) => {
|
|
1753
|
+
const P = S.partial; S.partial = null;
|
|
1754
|
+
const idx = P.idx;
|
|
1755
|
+
const sec = { ...e };
|
|
1756
|
+
S.sections[idx] = sec;
|
|
1757
|
+
const el = P.el;
|
|
1758
|
+
el.classList.toggle('cont', Boolean(sec.live && sec.cont));
|
|
1759
|
+
for (let k = 0; k < sec.cards.length; k++) {
|
|
1760
|
+
const old = k < P.shown ? el.querySelector('[data-card="' + k + '"]') : null;
|
|
1761
|
+
if (old) swapCard(old, renderCard(sec.cards[k], k, idx, false));
|
|
1762
|
+
else placeCard(P, sec, k, idx);
|
|
1763
|
+
// 实时:换了样子的卡标注补画,新铺的照落下画、写
|
|
1764
|
+
liveLand(idx, k, !old);
|
|
1765
|
+
}
|
|
1766
|
+
dispatch({ type: 'liveFinal', section: idx });
|
|
1767
|
+
if (LV.on) liveDispatch({ type: 'cards' });
|
|
1768
|
+
};
|
|
1769
|
+
/** 一张卡的状态变了:紧凑态原地重画(标注会掉,重画这张卡的;选中态照旧) */
|
|
1770
|
+
const repaintCard = (secIdx, idx) => {
|
|
1771
|
+
const sec = $('#board').querySelector('[data-sec="' + secIdx + '"]');
|
|
1772
|
+
const old = sec && sec.querySelector('[data-card="' + idx + '"]');
|
|
1773
|
+
if (!old) return;
|
|
1774
|
+
swapCard(old, renderCard(S.sections[secIdx].cards[idx], idx, secIdx, false));
|
|
1775
|
+
for (const l of S.sections[secIdx].lines) for (const m of l.marks) if (m.card === idx) applyMark(secIdx, m, false);
|
|
1776
|
+
};
|
|
1777
|
+
/** 一张卡换成新画的:选中态与「田字格写过了」跟着搬 */
|
|
1778
|
+
const swapCard = (old, fresh) => {
|
|
1779
|
+
// 田字格不换元素:老师写完那一刻它可能正在写,换了动画就断(mock 里 1.5 秒必现);同一个词只把定稿的样子搬过去
|
|
1780
|
+
if (old.classList.contains('c-tianzige') && fresh.classList.contains('c-tianzige') && old.dataset.ch === fresh.dataset.ch) { old.dataset.tint = fresh.dataset.tint; old.dataset.look = fresh.dataset.look; old.dataset.card = fresh.dataset.card; return; }
|
|
1781
|
+
// 单词卡同理;定稿才有的 emoji 搬过去
|
|
1782
|
+
if (old.classList.contains('c-word') && fresh.classList.contains('c-word') && old.dataset.w === fresh.dataset.w) {
|
|
1783
|
+
old.dataset.tint = fresh.dataset.tint; old.dataset.look = fresh.dataset.look; old.dataset.card = fresh.dataset.card;
|
|
1784
|
+
const fe = fresh.querySelector('.we'), oe = old.querySelector('.we');
|
|
1785
|
+
if (fe && oe) oe.textContent = fe.textContent; else if (fe) old.prepend(fe);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (old.classList.contains('now')) fresh.classList.add('now'); if (old.classList.contains('pend')) fresh.classList.add('pend'); if (old.dataset.wrote) fresh.dataset.wrote = old.dataset.wrote; old.replaceWith(fresh);
|
|
1789
|
+
};
|
|
1790
|
+
/** 选中态:一节里同一时刻只有一张(舞台开着时是舞台那张;停下等答停在末句的卡) */
|
|
1791
|
+
const setNow = (secIdx, idx) => {
|
|
1792
|
+
for (const x of document.querySelectorAll('#board .c.now')) x.classList.remove('now');
|
|
1793
|
+
if (secIdx === null || idx === null) return;
|
|
1794
|
+
const el = $('#board').querySelector('[data-sec="' + secIdx + '"] [data-card="' + idx + '"]');
|
|
1795
|
+
if (!el) return;
|
|
1796
|
+
el.classList.add('now');
|
|
1797
|
+
};
|
|
1798
|
+
const showNow = () => setNow(S.state.section, nowCard(S.sections, S.state));
|
|
1799
|
+
/** 换端(转屏)或窗口变了:端或半宽变了就整节重排(卡重新量),标注画齐,选中态照旧 */
|
|
1800
|
+
const relayout = () => {
|
|
1801
|
+
const d = debug.get('device') || deviceFor(innerWidth, innerHeight);
|
|
1802
|
+
// 录像:孩子的屏幕转屏 / 换端整块重画;家长页这头只是 iframe 换个缩放(reelDock 自己跟着窗口)
|
|
1803
|
+
if (S.reel) { if (REEL) { S.device = d; S.reel.key = ''; reelTick(); } return; }
|
|
1804
|
+
const hw = halfWidth();
|
|
1805
|
+
if (d !== S.device || hw !== S.layHalf) {
|
|
1806
|
+
S.device = d; S.layHalf = hw;
|
|
1807
|
+
S.sections.forEach((s, i) => { const old = $('#board').querySelector('[data-sec="' + i + '"]'); if (old && !(S.partial && S.partial.el === old)) { old.replaceWith(renderSection(s, i)); paintAll(i); } });
|
|
1808
|
+
if (S.stage) setNow(S.stage.section, S.stage.card); else if (!LV.on) showNow();
|
|
1809
|
+
} else for (const span of document.querySelectorAll('#board .mk[data-pen]')) { const card = span.closest('.c'); if (card && LINE_PENS.includes(span.dataset.pen)) drawPen(span, card, span.dataset.pen, false); }
|
|
1810
|
+
};
|
|
1811
|
+
let relayoutTimer = null;
|
|
1812
|
+
window.addEventListener('resize', () => { clearTimeout(relayoutTimer); relayoutTimer = setTimeout(relayout, 150); });
|
|
1813
|
+
S.device = debug.get('device') || deviceFor(innerWidth, innerHeight);
|
|
1814
|
+
S.layHalf = halfWidth();
|
|
1815
|
+
|
|
1816
|
+
// ---- 舞台:点卡放大,交互都在这里 ----
|
|
1817
|
+
const KIND_NAME = { text: '', read: '点读', choice: '选一选', image: '看图', tianzige: '田字格', dictation: '听写', word: '单词', lecture: '小课堂', canvas: '画一画', code: '' };
|
|
1818
|
+
const GO_LABEL = { canvas: '给老师看' };
|
|
1819
|
+
// ---- 画板:画板卡与左栏的纸都铺满;纸一个话题一张、一直能改;边画边聊:画板开着时按头像说的给嘴(看得见画),
|
|
1820
|
+
// 「给老师看」轻点只交画、按住说 = 画和这句话一起给脑;嘴什么时候主动说一句照 canvas-chat.ts 的规矩(已内联),孩子端定、服务端再看一眼安不安静 ----
|
|
1821
|
+
const CV = { chat: null, strokes: [], timer: 0, snapWait: null, snapP: null, capT: 0, give: null, asking: null };
|
|
1822
|
+
/** 舞台上那张卡:板上的卡,或者左栏的纸(纸不在板上,S.paper.card 是一张只在这里的画板卡) */
|
|
1823
|
+
const stageCardNow = () => (!S.stage ? null : S.stage.paper ? (S.paper ? S.paper.card : null) : S.sections[S.stage.section] ? S.sections[S.stage.section].cards[S.stage.card] : null);
|
|
1824
|
+
/** 画板开着、还能画(画板卡没交,或者纸);只读、家长端、录像里不算 */
|
|
1825
|
+
const canvasNow = () => { const c = stageCardNow(); return c && c.kind === 'canvas' && !c.submitted && !S.readonly && !REEL ? c : null; };
|
|
1826
|
+
/** 舞台存回来的只有笔迹;清空前存的那几版(cleared)在页面这份状态里,并上 */
|
|
1827
|
+
const withCleared = (prev, next) => (prev && Array.isArray(prev.cleared) && prev.cleared.length ? { ...next, cleared: prev.cleared } : next);
|
|
1828
|
+
/** 纸的名字:这个话题;话题还没有(新话题、空板)叫 new,第一条发出去服务端改名 */
|
|
1829
|
+
const paperKey = () => (S.newThread || !S.thread ? 'new' : S.thread);
|
|
1830
|
+
const paperOk = () => LV.on && !S.readonly && !REEL && !PREVIEW && Boolean(S.tutor);
|
|
1831
|
+
const savePaper = () => (S.paper && S.tutor && S.paper.tutor === S.tutor.name ? api('PUT', CONV + S.tutor.name + '/paper', { key: S.paper.key, state: S.paper.card.state }).catch(() => {}) : Promise.resolve());
|
|
1832
|
+
/** 左栏「画板」、加号单子里的「画板」、板上画卡的「接着画」:打开这个话题的那张纸(关了不丢,状态在服务端) */
|
|
1833
|
+
const openPaper = () => {
|
|
1834
|
+
if (!paperOk() || S.stage) return;
|
|
1835
|
+
const key = paperKey();
|
|
1836
|
+
const show = (st) => {
|
|
1837
|
+
if (S.stage || !paperOk()) return;
|
|
1838
|
+
S.paper = { tutor: S.tutor.name, key, card: { kind: 'canvas', props: { base: null }, state: st && Array.isArray(st.ink) ? st : { ink: [] } } };
|
|
1839
|
+
S.stage = { paper: true, section: null, card: null, id: 'paper', playing: null, delegate: false, autoplay: false };
|
|
1840
|
+
$('#st-ttl').textContent = '画板'; $('#st-ttl').classList.remove('open');
|
|
1841
|
+
$('#st-kd').hidden = true;
|
|
1842
|
+
$('#stage').classList.add('cv');
|
|
1843
|
+
renderStage();
|
|
1844
|
+
$('#stage').classList.add('on');
|
|
1845
|
+
canvasOpened();
|
|
1846
|
+
};
|
|
1847
|
+
api('GET', CONV + S.tutor.name + '/paper?key=' + encodeURIComponent(key)).then((r) => show(r && r.state), () => show(null));
|
|
1848
|
+
};
|
|
1849
|
+
/** 画板开了:边画边聊从这一刻算(点开 20 秒内不说) */
|
|
1850
|
+
const canvasOpened = () => {
|
|
1851
|
+
const c = stageCardNow();
|
|
1852
|
+
CV.chat = canvasChatInit(Date.now());
|
|
1853
|
+
CV.strokes = c && c.state && Array.isArray(c.state.ink) ? canvasStrokes(c.state.ink) : [];
|
|
1854
|
+
clearInterval(CV.timer); CV.timer = 0;
|
|
1855
|
+
if (LV.on && canvasNow()) CV.timer = setInterval(canvasTick, 1000);
|
|
1856
|
+
};
|
|
1857
|
+
const canvasClosed = () => {
|
|
1858
|
+
clearInterval(CV.timer); CV.timer = 0; CV.chat = null; CV.strokes = [];
|
|
1859
|
+
if (CV.asking) { clearTimeout(CV.asking.timer); CV.asking = null; }
|
|
1860
|
+
if (CV.snapWait) { const w = CV.snapWait; CV.snapWait = null; w(null); }
|
|
1861
|
+
if (CV.give) { const g = CV.give; CV.give = null; g(null); }
|
|
1862
|
+
clearTimeout(CV.capT); CV.capT = 0; $('#cv-cap').classList.remove('on');
|
|
1863
|
+
$('#stage').classList.remove('cv'); $('#st-go').classList.remove('holding');
|
|
1864
|
+
};
|
|
1865
|
+
/** 这一刻的画(长边 768 的 jpeg data URL):问舞台要,1.5 秒没回就算没有 */
|
|
1866
|
+
const canvasSnap = () => new Promise((resolve) => {
|
|
1867
|
+
if (!canvasNow()) { resolve(null); return; }
|
|
1868
|
+
if (CV.snapWait) { const w = CV.snapWait; CV.snapWait = null; w(null); }
|
|
1869
|
+
let t = 0;
|
|
1870
|
+
const done = (img) => { clearTimeout(t); resolve(img || null); };
|
|
1871
|
+
t = setTimeout(() => { if (CV.snapWait === done) CV.snapWait = null; resolve(null); }, 1500);
|
|
1872
|
+
CV.snapWait = done;
|
|
1873
|
+
postStage({ type: 'control', action: 'snap' });
|
|
1874
|
+
});
|
|
1875
|
+
/** 给嘴的题(cue.ts 的 ChatAsk):题目、说哪个颜色、问不问、画了多久、各颜色几笔在哪、清空过几次 */
|
|
1876
|
+
const canvasAsk = (due) => {
|
|
1877
|
+
const c = canvasNow();
|
|
1878
|
+
const sum = canvasSummary(CV.strokes);
|
|
1879
|
+
const cleared = c && c.state && Array.isArray(c.state.cleared) ? c.state.cleared : [];
|
|
1880
|
+
const starts = cleared.map((x) => x.from || x.at).concat(sum.firstAt ? [sum.firstAt] : []).filter((x) => x > 0);
|
|
1881
|
+
const ms = starts.length && sum.lastAt ? sum.lastAt - Math.min(...starts) : 0;
|
|
1882
|
+
return { ...(c && c.props && c.props.prompt ? { prompt: c.props.prompt } : {}), ...(due ? { color: due.color, ask: due.ask } : {}), minutes: canvasMinutesText(ms), colors: sum.colors, cleared: cleared.length };
|
|
1883
|
+
};
|
|
1884
|
+
/**
|
|
1885
|
+
* 每秒看一眼该不该主动说一句(canvasChatDue);该说就要一张图交给嘴。这一句先挂着(CV.asking),服务端回 {t:'chat', said} 才记成说过,
|
|
1886
|
+
* 回 skipped(不安静)或 6 秒没回就作废——不然没说出去的也占了一句、算成没回
|
|
1887
|
+
*/
|
|
1888
|
+
const canvasTick = () => {
|
|
1889
|
+
if (!canvasNow() || !CV.chat || !LV.on || !LV.ready || LV.model.open) return;
|
|
1890
|
+
const busy = LV.model.status !== 'quiet' || S.pending || Boolean(press) || LV.letGo || Boolean(CV.snapWait) || Boolean(CV.give) || Boolean(CV.asking);
|
|
1891
|
+
const now = Date.now();
|
|
1892
|
+
const due = canvasChatDue(CV.chat, now, CV.strokes, busy);
|
|
1893
|
+
if (!due.say) return;
|
|
1894
|
+
const id = S.stage.id;
|
|
1895
|
+
const asking = { id, due, at: now, strokes: CV.strokes.slice(), timer: 0 };
|
|
1896
|
+
CV.asking = asking;
|
|
1897
|
+
asking.timer = setTimeout(() => { if (CV.asking === asking) CV.asking = null; }, 6000);
|
|
1898
|
+
canvasSnap().then((img) => {
|
|
1899
|
+
if (CV.asking !== asking) return;
|
|
1900
|
+
if (img && canvasNow() && S.stage && S.stage.id === id && LV.model.status === 'quiet' && !press && !LV.letGo) lvSend({ t: 'chat', canvas: id, image: img, ask: canvasAsk(due) });
|
|
1901
|
+
else { clearTimeout(asking.timer); CV.asking = null; }
|
|
1902
|
+
});
|
|
1903
|
+
};
|
|
1904
|
+
/** 服务端回了主动说的那一句:said = 嘴说了,记下;skipped = 不安静没说,作废 */
|
|
1905
|
+
const canvasChatAck = (m) => {
|
|
1906
|
+
const a = CV.asking;
|
|
1907
|
+
if (!a || m.canvas !== a.id) return;
|
|
1908
|
+
clearTimeout(a.timer); CV.asking = null;
|
|
1909
|
+
if (m.said && CV.chat && S.stage && S.stage.id === a.id) CV.chat = canvasChatSaid(CV.chat, a.at, a.due, a.strokes);
|
|
1910
|
+
};
|
|
1911
|
+
/** 画板铺满时嘴说的话:画布顶上浮一条,说完 4 秒淡掉 */
|
|
1912
|
+
const cvCap = () => {
|
|
1913
|
+
const el = $('#cv-cap');
|
|
1914
|
+
if (!$('#stage').classList.contains('cv')) { el.classList.remove('on'); return; }
|
|
1915
|
+
if (LV.model.status === 'mouth' && LV.cap.cur) { if (el.textContent !== LV.cap.cur) el.textContent = LV.cap.cur; el.classList.add('on'); clearTimeout(CV.capT); CV.capT = 0; return; }
|
|
1916
|
+
if (el.classList.contains('on') && !CV.capT) CV.capT = setTimeout(() => { CV.capT = 0; el.classList.remove('on'); }, 4000);
|
|
1917
|
+
};
|
|
1918
|
+
/** 「给老师看」:让舞台导出 png;画板卡存状态(连图)、纸把图传进 captures/,回这一条要带的(交卡的 action + focus / 纸的 drawing);舞台关掉。8 秒没导出来算了 */
|
|
1919
|
+
const canvasGive = () => new Promise((resolve) => {
|
|
1920
|
+
if (CV.give) { const g = CV.give; CV.give = null; g(null); }
|
|
1921
|
+
CV.give = resolve;
|
|
1922
|
+
postStage({ type: 'control', action: 'submit' });
|
|
1923
|
+
setTimeout(() => { if (CV.give === resolve) { CV.give = null; resolve(null); } }, 8000);
|
|
1924
|
+
});
|
|
1925
|
+
/** 纸上的画交出去:先存纸(服务端按它算摘要),再传图;纸不锁,接着能画 */
|
|
1926
|
+
const paperSubmit = (m) => {
|
|
1927
|
+
const P = S.paper;
|
|
1928
|
+
if (!P || !m.image) return Promise.resolve(null);
|
|
1929
|
+
P.card.state = withCleared(P.card.state, m.state);
|
|
1930
|
+
closeStage();
|
|
1931
|
+
return savePaper().then(() => api('POST', CONV + S.tutor.name + '/photos', { image: m.image })).then((r) => (r && r.path ? { drawing: { key: P.key, image: r.path } } : null), () => null);
|
|
1932
|
+
};
|
|
1933
|
+
/** 清空了(舞台先导出了那一版):图传进 captures/,记进这张的 cleared;边画边聊重新算 */
|
|
1934
|
+
const canvasCleared = (card, m) => {
|
|
1935
|
+
if (CV.chat) CV.chat = canvasChatCleared(CV.chat, m.at || Date.now());
|
|
1936
|
+
const entry = { at: m.at || Date.now(), strokes: m.strokes || 0, ...(m.from ? { from: m.from } : {}) };
|
|
1937
|
+
// 先记下是哪一张:图传上去要一会儿,这时舞台可能关了、换了一张;存回清空的那张(画板卡按 job 与第几张,纸按那个对象)
|
|
1938
|
+
const tutor = S.tutor ? S.tutor.name : null;
|
|
1939
|
+
const P = S.stage && S.stage.paper ? S.paper : null;
|
|
1940
|
+
const where = P ? null : { job: S.sections[S.stage.section].job, idx: S.stage.card };
|
|
1941
|
+
const put = (image) => {
|
|
1942
|
+
if (!S.tutor || S.tutor.name !== tutor) return;
|
|
1943
|
+
const add = (st) => ({ ...(st || { ink: [] }), cleared: [...(st && Array.isArray(st.cleared) ? st.cleared : []), { ...entry, ...(image ? { image } : {}) }].slice(-50) });
|
|
1944
|
+
if (P) { if (S.paper !== P) return; P.card.state = add(P.card.state); savePaper(); return; }
|
|
1945
|
+
const sec = S.sections.find((x) => x.job === where.job);
|
|
1946
|
+
const c = sec ? sec.cards[where.idx] : card;
|
|
1947
|
+
if (!c || c.submitted) return;
|
|
1948
|
+
c.state = add(c.state);
|
|
1949
|
+
if (c !== card) card.state = c.state;
|
|
1950
|
+
saveState(where.job, where.idx, c.state);
|
|
1951
|
+
};
|
|
1952
|
+
if (!m.image || !S.tutor) { put(null); return; }
|
|
1953
|
+
api('POST', CONV + S.tutor.name + '/photos', { image: m.image }).then((r) => put(r && r.path), () => put(null));
|
|
1954
|
+
};
|
|
1955
|
+
const openStage = (secIdx, idx, opts = {}) => {
|
|
1956
|
+
const card = S.sections[secIdx] && S.sections[secIdx].cards[idx];
|
|
1957
|
+
if (!card) return;
|
|
1958
|
+
if (card.kind === 'lecture' && !lectureReady(card)) return; // 课包读不出来:紧凑态写着「课还没放进来」,不开
|
|
1959
|
+
if (S.readonly && hasState(card) && !opts.delegate) return; // 以前的只能看:选择 / 填空 / 画板不开,免得改了当时的答案
|
|
1960
|
+
S.stage = { section: secIdx, card: idx, id: S.sections[secIdx].job + '/' + idx, playing: null, delegate: Boolean(opts.delegate), autoplay: Boolean(opts.autoplay) };
|
|
1961
|
+
playRec({ k: 'stage', job: S.sections[secIdx].job, card: idx, open: true });
|
|
1962
|
+
// 画板铺满:题目一行写在顶栏,点开看全
|
|
1963
|
+
$('#st-ttl').textContent = card.kind === 'canvas' ? (card.props.prompt || '画一画') : cardTitle(card);
|
|
1964
|
+
$('#st-ttl').classList.remove('open');
|
|
1965
|
+
$('#st-kd').textContent = KIND_NAME[card.kind] || card.kind;
|
|
1966
|
+
$('#st-kd').hidden = card.kind === 'canvas' || !(KIND_NAME[card.kind] || card.kind);
|
|
1967
|
+
$('#stage').classList.toggle('cv', card.kind === 'canvas');
|
|
1968
|
+
renderStage();
|
|
1969
|
+
$('#stage').classList.add('on');
|
|
1970
|
+
setNow(secIdx, idx);
|
|
1971
|
+
if (card.kind === 'canvas') canvasOpened();
|
|
1972
|
+
};
|
|
1973
|
+
const STAGE_SOURCE = 'cotutor-stage';
|
|
1974
|
+
const frame = $('#st-frame');
|
|
1975
|
+
const postStage = (m) => { try { frame.contentWindow.postMessage({ source: STAGE_SOURCE, ...m }, '*'); } catch {} };
|
|
1976
|
+
const renderStage = () => {
|
|
1977
|
+
if (!S.stage) return;
|
|
1978
|
+
const card = stageCardNow();
|
|
1979
|
+
if (!card) return;
|
|
1980
|
+
const heavy = isHeavy(card);
|
|
1981
|
+
$('#st-body').hidden = heavy;
|
|
1982
|
+
frame.hidden = !heavy;
|
|
1983
|
+
if (heavy) { $('#st-body').replaceChildren(); frame.src = stageUrl(S.stage.id); }
|
|
1984
|
+
else { frame.src = 'about:blank'; $('#st-body').replaceChildren(renderCard(card, S.stage.card, S.stage.section, true)); }
|
|
1985
|
+
const act = $('#st-act'); act.hidden = !hasState(card);
|
|
1986
|
+
$('#st-go').textContent = GO_LABEL[card.kind] || '交给老师';
|
|
1987
|
+
$('#st-go').disabled = !stateSummary(card).length;
|
|
1988
|
+
$('#st-go').hidden = false;
|
|
1989
|
+
$('#st-note').textContent = card.kind === 'canvas' ? stateSummary(card).join('、') : '';
|
|
1990
|
+
$('#st-go').classList.remove('holding');
|
|
1991
|
+
// 交过的卡锁住:只看,没有「交给老师」
|
|
1992
|
+
if (card.submitted) { act.hidden = S.readonly; $('#st-go').hidden = true; $('#st-note').innerHTML = ICON.lock + ' 交给老师了'; }
|
|
1993
|
+
};
|
|
1994
|
+
/** 发给舞台包的这张卡(ready 时发;看录像时孩子改了卡再发一次,包按新状态重画) */
|
|
1995
|
+
const stageCard = (card) => {
|
|
1996
|
+
const b = card.kind === 'lecture' ? card.props.bundle : card.kind === 'canvas' && card.props.base && card.props.base.bundle ? card.props.base.bundle : null;
|
|
1997
|
+
const im = card.kind === 'canvas' && card.props.base && typeof card.props.base.image === 'string' ? card.props.base.image : null;
|
|
1998
|
+
return { type: 'card', id: S.stage.id, kind: card.kind, props: card.props, state: card.state === undefined ? null : card.state, readonly: Boolean(card.submitted), ...(card.kind === 'canvas' ? { full: true, hand: document.body.classList.contains('hand-left') ? 'left' : 'right' } : {}), bundleUrl: b ? lcBundleUrl(b, card.kind === 'lecture' && card.props.video === true) : undefined, imageUrl: im ? '/api/kid/image?p=' + encodeURIComponent(im) : undefined, autoplay: S.stage.autoplay };
|
|
1999
|
+
};
|
|
2000
|
+
/** 舞台包说话:ready → 把卡发过去;phase → 记下在放还是放完;state → 存;done 且是讲稿委托的 → 关舞台接着念 */
|
|
2001
|
+
window.addEventListener('message', (e) => {
|
|
2002
|
+
const m = e.data;
|
|
2003
|
+
if (!m || m.source !== STAGE_SOURCE || !S.stage) return;
|
|
2004
|
+
const card = stageCardNow();
|
|
2005
|
+
if (!card) return;
|
|
2006
|
+
if (m.type === 'ready') postStage(stageCard(card));
|
|
2007
|
+
else if (m.type === 'snap') { const w = CV.snapWait; CV.snapWait = null; if (w) w(m.image); }
|
|
2008
|
+
else if (m.type === 'cleared') { if (!card.submitted) canvasCleared(card, m); }
|
|
2009
|
+
else if (m.type === 'phase') {
|
|
2010
|
+
S.stage.playing = { phase: m.phase, line: m.line };
|
|
2011
|
+
// 小课堂卡(仲裁表「交给小课堂」):那一段放完,舞台不关、停在末帧,嘴接着念
|
|
2012
|
+
if (m.phase === 'done' && card.kind === 'lecture') { S.stage.playing = null; if (S.stage.delegate) { S.stage.delegate = false; resumeAfter(); } }
|
|
2013
|
+
renderSubtitle();
|
|
2014
|
+
}
|
|
2015
|
+
else if (m.type === 'state') {
|
|
2016
|
+
if (card.submitted) return;
|
|
2017
|
+
card.state = withCleared(card.state, m.state);
|
|
2018
|
+
if (card.kind === 'canvas') CV.strokes = canvasStrokes(Array.isArray(m.state && m.state.ink) ? m.state.ink : []);
|
|
2019
|
+
$('#st-go').disabled = !stateSummary(card).length; $('#st-note').textContent = stateSummary(card).join('、');
|
|
2020
|
+
if (S.stage.paper) { savePaper(); return; }
|
|
2021
|
+
repaintCard(S.stage.section, S.stage.card); saveState(S.sections[S.stage.section].job, S.stage.card, card.state);
|
|
2022
|
+
}
|
|
2023
|
+
else if (m.type === 'submit') {
|
|
2024
|
+
// 「给老师看」:轻点的直接发这一条;按住说的(CV.give)把要带的交回去,等松手那一下发
|
|
2025
|
+
const done = CV.give; CV.give = null;
|
|
2026
|
+
const finish = (patch) => { if (done) done(patch); else if (patch) send('', patch); };
|
|
2027
|
+
if (S.stage.paper) { paperSubmit(m).then(finish, () => finish(null)); return; }
|
|
2028
|
+
if (card.submitted) { finish(null); return; }
|
|
2029
|
+
card.state = withCleared(card.state, m.state); card.submitted = true;
|
|
2030
|
+
const id = S.stage.id; const secI = S.stage.section, job = S.sections[secI].job; const idx = S.stage.card;
|
|
2031
|
+
closeStage(); repaintCard(secI, idx);
|
|
2032
|
+
api('PUT', CONV + S.tutor.name + '/cards/' + job + '/' + idx, m.image ? { ...card.state, image: m.image } : card.state).catch(() => {}).then(() => finish({ action: 'submit', focus: { card: id } }));
|
|
2033
|
+
}
|
|
2034
|
+
else if (m.type === 'close' || m.type === 'error') { const d = S.stage; closeStage(); if (d.delegate) resumeAfter(d); }
|
|
2035
|
+
});
|
|
2036
|
+
const resumeAfter = () => { if (LV.on) liveDispatch({ type: 'lectureDone' }); };
|
|
2037
|
+
const closeStage = () => { if (S.stage && S.sections[S.stage.section]) playRec({ k: 'stage', job: S.sections[S.stage.section].job, card: S.stage.card, open: false });
|
|
2038
|
+
// 单词卡舞台:自动那一遍停下,慢念也停
|
|
2039
|
+
for (const v of document.querySelectorAll('#st-body video')) { try { v.pause(); } catch {} }
|
|
2040
|
+
wordRun++; if (S.stage && !S.reel && S.sections[S.stage.section] && (S.sections[S.stage.section].cards[S.stage.card] || {}).kind === 'word') silence(); S.stage = null; frame.src = 'about:blank'; canvasClosed(); $('#stage').classList.remove('on'); renderSubtitle(); if (LV.on) setNow(null, null); else showNow(); };
|
|
2041
|
+
$('#st-x').innerHTML = ICON.close;
|
|
2042
|
+
// 孩子关:讲稿交给小课堂卡、还没放完的,关了接着念
|
|
2043
|
+
const closeByKid = () => { const d = S.stage; const card = d && S.sections[d.section] ? S.sections[d.section].cards[d.card] : null; closeStage(); if (d && d.delegate && card && (!isHeavy(card) || card.kind === 'lecture')) resumeAfter(); };
|
|
2044
|
+
$('#st-x').addEventListener('click', closeByKid);
|
|
2045
|
+
$('#st-dim').addEventListener('click', closeByKid);
|
|
2046
|
+
/** 选择题:点一项 → 本地改状态、重画、PUT 到服务端(失败不响,下次再点再存) */
|
|
2047
|
+
const pick = (secIdx, idx, i) => {
|
|
2048
|
+
const card = S.sections[secIdx].cards[idx];
|
|
2049
|
+
if (card.submitted) return;
|
|
2050
|
+
const cur = (card.state && Array.isArray(card.state.picked)) ? card.state.picked : [];
|
|
2051
|
+
card.state = { picked: togglePick(cur, i, Boolean(card.props.multi)) };
|
|
2052
|
+
renderStage(); repaintCard(secIdx, idx);
|
|
2053
|
+
saveState(S.sections[secIdx].job, idx, card.state);
|
|
2054
|
+
};
|
|
2055
|
+
const saveState = (job, idx, state) => { if (!S.tutor || S.readonly) return; api('PUT', CONV + S.tutor.name + '/cards/' + job + '/' + idx, state).catch(() => {}); };
|
|
2056
|
+
/** 「交给老师」/「给老师看」点一下;画板的按住说在 bindGive(按住说话那几颗旁边) */
|
|
2057
|
+
const giveTap = () => {
|
|
2058
|
+
if (!S.stage) return;
|
|
2059
|
+
const card = stageCardNow();
|
|
2060
|
+
if (!card || card.submitted) return;
|
|
2061
|
+
if (isHeavy(card)) { if (card.kind === 'canvas' && !stateSummary(card).length) return; postStage({ type: 'control', action: 'submit' }); return; } // 画板:让舞台包导出 png 再交
|
|
2062
|
+
if (!stateSummary(card).length) return;
|
|
2063
|
+
if (card.submitted) return;
|
|
2064
|
+
const id = S.stage.id, secI = S.stage.section, idx = S.stage.card;
|
|
2065
|
+
card.submitted = true;
|
|
2066
|
+
closeStage();
|
|
2067
|
+
repaintCard(secI, idx);
|
|
2068
|
+
send('', { action: 'submit', focus: { card: id } });
|
|
2069
|
+
};
|
|
2070
|
+
$('#st-ttl').addEventListener('click', () => { if ($('#stage').classList.contains('cv')) $('#st-ttl').classList.toggle('open'); });
|
|
2071
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
2072
|
+
/** 线条类的笔:被标注的字每一行一段 SVG 路径,贴在卡上(卡是 position:relative);animate = 描出来(dashoffset 从全长到 0) */
|
|
2073
|
+
const drawPen = (span, card, pen, animate) => {
|
|
2074
|
+
for (const old of span._pens || []) old.remove();
|
|
2075
|
+
span._pens = [];
|
|
2076
|
+
const cr = card.getBoundingClientRect();
|
|
2077
|
+
const rects = [...span.getClientRects()].filter((r) => r.width > 0);
|
|
2078
|
+
rects.forEach((r, k) => {
|
|
2079
|
+
const b = penBox(pen, r.width, r.height);
|
|
2080
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
2081
|
+
svg.setAttribute('class', 'pen pen-' + pen);
|
|
2082
|
+
svg.setAttribute('viewBox', '0 0 ' + b.w + ' ' + b.h);
|
|
2083
|
+
svg.style.left = (r.left - cr.left - card.clientLeft + b.x) + 'px';
|
|
2084
|
+
svg.style.top = (r.top - cr.top - card.clientTop + b.y) + 'px';
|
|
2085
|
+
svg.style.width = b.w + 'px'; svg.style.height = b.h + 'px';
|
|
2086
|
+
const path = document.createElementNS(NS, 'path');
|
|
2087
|
+
path.setAttribute('d', penPath(pen, b.w, b.h, span.dataset.phrase + ':' + k));
|
|
2088
|
+
svg.append(path); card.append(svg); span._pens.push(svg);
|
|
2089
|
+
if (animate) { const L = path.getTotalLength(); path.style.strokeDasharray = L; path.style.strokeDashoffset = L; if (typeof animate === 'number') path.style.transitionDuration = animate + 'ms'; requestAnimationFrame(() => { svg.classList.add('draw'); path.style.strokeDashoffset = 0; }); }
|
|
2090
|
+
});
|
|
2091
|
+
};
|
|
2092
|
+
/** 一处标注落到卡上:找到词、包一个 span、按笔画;animate 缺省 true(播到这句时;给数字 = 描线用这么多毫秒),画齐 / 重画时 false */
|
|
2093
|
+
const applyMark = (secIdx, mark, animate) => {
|
|
2094
|
+
const sec = $('#board').querySelector('[data-sec="' + secIdx + '"]');
|
|
2095
|
+
const card = sec && sec.querySelector('[data-card="' + mark.card + '"]');
|
|
2096
|
+
if (!card || !card.classList.contains('c')) return null;
|
|
2097
|
+
// 单词卡格里是笔顺不是文字、卡上只有这一个词:标注只管讲到哪张亮哪张,不画(涂荧光盖住字形,2026-10-01 真机看着难看)
|
|
2098
|
+
if (card.classList.contains('c-word')) return card;
|
|
2099
|
+
const cardData = S.sections[secIdx].cards[mark.card];
|
|
2100
|
+
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
|
|
2101
|
+
let n;
|
|
2102
|
+
while ((n = walker.nextNode())) {
|
|
2103
|
+
const i = findPhrase(n.nodeValue, mark.phrase);
|
|
2104
|
+
if (i < 0 || n.parentElement.closest('.mk')) continue;
|
|
2105
|
+
const pen = mark.pen || penFor(cardData, mark.phrase);
|
|
2106
|
+
const range = document.createRange(); range.setStart(n, i); range.setEnd(n, i + mark.phrase.length);
|
|
2107
|
+
const span = document.createElement('span'); span.className = 'mk mk-' + pen; span.dataset.pen = pen; span.dataset.phrase = mark.phrase;
|
|
2108
|
+
range.surroundContents(span);
|
|
2109
|
+
if (LINE_PENS.includes(pen)) drawPen(span, card, pen, animate !== false);
|
|
2110
|
+
return card;
|
|
2111
|
+
}
|
|
2112
|
+
return card;
|
|
2113
|
+
};
|
|
2114
|
+
/** 状态照的播放位置:平时是播放器的 S.state;录像里孩子的屏幕是录像这一刻的(reelKidSub 算的),不写进 S.state */
|
|
2115
|
+
const playView = () => (REEL && S.reel && S.reel.view) || S.state;
|
|
2116
|
+
/** 页面跟着播放与嘴走:body 上的类(pending 在想 · waiting 等孩子答 · speaking 在说)、头像点头、左栏字幕、按钮的样子。
|
|
2117
|
+
* 录像里孩子的屏幕:状态是录像这一刻的(playView),字幕由 reelLiveCap 画 */
|
|
2118
|
+
const renderSubtitle = () => {
|
|
2119
|
+
if (!S.pending) S.waitSince = null;
|
|
2120
|
+
const ps = playView();
|
|
2121
|
+
// 实时:脑还在写时嘴在说,头像是「说」不是「想」
|
|
2122
|
+
document.body.classList.toggle('pending', S.pending && !(LV.on && LV.model.status === 'mouth'));
|
|
2123
|
+
// 老师等着孩子答:最后那张问题卡的答案栏露「等你说…」
|
|
2124
|
+
document.body.classList.toggle('waiting', ps.status === 'waiting' && !S.pending);
|
|
2125
|
+
// 头像跟着嘴(实时是 liveStep 的状态,录像是这一刻在不在念);嘴停了、脑也写完了点一下头
|
|
2126
|
+
const status = LV.on ? LV.model.status : ps.status === 'playing' && !S.pending ? 'mouth' : ps.status;
|
|
2127
|
+
document.body.classList.toggle('speaking', status === 'mouth');
|
|
2128
|
+
if (LV.was === 'mouth' && (LV.on ? status !== 'mouth' : status === 'waiting' || status === 'done') && !S.pending) liveNod();
|
|
2129
|
+
LV.was = status;
|
|
2130
|
+
if (!REEL) liveCaption();
|
|
2131
|
+
voiceSync();
|
|
2132
|
+
};
|
|
2133
|
+
|
|
2134
|
+
/** 把一节的标注一次画齐:不描,直接在 */
|
|
2135
|
+
const paintAll = (secIdx) => { const s = S.sections[secIdx]; if (!s) return; for (const l of s.lines) for (const m of l.marks) applyMark(secIdx, m, false); };
|
|
2136
|
+
|
|
2137
|
+
// ---- 板上的节:改位置的事都走 dispatch → step(board/presentation.ts,纯函数,见 tests/player.test.ts),这里只照单执行它回的事。
|
|
2138
|
+
// 页面里不许再直接改 S.state(tests/player.test.ts 数着);讲稿不念了,嘴念的走 liveDispatch ----
|
|
2139
|
+
const runEffect = (f) => {
|
|
2140
|
+
switch (f.kind) {
|
|
2141
|
+
case 'stop': silence(); break;
|
|
2142
|
+
case 'render': renderSubtitle(); break;
|
|
2143
|
+
case 'showNow': if (!LV.on) showNow(); break;
|
|
2144
|
+
case 'paint': paintAll(f.section); break;
|
|
2145
|
+
case 'scrollLast': { pin.on = false; const last = $('#board').querySelector('[data-sec="' + (S.sections.length - 1) + '"] .c'); if (last) last.scrollIntoView({ block: 'start', behavior: 'instant' }); break; }
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
// ---- 录像的实录(只在孩子端):孩子看到、听到、做了什么,攒着每 10 秒一批发给服务端(页面退到后台时 sendBeacon)。
|
|
2149
|
+
// 只记今天、能发消息的话题(以前的只读回放不记);卡的每次改动服务端在存卡时自己记。发不出去就丢,不重试 ----
|
|
2150
|
+
const PLAY = { buf: [], last: '' };
|
|
2151
|
+
const playRec = (r) => {
|
|
2152
|
+
if (!S.tutor || S.readonly || S.hist) return;
|
|
2153
|
+
PLAY.buf.push({ tutor: S.tutor.name, thread: S.newThread ? null : S.thread, rec: { at: Date.now(), ...r } });
|
|
2154
|
+
if (PLAY.buf.length > 600) PLAY.buf.splice(0, PLAY.buf.length - 600);
|
|
2155
|
+
};
|
|
2156
|
+
// ---- 实录补记:孩子看到什么记什么——屏幕多大(键盘弹起来多高)、按住说话的每一步、输入框里的字、发照片屏、自己翻到哪 ----
|
|
2157
|
+
/** 屏幕尺寸、键盘高(iOS 键盘弹起来只缩 visualViewport)、页面版本;变了才记,转屏、键盘起落停手 300 毫秒记一次 */
|
|
2158
|
+
let viewKey = '', viewTimer = 0;
|
|
2159
|
+
const viewNote = () => {
|
|
2160
|
+
const vv = window.visualViewport;
|
|
2161
|
+
const kb0 = vv ? Math.round(innerHeight - vv.height - (vv.offsetTop || 0)) : 0;
|
|
2162
|
+
const kb = kb0 > 80 ? Math.min(2000, kb0) : 0;
|
|
2163
|
+
const key = innerWidth + 'x' + innerHeight + '/' + kb;
|
|
2164
|
+
if (key === viewKey) return;
|
|
2165
|
+
viewKey = key;
|
|
2166
|
+
playRec({ k: 'view', w: Math.max(100, Math.min(4000, innerWidth)), h: Math.max(100, Math.min(4000, innerHeight)), kb, v: PAGE_V });
|
|
2167
|
+
};
|
|
2168
|
+
const viewSoon = () => { clearTimeout(viewTimer); viewTimer = setTimeout(viewNote, 300); };
|
|
2169
|
+
window.addEventListener('resize', viewSoon);
|
|
2170
|
+
if (window.visualViewport) window.visualViewport.addEventListener('resize', viewSoon);
|
|
2171
|
+
/** 两次之间不到 gap 毫秒的值先攒着,到点再记最新的那个(按住时认出的字、输入框里的字一秒能变好几次) */
|
|
2172
|
+
const throttled = (gap, rec) => {
|
|
2173
|
+
const T0 = { last: '', at: 0, timer: 0, pend: null };
|
|
2174
|
+
const flush = () => { clearTimeout(T0.timer); if (T0.pend !== null && T0.pend !== T0.last) { T0.last = T0.pend; T0.at = Date.now(); rec(T0.pend); } T0.pend = null; };
|
|
2175
|
+
return { put: (v) => { if (v === T0.last && T0.pend === null) return; T0.pend = v; const wait = gap - (Date.now() - T0.at); if (wait <= 0) flush(); else { clearTimeout(T0.timer); T0.timer = setTimeout(flush, wait); } }, flush, reset: () => { clearTimeout(T0.timer); T0.last = ''; T0.at = 0; T0.pend = null; } };
|
|
2176
|
+
};
|
|
2177
|
+
/** 按住说话:按下 / 话筒开了 / 上滑进出取消区 / 松手 / 收尾(结果 + 每 100 毫秒一位的音量) */
|
|
2178
|
+
const HR = { on: false, t0: 0, lv: [] };
|
|
2179
|
+
const holdRec = (e, extra) => { if (e === 'down') { HR.on = true; HR.t0 = Date.now(); HR.lv = []; } else if (!HR.on) return; playRec({ k: 'hold', e, ...(extra || {}) }); };
|
|
2180
|
+
const holdLvRec = (v) => { if (!HR.on) return; const i = Math.floor((Date.now() - HR.t0) / 100); if (i >= 0 && i < 600) HR.lv[i] = Math.max(HR.lv[i] || 0, v); };
|
|
2181
|
+
const holdEnd = (r) => {
|
|
2182
|
+
if (!HR.on) return;
|
|
2183
|
+
const lv = Array.from({ length: HR.lv.length }, (_, i) => String(Math.min(9, Math.round((HR.lv[i] || 0) * 9)))).join('');
|
|
2184
|
+
holdRec('end', { r, ...(lv ? { lv } : {}) });
|
|
2185
|
+
HR.on = false;
|
|
2186
|
+
};
|
|
2187
|
+
/** 打字:点进输入框 / 值变了(300 毫秒一次)/ 发了 / 收起 */
|
|
2188
|
+
const typeHeard = throttled(300, (v) => playRec({ k: 'type', e: 'v', v: v.slice(0, 400) }));
|
|
2189
|
+
const typeRec = (e) => { if (e === 'focus') typeHeard.reset(); else typeHeard.flush(); playRec({ k: 'type', e }); };
|
|
2190
|
+
/** 孩子自己翻板书(拖、滚轮;程序滚的不算):停手 400 毫秒记一次,锚在最上面露出来的那张卡上 */
|
|
2191
|
+
const scrollNote = () => {
|
|
2192
|
+
const b = $('#board'), top = b.getBoundingClientRect().top;
|
|
2193
|
+
const c = [...b.querySelectorAll(':scope > .sec[data-job] .c[data-card]')].find((el) => el.getBoundingClientRect().bottom > top + 8);
|
|
2194
|
+
if (!c) return;
|
|
2195
|
+
playRec({ k: 'scroll', job: c.closest('.sec').dataset.job, card: Number(c.dataset.card), dy: Math.max(-20000, Math.min(20000, Math.round(c.getBoundingClientRect().top - top))) });
|
|
2196
|
+
};
|
|
2197
|
+
let scrollTimer = 0;
|
|
2198
|
+
$('#board').addEventListener('scroll', () => { if (Date.now() - pin.touched > 1500) return; clearTimeout(scrollTimer); scrollTimer = setTimeout(scrollNote, 400); }, { passive: true });
|
|
2199
|
+
/** 播放器的位置变了才记(哪一节 · 第几句 · 在念 / 暂停 / 等答 …) */
|
|
2200
|
+
const playFlush = (beacon) => {
|
|
2201
|
+
if (!PLAY.buf.length) return;
|
|
2202
|
+
const groups = new Map(), keep = [];
|
|
2203
|
+
for (const x of PLAY.buf) {
|
|
2204
|
+
// 新话题发出第一句之前还没有话题 id:等有了再发
|
|
2205
|
+
const th = x.thread || (S.tutor && S.tutor.name === x.tutor && !S.newThread ? S.thread : null);
|
|
2206
|
+
if (!th) { keep.push(x); continue; }
|
|
2207
|
+
const k = x.tutor + ' ' + th;
|
|
2208
|
+
if (!groups.has(k)) groups.set(k, { tutor: x.tutor, thread: th, records: [] });
|
|
2209
|
+
groups.get(k).records.push(x.rec);
|
|
2210
|
+
}
|
|
2211
|
+
PLAY.buf = keep.slice(-200);
|
|
2212
|
+
for (const g of groups.values()) {
|
|
2213
|
+
const url = '/api/kid/conversations/' + g.tutor + '/play';
|
|
2214
|
+
for (let i = 0; i < g.records.length; i += 400) {
|
|
2215
|
+
const body = JSON.stringify({ thread: g.thread, sentAt: Date.now(), records: g.records.slice(i, i + 400) });
|
|
2216
|
+
if (beacon && navigator.sendBeacon) { try { if (navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))) continue; } catch {} }
|
|
2217
|
+
try { fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: true }).catch(() => {}); } catch {}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
};
|
|
2221
|
+
setInterval(() => playFlush(false), 10000);
|
|
2222
|
+
window.addEventListener('pagehide', () => playFlush(true));
|
|
2223
|
+
document.addEventListener('visibilitychange', () => { playRec({ k: 'visible', on: !document.hidden }); if (document.hidden) playFlush(true); });
|
|
2224
|
+
const dispatch = (ev) => {
|
|
2225
|
+
const r = step({ state: S.state }, ev, { sections: S.sections });
|
|
2226
|
+
S.state = r.model.state;
|
|
2227
|
+
for (const f of r.effects) runEffect(f);
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
const loadDay = async (silent) => {
|
|
2231
|
+
if (!S.tutor || S.reel) return;
|
|
2232
|
+
try {
|
|
2233
|
+
// 今天、选了话题:带上它,服务端把 resume 那个预热进程换成这个话题的
|
|
2234
|
+
const warmHint = !S.hist && S.thread ? '?thread=' + encodeURIComponent(S.thread) : '';
|
|
2235
|
+
const d = await api('GET', CONV + S.tutor.name + '/' + (S.hist || 'today') + warmHint);
|
|
2236
|
+
setOffline(false);
|
|
2237
|
+
S.day = d;
|
|
2238
|
+
// 话题:没选过就是当前(末条所在)的;板书只铺这个话题的节。新话题空白态什么都不铺,发出第一句才有话题
|
|
2239
|
+
if (!S.thread && !S.newThread) S.thread = d.thread;
|
|
2240
|
+
const mine = S.newThread ? [] : d.messages.filter((m) => m.thread === S.thread);
|
|
2241
|
+
const first = mine.find((m) => m.question !== null) || mine[0];
|
|
2242
|
+
S.threadAt = first ? first.at : null;
|
|
2243
|
+
const entries = sectionsFromMessages(mine);
|
|
2244
|
+
// 卡上的配音(点读段、单词、示范音)这一轮跑完才在后台配,比节晚一两秒:已经铺上的节把 card.assets 补上,不重画,点的时候才看(cardClip)。原来不补,不重进老师页就一直没有
|
|
2245
|
+
for (const e of entries) { const s = S.sections.find((x) => x.job === e.job); if (s && s !== e) e.cards.forEach((c, i) => { const t = s.cards[i]; if (t && Array.isArray(c.assets) && c.assets.length > (t.assets || []).length) t.assets = c.assets; }); }
|
|
2246
|
+
const fresh = [];
|
|
2247
|
+
for (const e of entries) {
|
|
2248
|
+
if (S.partial && S.partial.job === e.job && S.partial.live) { if (e.partial) renderLive(e); else finalizeLive(e); continue; }
|
|
2249
|
+
if (S.played.has(e.job)) continue;
|
|
2250
|
+
if (e.partial) { renderLive(e); continue; }
|
|
2251
|
+
S.played.add(e.job); S.sections.push(e); fresh.push(S.sections.length - 1);
|
|
2252
|
+
const idx = S.sections.length - 1;
|
|
2253
|
+
if (S.partial && S.partial.job === e.job) {
|
|
2254
|
+
// 流式时一行一张先铺着;跑完了按定稿的行整节重画,节的编号就是它现在的位置
|
|
2255
|
+
const el = S.partial.el; S.partial = null;
|
|
2256
|
+
el.replaceWith(renderSection(e, idx));
|
|
2257
|
+
} else $('#board').append(renderSection(e, idx));
|
|
2258
|
+
// 实时的一节整节到了(没流过):标注都画上;不是回来看(silent)就当刚落下,田字格写一遍
|
|
2259
|
+
if (e.live) e.cards.forEach((_, k) => liveLand(idx, k, !silent));
|
|
2260
|
+
}
|
|
2261
|
+
// 这条既不 pending 也没定稿(运行出错了):撤掉;live 的那节也撤(孩子端出错的运行不出现)
|
|
2262
|
+
if (S.partial && !entries.some((e) => e.job === S.partial.job)) { const P = S.partial; S.partial = null; P.el.remove(); if (P.live) { S.sections.splice(P.idx, 1); dispatch({ type: 'liveDropped' }); } }
|
|
2263
|
+
// 服务端的状态是真相(别的设备上选的、重开页面):没在舞台里改着的卡照它画;props 也跟(小课堂卡的 ready、起止是服务端下发时现读的)
|
|
2264
|
+
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 (!mine || (S.stage && S.stage.section === i && S.stage.card === idx)) return; const ds = JSON.stringify(mine.state) !== JSON.stringify(c.state), dp = JSON.stringify(mine.props) !== JSON.stringify(c.props); if (ds || dp) { mine.state = c.state; mine.props = c.props; repaintCard(i, idx); } }); });
|
|
2265
|
+
if (fresh.length && S.stage && !S.stage.paper) closeStage();
|
|
2266
|
+
// 孩子的话:刚发出去、这一节还没出来的先挂上;答案栏对一遍
|
|
2267
|
+
{
|
|
2268
|
+
const lastM = mine[mine.length - 1];
|
|
2269
|
+
const sd = lastM && lastM.pending ? kidSaid(mine).get(lastM.job) : null;
|
|
2270
|
+
const shown = lastM && (S.sections.some((s) => s.job === lastM.job) || (S.partial && S.partial.job === lastM.job));
|
|
2271
|
+
S.pendSaid = sd && !shown ? { job: lastM.job, said: sd.said, on: sd.on, live: Boolean(lastM.live) } : null;
|
|
2272
|
+
syncPendSay(); syncAnswers();
|
|
2273
|
+
}
|
|
2274
|
+
const stillPending = Boolean(d.pending) || d.messages.some((m) => m.pending);
|
|
2275
|
+
S.pending = stillPending;
|
|
2276
|
+
if (stillPending && !S.waitSince) S.waitSince = Date.now();
|
|
2277
|
+
if (fresh.length) dispatch({ type: 'fresh', sections: fresh });
|
|
2278
|
+
else renderSubtitle();
|
|
2279
|
+
if (S.lecture) renderLecturePending();
|
|
2280
|
+
if (!S.sections.length && !S.partial && !stillPending && !S.readonly && !S.lecture && !$('#board .blank')) $('#board').append(blankBoard('想问什么?'));
|
|
2281
|
+
renderHeader();
|
|
2282
|
+
clearTimeout(S.pollTimer);
|
|
2283
|
+
// 画图卡还在画(老师已经说完了):隔 3 秒拉一次,图到了 props 变了就重画那张卡
|
|
2284
|
+
const painting = S.sections.some((sec) => sec.cards.some((c) => c.kind === 'paint' && c.props && c.props.drawn === 'drawing'));
|
|
2285
|
+
if (stillPending) S.pollTimer = setTimeout(() => loadDay(false), 1000);
|
|
2286
|
+
else if (painting && !S.hist) S.pollTimer = setTimeout(() => loadDay(true), 3000);
|
|
2287
|
+
} catch (e) {
|
|
2288
|
+
if (e && e.status === 404) return closeTutor();
|
|
2289
|
+
setOffline(true);
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
// ---- 话题:右上角「更多」里的「以前的」「新话题」、空白态、只读回放;话题状态是头像旁的小标签 ----
|
|
2293
|
+
const dateLabel = (date, today) => {
|
|
2294
|
+
if (date === today) return '今天';
|
|
2295
|
+
const [y, mo, da] = date.split('-').map(Number);
|
|
2296
|
+
const t = today.split('-').map(Number);
|
|
2297
|
+
const diff = Math.round((new Date(t[0], t[1] - 1, t[2]) - new Date(y, mo - 1, da)) / 86400000);
|
|
2298
|
+
return diff === 1 ? '昨天' : mo + ' 月 ' + da + ' 日';
|
|
2299
|
+
};
|
|
2300
|
+
const clock = (at) => (at && at.length >= 16 ? at.slice(11, 16) : '');
|
|
2301
|
+
const renderHeader = () => {
|
|
2302
|
+
if (!S.tutor) return;
|
|
2303
|
+
const today = S.day ? S.day.date : null;
|
|
2304
|
+
let mo = '';
|
|
2305
|
+
if (S.hist && today) mo = '以前的 · ' + dateLabel(S.hist, S.hist === today ? '' : today) + ' ' + clock(S.threadAt);
|
|
2306
|
+
else if (S.cont && today) { const d = dateLabel(S.cont, today); mo = '接着' + (d === '昨天' ? d : ' ' + d + ' ') + '的话题'; }
|
|
2307
|
+
// 新话题不挂小标签(空板本身就说明了);小课堂照旧
|
|
2308
|
+
else if (S.newThread && S.lecture) mo = '小课堂';
|
|
2309
|
+
else if (S.thread && S.day && S.thread !== S.day.thread) mo = '今天的话题 · ' + clock(S.threadAt);
|
|
2310
|
+
$('#c-mo').textContent = mo; $('#c-mo').hidden = !mo;
|
|
2311
|
+
const nb = $('#new-btn');
|
|
2312
|
+
nb.hidden = S.newThread || S.readonly || (!S.sections.length && !S.pending && !S.partial);
|
|
2313
|
+
nb.classList.toggle('dim', S.pending);
|
|
2314
|
+
};
|
|
2315
|
+
// 家长端的 chrome:只看(S.readonly:选择 / 填空 / 画板不开)、没有输入条、没有「回到今天」(回清单走左上角)、没有「更多」。
|
|
2316
|
+
// 家长要试老师,到孩子端当一回孩子,用完回清单删那个话题(写进记忆、日记的一起撤)
|
|
2317
|
+
/** 能不能发消息:孩子端 = 不只读;家长端不发 */
|
|
2318
|
+
const canSend = () => !S.readonly && !S.ask && !S.talkWait && !(S.lecture && !S.lecture.watch);
|
|
2319
|
+
/** 空板上等孩子先点上面一个(今天点过同一个按钮、口语单拍好):点之前不发;实时左栏那颗灰着写「点上面开始」 */
|
|
2320
|
+
const choosing = () => Boolean(S.ask || S.talkWait) && !S.readonly && !REEL;
|
|
2321
|
+
const renderBar = () => { $('#pill').hidden = !canSend() && !REEL; document.body.classList.toggle('nosend', !canSend() && !REEL); document.body.classList.toggle('choose', choosing()); document.body.classList.toggle('ro', S.readonly && !REEL); $('#back-today').hidden = !S.readonly || REEL; voiceSync(); };
|
|
2322
|
+
const resetBoard = () => { dispatch({ type: 'halt' }); if (S.stage) closeStage(); clearTimeout(S.pollTimer); S.sections = []; S.played = new Set(); dispatch({ type: 'reset' }); S.partial = null; pin.on = false; $('#board').replaceChildren(); };
|
|
2323
|
+
/** 换到某天的某个话题:今天的能接着聊;以前的只读回放(从第一句播) */
|
|
2324
|
+
const switchThread = (date, thread) => {
|
|
2325
|
+
resetBoard();
|
|
2326
|
+
S.hist = date; S.readonly = Boolean(date); S.thread = thread; S.newThread = false; S.threadAt = null; S.via = null; S.cont = null; S.lecture = null; S.ask = null;
|
|
2327
|
+
if (S.readonly) liveClose(); else if (!LV.on && liveWanted(S.tutor)) liveOpen();
|
|
2328
|
+
renderBar(); renderSubtitle(); renderHeader();
|
|
2329
|
+
loadDay(!S.readonly);
|
|
2330
|
+
};
|
|
2331
|
+
$('#back-today').addEventListener('click', () => switchThread(null, null));
|
|
2332
|
+
$('#lv-today').addEventListener('click', () => switchThread(null, null));
|
|
2333
|
+
$('#more-btn').innerHTML = ICON.more;
|
|
2334
|
+
const closeMenu = () => $('#menu').classList.remove('on');
|
|
2335
|
+
$('#more-btn').addEventListener('click', () => $('#menu').classList.add('on'));
|
|
2336
|
+
$('#menu .dimmer').addEventListener('click', closeMenu);
|
|
2337
|
+
$('#new-btn .ic').innerHTML = ICON.spark;
|
|
2338
|
+
$('#new-btn').addEventListener('click', () => {
|
|
2339
|
+
closeMenu();
|
|
2340
|
+
if (S.pending || S.readonly) return;
|
|
2341
|
+
resetBoard();
|
|
2342
|
+
S.newThread = true; S.thread = null; S.threadAt = null; S.lecture = null;
|
|
2343
|
+
S.via = null; S.cont = null;
|
|
2344
|
+
$('#board').append(blankBoard('换个话题吧,想问什么?'));
|
|
2345
|
+
setBar('idle'); renderSubtitle(); renderHeader();
|
|
2346
|
+
});
|
|
2347
|
+
$('#hist-btn .ic').innerHTML = ICON.history;
|
|
2348
|
+
$('#hist-x').innerHTML = ICON.close;
|
|
2349
|
+
const closeHist = () => $('#hist').classList.remove('on');
|
|
2350
|
+
$('#hist-x').addEventListener('click', closeHist);
|
|
2351
|
+
$('#hist .dimmer').addEventListener('click', closeHist);
|
|
2352
|
+
$('#hist-btn').addEventListener('click', async () => {
|
|
2353
|
+
closeMenu();
|
|
2354
|
+
if (!S.tutor) return;
|
|
2355
|
+
const ls = $('#hist .ls'); ls.replaceChildren();
|
|
2356
|
+
$('#hist').classList.add('on');
|
|
2357
|
+
try {
|
|
2358
|
+
const hst = await api('GET', CONV + S.tutor.name + '/history?days=30');
|
|
2359
|
+
const nodes = [];
|
|
2360
|
+
for (const dd of hst.days) {
|
|
2361
|
+
nodes.push(h('div', { class: 'dt' }, dateLabel(dd.date, hst.today)));
|
|
2362
|
+
for (const t of dd.threads) {
|
|
2363
|
+
const open = t.thread === S.thread && (dd.date === hst.today ? !S.hist : S.hist === dd.date);
|
|
2364
|
+
nodes.push(h('div', { class: 'tr' + (open ? ' on' : ''), on: { click: () => { closeHist(); if (!open) switchThread(dd.date === hst.today ? null : dd.date, t.thread); } } }, h('b', {}, t.title), h('small', {}, clock(t.at) + ' · ' + t.sections + ' 节 · ' + t.cards + ' 张卡' + (open ? ' · 正在看' : ''))));
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
ls.replaceChildren(...(nodes.length ? nodes : [h('div', { class: 'empty' }, '还没有以前的')]));
|
|
2368
|
+
} catch { ls.replaceChildren(); }
|
|
2369
|
+
});
|
|
2370
|
+
/**
|
|
2371
|
+
* 小课堂:看完后的第一条带看的情况与画的;问过以后再看一遍又画了,下一条带新画的(板书、实时都走这里)。
|
|
2372
|
+
* 画的一处带截图:先传成 captures/ 里的文件(同照片),换成路径;传不上就不带这张图。时刻换成离发出去多少毫秒(孩子端自己的钟,不用对钟)
|
|
2373
|
+
*/
|
|
2374
|
+
const lectureBody = async () => {
|
|
2375
|
+
const L = S.lecture;
|
|
2376
|
+
if (!L || !L.watch) return null;
|
|
2377
|
+
if (L.marks && L.marks.some((k) => k.image && k.image.startsWith('data:'))) {
|
|
2378
|
+
for (const k of L.marks) if (k.image && k.image.startsWith('data:')) { try { const r = await api('POST', CONV + S.tutor.name + '/photos', { image: k.image }); k.image = r.path; } catch { delete k.image; } }
|
|
2379
|
+
}
|
|
2380
|
+
const sentAt = Date.now();
|
|
2381
|
+
if (L.sent && !L.marks.length && !L.log.length) return null;
|
|
2382
|
+
return { bundle: L.bundle, ...(L.sent ? (L.rewatch || { watchedMs: 0, finished: false, pauses: 0 }) : L.watch), ...(L.marks.length ? { marks: L.marks.map((k) => ({ atMs: k.atMs, strokes: k.strokes, ...(k.image ? { image: k.image } : {}), ...(k.at ? { t: Math.min(0, k.at - sentAt) } : {}) })) } : {}), ...(L.log.length ? { log: L.log.map((e) => ({ t: Math.min(0, e.at - sentAt), pos: e.pos, play: e.play })) } : {}) };
|
|
2383
|
+
};
|
|
2384
|
+
/** 带着小课堂的那条发出去了:画的挪到「飞着的」(画在回答它的那节前面),job 是那一轮(实时的到 turn 才知道,先 null) */
|
|
2385
|
+
const lectureSent = (job) => {
|
|
2386
|
+
const L = S.lecture;
|
|
2387
|
+
if (!L) return;
|
|
2388
|
+
L.flying = L.marks; L.marks = []; L.log = []; if (!L.sent) L.firstJob = job; L.sent = true; L.job = job; L.rewatch = null; renderLecturePending();
|
|
2389
|
+
};
|
|
2390
|
+
/** 孩子发一条(打字、拍照、交卡、首页按钮的开场):都走实时那条连接,嘴先接住;嘴还没连上(刚进来就发开场)先排着,连上就发。
|
|
2391
|
+
* 不走实时的页面(以前的话题、家长端、录像、预览)发不了 */
|
|
2392
|
+
const send = (text, opts = {}) => {
|
|
2393
|
+
text = (text || '').trim();
|
|
2394
|
+
if (!text && !opts.action && !(opts.photos && opts.photos.length) && !opts.drawing) return;
|
|
2395
|
+
if (!S.tutor || !canSend() || !LV.on) return;
|
|
2396
|
+
unlock();
|
|
2397
|
+
if (LV.ready) liveSendNow(text, opts); else LV.queued = [text, opts];
|
|
2398
|
+
};
|
|
2399
|
+
|
|
2400
|
+
// ---- 看录像:录像 = 孩子的屏幕 + 录像栏。
|
|
2401
|
+
// 孩子的屏幕:这个页面以 MODE.reel 开在 iframe 里(/reel?tutor=&date=&thread=),按孩子那台设备的尺寸开,家长页等比缩。
|
|
2402
|
+
// 轨道是服务端排好的(ui/kid/reel.ts,已内联),每 100 毫秒算一次 reelFrameAt:板书用 renderSection 现画(孩子端条目:孩子的话当节头、进答案栏)、
|
|
2403
|
+
// 露几张卡与卡的状态照它、卡露出来时画上它的标注、放那次通话的声音(从这句起);头像、「等你说…」照孩子端自己的逻辑(这一刻的位置放进 S.reel.view 再 renderSubtitle)。
|
|
2404
|
+
// 不经过 step、不 dispatch;轮询停着(loadDay 见 S.reel 就回)。
|
|
2405
|
+
// 录像栏(进度条、这一刻的说明、播放、跳过空白、倍速、退出)在家长页上,只经 iframe 的 window.cotutorReel 说话:同源直接调,家长点「播放」的那一下调进去;
|
|
2406
|
+
// iframe 放声音用的是家长页那个 Audio(家长点「看录像」时已解锁)。直接打开 iframe 的地址(调试)时,录像栏在它自己身上 ----
|
|
2407
|
+
const REEL_SPEEDS = [1, 1.5, 2];
|
|
2408
|
+
/** 一张卡的答案(家长接口不剥):选择题写选项,填空写各空 */
|
|
2409
|
+
const reelAnswer = (c) => {
|
|
2410
|
+
const p = c.props || {};
|
|
2411
|
+
if (c.kind === 'choice' && Array.isArray(p.answer) && p.answer.length) return p.answer.map((i) => ('ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i] || String(i + 1)) + (p.options && p.options[i] ? ' ' + p.options[i] : '')).join('、');
|
|
2412
|
+
return '';
|
|
2413
|
+
};
|
|
2414
|
+
/** 孩子那台设备的 CSS 尺寸:孩子端还没记屏幕尺寸之前,按消息里的 device 取常见的;没记 device 的按 iPad 横屏 */
|
|
2415
|
+
const REEL_SIZES = { 'tablet-landscape': [1180, 820], 'tablet-portrait': [820, 1180], phone: [390, 844] };
|
|
2416
|
+
|
|
2417
|
+
// ---- 录像的引擎:只在孩子的屏幕里(REEL)跑 ----
|
|
2418
|
+
const reelSetClock = () => {
|
|
2419
|
+
const R = S.reel;
|
|
2420
|
+
const w = R.clock ? R.clock.toWall(R.p) : R.d.reel.startAt;
|
|
2421
|
+
R.clock = reelClock(R.d.reel, R.real);
|
|
2422
|
+
R.p = R.clock.toPlay(w);
|
|
2423
|
+
};
|
|
2424
|
+
/** 弹窗(实录):孩子开着哪张就只读地开哪张,改了就重画(重卡重发一次 card);关了就关。点不动(body.rlk) */
|
|
2425
|
+
const reelStage = (f, rebuilt) => {
|
|
2426
|
+
const R = S.reel;
|
|
2427
|
+
const i = f.stage ? S.sections.findIndex((x) => x.job === f.stage.job) : -1;
|
|
2428
|
+
const want = i >= 0 && S.sections[i].cards[f.stage.card] ? f.stage.job + '/' + f.stage.card : '';
|
|
2429
|
+
if (!want) { if (S.stage) { closeStage(); R.now = ''; } R.stageKey = ''; return; }
|
|
2430
|
+
const card = S.sections[i].cards[f.stage.card];
|
|
2431
|
+
const sk = JSON.stringify(card.state === undefined ? null : card.state);
|
|
2432
|
+
if (R.stageKey !== want || !S.stage) { if (S.stage) closeStage(); openStage(i, f.stage.card, { delegate: true }); R.stageKey = want; R.stageState = sk; return; }
|
|
2433
|
+
S.stage.section = i;
|
|
2434
|
+
if (sk === R.stageState && !rebuilt) return;
|
|
2435
|
+
R.stageState = sk;
|
|
2436
|
+
if (isHeavy(card)) postStage(stageCard(card)); else renderStage();
|
|
2437
|
+
};
|
|
2438
|
+
/** 板书:哪几节、卡的状态变了就整块重画(往回拖也是);露几张卡、露出来的卡的标注、孩子刚发出去的话每次对一下 */
|
|
2439
|
+
const reelDraw = (f) => {
|
|
2440
|
+
const R = S.reel;
|
|
2441
|
+
const shown = f.sections.filter((fs) => R.entries.some((e) => e.job === fs.job));
|
|
2442
|
+
const key = JSON.stringify([shown.map((fs) => fs.job), f.cards]);
|
|
2443
|
+
if (key !== R.key) {
|
|
2444
|
+
R.key = key; R.painted = {}; R.now = '';
|
|
2445
|
+
S.sections = shown.map((fs) => { const e = R.entries.find((x) => x.job === fs.job); return { ...e, cards: e.cards.map((c, k) => { const o = { ...c }; const st = f.cards[fs.job + '/' + k]; if (st === undefined) delete o.state; else o.state = st; return o; }) }; });
|
|
2446
|
+
const board = $('#board'); const top = board.scrollTop;
|
|
2447
|
+
board.replaceChildren(...S.sections.map((sec, i) => renderSection(sec, i)));
|
|
2448
|
+
board.scrollTop = top;
|
|
2449
|
+
R.rebuilt = true;
|
|
2450
|
+
}
|
|
2451
|
+
// 孩子刚发出去、它引出的那一节还没出来:那句话先挂在板尾,或进上一张问题卡的答案栏(孩子端 loadDay 的 pendSaid)
|
|
2452
|
+
const lastN = f.notes[f.notes.length - 1];
|
|
2453
|
+
const sd = lastN && !shown.some((fs) => fs.job === lastN.job) ? R.said.get(lastN.job) : null;
|
|
2454
|
+
const pk = sd ? lastN.job : '';
|
|
2455
|
+
if (pk !== R.pend || R.rebuilt) { R.pend = pk; S.pendSaid = sd ? { job: lastN.job, said: sd.said, on: sd.on } : null; syncPendSay(); syncAnswers(); }
|
|
2456
|
+
let fresh = null;
|
|
2457
|
+
shown.forEach((fs, i) => {
|
|
2458
|
+
const el = $('#board').querySelector(':scope > .sec[data-sec="' + i + '"]'); if (!el) return;
|
|
2459
|
+
for (const row of el.querySelectorAll(':scope > .row')) {
|
|
2460
|
+
let any = false;
|
|
2461
|
+
for (const c of row.querySelectorAll(':scope > [data-card]')) { const hide = Number(c.dataset.card) >= fs.cards; if (!hide && c.classList.contains('pend')) fresh = c; c.classList.toggle('pend', hide); any = any || !hide; }
|
|
2462
|
+
row.classList.toggle('pend', !any);
|
|
2463
|
+
}
|
|
2464
|
+
// 标注不跟句子:卡露出来就画上这张卡的(同孩子端 liveLand;藏着的卡量不出位置,画线的笔要等它露出来),实时与不是实时的轮一样
|
|
2465
|
+
const sec = S.sections[i];
|
|
2466
|
+
for (let k = 0; k < Math.min(fs.cards, sec.cards.length); k++) {
|
|
2467
|
+
const id = fs.job + '/' + k;
|
|
2468
|
+
if (R.painted[id]) continue;
|
|
2469
|
+
R.painted[id] = true;
|
|
2470
|
+
for (const l of sec.lines) for (const m of l.marks) if (m.card === k) applyMark(i, m, false);
|
|
2471
|
+
}
|
|
2472
|
+
});
|
|
2473
|
+
// 念到哪张亮哪张
|
|
2474
|
+
const at = f.saying || f.last;
|
|
2475
|
+
const i = at ? S.sections.findIndex((x) => x.job === at.job) : -1;
|
|
2476
|
+
const nk = i >= 0 ? i + '/' + at.line : '';
|
|
2477
|
+
if (nk !== R.now) { R.now = nk; if (i >= 0 && !S.sections[i].live) { setNow(i, nowCard(S.sections, { section: i, line: at.line, status: 'playing' })); const el = $('#board .c.now'); if (el) fresh = el; } }
|
|
2478
|
+
// 跟着最新的走:板上看得见的多了(新的一节、刚发出去的话、露出来的卡)或刚拖过进度条,就滚到最底下那个;只是念到了上面另一张卡,滚到那张
|
|
2479
|
+
const board = $('#board');
|
|
2480
|
+
const seen = board.querySelectorAll(':scope > .sec, :scope > .sh.said.pend, :scope > .sec .c:not(.pend)').length;
|
|
2481
|
+
if (seen > R.seen || R.follow) {
|
|
2482
|
+
const tail = [...board.querySelectorAll(':scope > .sec, :scope > .sh.said.pend')].reverse().find((el) => el.getClientRects().length);
|
|
2483
|
+
if (tail) tail.scrollIntoView({ block: 'end', behavior: R.follow ? 'instant' : 'smooth' });
|
|
2484
|
+
} else if (fresh) fresh.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2485
|
+
R.seen = seen; R.follow = false;
|
|
2486
|
+
reelStage(f, R.rebuilt); R.rebuilt = false;
|
|
2487
|
+
};
|
|
2488
|
+
/** 头像、「等你说…」、输入条压淡:孩子端自己的 renderSubtitle 照这一刻画。播放位置放在 S.reel.view(playView 读它),不碰 S.state、不 dispatch;
|
|
2489
|
+
* 句与句之间那 200 毫秒还算在念上一句,不然字幕一闪一闪 */
|
|
2490
|
+
const reelKidSub = (f, t) => {
|
|
2491
|
+
const R = S.reel;
|
|
2492
|
+
const at = f.saying || f.last;
|
|
2493
|
+
const i = at ? S.sections.findIndex((x) => x.job === at.job) : -1;
|
|
2494
|
+
let lastTo = 0; for (const x of R.d.reel.says) { if (x.from > t) break; lastTo = x.to; }
|
|
2495
|
+
const tr = at ? R.d.reel.tracks.find((x) => x.job === at.job) : null;
|
|
2496
|
+
const going = Boolean(tr && tr.doneAt > t);
|
|
2497
|
+
const status = i < 0 ? 'idle' : f.saying || (going && t - lastTo < 600) ? 'playing' : going ? 'thinking' : f.asking ? 'waiting' : 'done';
|
|
2498
|
+
const st = i < 0 ? { section: -1, line: -1, status } : { section: i, line: at.line, status };
|
|
2499
|
+
const waitMs = f.wait ? f.wait.ms : 0;
|
|
2500
|
+
const key = JSON.stringify([st, Boolean(f.wait), Math.floor(waitMs / 1000), R.key, R.pend]);
|
|
2501
|
+
if (key === R.subKey) return;
|
|
2502
|
+
R.subKey = key;
|
|
2503
|
+
R.view = st; S.pending = Boolean(f.wait);
|
|
2504
|
+
S.waitSince = f.wait ? Date.now() - waitMs : null;
|
|
2505
|
+
renderSubtitle();
|
|
2506
|
+
};
|
|
2507
|
+
/** 有实时轮的话题:左栏字幕照这一刻——最近开始的一句讲稿或嘴接住的那句在说、它前一句淡;嘴在出声(念句、接住)头像外的圈亮 */
|
|
2508
|
+
const reelLiveCap = (f, t) => {
|
|
2509
|
+
const R = S.reel;
|
|
2510
|
+
if (!R.realtime) return;
|
|
2511
|
+
if (R.capOf !== R.d) {
|
|
2512
|
+
R.capOf = R.d;
|
|
2513
|
+
const text = (job, line) => { const e = R.entries.find((x) => x.job === job); return e && e.lines[line] ? plainLine(e.lines[line].text) : ''; };
|
|
2514
|
+
R.cap = [...R.d.reel.says.map((x) => ({ at: x.from, text: text(x.job, x.line) })), ...R.d.reel.clips.filter((c) => c.kind === 'mouth').map((c) => ({ at: c.from, text: c.text || '' }))].filter((x) => x.text).sort((a, b) => a.at - b.at);
|
|
2515
|
+
}
|
|
2516
|
+
let i = -1; while (i + 1 < R.cap.length && R.cap[i + 1].at <= t) i++;
|
|
2517
|
+
const speaking = Boolean(f.saying || (f.clip && f.clip.kind === 'mouth'));
|
|
2518
|
+
const key = i + '/' + speaking;
|
|
2519
|
+
document.body.classList.toggle('speaking', speaking);
|
|
2520
|
+
if (VB.reelSpeak !== speaking) { VB.reelSpeak = speaking; voiceSync(); }
|
|
2521
|
+
if (key === R.capKey) return;
|
|
2522
|
+
R.capKey = key;
|
|
2523
|
+
$('#lv-cap .lv-prev').textContent = i > 0 ? R.cap[i - 1].text : '';
|
|
2524
|
+
const c = $('#lv-cap .lv-cur'); c.textContent = i >= 0 ? R.cap[i].text : ''; c.classList.toggle('nm', i < 0);
|
|
2525
|
+
$('#lv-cap').classList.toggle('done', !speaking);
|
|
2526
|
+
};
|
|
2527
|
+
/** 按住说话:有实录(hold)照它画——话筒没开「等一下…」、在听、上滑「松手取消」、松手「正在听清…」,波形照记下的音量(声音流式推给嘴,按着的时候没有字);
|
|
2528
|
+
* 没有实录的话题:原声那一段就是孩子按着的时候,嘴认成的字按原声的进度一点点露、波形是浮层的假波形 */
|
|
2529
|
+
const reelHold = (f) => {
|
|
2530
|
+
const R = S.reel;
|
|
2531
|
+
const recorded = R.d.reel.holds.length > 0;
|
|
2532
|
+
const c = !f.hold && !recorded && f.clip && f.clip.kind === 'voice' ? f.clip : null;
|
|
2533
|
+
const on = Boolean(f.hold || c);
|
|
2534
|
+
// 平板横屏的按钮照这一刻画(按着、话筒开没开、上滑);孩子的竖条照录下的音量(最新那一格)。嘴的音量录像里没有,老师的弧只按最低深浅画
|
|
2535
|
+
const rh = f.hold ? { phase: f.hold.phase, slide: Boolean(f.hold.slide) } : c ? { phase: c.offset < 900 ? 'wait' : 'live', slide: false } : null;
|
|
2536
|
+
if (JSON.stringify(rh) !== JSON.stringify(VB.reelHold)) { VB.reelHold = rh; voiceSync(); }
|
|
2537
|
+
if (f.hold && f.hold.lv) VB.btn.level('kid', Number(f.hold.lv[f.hold.lv.length - 1] || 0) / 9);
|
|
2538
|
+
$('#hold').classList.toggle('on', on);
|
|
2539
|
+
if (!on) { if (R.hold) { R.hold = false; $('#hold .t').textContent = ''; $('#hold').classList.remove('real'); holdBars.forEach((b, i) => { b.style.height = R.barsH[i]; }); } return; }
|
|
2540
|
+
R.hold = true;
|
|
2541
|
+
let text = '', phase = 'live', slide = false, lv = null;
|
|
2542
|
+
if (f.hold) ({ phase, slide, lv } = f.hold);
|
|
2543
|
+
else {
|
|
2544
|
+
const clip = R.d.reel.clips.find((x) => x.kind === 'voice' && x.job === c.job);
|
|
2545
|
+
const dur = clip ? clip.to - clip.from : 1;
|
|
2546
|
+
const all = c.text || '';
|
|
2547
|
+
text = all.slice(0, Math.max(0, Math.min(all.length, Math.ceil(all.length * (c.offset - 900) / Math.max(1, dur - 1200)))));
|
|
2548
|
+
phase = c.offset < 900 ? 'wait' : 'live';
|
|
2549
|
+
}
|
|
2550
|
+
$('#hold .t').textContent = text.length > 60 ? '…' + text.slice(-60) : text;
|
|
2551
|
+
$('#hold').classList.toggle('wait', phase !== 'live');
|
|
2552
|
+
$('#hold span').textContent = phase === 'tail' ? '正在听清…' : slide ? '松手取消' : phase === 'wait' ? '等一下…' : '松手发送,上移取消';
|
|
2553
|
+
$('#hold').classList.toggle('real', lv !== null);
|
|
2554
|
+
if (lv !== null) holdBars.forEach((b, i) => { b.style.height = Math.round(4 + Number(lv[i] || 0) / 9 * 24) + 'px'; });
|
|
2555
|
+
};
|
|
2556
|
+
/** 输入条:在打字就是输入框里那一刻的字;没听清那 2.5 秒是「没听清,再按住说一次」;别的时候是占位字 */
|
|
2557
|
+
const reelInput = (f) => {
|
|
2558
|
+
const R = S.reel, ty = f.typing;
|
|
2559
|
+
const key = JSON.stringify([ty, f.unclear]);
|
|
2560
|
+
if (key === R.inKey) return;
|
|
2561
|
+
R.inKey = key;
|
|
2562
|
+
$('#ph').hidden = Boolean(ty); $('#typed').hidden = !ty; $('#go').hidden = !ty;
|
|
2563
|
+
$('#typed').value = ty ? ty.v : '';
|
|
2564
|
+
$('#ph').textContent = f.unclear ? '没听清,再按住说一次' : '发消息或按住说话…';
|
|
2565
|
+
};
|
|
2566
|
+
/** 键盘:在打字、记下的屏幕尺寸里有键盘高,就在底下垫一块示意的键盘,输入条挪到它上面(iPad 上键盘弹起来只缩可见的那块) */
|
|
2567
|
+
const reelKbd = (f) => {
|
|
2568
|
+
const R = S.reel, kb = f.typing && f.view ? f.view.kb : 0;
|
|
2569
|
+
if (kb === R.kb) return;
|
|
2570
|
+
R.kb = kb;
|
|
2571
|
+
document.body.classList.toggle('kbd', kb > 0);
|
|
2572
|
+
document.body.style.setProperty('--rl-kb', kb + 'px');
|
|
2573
|
+
};
|
|
2574
|
+
/** 发照片屏:开着就盖上,照片用发出去的那张(裁过、圈过的成品;取消了的没有照片),亮着最近用的工具 */
|
|
2575
|
+
const reelPs = (f) => {
|
|
2576
|
+
const R = S.reel, p = f.photoScreen;
|
|
2577
|
+
const key = p ? p.tool + '|' + (p.photo || '') : '';
|
|
2578
|
+
if (key === R.psKey) return;
|
|
2579
|
+
R.psKey = key;
|
|
2580
|
+
psEl.classList.toggle('on', Boolean(p));
|
|
2581
|
+
if (!p) return;
|
|
2582
|
+
let im = $('#rl-ps-img');
|
|
2583
|
+
if (!im) { im = h('img', { id: 'rl-ps-img', alt: '' }); $('#ps .view').append(im); }
|
|
2584
|
+
if (p.photo) im.src = '/api/kid/image?p=' + encodeURIComponent(p.photo); else im.removeAttribute('src');
|
|
2585
|
+
im.hidden = !p.photo;
|
|
2586
|
+
for (const [id, t] of [['#ps-t-crop', 'crop'], ['#ps-t-pen', 'pen'], ['#ps-t-rot', 'rot']]) $(id).classList.toggle('on', p.tool === t);
|
|
2587
|
+
};
|
|
2588
|
+
/** 孩子自己翻板书:翻到那张卡离板书上沿那么远。拖过进度条时,只认最后一次翻之后板上没再出新东西的(出了新东西孩子端会跟过去) */
|
|
2589
|
+
const reelScroll = (f, t) => {
|
|
2590
|
+
const R = S.reel, x = f.scroll;
|
|
2591
|
+
if (!x || x.at === R.scrollAt) return;
|
|
2592
|
+
if (R.scrollAt === undefined) {
|
|
2593
|
+
let shownAt = -Infinity;
|
|
2594
|
+
for (const tr of R.d.reel.tracks) { if (tr.at <= t) shownAt = Math.max(shownAt, tr.at); for (const c of tr.cards) if (c.at <= t) shownAt = Math.max(shownAt, c.at); }
|
|
2595
|
+
if (x.at < shownAt) { R.scrollAt = x.at; return; }
|
|
2596
|
+
}
|
|
2597
|
+
R.scrollAt = x.at;
|
|
2598
|
+
const b = $('#board'), el = b.querySelector(':scope > .sec[data-job="' + x.job + '"] .c[data-card="' + x.card + '"]');
|
|
2599
|
+
if (el) b.scrollTop += el.getBoundingClientRect().top - b.getBoundingClientRect().top - x.dy;
|
|
2600
|
+
};
|
|
2601
|
+
/** 声音:换了一句(或孩子按住说的那一段)就从这一刻的偏移放那个文件 */
|
|
2602
|
+
const reelVoice = (f) => {
|
|
2603
|
+
const R = S.reel;
|
|
2604
|
+
const cur = f.saying ? 's:' + f.saying.job + '/' + f.saying.line : f.clip ? 'c:' + f.clip.kind + ':' + f.clip.job + '/' + f.clip.seek : '';
|
|
2605
|
+
if (cur === R.voice) return;
|
|
2606
|
+
R.voice = cur;
|
|
2607
|
+
silence();
|
|
2608
|
+
const x = f.saying || f.clip;
|
|
2609
|
+
if (!R.playing || R.seeking || !x || !x.audio) return;
|
|
2610
|
+
audioEl.onended = null; audioEl.onerror = null; audioEl.onplaying = null;
|
|
2611
|
+
// seek:实时的轮整次通话是一条声音,这句 / 这段在里面从哪开始
|
|
2612
|
+
const off = (x.seek || 0) + x.offset;
|
|
2613
|
+
audioEl.src = AUDIO + S.tutor.name + '/' + x.audio.split('/').map(encodeURIComponent).join('/') + (off > 300 ? '#t=' + (off / 1000).toFixed(2) : '');
|
|
2614
|
+
audioEl.defaultPlaybackRate = R.speed; audioEl.playbackRate = R.speed;
|
|
2615
|
+
audioEl.play().catch(() => {});
|
|
2616
|
+
};
|
|
2617
|
+
/** 录像栏上这一刻的说明(孩子屏幕上没有的):孩子的原声认成了什么、等老师多久、孩子想了多久(只露这两种时长)、切到别处、小课堂停在哪 */
|
|
2618
|
+
const reelCaption = (f) => {
|
|
2619
|
+
const streak = f.miss ? '⚠ 按住说话连着 ' + f.miss.n + ' 次没发出去' : '';
|
|
2620
|
+
if (f.hold) {
|
|
2621
|
+
const how = f.hold.phase === 'wait' ? ' · 话筒没开' : f.hold.lv && /^0+$/.test(f.hold.lv) ? ' · 话筒没声音' : '';
|
|
2622
|
+
return streak ? { text: streak + ' · 第 ' + f.miss.k + ' 次' + how, cls: 'miss' } : { text: '🎙 孩子按住说话' + how, cls: 'kid' };
|
|
2623
|
+
}
|
|
2624
|
+
if (streak) return { text: streak, cls: 'miss' };
|
|
2625
|
+
if (f.unclear) return { text: '🎙 没发出去:没认出字', cls: 'miss' };
|
|
2626
|
+
if (f.typing) return { text: '⌨ 孩子在打字', cls: 'kid' };
|
|
2627
|
+
if (f.photoScreen) return { text: '📷 发照片屏' + ({ crop: ' · 在裁剪', pen: ' · 在圈画', rot: ' · 转了一下' }[f.photoScreen.tool] || ''), cls: 'kid' };
|
|
2628
|
+
if (f.lecture) return { text: (f.lecture.playing ? '▷ 在看小课堂 ' : '⏸ 小课堂停在 ') + lcClock(f.lecture.pos) + (f.gap ? ' · 停了 ' + reelDuration(f.gap.ms) : ''), cls: f.lecture.playing ? 'line' : 'gap' };
|
|
2629
|
+
if (f.clip && f.clip.kind === 'voice') return { text: '🎙 孩子按住说话 · 认成「' + (f.clip.text || '') + '」', cls: 'kid' };
|
|
2630
|
+
if (f.clip && f.clip.kind === 'mouth') return { text: '🔊 老师接住:「' + (f.clip.text || '') + '」', cls: 'line' };
|
|
2631
|
+
if (f.saying) return { text: '🔊 老师在念', cls: 'line' };
|
|
2632
|
+
if (f.wait) return { text: '⏳ 等老师 ' + reelDuration(f.wait.ms), cls: 'gap' };
|
|
2633
|
+
if (f.away) return { text: '📴 孩子切到别处了', cls: 'gap' };
|
|
2634
|
+
if (f.thinking) return { text: '⏱ 弹窗开着还没动 · 想了 ' + reelDuration(f.thinking.ms), cls: 'gap' };
|
|
2635
|
+
if (f.gap) return { text: (f.gap.cont ? '⏩ 又过了 ' : '⏩ 孩子想了 ') + reelDuration(f.gap.ms), cls: 'gap' };
|
|
2636
|
+
if (f.stage) return { text: '弹窗开着', cls: 'gap' };
|
|
2637
|
+
if (f.asking) return { text: '⏸ 停下等孩子', cls: 'gap' };
|
|
2638
|
+
return { text: '', cls: '' };
|
|
2639
|
+
};
|
|
2640
|
+
/** 看小课堂的那一段(第 5 步):舞台包的播放器跟着录像的时钟(follow) */
|
|
2641
|
+
const reelLecture = (f) => {
|
|
2642
|
+
const R = S.reel, x = f.lecture;
|
|
2643
|
+
if (!x) { if (R.lc) { R.lc = null; hideLecture(); } return; }
|
|
2644
|
+
if (!R.lc || R.lc !== x.job) { R.lc = x.job; openLecture({ bundle: x.bundle, title: x.title, follow: true, ...(x.video ? { video: true } : {}) }); }
|
|
2645
|
+
const msg = { type: 'follow', ms: x.pos, playing: x.playing && R.playing && !R.seeking, rate: R.speed, marks: x.marks.map((k) => ({ atMs: k.atMs, svgMs: k.svgMs, strokes: k.strokes, ...(k.image ? { image: k.image } : {}) })) };
|
|
2646
|
+
if (lcShowing) lcShowing.followMsg = msg;
|
|
2647
|
+
lcPost(msg);
|
|
2648
|
+
};
|
|
2649
|
+
const reelTick = () => {
|
|
2650
|
+
const R = S.reel; if (!R) return;
|
|
2651
|
+
const now = performance.now();
|
|
2652
|
+
if (R.playing && !R.seeking) R.p = Math.min(R.clock.total, R.p + (now - R.last) * R.speed);
|
|
2653
|
+
R.last = now;
|
|
2654
|
+
const t = R.clock.toWall(R.p);
|
|
2655
|
+
const f = reelFrameAt(R.d.reel, t);
|
|
2656
|
+
R.f = f; R.t = t;
|
|
2657
|
+
reelDraw(f); reelScroll(f, t); reelKidSub(f, t); reelLiveCap(f, t); reelHold(f); reelInput(f); reelKbd(f); reelPs(f); reelVoice(f); reelLecture(f);
|
|
2658
|
+
if (R.playing && R.p >= R.clock.total) reelPlay(false);
|
|
2659
|
+
};
|
|
2660
|
+
const reelPlay = (on) => {
|
|
2661
|
+
const R = S.reel; if (!R) return;
|
|
2662
|
+
if (on && R.p >= R.clock.total) R.p = 0;
|
|
2663
|
+
if (on) R.follow = true;
|
|
2664
|
+
R.playing = on; R.last = performance.now(); R.voice = null;
|
|
2665
|
+
clearInterval(R.timer); R.timer = on ? setInterval(reelTick, 100) : null;
|
|
2666
|
+
reelTick();
|
|
2667
|
+
};
|
|
2668
|
+
const reelSeek = (p) => { const R = S.reel; if (!R) return; R.seeking = false; R.p = Math.max(0, Math.min(R.clock.total, p)); R.last = performance.now(); R.voice = null; R.follow = true; R.scrollAt = undefined; reelTick(); };
|
|
2669
|
+
/** 今天的话题:停在最后、或放到了最后时,隔 5 秒重拿一次轨道;老师写了新的、孩子又说了,接上并停在新的最后 */
|
|
2670
|
+
const reelGrow = async () => {
|
|
2671
|
+
const R = S.reel; if (!R || R.seeking || document.hidden) return;
|
|
2672
|
+
const atEnd = R.p >= R.clock.total - 300;
|
|
2673
|
+
if (!atEnd) return;
|
|
2674
|
+
let d = null;
|
|
2675
|
+
try { d = await api('GET', '/api/conversations/' + encodeURIComponent(S.tutor.name) + '/' + encodeURIComponent(R.d.date) + '/threads/' + encodeURIComponent(R.d.thread) + '/reel'); } catch {}
|
|
2676
|
+
if (!d || S.reel !== R || R.seeking || R.p < R.clock.total - 300) return;
|
|
2677
|
+
const grew = d.reel.endAt !== R.d.reel.endAt || d.kid.length !== R.d.kid.length || JSON.stringify(d.kid.map((m) => [m.job, m.result])) !== JSON.stringify(R.d.kid.map((m) => [m.job, m.result]));
|
|
2678
|
+
if (!grew) return;
|
|
2679
|
+
R.d = d; R.entries = sectionsFromMessages(d.kid); R.said = kidSaid(d.kid); R.key = '';
|
|
2680
|
+
R.clock = reelClock(d.reel, R.real); R.p = R.clock.total; R.follow = true;
|
|
2681
|
+
if (!d.live) { clearInterval(R.liveTimer); R.liveTimer = 0; }
|
|
2682
|
+
reelTick();
|
|
2683
|
+
};
|
|
2684
|
+
/** 孩子屏幕的把手:录像栏只经它说话(家长页上隔着 iframe 直接调) */
|
|
2685
|
+
const reelApi = {
|
|
2686
|
+
get: () => { const R = S.reel; if (!R) return null; return { p: R.p, total: R.clock.total, wall: R.t || R.clock.toWall(R.p), playing: R.playing, speed: R.speed, real: R.real, cap: R.f ? reelCaption(R.f) : { text: '', cls: '' }, size: R.f && R.f.view ? [R.f.view.w, R.f.view.h] : R.size, stale: R.d.reel.views.some((v) => v.v && v.v !== PAGE_V) }; },
|
|
2687
|
+
marks: () => { const R = S.reel; return R ? R.d.reel.marks.map((m) => ({ kind: m.kind, label: m.label, at: m.at, p: R.clock.toPlay(m.at), ...(m.to ? { pTo: R.clock.toPlay(m.to) } : {}) })) : []; },
|
|
2688
|
+
play: (on) => reelPlay(on),
|
|
2689
|
+
seek: (p) => reelSeek(p),
|
|
2690
|
+
speeds: REEL_SPEEDS,
|
|
2691
|
+
/** 章:孩子每开口一次是一轮 */
|
|
2692
|
+
chapters: () => {
|
|
2693
|
+
const R = S.reel; if (!R) return [];
|
|
2694
|
+
const rl = R.d.reel, said = rl.marks.filter((m) => m.kind === 'said');
|
|
2695
|
+
// 章尾:下一章的前奏(按住说话、打字、发照片屏)开始之前;上一章 / 下一章落在这里
|
|
2696
|
+
const ends = reelChapterEnds(said.map((m) => m.at), [...rl.holds, ...rl.types, ...rl.photoScreens], rl.endAt);
|
|
2697
|
+
return said.map((m, i) => ({ p: R.clock.toPlay(m.at), end: R.clock.toPlay(ends[i]), title: '第 ' + (i + 1) + ' 轮' }));
|
|
2698
|
+
},
|
|
2699
|
+
/** 这个话题的经过(录像栏展开后的列表):孩子做的每件事、等了多久、给家长的,一条一行,按时间排 */
|
|
2700
|
+
events: () => {
|
|
2701
|
+
const R = S.reel; if (!R) return [];
|
|
2702
|
+
const rl = R.d.reel, out = [];
|
|
2703
|
+
const add = (at, kind, chip, text) => { if (Number.isFinite(at)) out.push({ at, p: R.clock.toPlay(at), kind, chip, text }); };
|
|
2704
|
+
const msgOf = (job) => R.d.messages.find((m) => m.job === job);
|
|
2705
|
+
const took = (t) => (!t ? '' : [t.think !== null ? '想了 ' + reelDuration(t.think) : '', t.changes > 1 ? '改过 ' + (t.changes - 1) + ' 次' : ''].filter(Boolean).map((x) => ' · ' + x).join(''));
|
|
2706
|
+
for (const m of rl.marks) {
|
|
2707
|
+
if (m.kind === 'said') { const pm = msgOf(m.job); add(m.at, 'said', pm && pm.via ? '首页' : pm && pm.from === 'system' ? '系统' : '孩子', m.label); if (pm && pm.cards && pm.cards.length) add(m.at + 1, 'card', '做了', pm.cards.map((c) => c.text + took(c.took)).join(';')); }
|
|
2708
|
+
else if (m.kind === 'ask') add(m.at, 'wait', '等孩子', m.label);
|
|
2709
|
+
else if (m.kind === 'error') add(m.at, 'err', '没成', m.label);
|
|
2710
|
+
else if (m.kind === 'lecture' || m.kind === 'ink') add(m.at, 'card', '小课堂', m.label);
|
|
2711
|
+
else if (m.kind === 'miss') add(m.at, 'miss', '没发出去', m.label.replace('按住说话没发出去 · ', '按住说话 · '));
|
|
2712
|
+
else if (m.kind === 'misses') add(m.at - 1, 'miss', '⚠ 连着', m.label);
|
|
2713
|
+
}
|
|
2714
|
+
for (const w of rl.waits) if (w.to - w.from >= 3000) add(w.from + 1, 'wait', '等老师', reelDuration(w.to - w.from));
|
|
2715
|
+
for (const g of rl.gaps) if (g.kind === 'think') add(g.from, 'wait', g.cont ? '又过了' : '孩子想了', reelDuration(g.ms));
|
|
2716
|
+
for (const x of rl.stages) { const e = R.entries.find((y) => y.job === x.job); const c = e && e.cards[x.card]; add(x.from, 'card', '弹窗', (c ? cardTitle(c) : '一张卡') + ' · 开了 ' + reelDuration(x.to - x.from)); }
|
|
2717
|
+
for (const x of rl.types) add(x.from, 'said', '打字', (x.vals.length ? x.vals[x.vals.length - 1].v : '') + (x.sent ? '' : ' · 没发'));
|
|
2718
|
+
for (const x of rl.photoScreens) add(x.from, 'said', '拍照', x.sent ? '发了照片' : '打开又取消了');
|
|
2719
|
+
// 节尾的旁注(给家长的、记住了):原来插在板书里,现在在这里
|
|
2720
|
+
for (const n of rl.notes) {
|
|
2721
|
+
const pm = msgOf(n.job); if (!pm) continue;
|
|
2722
|
+
if (pm.parentText) add(n.postAt, 'tail', '给家长', pm.parentText.replace('## 家长', '').trim());
|
|
2723
|
+
if (pm.remembered && pm.remembered.length) add(n.postAt, 'tail', '记住了', pm.remembered.join(';'));
|
|
2724
|
+
}
|
|
2725
|
+
// 家长板书页别的旁注:答案、画图给家长的、小课堂里画的那段话、提醒;记账与整理记忆那两轮不进录像,记在最后
|
|
2726
|
+
for (const pm of R.d.messages) {
|
|
2727
|
+
const tr = rl.tracks.find((x) => x.job === pm.job), nt = rl.notes.find((x) => x.job === pm.job);
|
|
2728
|
+
const own = reelParse(pm.timing && pm.timing.startedAt), at0 = nt ? nt.at : tr ? tr.at : Math.min(rl.endAt, Number.isNaN(own) ? rl.endAt : own);
|
|
2729
|
+
const post = nt ? nt.postAt : tr ? tr.doneAt : at0;
|
|
2730
|
+
const cards = (pm.section && pm.section.cards) || [];
|
|
2731
|
+
const ans = cards.map((c) => { const a = reelAnswer(c); return a ? cardTitle(c) + ':' + a : ''; }).filter(Boolean);
|
|
2732
|
+
if (ans.length && tr) add(tr.at + 1, 'card', '答案', ans.join(';'));
|
|
2733
|
+
for (const c of cards) {
|
|
2734
|
+
if (c.kind === 'paint' && c.props && c.props.note) add(tr ? tr.at + 2 : at0, 'tail', '画图', c.props.note);
|
|
2735
|
+
}
|
|
2736
|
+
const lec = rl.lectures.find((x) => x.job === pm.job);
|
|
2737
|
+
((pm.lecture && pm.lecture.marks) || []).forEach((mk, k) => add(lec && lec.marks[k] ? lec.marks[k].at + 1 : at0, 'card', '画的', lcClock(mk.atMs) + ' · ' + (mk.text || '(课读不出来,没给老师那段话)')));
|
|
2738
|
+
if (pm.warnings && pm.warnings.length) add(post + 2, 'err', '提醒', pm.warnings.join(';'));
|
|
2739
|
+
if (pm.bookkeep) add(Math.min(rl.endAt, at0), 'tail', '记账', pm.pending ? '记账中' : pm.error ? '记账没成:' + pm.error : '已记进日记');
|
|
2740
|
+
if ((pm.bookkeep || pm.tidy) && pm.remembered && pm.remembered.length) add(Math.min(rl.endAt, at0) + 1, 'tail', pm.tidy ? '整理了记忆' : '记住了', pm.remembered.join(';'));
|
|
2741
|
+
}
|
|
2742
|
+
return out.sort((a, b) => a.at - b.at);
|
|
2743
|
+
},
|
|
2744
|
+
/** 跳到墙钟的某一刻(调试、探针) */
|
|
2745
|
+
seekAt: (w) => { const R = S.reel; if (R) reelSeek(R.clock.toPlay(w)); },
|
|
2746
|
+
/** 拖着进度条:不出声,画面跟着;松手走 seek */
|
|
2747
|
+
drag: (p) => { const R = S.reel; if (!R) return; R.seeking = true; R.p = Math.max(0, Math.min(R.clock.total, p)); R.voice = null; R.follow = true; R.scrollAt = undefined; reelTick(); },
|
|
2748
|
+
real: (on) => { const R = S.reel; if (!R) return; R.real = on; reelSetClock(); R.voice = null; reelTick(); },
|
|
2749
|
+
speed: (x) => { const R = S.reel; if (!R) return; R.speed = x; audioEl.defaultPlaybackRate = x; audioEl.playbackRate = x; },
|
|
2750
|
+
stop: () => reelStop(),
|
|
2751
|
+
};
|
|
2752
|
+
/** 孩子的屏幕开起来:要 /reel 的数据,孩子端的样子铺好(只读、点不动),停在开头;家长页在等就把把手交过去 */
|
|
2753
|
+
const reelBoot = async () => {
|
|
2754
|
+
const tutor = debug.get('tutor') || '', date = debug.get('date') || 'today', thread = debug.get('thread') || '';
|
|
2755
|
+
let host = null; try { if (window.parent !== window && typeof window.parent.cotutorReelReady === 'function') host = window.parent; } catch {}
|
|
2756
|
+
let d = null;
|
|
2757
|
+
try { d = await api('GET', '/api/conversations/' + encodeURIComponent(tutor) + '/' + encodeURIComponent(date) + '/threads/' + encodeURIComponent(thread) + '/reel'); } catch {}
|
|
2758
|
+
if (!d) { if (host) { try { host.cotutorReelFail(); } catch {} } else document.body.append(h('div', { class: 'blank' }, h('b', {}, '这个话题没有录像'))); return; }
|
|
2759
|
+
document.body.classList.add('rlk');
|
|
2760
|
+
S.tutor = d.face; S.thread = d.thread; S.hist = d.date; S.readonly = true;
|
|
2761
|
+
// 孩子的屏幕照实时老师页的样子(左栏大头像 + 字幕、卡角没有钮);有实时轮的话题字幕照这一刻(reelLiveCap),不是实时的轮左栏不写字幕
|
|
2762
|
+
liveFace(S.tutor); tintBack(S.tutor); markBoard($('#bmark'), S.tutor);
|
|
2763
|
+
$('#tutor').classList.add('on');
|
|
2764
|
+
renderBar(); setBar('idle');
|
|
2765
|
+
const v0 = d.reel.views[0];
|
|
2766
|
+
// 示意的键盘(打字时垫在底下):三排字母一排空格,不画真键盘
|
|
2767
|
+
document.body.append(h('div', { id: 'rl-kbd' }, ...['qwertyuiop', 'asdfghjkl', 'zxcvbnm', ' '].map((r) => h('div', { class: 'r' }, ...[...r].map((k) => h('span', { class: k === ' ' ? 'sp' : '' }, k === ' ' ? '拼音' : k))))));
|
|
2768
|
+
S.reel = { d, entries: sectionsFromMessages(d.kid), said: kidSaid(d.kid), size: v0 ? [v0.w, v0.h] : REEL_SIZES[d.device] || REEL_SIZES['tablet-landscape'], barsH: holdBars.map((b) => b.style.height), kb: 0, inKey: '', psKey: '', scrollAt: undefined, real: false, speed: 1, p: 0, playing: false, seeking: false, last: 0, key: '', painted: {}, now: '', voice: null, timer: null, clock: null, seen: 0, follow: false, pend: null, subKey: '', hold: null, f: null, t: 0, realtime: Boolean(d.realtime), cap: null, capOf: null, capKey: '' };
|
|
2769
|
+
reelSetClock();
|
|
2770
|
+
// ?at=时:分:秒 停在那一刻(调试、截图);那天的日期从录像起点取
|
|
2771
|
+
const at = /^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/.exec(debug.get('at') || '');
|
|
2772
|
+
if (at) { const d0 = new Date(d.reel.startAt); d0.setHours(Number(at[1]), Number(at[2]), Number(at[3] || 0), 0); S.reel.p = Math.max(0, Math.min(S.reel.clock.total, S.reel.clock.toPlay(d0.getTime()))); S.reel.follow = true; }
|
|
2773
|
+
// ?end=1 停在最后一帧(家长从记录点进来:最后一帧信息最多,要看过程往前拖)
|
|
2774
|
+
else if (debug.get('end')) { S.reel.p = S.reel.clock.total; S.reel.follow = true; }
|
|
2775
|
+
// 孩子的屏幕点不动,只能上下翻:点、按在捕获阶段挡掉(滚动不受影响)
|
|
2776
|
+
for (const k of ['click', 'pointerdown', 'pointerup', 'dblclick', 'contextmenu']) document.addEventListener(k, (e) => { if (e.target.closest && e.target.closest('#reel')) return; e.stopPropagation(); if (k !== 'pointerdown' && k !== 'pointerup') e.preventDefault(); }, true);
|
|
2777
|
+
if (d.live) S.reel.liveTimer = setInterval(reelGrow, 5000);
|
|
2778
|
+
renderHeader();
|
|
2779
|
+
reelTick();
|
|
2780
|
+
window.cotutorReel = reelApi;
|
|
2781
|
+
if (host) host.cotutorReelReady(reelApi, { size: S.reel.size });
|
|
2782
|
+
// 焦点在孩子的屏幕里时按键也认:交给外面的录像栏,它认了就不让这一页再处理
|
|
2783
|
+
if (host) document.addEventListener('keydown', (e) => { try { if (host.cotutorReelKey && host.cotutorReelKey({ key: e.key, shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey, altKey: e.altKey })) e.preventDefault(); } catch {} });
|
|
2784
|
+
else reelDock.open(reelApi, { toast });
|
|
2785
|
+
};
|
|
2786
|
+
|
|
2787
|
+
__REEL_DOCK_JS__
|
|
2788
|
+
/** 只收拾录像自己的东西(关老师页时也调);不重开话题 */
|
|
2789
|
+
const reelStop = () => {
|
|
2790
|
+
if (!S.reel) return;
|
|
2791
|
+
const R = S.reel; clearInterval(R.timer); clearInterval(R.liveTimer); S.reel = null; silence(); if (S.stage) closeStage(); if (R.lc) hideLecture();
|
|
2792
|
+
};
|
|
2793
|
+
|
|
2794
|
+
// ---- 输入条:相机 | 发消息或按住说话 | 加号 ----
|
|
2795
|
+
$('#cam').insertAdjacentHTML('afterbegin', ICON.camera);
|
|
2796
|
+
$('#plus').innerHTML = ICON.plus;
|
|
2797
|
+
$('#go').innerHTML = ICON.send;
|
|
2798
|
+
$('#ic-album').innerHTML = ICON.album; $('#ic-cam2').innerHTML = ICON.cameraLg;
|
|
2799
|
+
const setBar = (mode) => {
|
|
2800
|
+
if (mode === 'typing' && S.bar !== 'typing') typeRec('focus');
|
|
2801
|
+
else if (mode !== 'typing' && S.bar === 'typing') typeRec('blur');
|
|
2802
|
+
S.bar = mode; avListen();
|
|
2803
|
+
$('#ph').hidden = mode === 'typing'; $('#typed').hidden = mode !== 'typing'; $('#go').hidden = mode !== 'typing';
|
|
2804
|
+
$('#hold').classList.toggle('on', mode === 'holding');
|
|
2805
|
+
// 平板横屏实时:输入条平时收着,打字时贴着软键盘上沿出来(先露出来才 focus 得上)
|
|
2806
|
+
document.body.classList.toggle('typing', mode === 'typing');
|
|
2807
|
+
if (mode === 'typing') $('#typed').focus();
|
|
2808
|
+
};
|
|
2809
|
+
/** 软键盘的高(--kb):打字时输入条贴着它上沿;visualViewport 没有就当 0 */
|
|
2810
|
+
const kbSync = () => { const vv = window.visualViewport; const kb = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0; document.documentElement.style.setProperty('--kb', kb + 'px'); };
|
|
2811
|
+
if (window.visualViewport) { window.visualViewport.addEventListener('resize', kbSync); window.visualViewport.addEventListener('scroll', kbSync); }
|
|
2812
|
+
const mid = $('#mid'), typed = $('#typed');
|
|
2813
|
+
$('#go').addEventListener('click', () => { const t = typed.value; typeHeard.put(t); typeRec('send'); typed.value = ''; setBar(barNext(S.bar, 'sent')); send(t); });
|
|
2814
|
+
typed.addEventListener('input', () => typeHeard.put(typed.value));
|
|
2815
|
+
typed.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $('#go').click(); } });
|
|
2816
|
+
typed.addEventListener('blur', () => { if (!typed.value.trim()) setBar(barNext(S.bar, 'blur')); });
|
|
2817
|
+
$('#plus').addEventListener('click', () => $('#sheet').classList.add('on'));
|
|
2818
|
+
$('#sheet .dimmer').addEventListener('click', () => $('#sheet').classList.remove('on'));
|
|
2819
|
+
$('#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' })));
|
|
2820
|
+
|
|
2821
|
+
// 中间那段:点 = 打字;实时老师页上按住 150ms = 说话(推给嘴),松手发,上滑 60px 取消。浏览器识别 2026-10-22 删了(经验归档在本机 docs/archive/浏览器识别@2026-10-22/)
|
|
2822
|
+
const PH_IDLE = () => (LV.on && LV.model.open ? (LV.model.muted ? '麦关了,点右边的麦克风再听' : '我在听,直接说就行…') : '发消息或按住说话…');
|
|
2823
|
+
/** 输入条上是空闲那句就按现在的重写(实时连上、一直听开关),「没听清」那几秒不动 */
|
|
2824
|
+
const phIdle = () => { const ph = $('#ph'); if (ph && /^(发消息|我在听|麦关了)/.test(ph.textContent)) ph.textContent = PH_IDLE(); };
|
|
2825
|
+
$('#ph').textContent = PH_IDLE();
|
|
2826
|
+
let press = null;
|
|
2827
|
+
// 麦克风常开(真机诊断 2026-09-19)。这台 iPad 上的三条事实:
|
|
2828
|
+
// ① Safari 的识别每次自己开关话筒,常交来一路纯静音,一个字不出、一停就 aborted;页面自己先 getUserMedia 留一路,识别再起就有声(先麦克风、后识别)
|
|
2829
|
+
// ② 麦克风关了再开,也常开到哑的 → 开了就不关(只有页面退到后台才放,那时系统本来也会掐);开完自检 600ms,纯 0 就拆掉重开,最多 3 回
|
|
2830
|
+
// ③ 冷开要 1 秒多,这段时间说的字进不去 → 给过权限之后,进老师页就先开好(micWarm)
|
|
2831
|
+
// 这一路画音量条。每次开麦克风也记一行诊断(where: 'mic')
|
|
2832
|
+
const mic = { stream: null, ac: null, an: null, buf: null, opening: null, ok: true };
|
|
2833
|
+
const micLive = () => { const t = mic.stream && mic.stream.getAudioTracks()[0]; return Boolean(t && t.readyState === 'live' && !t.muted && mic.ok); };
|
|
2834
|
+
/** 实时开着时,麦克风的自检(分析器)也开在实时那个 AudioContext 里,页面上只有一个 AudioContext(口语课一直只用一个)。
|
|
2835
|
+
* 2026-10-19 真机:实时页切到后台再回来,实时那个 AudioContext 还留着,之后每次开麦克风自检都读到 0(dead),按住说的话发不出去。
|
|
2836
|
+
* 所以实时那个切后台、离开实时就关(liveAudioDrop),回来先起一个新的再开麦克风(liveCtxEnsure) */
|
|
2837
|
+
const liveCtx = () => (LV.on && LV.audio && LV.audio.ctx) || null;
|
|
2838
|
+
const liveCtxEnsure = () => { if (!LV.on || !LV.audio) return null; if (!LV.audio.ctx) LV.audio.wake().catch(() => {}); return LV.audio.ctx; };
|
|
2839
|
+
const micDrop = () => { if (mic.stream) { for (const t of mic.stream.getTracks()) t.stop(); } if (mic.ac && mic.ac !== liveCtx()) { try { mic.ac.close(); } catch {} } mic.stream = mic.ac = mic.an = mic.buf = null; };
|
|
2840
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2841
|
+
/** 这一路有没有声:600ms 里采样全是 0 = 哑的(安静也有底噪);没有分析器就当它有 */
|
|
2842
|
+
const micProbe = async () => {
|
|
2843
|
+
if (!mic.an || mic.ac.state !== 'running') return true; // 分析器没在跑(没有手势时会被挂起)读出来也全是 0,不能当哑的
|
|
2844
|
+
const an = mic.an, buf = mic.buf, t1 = Date.now();
|
|
2845
|
+
while (Date.now() - t1 < 600) {
|
|
2846
|
+
an.getFloatTimeDomainData(buf);
|
|
2847
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] !== 0) return true;
|
|
2848
|
+
await wait(50);
|
|
2849
|
+
}
|
|
2850
|
+
return false;
|
|
2851
|
+
};
|
|
2852
|
+
const micOpenOnce = async () => {
|
|
2853
|
+
const st = await new Promise((resolve, reject) => {
|
|
2854
|
+
let late = false; const timer = setTimeout(() => { late = true; reject(new Error('timeout')); }, 6000);
|
|
2855
|
+
navigator.mediaDevices.getUserMedia({ audio: true }).then((x) => { clearTimeout(timer); if (late) { for (const t of x.getTracks()) t.stop(); } else resolve(x); }, (e) => { clearTimeout(timer); reject(e); });
|
|
2856
|
+
});
|
|
2857
|
+
mic.stream = st;
|
|
2858
|
+
// 一直听靠设备的回声消除:开没开记进诊断(getUserMedia 要的是缺省,两家浏览器缺省都开,真开了没有看这里)
|
|
2859
|
+
try { const s0 = st.getAudioTracks()[0].getSettings(); mic.aec = s0.echoCancellation; } catch { mic.aec = undefined; }
|
|
2860
|
+
try { localStorage.setItem('kid-mic-ok', '1'); } catch {}
|
|
2861
|
+
try {
|
|
2862
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
2863
|
+
mic.ac = liveCtxEnsure() || new AC(); mic.an = mic.ac.createAnalyser(); mic.an.fftSize = 512; mic.buf = new Float32Array(512);
|
|
2864
|
+
mic.ac.createMediaStreamSource(st).connect(mic.an);
|
|
2865
|
+
// 实时那个可能是不在点击里起的(切后台回来):Safari 的 resume() 要等到下一次点击才回,这里等它麦克风就一直「在开」,按住说的全发不出去。
|
|
2866
|
+
// 不等:没跑起来时自检本来就跳过(micProbe),下一次点击 wake() 会让它跑起来
|
|
2867
|
+
if (mic.ac.state === 'suspended') { const r = mic.ac.resume().catch(() => {}); if (mic.ac !== liveCtx()) await r; }
|
|
2868
|
+
} catch { mic.an = null; }
|
|
2869
|
+
};
|
|
2870
|
+
/** 要一路有声的麦克风:hot(本来就开着)/ open(新开的,自检过)/ dead(重开 3 回还是哑的)/ none(浏览器没有);同时只开一次 */
|
|
2871
|
+
const micOpen = () => {
|
|
2872
|
+
if (micLive()) return Promise.resolve('hot');
|
|
2873
|
+
if (mic.opening) return mic.opening;
|
|
2874
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return Promise.resolve('none');
|
|
2875
|
+
const t0 = Date.now(), ev = [], mark = (k, v) => ev.push(v === undefined ? [k, Date.now() - t0] : [k, Date.now() - t0, v]);
|
|
2876
|
+
const job = (async () => {
|
|
2877
|
+
for (let i = 0; i < 4; i++) {
|
|
2878
|
+
micDrop(); if (i) { mark('retry', i); await wait(400 * i); }
|
|
2879
|
+
await micOpenOnce(); mark('open'); mark('aec', String(mic.aec));
|
|
2880
|
+
mic.ok = await micProbe(); mark(mic.ok ? 'alive' : 'dead');
|
|
2881
|
+
if (mic.ok) return 'open';
|
|
2882
|
+
}
|
|
2883
|
+
return 'dead';
|
|
2884
|
+
})();
|
|
2885
|
+
mic.opening = job;
|
|
2886
|
+
const fin = (tail) => { if (mic.opening === job) mic.opening = null; if (tail) mark(tail[0], tail[1]); try { fetch('/api/kid/voice-diag', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ where: 'mic', ua: navigator.userAgent, ev }), keepalive: true }).catch(() => {}); } catch {} };
|
|
2887
|
+
job.then(() => fin(null), (e) => fin(['fail', String(e && (e.name || e.message))]));
|
|
2888
|
+
return job;
|
|
2889
|
+
};
|
|
2890
|
+
const micWarm = () => {
|
|
2891
|
+
let ok = false; try { ok = localStorage.getItem('kid-mic-ok') === '1'; } catch {}
|
|
2892
|
+
if (!ok || !LV.on || !S.tutor || !canSend() || document.hidden || micLive()) return;
|
|
2893
|
+
micOpen().catch(() => {});
|
|
2894
|
+
};
|
|
2895
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) { if (!mic.opening) micDrop(); } else micWarm(); });
|
|
2896
|
+
document.addEventListener('pointerdown', micWarm, true);
|
|
2897
|
+
document.addEventListener('touchstart', () => {}, { passive: true }); // iOS Safari:没有触摸监听时 :active 不一定触发,按压反馈靠它
|
|
2898
|
+
// 按住时那行字:话筒还没开 → 等一下(这时说的话进不去);开了 → 松手发送
|
|
2899
|
+
const hold = { audio: false };
|
|
2900
|
+
const renderHold = () => {
|
|
2901
|
+
$('#hold').classList.toggle('wait', !hold.audio);
|
|
2902
|
+
$('#hold span').textContent = press && press.cancelled ? '松手取消' : hold.audio ? '松手发送,上移取消' : '等一下…';
|
|
2903
|
+
voiceSync();
|
|
2904
|
+
};
|
|
2905
|
+
let phTimer = 0;
|
|
2906
|
+
// 真音量:25 根条往左滚;没有麦克风那一路(老浏览器、没授权)就还是原来的假波形
|
|
2907
|
+
const holdBars = [...document.querySelectorAll('#hold .w i')], holdLv = new Array(25).fill(0);
|
|
2908
|
+
const holdLevel = (v) => { holdLvRec(v); $('#hold').classList.add('real'); holdLv.shift(); holdLv.push(v); for (let i = 0; i < 25; i++) holdBars[i].style.height = Math.round(4 + holdLv[i] * 24) + 'px'; };
|
|
2909
|
+
// ---- 实时的老师:嘴是 realtime,讲稿由它照念,声音从 WebSocket /api/live/<老师>/ws 下来,
|
|
2910
|
+
// 孩子端不念讲稿(板书的 step 照旧收事件、只是不自动念);卡照旧从「今天」拉,闭合了就铺。谁出声、谁停走 liveDispatch → liveStep(runtime/delivery/player.ts),
|
|
2911
|
+
// 这里只照单执行。按住说话推 PCM(常开那路麦克风,makeAudio().use);连不上、断了 → 回到「正在叫老师」,过一会儿再连 ----
|
|
2912
|
+
/** 这次进老师页走不走实时:孩子端今天能说话的话题都走;以前的、家长端、录像、预览不走(只看) */
|
|
2913
|
+
const liveWanted = (t) => Boolean(t) && !REEL && !PREVIEW && !S.readonly;
|
|
2914
|
+
const lvSend = (o) => { if (LV.ws && LV.ws.readyState === 1) LV.ws.send(JSON.stringify(o)); };
|
|
2915
|
+
/** 实时时改「谁出声」的事都走这里,页面里不许再直接改 LV.model(tests/live-player.test.ts 数着)。data = release / send 的 body、audio 的那块声音 */
|
|
2916
|
+
const liveDispatch = (ev, data) => {
|
|
2917
|
+
const r = liveStep(LV.model, ev);
|
|
2918
|
+
LV.model = r.model;
|
|
2919
|
+
for (const f of r.effects) {
|
|
2920
|
+
switch (f.kind) {
|
|
2921
|
+
case 'hold': LV.playLine = null; LV.endLine = null; lvSend({ t: 'hold' }); break;
|
|
2922
|
+
case 'release': lvSend({ t: 'release', body: data, ...(LV.chatNext ? { chat: LV.chatNext } : {}) }); break;
|
|
2923
|
+
case 'hush': lvSend({ t: 'hush' }); break;
|
|
2924
|
+
case 'send': lvSend({ t: 'send', body: data }); break;
|
|
2925
|
+
case 'enqueue': if (LV.audio) LV.audio.play(data); liveDrainWatch(); break;
|
|
2926
|
+
case 'flush': LV.playLine = null; LV.endLine = null; if (LV.audio) LV.audio.flush(); break;
|
|
2927
|
+
case 'stopCard': silence(); break;
|
|
2928
|
+
case 'openLecture': liveLecture(); break;
|
|
2929
|
+
// 小课堂那一段停下:舞台停在那一帧,不再算讲稿委托的(放完 / 关了不再回 staged)
|
|
2930
|
+
case 'stopLecture': if (S.stage && S.stage.delegate) { S.stage.delegate = false; postStage({ type: 'control', action: 'pause' }); } break;
|
|
2931
|
+
case 'staged': lvSend({ t: 'staged' }); break;
|
|
2932
|
+
// 连不上、断了:不回板书,回到「正在叫老师」,过一会儿再连;排着的那条(开场、打字)连上再发
|
|
2933
|
+
case 'retry': liveRetry(); break;
|
|
2934
|
+
case 'render': renderSubtitle(); break;
|
|
2935
|
+
case 'listen': lvSend({ t: 'listen', open: LV.model.open }); break;
|
|
2936
|
+
case 'gate': liveGateSync(); break;
|
|
2937
|
+
case 'duck': case 'unduck': if (LV.audio) LV.audio.duck(f.kind === 'duck' ? 0.3 : 1); break;
|
|
2938
|
+
case 'ring': liveRing(); break;
|
|
2939
|
+
case 'stopRing': liveRingStop(); break;
|
|
2940
|
+
case 'playReplay': replayPlay(); break;
|
|
2941
|
+
case 'stopReplay': replayStop(); break;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
return r.effects;
|
|
2945
|
+
};
|
|
2946
|
+
/** 口语课 [[call]] 那句放完:孩子端自己响铃(440 + 480 赫兹,响两下停两秒,最多 15 秒),头像晃;嘴一出声铃停(liveStep) */
|
|
2947
|
+
const RING_MAX_MS = 15000;
|
|
2948
|
+
const liveRing = () => {
|
|
2949
|
+
liveRingStop();
|
|
2950
|
+
LV.ringEnd = setTimeout(() => { liveRingStop(); if (LV.model.status === 'ring') liveDispatch({ type: 'ringEnd' }); }, RING_MAX_MS);
|
|
2951
|
+
const ac = LV.audio && LV.audio.ctx;
|
|
2952
|
+
if (!ac) return;
|
|
2953
|
+
try {
|
|
2954
|
+
const g = ac.createGain(); g.gain.value = 0; g.connect(ac.destination);
|
|
2955
|
+
const o1 = ac.createOscillator(), o2 = ac.createOscillator();
|
|
2956
|
+
o1.frequency.value = 440; o2.frequency.value = 480; o1.connect(g); o2.connect(g);
|
|
2957
|
+
const t0 = ac.currentTime + 0.05;
|
|
2958
|
+
for (let b = t0; b < t0 + RING_MAX_MS / 1000; b += 3) for (const [a, d] of [[0, 0.4], [0.6, 0.4]]) { g.gain.setValueAtTime(0.06, b + a); g.gain.setValueAtTime(0, b + a + d); }
|
|
2959
|
+
o1.start(t0); o2.start(t0);
|
|
2960
|
+
LV.ring = { o1, o2, g };
|
|
2961
|
+
} catch {}
|
|
2962
|
+
};
|
|
2963
|
+
const liveRingStop = () => {
|
|
2964
|
+
clearTimeout(LV.ringEnd);
|
|
2965
|
+
const r = LV.ring; LV.ring = null;
|
|
2966
|
+
if (r) { try { r.o1.stop(); r.o2.stop(); r.g.disconnect(); } catch {} }
|
|
2967
|
+
};
|
|
2968
|
+
/** 热身那拍(讲稿 [[echo]] / [[say]])锚的卡:板上的那个元素;节还没拉到 → null */
|
|
2969
|
+
const liveLineCard = (l) => {
|
|
2970
|
+
const i = S.sections.findIndex((x) => x.job === l.job);
|
|
2971
|
+
const ln = i >= 0 ? S.sections[i].lines[l.line] : null;
|
|
2972
|
+
const k = ln ? lineTarget(ln) : null;
|
|
2973
|
+
return k === null ? null : $('#board').querySelector('[data-sec="' + i + '"] [data-card="' + k + '"]');
|
|
2974
|
+
};
|
|
2975
|
+
/** 轮到孩子跟:那张卡亮着(liveStep 的 turn);在听你说时光变实 */
|
|
2976
|
+
const liveTurnUi = () => {
|
|
2977
|
+
const el = LV.on && LV.model.turn && LV.endLine && LV.endLine.echo ? liveLineCard(LV.endLine) : null;
|
|
2978
|
+
for (const x of document.querySelectorAll('#board .c.lv-turn')) if (x !== el) x.classList.remove('lv-turn', 'hear');
|
|
2979
|
+
if (el) { el.classList.add('lv-turn'); el.classList.toggle('hear', LV.model.hearing); }
|
|
2980
|
+
};
|
|
2981
|
+
/** 嘴在说时每 150 毫秒看一眼孩子端的队列放完没有 */
|
|
2982
|
+
const liveDrainWatch = () => {
|
|
2983
|
+
if (LV.drain) return;
|
|
2984
|
+
LV.drain = setInterval(() => {
|
|
2985
|
+
if (LV.model.status === 'mouth' && LV.audio && LV.audio.playing()) return;
|
|
2986
|
+
clearInterval(LV.drain); LV.drain = 0;
|
|
2987
|
+
if (LV.model.status === 'mouth') liveDispatch({ type: 'drained', ...(LV.playLine ? { play: livePlayable(LV.playLine) ? 'open' : 'skip' } : LV.endLine && LV.endLine.echo ? { echo: true } : LV.endLine && LV.endLine.call ? { call: true } : {}) });
|
|
2988
|
+
}, 150);
|
|
2989
|
+
};
|
|
2990
|
+
/** 卡上的声音(点读、单词、示范、回放)要响:实时时问 liveStep(嘴在说、孩子按着、在录都不响);不是实时(家长端、只读)先停别的声音 */
|
|
2991
|
+
const cardSoundOk = () => {
|
|
2992
|
+
if (LV.on) return liveDispatch({ type: 'cardTap' }).some((f) => f.kind === 'playCard');
|
|
2993
|
+
dispatch({ type: 'halt' });
|
|
2994
|
+
return true;
|
|
2995
|
+
};
|
|
2996
|
+
for (const k of ['ended', 'error']) audioEl.addEventListener(k, () => { if (LV.on && LV.model.status === 'card') liveDispatch({ type: 'cardEnded' }); });
|
|
2997
|
+
/** 字幕:在说的那句、上一句;被打断的那句淡、加省略号;什么都还没说写老师的名字;到了上限写上限那句 */
|
|
2998
|
+
/** 一直听的样子:输入条里的麦克风(关着 / 在听你说)、左栏「在听你说」、输入条上那句 */
|
|
2999
|
+
const liveListenUi = () => {
|
|
3000
|
+
const open = LV.on && LV.model.open;
|
|
3001
|
+
document.body.classList.toggle('lv-open', open);
|
|
3002
|
+
const m = $('#lv-mic'); m.innerHTML = LV.model.muted ? ICON.micOff : ICON.micMd; m.classList.toggle('off', LV.model.muted); m.classList.toggle('hear', open && LV.model.hearing); m.setAttribute('aria-label', LV.model.muted ? '开麦' : '关麦');
|
|
3003
|
+
voiceSync();
|
|
3004
|
+
avListen();
|
|
3005
|
+
phIdle();
|
|
3006
|
+
};
|
|
3007
|
+
/** 这一刻的状态凑成 VoiceIn(voice-view.ts);录像里孩子的屏幕照录像这一刻(没有一直听、嘴的音量取不到——老师的弧只按最低深浅画) */
|
|
3008
|
+
const voiceNow = () => {
|
|
3009
|
+
if (!LV.on) {
|
|
3010
|
+
if (S.readonly && !REEL) return voiceIn({ readonly: true });
|
|
3011
|
+
const rh = VB.reelHold;
|
|
3012
|
+
return voiceIn({ status: rh ? 'holding' : VB.reelSpeak ? 'mouth' : 'quiet', pending: S.pending, holdAudio: Boolean(rh && rh.phase !== 'wait'), cancel: Boolean(rh && rh.slide) });
|
|
3013
|
+
}
|
|
3014
|
+
const m = LV.model, holding = m.status === 'holding';
|
|
3015
|
+
return voiceIn({ status: m.status, open: m.open, hearing: m.hearing, ducked: m.ducked, muted: m.muted, turn: m.turn, pending: S.pending, photo: VB.photo, ready: LV.ready, choose: choosing(), holdAudio: holding && hold.audio, cancel: holding && Boolean(press && press.cancelled), silentMs: holding && hold.audio ? Date.now() - Math.max(VB.loudAt, LV.holdAt) : 0, hint: VB.hint });
|
|
3016
|
+
};
|
|
3017
|
+
/** 按钮换成这一刻的样子;手机那颗小头像(#lv-av)的动作跟同一份。按着没声音满 3 秒要换小字:到点再来一次 */
|
|
3018
|
+
function voiceSync() {
|
|
3019
|
+
if (!S.pending) VB.photo = false;
|
|
3020
|
+
const side = document.body.classList.contains('hand-left') ? 'right' : 'left';
|
|
3021
|
+
if (VB.side !== side) { VB.side = side; VB.btn.side(side); }
|
|
3022
|
+
const v = voiceView(voiceNow());
|
|
3023
|
+
VB.btn.set(v);
|
|
3024
|
+
voiceFace($('#lv-av'), v.face);
|
|
3025
|
+
clearTimeout(VB.silentT);
|
|
3026
|
+
if (v.key === 'hold') { const left = VOICE_SILENT_MS - (Date.now() - Math.max(VB.loudAt, LV.holdAt)); if (left > 0) VB.silentT = setTimeout(voiceSync, left + 30); }
|
|
3027
|
+
}
|
|
3028
|
+
/** 刚才那一下的提示:按钮晃一下(每按一次都晃)、字留 2 秒;ph = 手机输入条上那行字 */
|
|
3029
|
+
const voiceHint = (k, ph) => {
|
|
3030
|
+
VB.hint = k; clearTimeout(VB.hintT); VB.hintT = setTimeout(() => { VB.hint = ''; voiceSync(); }, 2000);
|
|
3031
|
+
voiceSync();
|
|
3032
|
+
VB.btn.shake();
|
|
3033
|
+
if (ph) { $('#ph').textContent = ph; clearTimeout(phTimer); phTimer = setTimeout(() => { $('#ph').textContent = PH_IDLE(); }, 2000); }
|
|
3034
|
+
};
|
|
3035
|
+
/** 实时老师页按了按住说话却不接的那几下记一笔 voice-diag(where: busy):看一晚碰到几次、等了多久;st = 嘴那时的状态,还没连上记 connecting */
|
|
3036
|
+
const busyDiag = (st) => {
|
|
3037
|
+
const row = { where: 'busy', tutor: S.tutor ? S.tutor.name : null, live: LV.on, st, waited: S.waitSince ? Date.now() - S.waitSince : null, ua: navigator.userAgent };
|
|
3038
|
+
try { fetch('/api/kid/voice-diag', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(row), keepalive: true }).catch(() => {}); } catch {}
|
|
3039
|
+
};
|
|
3040
|
+
/** 老师在想(脑在写、嘴没在说)时按了按住说话:不接,但要让孩子知道——10-20 晚孩子几次想打断按不动,那时这块关着 pointer-events,按了什么都不发生 */
|
|
3041
|
+
const busyHint = () => { voiceHint('busy', '老师在想,等一下再说'); busyDiag(LV.model.status); };
|
|
3042
|
+
/** 嘴还没连上(刚进老师页、换连接、断了在重连)时按了:不接,说一声 */
|
|
3043
|
+
const connectingHint = () => { voiceHint('connecting', '正在叫老师,等一下'); busyDiag('connecting'); };
|
|
3044
|
+
/** 孩子刚说的那句(平板横屏左栏,字幕上面):节头不再是孩子的话,他说了什么在这里看;老师开口了(或这一轮写完了)变淡,下次说话换掉 */
|
|
3045
|
+
const lvKid = () => {
|
|
3046
|
+
const el = $('#lv-kid');
|
|
3047
|
+
const last = S.sections[S.sections.length - 1];
|
|
3048
|
+
const p = S.pendSaid ? { job: S.pendSaid.job, sd: S.pendSaid.said } : last && last.live && last.said ? { job: last.job, sd: last.said } : null;
|
|
3049
|
+
if (!p || !p.sd || !p.sd.text) { if (el.firstChild) el.replaceChildren(); delete el.dataset.k; return; }
|
|
3050
|
+
const old = LV.heard.has(p.job) || !S.pending;
|
|
3051
|
+
const key = p.job + '|' + p.sd.text;
|
|
3052
|
+
if (el.dataset.k !== key) { el.dataset.k = key; el.replaceChildren(h('span', { class: 'tx' }, p.sd.text)); }
|
|
3053
|
+
el.classList.toggle('old', old);
|
|
3054
|
+
};
|
|
3055
|
+
// ---- 字幕重放:这一轮老师说的,从第一句放(家长的用法是「老师刚讲过了」);声音是服务端留的那份嘴的声音
|
|
3056
|
+
// (孩子按下时清掉、没放出来的也在),没有(以前的轮、mock 以外没念过)就不露。谁让谁在 liveStep;这里只取、放、让字幕跟着走 ----
|
|
3057
|
+
const RP = { job: null, data: null, miss: false, loading: false, el: null, timer: 0, line: -1 };
|
|
3058
|
+
/** 这一轮:板上最后一节实时的、有讲稿的 */
|
|
3059
|
+
const replaySec = () => { for (let i = S.sections.length - 1; i >= 0; i--) { const s = S.sections[i]; if (s.live && s.lines.length) return s; } return null; };
|
|
3060
|
+
const replayUi = () => {
|
|
3061
|
+
const b = $('#lv-replay');
|
|
3062
|
+
const s = LV.on ? replaySec() : null;
|
|
3063
|
+
if (s && RP.job !== s.job && LV.model.status !== 'replay') { RP.job = s.job; RP.data = null; RP.miss = false; }
|
|
3064
|
+
const playing = LV.on && LV.model.status === 'replay';
|
|
3065
|
+
const quiet = LV.on && LV.model.status === 'quiet' && !S.pending && !S.readonly;
|
|
3066
|
+
if (quiet && s && !RP.data && !RP.miss && !RP.loading && S.day && S.tutor) {
|
|
3067
|
+
RP.loading = true;
|
|
3068
|
+
const job = s.job;
|
|
3069
|
+
api('GET', '/api/kid/conversations/' + S.tutor.name + '/mouth/' + S.day.date + '/' + job).then((d) => { RP.loading = false; if (RP.job === job && d && d.audio && Array.isArray(d.lines) && d.lines.length) { RP.data = d; renderSubtitle(); } else if (RP.job === job) RP.miss = true; }, () => { RP.loading = false; if (RP.job === job) RP.miss = true; });
|
|
3070
|
+
}
|
|
3071
|
+
const show = playing || (quiet && Boolean(RP.data));
|
|
3072
|
+
b.hidden = !show;
|
|
3073
|
+
if (!show) return;
|
|
3074
|
+
const key = playing ? 'stop' : 'again';
|
|
3075
|
+
if (b.dataset.k !== key) { b.dataset.k = key; b.querySelector('.ic').innerHTML = playing ? ICON.stop : ICON.again; b.querySelector('.tx').textContent = playing ? '停' : '再听一遍'; b.setAttribute('aria-label', playing ? '停,不听了' : '再听一遍老师这一轮说的'); }
|
|
3076
|
+
b.classList.toggle('stop', playing);
|
|
3077
|
+
};
|
|
3078
|
+
/** 放:从第一句的起点往前让 200 毫秒(段里的起点按字数摊,差一点);字幕跟着 lines[].ms 走;放完告诉 liveStep */
|
|
3079
|
+
const replayPlay = () => {
|
|
3080
|
+
const d = RP.data;
|
|
3081
|
+
if (!d) { setTimeout(() => liveDispatch({ type: 'replayEnded' }), 0); return; }
|
|
3082
|
+
const a = RP.el || (RP.el = new Audio());
|
|
3083
|
+
const from = Math.max(0, d.lines[0].ms - 200);
|
|
3084
|
+
a.onended = () => { clearInterval(RP.timer); RP.line = -1; if (LV.model.status === 'replay') liveDispatch({ type: 'replayEnded' }); };
|
|
3085
|
+
a.src = d.audio + (from ? '#t=' + (from / 1000).toFixed(2) : '');
|
|
3086
|
+
RP.line = 0;
|
|
3087
|
+
const p = a.play(); if (p && p.catch) p.catch(() => { if (LV.model.status === 'replay') liveDispatch({ type: 'replayEnded' }); });
|
|
3088
|
+
clearInterval(RP.timer);
|
|
3089
|
+
RP.timer = setInterval(() => { const t = a.currentTime * 1000; let k = 0; for (let j = 0; j < d.lines.length; j++) if (d.lines[j].ms <= t + 150) k = j; if (k !== RP.line) { RP.line = k; renderSubtitle(); } }, 120);
|
|
3090
|
+
renderSubtitle();
|
|
3091
|
+
};
|
|
3092
|
+
const replayStop = () => { clearInterval(RP.timer); RP.line = -1; if (RP.el) { RP.el.onended = null; RP.el.pause(); } renderSubtitle(); };
|
|
3093
|
+
$('#lv-replay').addEventListener('click', () => { if (!LV.on) return; unlock(); liveDispatch(LV.model.status === 'replay' ? { type: 'replayStop' } : { type: 'replay', pending: S.pending }); });
|
|
3094
|
+
const liveCaption = () => {
|
|
3095
|
+
liveListenUi();
|
|
3096
|
+
liveTurnUi();
|
|
3097
|
+
lvKid();
|
|
3098
|
+
replayUi();
|
|
3099
|
+
// 在重放:上面一行「再听一遍 · 第 n 句,共 m 句」,在说那句前面一个小喇叭;头像不动(不是嘴在说)
|
|
3100
|
+
if (LV.model.status === 'replay' && RP.data && RP.line >= 0) {
|
|
3101
|
+
const sec = S.sections.find((x) => x.job === RP.job), ln = RP.data.lines[RP.line], line = sec && ln ? sec.lines[ln.line] : null;
|
|
3102
|
+
const cap = $('#lv-cap');
|
|
3103
|
+
cap.classList.add('rp'); cap.classList.remove('done');
|
|
3104
|
+
cap.querySelector('.lv-prev').textContent = '再听一遍 · 第 ' + (RP.line + 1) + ' 句,共 ' + RP.data.lines.length + ' 句';
|
|
3105
|
+
const c = cap.querySelector('.lv-cur'); c.classList.remove('cut', 'nm');
|
|
3106
|
+
c.replaceChildren(h('span', { class: 'rp', html: ICON.replay }), line ? plainLine(line.text) : '');
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
$('#lv-cap').classList.remove('rp');
|
|
3110
|
+
const cap = $('#lv-cap'), c = cap.querySelector('.lv-cur');
|
|
3111
|
+
cap.querySelector('.lv-prev').textContent = LV.cap.prev;
|
|
3112
|
+
const cut = Boolean(LV.cap.cut) && LV.cap.cut === LV.cap.cur;
|
|
3113
|
+
c.textContent = LV.cap.cur ? LV.cap.cur + (cut ? '……' : '') : '';
|
|
3114
|
+
c.classList.toggle('cut', cut);
|
|
3115
|
+
c.classList.toggle('nm', !LV.cap.cur);
|
|
3116
|
+
cap.classList.toggle('done', LV.model.status !== 'mouth');
|
|
3117
|
+
cvCap();
|
|
3118
|
+
};
|
|
3119
|
+
/** 字幕换一句:上一句挪上去 */
|
|
3120
|
+
const liveSay = (text) => { if (!text) return; if (LV.cap.cur && LV.cap.cur !== text) LV.cap.prev = LV.cap.cur; LV.cap.cur = text; renderSubtitle(); };
|
|
3121
|
+
/** 服务端说:这一句开始放了(按孩子端的排法算好的时刻)。字幕换成它;那一节露到它、画它的标注、它锚的卡描边 */
|
|
3122
|
+
/** 讲稿 [[play]] 那句锚的小课堂卡:板上的第几节、第几张;节还没拉到 → null */
|
|
3123
|
+
const livePlayAt = (pl) => {
|
|
3124
|
+
const i = S.sections.findIndex((x) => x.job === pl.job);
|
|
3125
|
+
const ln = i >= 0 ? S.sections[i].lines[pl.line] : null;
|
|
3126
|
+
const cue = ln ? ln.cues.find((c) => c.name === 'play') : null;
|
|
3127
|
+
const card = cue ? S.sections[i].cards[cue.card] : null;
|
|
3128
|
+
return card && card.kind === 'lecture' ? { section: i, card: cue.card, ready: lectureReady(card) } : null;
|
|
3129
|
+
};
|
|
3130
|
+
/** 放得了吗:节还没拉到也先当放得了(liveLecture 再拉一次);课包读不出来就不放 */
|
|
3131
|
+
const livePlayable = (pl) => { const at = livePlayAt(pl); return !at || at.ready; };
|
|
3132
|
+
/** 铺满放 [[play]] 那一段(同板书的「交给小课堂」:舞台不关、停在末帧);节还没拉到先拉一次,还没有就算放完了 */
|
|
3133
|
+
const liveLecture = () => {
|
|
3134
|
+
const pl = LV.playLine; LV.playLine = null;
|
|
3135
|
+
const open = () => { const at = pl && livePlayAt(pl); if (at && at.ready) { openStage(at.section, at.card, { delegate: true, autoplay: true }); return true; } return false; };
|
|
3136
|
+
if (open()) return;
|
|
3137
|
+
loadDay(false).then(() => { if (LV.model.status === 'lecture' && !open()) liveDispatch({ type: 'lectureDone' }); }, () => { if (LV.model.status === 'lecture') liveDispatch({ type: 'lectureDone' }); });
|
|
3138
|
+
};
|
|
3139
|
+
const liveRead = (job, line, text, play, echo, call) => {
|
|
3140
|
+
LV.playLine = play ? { job, line } : null;
|
|
3141
|
+
// 热身那拍([[echo]] / [[say]])、打电话那句([[call]]):放完了亮卡 / 响铃
|
|
3142
|
+
LV.endLine = echo || call ? { job, line, echo, call } : null;
|
|
3143
|
+
if (!LV.heard.has(job) || line > LV.heard.get(job)) LV.heard.set(job, line);
|
|
3144
|
+
// 板不跟句子:讲到某句不描边、不画标注,卡落下时就画好了(liveLand);字幕换成这句
|
|
3145
|
+
liveSay(text);
|
|
3146
|
+
};
|
|
3147
|
+
/** 脑开始写这一轮:话题定下来,拉今天(卡、讲稿从那里来) */
|
|
3148
|
+
const liveTurn = (m) => {
|
|
3149
|
+
if (m.job) LV.jobs.add(m.job);
|
|
3150
|
+
// 带着小课堂的那条:这时才知道是哪一轮
|
|
3151
|
+
if (LV.lcPending && S.lecture && m.job) { LV.lcPending = false; const L = S.lecture; if (!L.firstJob) L.firstJob = m.job; L.job = m.job; renderLecturePending(); }
|
|
3152
|
+
if (m.thread) S.thread = m.thread;
|
|
3153
|
+
// 纸是话题还没有时打开的(new):这个话题有了,归它(服务端第一条也改了名;这里再存一份,new 那张删掉)
|
|
3154
|
+
if (m.thread && S.paper && S.paper.key === 'new' && S.tutor && S.paper.tutor === S.tutor.name) { S.paper.key = m.thread; api('PUT', CONV + S.tutor.name + '/paper', { key: m.thread, from: 'new', state: S.paper.card.state }).catch(() => {}); }
|
|
3155
|
+
S.newThread = false; S.via = null;
|
|
3156
|
+
{ const b = $('#board .blank'); if (b) b.remove(); }
|
|
3157
|
+
S.pending = true; if (!S.waitSince) S.waitSince = Date.now();
|
|
3158
|
+
renderHeader(); renderSubtitle();
|
|
3159
|
+
clearTimeout(S.pollTimer); S.pollTimer = setTimeout(() => loadDay(false), 300);
|
|
3160
|
+
};
|
|
3161
|
+
const liveMsg = (m) => {
|
|
3162
|
+
switch (m.t) {
|
|
3163
|
+
case 'ready': { LV.ready = true; LV.retries = 0; phIdle(); voiceSync(); liveMicOn(); const q = LV.queued; LV.queued = null; if (q) liveSendNow(q[0], q[1]); break; }
|
|
3164
|
+
// 一直听:服务端说孩子开口 / 说完;开口时报一次这一轮的上下文(话题、舞台上的卡),收下时当松手的 body
|
|
3165
|
+
case 'vad': if (m.on) lvSend({ t: 'ctx', body: liveBody() }); liveDispatch({ type: 'vad', on: Boolean(m.on) }); break;
|
|
3166
|
+
case 'duck': liveDispatch({ type: 'duck' }); break;
|
|
3167
|
+
case 'unduck': liveDispatch({ type: 'unduck' }); break;
|
|
3168
|
+
case 'down': case 'end': liveDispatch({ type: 'drop' }); break;
|
|
3169
|
+
case 'tutor':
|
|
3170
|
+
// 嘴接住的字一到就上字幕;照念的字幕跟 read 走(到点才亮)
|
|
3171
|
+
if (m.of !== 'catch') break;
|
|
3172
|
+
if (typeof m.done === 'string') { LV.cap.catching = false; if (m.done) LV.cap.cur = m.done; renderSubtitle(); break; }
|
|
3173
|
+
if (!m.delta) break;
|
|
3174
|
+
// 接住开始:上一句念的热身那拍、打电话那句不再算(放完的是接住,不亮卡、不响铃)
|
|
3175
|
+
if (!LV.cap.catching) { LV.cap.catching = true; LV.endLine = null; LV.playLine = null; if (LV.cap.cur) LV.cap.prev = LV.cap.cur; LV.cap.cur = ''; }
|
|
3176
|
+
LV.cap.cur += m.delta; renderSubtitle();
|
|
3177
|
+
break;
|
|
3178
|
+
case 'read': liveRead(String(m.job || ''), Number(m.line), String(m.text || ''), m.play === true, m.echo === true, m.call === true); break;
|
|
3179
|
+
// 转写是空的(按了没说清):嘴说没听清,不会有这一轮
|
|
3180
|
+
case 'kid': if (!String(m.text || '').trim()) { S.pending = false; renderSubtitle(); } else if (LV.model.open) { S.pending = true; if (!S.waitSince) S.waitSince = Date.now(); renderSubtitle(); } break;
|
|
3181
|
+
case 'turn': liveTurn(m); break;
|
|
3182
|
+
case 'chat': canvasChatAck(m); break;
|
|
3183
|
+
case 'flush': liveDispatch({ type: 'flush' }); break;
|
|
3184
|
+
case 'err': S.pending = false; renderSubtitle(); break;
|
|
3185
|
+
default: break;
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3188
|
+
/** 一直听时推不推:liveStep 的(关麦、卡的声音、小课堂那一段)再加页面的——这会儿能不能发(小课堂还没看完、「接着那个还是重新开始」、只读)、到了每天的上限;
|
|
3189
|
+
* 变了才告诉服务端 gate(关的时候孩子说到一半的那句作废)。每块声音来时看一眼,能不能发变了不用另外通知 */
|
|
3190
|
+
const liveGateSync = () => {
|
|
3191
|
+
const on = liveGate(LV.model) && canSend();
|
|
3192
|
+
if (on !== LV.gateOn) { LV.gateOn = on; lvSend({ t: 'gate', on }); }
|
|
3193
|
+
return on;
|
|
3194
|
+
};
|
|
3195
|
+
/** 一直听要那路常开的麦克风接到采音节点上(按住说是按下时才接) */
|
|
3196
|
+
const liveMicOn = () => {
|
|
3197
|
+
if (!LV.on || !LV.model.open || !LV.audio) return;
|
|
3198
|
+
micOpen().then(() => (mic.stream && LV.audio ? LV.audio.use(mic.stream) : null)).catch(() => {});
|
|
3199
|
+
};
|
|
3200
|
+
/** 孩子这一句的上下文(话题、设备、首页按钮、舞台上的卡):按住说松手时带上,一直听开口时报一次 */
|
|
3201
|
+
const liveBody = () => {
|
|
3202
|
+
const body = { device: S.device };
|
|
3203
|
+
if (S.newThread) body.newThread = true; else if (S.thread) body.thread = S.thread;
|
|
3204
|
+
if (S.via) body.via = S.via;
|
|
3205
|
+
if (S.stage && !S.stage.paper) body.focus = { card: S.stage.id };
|
|
3206
|
+
return body;
|
|
3207
|
+
};
|
|
3208
|
+
/**
|
|
3209
|
+
* 一直听还是按住说:口语课页过来的一直听(热身、打电话一问一答);别的照 cotutor.json 的 live.listen,缺省按住说。
|
|
3210
|
+
* 孩子端没有开关:10-20 真跑,孩子想一下停住、「老师。」「那。」、旁边的话都成了一句
|
|
3211
|
+
*/
|
|
3212
|
+
/** 老师页一律是实时老师页的样子(左栏 + 白板),连不连嘴都一样:以前的话题、不是实时的录像也照它(2026-10-28 起;原来退回底部字幕行那一版) */
|
|
3213
|
+
const liveFace = (t) => {
|
|
3214
|
+
document.body.classList.add('live');
|
|
3215
|
+
$('#tutor').style.setProperty('--tc', colorOf(t));
|
|
3216
|
+
$('#lv-av').replaceChildren(avatarEl(t));
|
|
3217
|
+
VB.btn.avatar(avatarEl(t)); VB.btn.color(colorOf(t));
|
|
3218
|
+
};
|
|
3219
|
+
const liveOpen = (intent) => {
|
|
3220
|
+
if (LV.ws) liveClose();
|
|
3221
|
+
liveDispatch({ type: 'reset' });
|
|
3222
|
+
LV.gateOn = false;
|
|
3223
|
+
liveDispatch({ type: 'listen', open: Boolean(intent && intent.talk) || Boolean(S.tutor && S.tutor.listen === 'open') });
|
|
3224
|
+
LV.on = true; LV.ready = false; LV.cap = { prev: '', cur: '', cut: '', catching: false }; LV.heard = new Map(); LV.jobs = new Set();
|
|
3225
|
+
liveFace(S.tutor);
|
|
3226
|
+
if (!LV.audio) {
|
|
3227
|
+
LV.audio = makeAudio();
|
|
3228
|
+
LV.audio.muted = false;
|
|
3229
|
+
// 按着才推;一直听时推不推看 liveGate(关麦、卡的声音、小课堂时不推)
|
|
3230
|
+
// 松了手、还在等画(边画边聊要那张图、给老师看在导出上传):状态还是按着,声音不再推
|
|
3231
|
+
LV.audio.onChunk = (ab) => { if (((LV.model.status === 'holding' && !LV.letGo) || liveGateSync()) && LV.ws && LV.ws.readyState === 1) LV.ws.send(ab); };
|
|
3232
|
+
LV.audio.onLevel = (v) => {
|
|
3233
|
+
if (LV.model.status === 'holding') {
|
|
3234
|
+
const lv = Math.min(1, v * 4);
|
|
3235
|
+
holdLevel(lv); VB.btn.level('kid', lv);
|
|
3236
|
+
// 有声音了:3 秒没声音那句小字收回
|
|
3237
|
+
if (lv > 0.12) { VB.loudAt = Date.now(); if (VB.btn.el.dataset.voice === 'hold-silent') voiceSync(); }
|
|
3238
|
+
if (LV.diag && v > LV.diag.peak) LV.diag.peak = v;
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
if (LV.gateOn) { const lv = Math.min(1, v * 6); $('#lv-mic').style.setProperty('--lv', String(lv)); VB.btn.level('kid', lv); }
|
|
3242
|
+
};
|
|
3243
|
+
}
|
|
3244
|
+
LV.audio.wake().catch(() => {});
|
|
3245
|
+
// 麦克风之前开在别的 AudioContext 里(板书页开的):放掉,下面 micWarm 重开在实时这个里
|
|
3246
|
+
if (mic.ac && mic.ac !== LV.audio.ctx) micDrop();
|
|
3247
|
+
LV.retries = 0;
|
|
3248
|
+
liveConnect();
|
|
3249
|
+
};
|
|
3250
|
+
/** 连嘴:开 WebSocket(进老师页、断了以后重连都走这里);连上(ready)之前按住说话不接,打字排着 */
|
|
3251
|
+
const liveConnect = () => {
|
|
3252
|
+
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/api/live/' + encodeURIComponent(S.tutor.name) + '/ws' + (LV.model.open ? '?listen=open' : ''));
|
|
3253
|
+
ws.binaryType = 'arraybuffer';
|
|
3254
|
+
LV.ws = ws;
|
|
3255
|
+
let wsOpenAt = 0;
|
|
3256
|
+
ws.onopen = () => { wsOpenAt = Date.now(); if (LV.ws === ws) lvSend({ t: 'view', w: innerWidth, h: innerHeight }); };
|
|
3257
|
+
ws.onmessage = (e) => {
|
|
3258
|
+
if (LV.ws !== ws) return;
|
|
3259
|
+
if (typeof e.data !== 'string') { liveDispatch({ type: 'audio' }, e.data); return; }
|
|
3260
|
+
let m; try { m = JSON.parse(e.data); } catch { return; }
|
|
3261
|
+
liveMsg(m);
|
|
3262
|
+
};
|
|
3263
|
+
// 不是孩子自己收的(liveClose 先摘了 onclose):记一笔流水,没连上还是连上又断(7.3)
|
|
3264
|
+
ws.onclose = () => { if (LV.ws === ws) { kidLog.live(wsOpenAt > 0, wsOpenAt ? Date.now() - wsOpenAt : 0); liveDispatch({ type: 'drop' }); } };
|
|
3265
|
+
micWarm();
|
|
3266
|
+
};
|
|
3267
|
+
/** 连不上、断了(liveStep 的 retry):收掉这条连接,回到「正在叫老师」,2、4、8… 秒后再连,最长 30 秒;一直连不上就一直等(原因在家长端的流水里) */
|
|
3268
|
+
const liveRetry = () => {
|
|
3269
|
+
const ws = LV.ws; LV.ws = null; LV.ready = false;
|
|
3270
|
+
if (ws) { ws.onclose = null; try { ws.close(); } catch {} }
|
|
3271
|
+
clearTimeout(LV.retryTimer);
|
|
3272
|
+
if (!LV.on || !S.tutor) return;
|
|
3273
|
+
LV.retries = (LV.retries || 0) + 1;
|
|
3274
|
+
phIdle(); voiceSync(); renderSubtitle();
|
|
3275
|
+
LV.retryTimer = setTimeout(() => { if (LV.on && S.tutor && !LV.ws) liveConnect(); }, Math.min(30000, 1000 * 2 ** Math.min(LV.retries, 5)));
|
|
3276
|
+
};
|
|
3277
|
+
/** 实时那个 AudioContext 关掉(连着它的麦克风也放掉);下次要用(进实时、开麦克风、按住)再起新的 */
|
|
3278
|
+
const liveAudioDrop = () => {
|
|
3279
|
+
if (!LV.audio || !LV.audio.ctx) return;
|
|
3280
|
+
if (mic.ac === LV.audio.ctx) micDrop();
|
|
3281
|
+
LV.audio.close();
|
|
3282
|
+
};
|
|
3283
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) liveAudioDrop(); else liveMicOn(); });
|
|
3284
|
+
// 输入条里的麦克风(一直听时):关麦 / 开麦
|
|
3285
|
+
$('#lv-mic').addEventListener('click', (e) => { e.stopPropagation(); if (LV.on) liveDispatch({ type: LV.model.muted ? 'unmute' : 'mute' }); });
|
|
3286
|
+
/** 收:告诉服务端挂了、关连接、清嘴的声音、关实时那个 AudioContext;页面回到板书的样子(按着的那一下作废) */
|
|
3287
|
+
const liveClose = () => {
|
|
3288
|
+
const ws = LV.ws; LV.ws = null; LV.ready = false; LV.queued = null; clearTimeout(LV.retryTimer);
|
|
3289
|
+
if (ws) { ws.onclose = null; try { if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'bye' })); ws.close(); } catch {} }
|
|
3290
|
+
liveAudioDrop();
|
|
3291
|
+
clearInterval(LV.drain); LV.drain = 0;
|
|
3292
|
+
if (!LV.on) return;
|
|
3293
|
+
LV.on = false;
|
|
3294
|
+
document.body.classList.remove('speaking', 'lv-open');
|
|
3295
|
+
if (press && press.live) { clearTimeout(press.timer); press = null; holdEnd('cancel'); setBar(barNext(S.bar, 'holdCancel')); }
|
|
3296
|
+
phIdle();
|
|
3297
|
+
if (S.tutor) renderSubtitle();
|
|
3298
|
+
};
|
|
3299
|
+
/** 实时的一次按住记一行诊断(/api/kid/voice-diag,where: live):麦克风怎么开的、多久能推声音、推上去的音量峰值、松手时怎么收的;真机上出错是静默的,只有这份能说清 */
|
|
3300
|
+
const liveDiagOf = () => { const t0 = Date.now(); const d = { where: 'live', tutor: S.tutor ? S.tutor.name : null, ua: navigator.userAgent, peak: 0, ev: [] }; d.mark = (k, v) => d.ev.push(v === undefined ? [k, Date.now() - t0] : [k, Date.now() - t0, v]); return d; };
|
|
3301
|
+
const liveDiagSend = (d) => { if (!d) return; const { mark: _m, ...row } = d; row.peak = Math.round(row.peak * 100) / 100; try { fetch('/api/kid/voice-diag', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(row), keepalive: true }).catch(() => {}); } catch {} };
|
|
3302
|
+
/** 按满 150 毫秒:嘴、卡的声音停(liveStep),告诉服务端 hold,开始推声音(常开那路麦克风接到采音节点上,第一次要等它开好) */
|
|
3303
|
+
const liveHold = () => {
|
|
3304
|
+
// 画板开着:按的是「给老师看」= 画和这句话给脑;按头像 = 边画边聊,给嘴,先要一张这一刻的画
|
|
3305
|
+
LV.holdKind = press && press.give ? 'give' : canvasNow() && !LV.model.open ? 'chat' : null;
|
|
3306
|
+
LV.letGo = false;
|
|
3307
|
+
CV.snapP = LV.holdKind === 'chat' ? canvasSnap() : null;
|
|
3308
|
+
$('#st-go').classList.toggle('holding', LV.holdKind === 'give');
|
|
3309
|
+
setBar(barNext(S.bar, 'holdStart'));
|
|
3310
|
+
dispatch({ type: 'halt' });
|
|
3311
|
+
hold.audio = false; renderHold();
|
|
3312
|
+
holdRec('down');
|
|
3313
|
+
LV.holdAt = Date.now();
|
|
3314
|
+
const d = LV.diag = liveDiagOf();
|
|
3315
|
+
liveDispatch({ type: 'press' });
|
|
3316
|
+
if (LV.model.cut) LV.cap.cut = LV.cap.cur;
|
|
3317
|
+
renderSubtitle();
|
|
3318
|
+
micOpen().then((how) => { d.mark('mic', how); return mic.stream && LV.audio ? LV.audio.use(mic.stream) : Promise.reject(new Error('no mic')); }).then(() => { d.mark('audio', LV.audio.ctx ? LV.audio.ctx.state : ''); if (press && press.live) { hold.audio = true; renderHold(); holdRec('audio'); } }, (e) => d.mark('fail', String(e && (e.name || e.message))));
|
|
3319
|
+
};
|
|
3320
|
+
/** 松手:告诉服务端 release(嘴马上接住);上滑、太短(不到 0.4 秒)、麦克风没开上 = 作废(hush)——麦克风没开上时输入条上说「没听清」,不能什么都不出(2026-10-19 真机:按了几次都没反应)。
|
|
3321
|
+
* sys = 不是孩子松的手,是系统抢走了这次触摸(pointercancel:iPadOS 底部手势等);行为照旧发出去,voice-diag 记 cancel 不记 up,好分清 */
|
|
3322
|
+
const liveRelease = (cancelled, sys) => {
|
|
3323
|
+
const long = Date.now() - LV.holdAt >= 400;
|
|
3324
|
+
const keep = !cancelled && hold.audio && long;
|
|
3325
|
+
const kind = LV.holdKind; LV.holdKind = null;
|
|
3326
|
+
const snapP = CV.snapP; CV.snapP = null;
|
|
3327
|
+
$('#st-go').classList.remove('holding');
|
|
3328
|
+
const d = LV.diag; LV.diag = null;
|
|
3329
|
+
if (d) { d.mark(sys ? 'cancel' : 'up', keep ? 'sent' : cancelled ? 'slide' : !long ? 'short' : 'nomic'); liveDiagSend(d); }
|
|
3330
|
+
holdEnd(keep ? 'sent' : cancelled ? 'cancel' : 'unclear');
|
|
3331
|
+
setBar(barNext(S.bar, keep ? 'holdEnd' : 'holdCancel'));
|
|
3332
|
+
if (LV.audio) LV.audio.wake().catch(() => {});
|
|
3333
|
+
if (!LV.on) return;
|
|
3334
|
+
if (!keep) {
|
|
3335
|
+
liveDispatch({ type: 'cancel' });
|
|
3336
|
+
// 按住「给老师看」没说出话(太短、麦克风没开上):照轻点交画;上滑取消的不交
|
|
3337
|
+
if (kind === 'give' && !cancelled) { giveTap(); renderSubtitle(); return; }
|
|
3338
|
+
if (!cancelled) voiceHint('unclear', long ? '没听清,再按住说一次' : '');
|
|
3339
|
+
renderSubtitle();
|
|
3340
|
+
return;
|
|
3341
|
+
}
|
|
3342
|
+
// 边画边聊:等那张画(最多 1.5 秒),松手带 chat;嘴接住,不起脑(板上不等老师)
|
|
3343
|
+
// 等画的这一会儿(LV.letGo):声音不推、别的按钮不接,发出去才放开
|
|
3344
|
+
if (kind === 'chat' && canvasNow() && S.stage) {
|
|
3345
|
+
const canvas = S.stage.id;
|
|
3346
|
+
LV.letGo = true;
|
|
3347
|
+
Promise.resolve(snapP).then((img) => {
|
|
3348
|
+
LV.letGo = false;
|
|
3349
|
+
LV.chatNext = { canvas, ...(img ? { image: img } : {}), ask: canvasAsk(null) };
|
|
3350
|
+
liveDispatch({ type: 'release' }, liveBody());
|
|
3351
|
+
LV.chatNext = null;
|
|
3352
|
+
if (CV.chat) CV.chat = canvasChatAnswered(CV.chat, Date.now());
|
|
3353
|
+
});
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
// 「给老师看」按住说:舞台导出、存好 / 传好那张画,这一句带着它交给脑(没说清服务端照交画);画没交成这一下作废
|
|
3357
|
+
if (kind === 'give' && canvasNow()) {
|
|
3358
|
+
LV.letGo = true;
|
|
3359
|
+
canvasGive().then((patch) => {
|
|
3360
|
+
LV.letGo = false;
|
|
3361
|
+
if (!patch) { liveDispatch({ type: 'cancel' }); renderSubtitle(); return; }
|
|
3362
|
+
liveDispatch({ type: 'release' }, { ...liveBody(), ...patch });
|
|
3363
|
+
S.pending = true; S.waitSince = Date.now(); renderSubtitle();
|
|
3364
|
+
});
|
|
3365
|
+
return;
|
|
3366
|
+
}
|
|
3367
|
+
const body = liveBody();
|
|
3368
|
+
// 小课堂看完问的:带上看的情况与画的(截图先传上去)
|
|
3369
|
+
const go = (lb) => {
|
|
3370
|
+
if (lb) { body.lecture = lb; lectureSent(null); LV.lcPending = true; }
|
|
3371
|
+
liveDispatch({ type: 'release' }, body);
|
|
3372
|
+
S.pending = true; S.waitSince = Date.now(); renderSubtitle();
|
|
3373
|
+
};
|
|
3374
|
+
if (S.lecture) lectureBody().then(go, () => go(null)); else go(null);
|
|
3375
|
+
};
|
|
3376
|
+
/** 打字、拍照、交卡:同说了一句,脑写(liveStep 的 send);舞台开着带 focus 那张卡 */
|
|
3377
|
+
const liveSendNow = (text, opts) => {
|
|
3378
|
+
dispatch({ type: 'halt' });
|
|
3379
|
+
const body = { text, device: S.device };
|
|
3380
|
+
if (opts.action) body.action = opts.action;
|
|
3381
|
+
if (opts.photos && opts.photos.length) { body.photos = opts.photos; VB.photo = true; }
|
|
3382
|
+
if (opts.drawing) body.drawing = opts.drawing;
|
|
3383
|
+
const focus = opts.focus || (S.stage && !S.stage.paper ? { card: S.stage.id } : null);
|
|
3384
|
+
if (focus) body.focus = focus;
|
|
3385
|
+
if (S.newThread) body.newThread = true; else if (S.thread) body.thread = S.thread;
|
|
3386
|
+
if (S.via) body.via = S.via;
|
|
3387
|
+
if (S.talk) { body.talk = { id: S.talk }; S.talk = null; }
|
|
3388
|
+
const go = (lb) => {
|
|
3389
|
+
if (lb) { body.lecture = lb; lectureSent(null); LV.lcPending = true; }
|
|
3390
|
+
liveDispatch({ type: 'send' }, body);
|
|
3391
|
+
if (LV.model.cut) LV.cap.cut = LV.cap.cur;
|
|
3392
|
+
S.pending = true; S.waitSince = Date.now(); renderSubtitle();
|
|
3393
|
+
};
|
|
3394
|
+
if (S.lecture) lectureBody().then(go, () => go(null)); else go(null);
|
|
3395
|
+
};
|
|
3396
|
+
/** 按住说话的那块:输入条中间那段(点一下 = 打字)、平板横屏实时左栏浮层里那块(点一下 = 一直听时关麦 / 开麦,别的时候提示要按住);
|
|
3397
|
+
* 上滑过 cancelPx 松手取消(浮层那块大,门限放宽到它的高) */
|
|
3398
|
+
const bindHold = (el, cancelPx, onTap) => {
|
|
3399
|
+
el.addEventListener('pointerdown', (e) => {
|
|
3400
|
+
if (S.bar === 'typing' || LV.letGo) return;
|
|
3401
|
+
e.preventDefault(); unlock();
|
|
3402
|
+
// 先点上面一个(按钮写着「点上面开始」)、看以前的话题:不接,晃一下
|
|
3403
|
+
if (choosing() || (S.readonly && !REEL)) { VB.btn.shake(); return; }
|
|
3404
|
+
// 嘴还没连上:不接,说一声
|
|
3405
|
+
if (LV.on && !LV.ready) { connectingHint(); return; }
|
|
3406
|
+
// 老师在想:不接,说一声(busyHint);嘴一出声 body.pending 就摘掉,能按。一直听时这颗是开关麦,照旧接
|
|
3407
|
+
if (document.body.classList.contains('pending') && !(LV.on && LV.model.open)) { busyHint(); return; }
|
|
3408
|
+
if (LV.audio && LV.on) LV.audio.wake().catch(() => {});
|
|
3409
|
+
press = { y: e.clientY, cancelled: false, held: false, live: false, cancelPx, onTap, timer: setTimeout(() => {
|
|
3410
|
+
if (!press) return;
|
|
3411
|
+
// 实时:按住推声音给嘴;一直听时这颗是开关麦,不是按住说话
|
|
3412
|
+
// 一直听:那条不是按住说话,按久了也当点了一下(打字)
|
|
3413
|
+
if (LV.on && LV.ready && !LV.model.open) { press.held = true; press.live = true; liveHold(); }
|
|
3414
|
+
}, 150) };
|
|
3415
|
+
try { el.setPointerCapture(e.pointerId); } catch {}
|
|
3416
|
+
});
|
|
3417
|
+
el.addEventListener('pointermove', (e) => { if (press && press.held) { const up = press.y - e.clientY > press.cancelPx; if (up !== press.cancelled) { press.cancelled = up; renderHold(); holdRec('slide', { on: up }); } } });
|
|
3418
|
+
/** pointerup = 孩子松手;pointercancel = 系统抢走了触摸(iPadOS 底部手势),照旧当松手,实录与 voice-diag 分开记 */
|
|
3419
|
+
const release = (e) => {
|
|
3420
|
+
if (!press) return;
|
|
3421
|
+
const sys = Boolean(e && e.type === 'pointercancel');
|
|
3422
|
+
clearTimeout(press.timer);
|
|
3423
|
+
const p = press; press = null;
|
|
3424
|
+
if (!p.held) { p.onTap(); return; }
|
|
3425
|
+
if (p.live) liveRelease(p.cancelled, sys);
|
|
3426
|
+
};
|
|
3427
|
+
el.addEventListener('pointerup', release);
|
|
3428
|
+
el.addEventListener('pointercancel', release);
|
|
3429
|
+
el.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
3430
|
+
};
|
|
3431
|
+
bindHold(mid, 60, () => setBar(barNext(S.bar, 'tap')));
|
|
3432
|
+
bindHold($('#lv-hold'), 120, () => {
|
|
3433
|
+
if (LV.on && LV.model.open) { liveDispatch({ type: LV.model.muted ? 'unmute' : 'mute' }); return; }
|
|
3434
|
+
// 点了一下没按住:晃一下提示,不打字(打字是下面那颗)
|
|
3435
|
+
voiceHint('tap');
|
|
3436
|
+
});
|
|
3437
|
+
// 画板铺满时工具列底下那颗小头像:同左栏那颗;画板开着按住说 = 边画边聊,给嘴
|
|
3438
|
+
bindHold($('#cv-hold'), 80, () => voiceHint('tap'));
|
|
3439
|
+
/**
|
|
3440
|
+
* 「给老师看」(画板):轻点 = 只交画;按住说(满 300 毫秒,比头像长一点,免得点一下成了按住)= 画和这句话一起给老师。
|
|
3441
|
+
* 嘴没连上、一直听、老师在想、不是画板:只能点(点了照旧交)
|
|
3442
|
+
*/
|
|
3443
|
+
const bindGive = (el) => {
|
|
3444
|
+
let tapAt = 0;
|
|
3445
|
+
el.addEventListener('pointerdown', (e) => {
|
|
3446
|
+
if (el.disabled || press || LV.letGo || !canvasNow() || !LV.on || !LV.ready || LV.model.open || S.bar === 'typing' || document.body.classList.contains('pending')) return;
|
|
3447
|
+
e.preventDefault(); unlock();
|
|
3448
|
+
if (LV.audio) LV.audio.wake().catch(() => {});
|
|
3449
|
+
press = { y: e.clientY, cancelled: false, held: false, live: false, give: true, cancelPx: 80, onTap: giveTap, timer: setTimeout(() => { if (!press || !press.give) return; press.held = true; press.live = true; liveHold(); }, 300) };
|
|
3450
|
+
try { el.setPointerCapture(e.pointerId); } catch {}
|
|
3451
|
+
});
|
|
3452
|
+
el.addEventListener('pointermove', (e) => { if (press && press.give && press.held) { const up = press.y - e.clientY > press.cancelPx; if (up !== press.cancelled) { press.cancelled = up; renderHold(); holdRec('slide', { on: up }); } } });
|
|
3453
|
+
const up = (e) => {
|
|
3454
|
+
if (!press || !press.give) return;
|
|
3455
|
+
const sys = Boolean(e && e.type === 'pointercancel');
|
|
3456
|
+
clearTimeout(press.timer);
|
|
3457
|
+
const p = press; press = null;
|
|
3458
|
+
tapAt = Date.now();
|
|
3459
|
+
if (!p.held) { p.onTap(); return; }
|
|
3460
|
+
if (p.live) liveRelease(p.cancelled, sys);
|
|
3461
|
+
};
|
|
3462
|
+
el.addEventListener('pointerup', up);
|
|
3463
|
+
el.addEventListener('pointercancel', up);
|
|
3464
|
+
el.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
3465
|
+
// 没走按住的那几种(嘴没连上、不是画板、键盘):照点
|
|
3466
|
+
el.addEventListener('click', () => { if (Date.now() - tapAt < 800 || LV.letGo) return; giveTap(); });
|
|
3467
|
+
};
|
|
3468
|
+
bindGive($('#st-go'));
|
|
3469
|
+
// 画板:左栏第一颗;手机、竖屏在加号那张单子里
|
|
3470
|
+
$('#lv-paper .ic').innerHTML = ICON.paper; $('#ic-paper').innerHTML = ICON.paper;
|
|
3471
|
+
$('#lv-paper').addEventListener('click', () => openPaper());
|
|
3472
|
+
$('#sheet-paper').addEventListener('click', () => { $('#sheet').classList.remove('on'); openPaper(); });
|
|
3473
|
+
// 左栏最底下那排:拍照、相册走输入条与加号面板的那两个 file input(wireFile),打字同点输入条中间那段
|
|
3474
|
+
$('#lv-cam .ic').innerHTML = ICON.cameraLg; $('#lv-album .ic').innerHTML = ICON.album; $('#lv-type .ic').innerHTML = ICON.keyboard;
|
|
3475
|
+
$('#lv-cam').addEventListener('click', () => $('#cam input').click());
|
|
3476
|
+
$('#lv-album').addEventListener('click', () => $('#sheet input[multiple]').click());
|
|
3477
|
+
$('#lv-type').addEventListener('click', () => setBar(barNext(S.bar, 'tap')));
|
|
3478
|
+
|
|
3479
|
+
// ---- 发照片屏:拍 / 选了先到这里——看一眼、裁剪 / 圈画 / 转 90°、配一句话,再一条消息发出去 ----
|
|
3480
|
+
// 坐标都在 photo-edit(内联在上面),这里只管画和点。原图只留 objectURL,导出时再解一次(几张 12MP 常驻内存 iPad 吃不消)
|
|
3481
|
+
const PS = { items: [], at: 0, mode: 'view', busy: false, work: null, drag: null, pen: null, sc: 1, R: null, raf: 0 };
|
|
3482
|
+
const psEl = $('#ps'), cv = $('#ps-cv'), cropEl = $('#ps-crop');
|
|
3483
|
+
const loadImg = (url) => new Promise((resolve, reject) => { const im = new Image(); im.onload = () => resolve(im); im.onerror = () => reject(new Error('图读不出')); im.src = url; });
|
|
3484
|
+
/** 一张:解一次拿宽高,留一张长边 ≤ 2048 的显示用底图;解不出 → null(不进缩略图条,不当成不通) */
|
|
3485
|
+
const psItem = async (file) => {
|
|
3486
|
+
const url = URL.createObjectURL(file);
|
|
3487
|
+
try {
|
|
3488
|
+
const im = await loadImg(url);
|
|
3489
|
+
const w = im.naturalWidth, hh = im.naturalHeight;
|
|
3490
|
+
if (!w || !hh) throw new Error('空图');
|
|
3491
|
+
const k = Math.min(1, 2048 / Math.max(w, hh));
|
|
3492
|
+
const disp = document.createElement('canvas'); disp.width = Math.max(1, Math.round(w * k)); disp.height = Math.max(1, Math.round(hh * k));
|
|
3493
|
+
disp.getContext('2d').drawImage(im, 0, 0, disp.width, disp.height);
|
|
3494
|
+
return { url, disp, edit: newEdit(w, hh), path: null };
|
|
3495
|
+
} catch { URL.revokeObjectURL(url); return null; }
|
|
3496
|
+
};
|
|
3497
|
+
const drawStrokes = (ctx, strokes) => {
|
|
3498
|
+
ctx.strokeStyle = PEN_COLOR; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
|
3499
|
+
for (const st of strokes) {
|
|
3500
|
+
ctx.lineWidth = st.width; ctx.beginPath(); ctx.moveTo(st.pts[0].x, st.pts[0].y);
|
|
3501
|
+
for (const p of st.pts.slice(1)) ctx.lineTo(p.x, p.y);
|
|
3502
|
+
if (st.pts.length === 1) ctx.lineTo(st.pts[0].x + 0.01, st.pts[0].y);
|
|
3503
|
+
ctx.stroke();
|
|
3504
|
+
}
|
|
3505
|
+
};
|
|
3506
|
+
/** 把编辑后的图里 R 那块画上去:图坐标 × sc = 画布 px;src 是原图或显示底图(都按原图宽高铺) */
|
|
3507
|
+
const paintEdit = (ctx, src, e, R, sc) => {
|
|
3508
|
+
ctx.setTransform(sc, 0, 0, sc, -R.x * sc, -R.y * sc);
|
|
3509
|
+
const m = rotMatrix(e); ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
|
3510
|
+
ctx.drawImage(src, 0, 0, e.w, e.h);
|
|
3511
|
+
ctx.setTransform(sc, 0, 0, sc, -R.x * sc, -R.y * sc);
|
|
3512
|
+
drawStrokes(ctx, e.strokes);
|
|
3513
|
+
};
|
|
3514
|
+
const psExport = async (it) => {
|
|
3515
|
+
const im = await loadImg(it.url);
|
|
3516
|
+
const plan = exportPlan(it.edit);
|
|
3517
|
+
const c = document.createElement('canvas'); c.width = plan.w; c.height = plan.h;
|
|
3518
|
+
paintEdit(c.getContext('2d'), im, it.edit, plan.src, plan.scale);
|
|
3519
|
+
return c.toDataURL('image/jpeg', 0.82);
|
|
3520
|
+
};
|
|
3521
|
+
const psCur = () => PS.items[PS.at];
|
|
3522
|
+
const psCropFrame = () => {
|
|
3523
|
+
const r = PS.work, sc = PS.sc, W = PS.R.w * sc, H = PS.R.h * sc;
|
|
3524
|
+
const x = r.x * sc, y = r.y * sc, w = r.w * sc, hh = r.h * sc;
|
|
3525
|
+
const put = (el, l, t, ww, h2) => { el.style.left = l + 'px'; el.style.top = t + 'px'; el.style.width = Math.max(0, ww) + 'px'; el.style.height = Math.max(0, h2) + 'px'; };
|
|
3526
|
+
const d = cropEl.querySelectorAll('.dim');
|
|
3527
|
+
put(d[0], 0, 0, W, y); put(d[1], 0, y + hh, W, H - y - hh); put(d[2], 0, y, x, hh); put(d[3], x + w, y, W - x - w, hh);
|
|
3528
|
+
put(cropEl.querySelector('.fr'), x, y, w, hh);
|
|
3529
|
+
};
|
|
3530
|
+
/** 画当前那张:裁剪时看整张 + 框,其余时候看裁好的那块 + 笔画 */
|
|
3531
|
+
const psRender = () => {
|
|
3532
|
+
const it = psCur(); if (!it) return;
|
|
3533
|
+
const e = it.edit, full = rotatedSize(e);
|
|
3534
|
+
const R = PS.mode === 'crop' ? { x: 0, y: 0, w: full.w, h: full.h } : region(e);
|
|
3535
|
+
const box = $('#ps .view'), cs = getComputedStyle(box);
|
|
3536
|
+
const bw = box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), bh = box.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
|
3537
|
+
const sc = fitScale(R.w, R.h, Math.max(bw, 40), Math.max(bh, 40));
|
|
3538
|
+
PS.sc = sc; PS.R = R;
|
|
3539
|
+
const dw = Math.round(R.w * sc), dh = Math.round(R.h * sc), dpr = window.devicePixelRatio || 1;
|
|
3540
|
+
cv.style.width = dw + 'px'; cv.style.height = dh + 'px';
|
|
3541
|
+
const pw = Math.round(dw * dpr), ph = Math.round(dh * dpr);
|
|
3542
|
+
if (cv.width !== pw || cv.height !== ph) { cv.width = pw; cv.height = ph; }
|
|
3543
|
+
const ctx = cv.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, pw, ph);
|
|
3544
|
+
paintEdit(ctx, it.disp, e, R, sc * dpr);
|
|
3545
|
+
if (PS.mode === 'crop') psCropFrame();
|
|
3546
|
+
};
|
|
3547
|
+
const psDraw = () => { if (!PS.raf) PS.raf = requestAnimationFrame(() => { PS.raf = 0; psRender(); }); };
|
|
3548
|
+
const psUi = () => {
|
|
3549
|
+
const strip = $('#ps-strip');
|
|
3550
|
+
strip.replaceChildren(...(PS.items.length > 1 ? PS.items.map((it, i) => h('div', { class: 't' + (i === PS.at ? ' on' : ''), on: { click: () => { if (PS.busy || i === PS.at) return; PS.at = i; psUi(); psRender(); } } }, h('img', { src: it.url, alt: '' }), h('button', { type: 'button', class: 'x', 'aria-label': '去掉这张', html: ICON.closeSm, on: { click: (e) => { e.stopPropagation(); psDrop(i); } } }))) : []));
|
|
3551
|
+
strip.style.display = PS.mode === 'view' && PS.items.length > 1 ? '' : 'none';
|
|
3552
|
+
$('#ps-go').disabled = PS.busy;
|
|
3553
|
+
const n = psCur() ? psCur().edit.strokes.length : 0;
|
|
3554
|
+
$('#ps-p-undo').disabled = !n; $('#ps-p-clear').disabled = !n;
|
|
3555
|
+
};
|
|
3556
|
+
const psMode = (mode) => {
|
|
3557
|
+
const it = psCur(); if (!it) return;
|
|
3558
|
+
if (mode === 'crop') { const f = rotatedSize(it.edit); PS.work = it.edit.crop || { x: 0, y: 0, w: f.w, h: f.h }; }
|
|
3559
|
+
PS.mode = mode; PS.drag = null; PS.pen = null;
|
|
3560
|
+
psEl.classList.toggle('ps-drawing', mode === 'pen');
|
|
3561
|
+
cropEl.hidden = mode !== 'crop';
|
|
3562
|
+
$('#ps-tools').hidden = mode !== 'view'; $('#ps-crop-bar').hidden = mode !== 'crop'; $('#ps-pen-bar').hidden = mode !== 'pen';
|
|
3563
|
+
$('#ps-foot').hidden = mode !== 'view';
|
|
3564
|
+
psUi(); psRender();
|
|
3565
|
+
};
|
|
3566
|
+
/** 改了这张:之前传上去的不作数 */
|
|
3567
|
+
const psEdit = (e) => { const it = psCur(); it.edit = e; it.path = null; };
|
|
3568
|
+
const psReset = () => {
|
|
3569
|
+
for (const it of PS.items) URL.revokeObjectURL(it.url);
|
|
3570
|
+
Object.assign(PS, { items: [], at: 0, mode: 'view', busy: false, work: null, drag: null, pen: null });
|
|
3571
|
+
};
|
|
3572
|
+
const psClose = () => { psReset(); psEl.classList.remove('on'); };
|
|
3573
|
+
const psDrop = (i) => {
|
|
3574
|
+
if (PS.busy) return;
|
|
3575
|
+
URL.revokeObjectURL(PS.items[i].url);
|
|
3576
|
+
PS.items.splice(i, 1);
|
|
3577
|
+
if (!PS.items.length) { psClose(); return; }
|
|
3578
|
+
PS.at = Math.min(PS.at > i ? PS.at - 1 : PS.at, PS.items.length - 1);
|
|
3579
|
+
psUi(); psRender();
|
|
3580
|
+
};
|
|
3581
|
+
const psOpen = async (files) => {
|
|
3582
|
+
if (!files.length || !S.tutor || !canSend() || S.pending) return;
|
|
3583
|
+
const items = (await Promise.all(files.map(psItem))).filter(Boolean);
|
|
3584
|
+
if (!items.length || !S.tutor) return;
|
|
3585
|
+
psReset(); PS.items = items;
|
|
3586
|
+
psEl.classList.add('on');
|
|
3587
|
+
$('#ps-to').replaceChildren(avatarEl(S.tutor), h('span', {}, h('small', {}, '发给 '), h('b', {}, S.tutor.display)));
|
|
3588
|
+
$('#ps-go').textContent = '发给' + S.tutor.display; $('#ps-go').style.background = colorOf(S.tutor);
|
|
3589
|
+
playRec({ k: 'ps', e: 'open' });
|
|
3590
|
+
psMode('view');
|
|
3591
|
+
};
|
|
3592
|
+
for (const f of document.querySelectorAll('input[type=file]')) wireFile(f);
|
|
3593
|
+
$('#ps-t-crop').addEventListener('click', () => { playRec({ k: 'ps', e: 'tool', tool: 'crop' }); psMode('crop'); });
|
|
3594
|
+
$('#ps-t-pen').addEventListener('click', () => { playRec({ k: 'ps', e: 'tool', tool: 'pen' }); psMode('pen'); });
|
|
3595
|
+
$('#ps-t-rot').addEventListener('click', () => { playRec({ k: 'ps', e: 'tool', tool: 'rot' }); psEdit(rotateEdit(psCur().edit)); psRender(); });
|
|
3596
|
+
$('#ps-c-full').addEventListener('click', () => { PS.work = { x: 0, y: 0, w: PS.R.w, h: PS.R.h }; psCropFrame(); });
|
|
3597
|
+
$('#ps-c-ok').addEventListener('click', () => { const e = setCrop(psCur().edit, PS.work); if (JSON.stringify(e.crop) !== JSON.stringify(psCur().edit.crop)) psEdit(e); psMode('view'); });
|
|
3598
|
+
$('#ps-p-undo').addEventListener('click', () => { const e = psCur().edit; psEdit({ ...e, strokes: e.strokes.slice(0, -1) }); psUi(); psRender(); });
|
|
3599
|
+
$('#ps-p-clear').addEventListener('click', () => { psEdit({ ...psCur().edit, strokes: [] }); psUi(); psRender(); });
|
|
3600
|
+
$('#ps-p-ok').addEventListener('click', () => psMode('view'));
|
|
3601
|
+
// 裁剪框:四角改大小、框里平移;显示 px 换成图坐标交给 dragCrop
|
|
3602
|
+
cropEl.addEventListener('pointerdown', (e) => {
|
|
3603
|
+
const g = e.target.closest('[data-g]'); if (!g || PS.drag) return;
|
|
3604
|
+
e.preventDefault(); try { cropEl.setPointerCapture(e.pointerId); } catch {}
|
|
3605
|
+
PS.drag = { id: e.pointerId, g: g.dataset.g, x: e.clientX, y: e.clientY, from: PS.work };
|
|
3606
|
+
});
|
|
3607
|
+
cropEl.addEventListener('pointermove', (e) => {
|
|
3608
|
+
const d = PS.drag; if (!d || d.id !== e.pointerId) return;
|
|
3609
|
+
PS.work = dragCrop(d.from, d.g, (e.clientX - d.x) / PS.sc, (e.clientY - d.y) / PS.sc, PS.R.w, PS.R.h, CROP_MIN_PX / PS.sc);
|
|
3610
|
+
psCropFrame();
|
|
3611
|
+
});
|
|
3612
|
+
const cropUp = (e) => { if (PS.drag && PS.drag.id === e.pointerId) PS.drag = null; };
|
|
3613
|
+
cropEl.addEventListener('pointerup', cropUp); cropEl.addEventListener('pointercancel', cropUp);
|
|
3614
|
+
// 红笔:一次只认一根手指(手掌压着屏不算第二笔)
|
|
3615
|
+
const toImg = (e) => { const b = cv.getBoundingClientRect(); return { x: PS.R.x + (e.clientX - b.left) / PS.sc, y: PS.R.y + (e.clientY - b.top) / PS.sc }; };
|
|
3616
|
+
cv.addEventListener('pointerdown', (e) => {
|
|
3617
|
+
if (PS.mode !== 'pen' || PS.pen) return;
|
|
3618
|
+
e.preventDefault(); try { cv.setPointerCapture(e.pointerId); } catch {}
|
|
3619
|
+
const st = newStroke(psCur().edit, toImg(e));
|
|
3620
|
+
psEdit({ ...psCur().edit, strokes: [...psCur().edit.strokes, st] });
|
|
3621
|
+
PS.pen = { id: e.pointerId, st }; psUi(); psDraw();
|
|
3622
|
+
});
|
|
3623
|
+
cv.addEventListener('pointermove', (e) => { if (!PS.pen || PS.pen.id !== e.pointerId) return; PS.pen.st.pts.push(toImg(e)); psDraw(); });
|
|
3624
|
+
const penUp = (e) => { if (PS.pen && PS.pen.id === e.pointerId) PS.pen = null; };
|
|
3625
|
+
cv.addEventListener('pointerup', penUp); cv.addEventListener('pointercancel', penUp);
|
|
3626
|
+
window.addEventListener('resize', () => { if (psEl.classList.contains('on')) psRender(); });
|
|
3627
|
+
$('#ps-cancel').addEventListener('click', () => { if (!PS.busy) { playRec({ k: 'ps', e: 'cancel' }); psClose(); } });
|
|
3628
|
+
// 发:逐张导出(转 → 裁 → 画 → 缩到长边 PHOTO_MAX_SIDE)上传拿 path,全拿到再发一条 {text, photos};传不上去屏不关、编辑留着,再点一次只传没传上的
|
|
3629
|
+
$('#ps-go').addEventListener('click', async () => {
|
|
3630
|
+
if (PS.busy || !PS.items.length) return;
|
|
3631
|
+
if (!S.tutor || !canSend() || S.pending) return;
|
|
3632
|
+
PS.busy = true; psUi();
|
|
3633
|
+
const name = S.tutor.name;
|
|
3634
|
+
try {
|
|
3635
|
+
for (const it of PS.items) if (!it.path) { const data = await psExport(it); const r = await api('POST', CONV + name + '/photos', { image: data }); it.path = r.path; }
|
|
3636
|
+
} catch (e) {
|
|
3637
|
+
PS.busy = false; psUi();
|
|
3638
|
+
if (e && e.status === 404) { psClose(); closeTutor(); }
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
const photos = PS.items.map((it) => it.path);
|
|
3642
|
+
playRec({ k: 'ps', e: 'send' });
|
|
3643
|
+
psClose();
|
|
3644
|
+
// 只发照片(2026-10-21 撤了配一句话:孩子一次都没用过;实时老师先说「我看一看图片」,孩子接着说就行)
|
|
3645
|
+
if (S.tutor && S.tutor.name === name) send('', { photos });
|
|
3646
|
+
});
|
|
3647
|
+
|
|
3648
|
+
// ---- 看大图:节头的小图点开;双指缩放、双击复位;几张就左右翻。不碰播放 ----
|
|
3649
|
+
const LB = { list: [], at: 0 };
|
|
3650
|
+
const lbShow = () => {
|
|
3651
|
+
const z = h('div', { class: 'zoom' }, h('img', { src: '/api/kid/image?p=' + encodeURIComponent(LB.list[LB.at]), alt: '' }));
|
|
3652
|
+
$('#lb .zoom').replaceWith(z); pinch(z);
|
|
3653
|
+
$('#lb-l').hidden = LB.at <= 0; $('#lb-r').hidden = LB.at >= LB.list.length - 1;
|
|
3654
|
+
$('#lb-n').textContent = LB.list.length > 1 ? (LB.at + 1) + ' / ' + LB.list.length : '';
|
|
3655
|
+
};
|
|
3656
|
+
const openLb = (list, at) => { LB.list = list; LB.at = at; $('#lb').classList.add('on'); lbShow(); };
|
|
3657
|
+
$('#lb-x').innerHTML = ICON.close;
|
|
3658
|
+
$('#lb-x').addEventListener('click', () => $('#lb').classList.remove('on'));
|
|
3659
|
+
$('#lb-l').addEventListener('click', () => { if (LB.at > 0) { LB.at--; lbShow(); } });
|
|
3660
|
+
$('#lb-r').addEventListener('click', () => { if (LB.at < LB.list.length - 1) { LB.at++; lbShow(); } });
|
|
3661
|
+
|
|
3662
|
+
// ---- 调试:?stage=<节>.<卡> 直接开某张卡的舞台(截图 / 测试用) ----
|
|
3663
|
+
const jumpTo = () => {
|
|
3664
|
+
const st = debug.get('stage');
|
|
3665
|
+
if (!st || !S.sections.length) return;
|
|
3666
|
+
const [x, y] = st.split('.').map(Number);
|
|
3667
|
+
const at = $('#board').querySelector('[data-sec="' + (x || 0) + '"] [data-card="' + (y || 0) + '"]');
|
|
3668
|
+
if (at) at.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
3669
|
+
openStage(x || 0, y || 0);
|
|
3670
|
+
};
|
|
3671
|
+
|
|
3672
|
+
// ---- 启动与心跳:不通就头像灰,什么都不报 ----
|
|
3673
|
+
if (REEL) reelBoot();
|
|
3674
|
+
else if (PREVIEW) loadHome().then(() => { const f = debug.get('front'); if (f) window.cotutorPreviewSet({ front: f }); pvTell({ act: 'ready', order: K.order ? [...K.order] : [] }); });
|
|
3675
|
+
else loadHome().then(() => {
|
|
3676
|
+
let resume = null; try { resume = sessionStorage.getItem('kid-resume'); sessionStorage.removeItem('kid-resume'); } catch {}
|
|
3677
|
+
const open = debug.get('tutor') || resume;
|
|
3678
|
+
// 地址栏里定落在哪的调试参数(tutor / new / hist / panel / step / stage)只管这一次打开:读完就去掉,只留 device。孩子之后从首页换了老师地址栏不跟着变,
|
|
3679
|
+
// 留着的话刷新(或 Safari 自己重载、服务重启后的自动重载)永远回到地址栏里那位(2026-10-08 真机:在数学小课堂刷新跳到语文老师)
|
|
3680
|
+
try { const q = new URLSearchParams(); if (debug.get('device')) q.set('device', debug.get('device')); const rest = q.toString(); if (location.search !== (rest ? '?' + rest : '')) history.replaceState(null, '', location.pathname + (rest ? '?' + rest : '')); } catch {}
|
|
3681
|
+
if (open && S.home) {
|
|
3682
|
+
const t = S.home.tutors.find((x) => x.name === open);
|
|
3683
|
+
const talk = debug.get('talk');
|
|
3684
|
+
if (t && talk) openTutor(t, { kind: 'new', talk });
|
|
3685
|
+
else if (t) {
|
|
3686
|
+
openTutor(t, { kind: 'today' });
|
|
3687
|
+
// 调试:&new=1 新话题空白态;&hist=<日期>.<话题> 只读回放以前的;&panel=1 打开「以前的」面板
|
|
3688
|
+
setTimeout(() => {
|
|
3689
|
+
if (debug.get('new')) $('#new-btn').click();
|
|
3690
|
+
const hv = debug.get('hist');
|
|
3691
|
+
if (hv) { const [d, th] = hv.split('.'); switchThread(d, th); }
|
|
3692
|
+
if (debug.get('panel')) $('#hist-btn').click();
|
|
3693
|
+
jumpTo();
|
|
3694
|
+
}, 400);
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
});
|
|
3698
|
+
/** 服务换了新代码:孩子手上没事时重载,回到原来那位老师。正按着、在打字、在等老师、老师正在念、舞台或发照片屏开着,都先不动 */
|
|
3699
|
+
const staleReload = () => {
|
|
3700
|
+
if (!stale || document.hidden || REEL) return;
|
|
3701
|
+
if (press || S.pending || S.stage || lcShowing || S.bar !== 'idle' || psEl.classList.contains('on')) return;
|
|
3702
|
+
try { if (S.tutor && !S.readonly) sessionStorage.setItem('kid-resume', S.tutor.name); } catch {}
|
|
3703
|
+
location.reload();
|
|
3704
|
+
};
|
|
3705
|
+
setInterval(() => { staleReload(); if (S.tutor) { if (!S.pending) loadDay(true); } else loadHome(); }, 5000);
|
|
3706
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) (S.tutor ? loadDay(true) : loadHome()); });
|
|
3707
|
+
// 孩子端刷新(手动的、Safari 自己重载的)回到手上这位老师;在首页刷新就回首页。只读回放不记(重载回来是今天的)
|
|
3708
|
+
if (!REEL && !PREVIEW) window.addEventListener('pagehide', () => { try { if (S.tutor && !S.readonly) sessionStorage.setItem('kid-resume', S.tutor.name); else sessionStorage.removeItem('kid-resume'); } catch {} });
|
|
3709
|
+
})();
|
|
3710
|
+
</script>
|
|
3711
|
+
</html>
|
|
3712
|
+
`;
|
|
3713
|
+
const KID_PAGE_SRC = PAGE.replace('__BOARD_JS__', () => libSource('kid-board')).replace('__WORD_DRAW_JS__', () => wordDrawJs()).replace('__PHOTO_JS__', () => libSource('photo-edit')).replace('__REEL_JS__', () => libSource('reel')).replace('__LIVE_JS__', () => libSource('live-board')).replace('__CANVAS_CHAT_JS__', () => libSource('canvas-chat')).replace('__AUDIO_JS__', () => AUDIO_CLIENT_JS).replace('__VOICE_VIEW_JS__', () => libSource('voice-view')).replace('__VOICE_JS__', () => VOICE_JS).replace('__VOICE_CSS__', () => VOICE_CSS).replace('__REEL_DOCK_CSS__', () => REEL_DOCK_CSS).replace('__REEL_DOCK_HTML__', () => REEL_DOCK_HTML).replace('__REEL_DOCK_JS__', () => REEL_DOCK_JS).replace('__NEUTRAL_COLOR__', () => JSON.stringify(NEUTRAL_COLOR)).replace('__BRAND_CSS__', () => BRAND_CSS).replace('__BACK_LOGO__', () => BACK_LOGO).replace('__BRAND_JS__', () => BRAND_JS)
|
|
3714
|
+
.replace('__CAMERA_22__', () => JSON.stringify(cameraSvg(22))).replace('__CAMERA_28__', () => JSON.stringify(cameraSvg(28))).replace('__CAMERA_20__', () => JSON.stringify(cameraSvg(20))).replace('__ALBUM_28__', () => JSON.stringify(albumSvg(28)));
|
|
3715
|
+
export const KID_PAGE = KID_PAGE_SRC.replace('__PAGE_V__', createHash('sha1').update(KID_PAGE_SRC).digest('hex').slice(0, 8));
|
|
3716
|
+
/** 孩子端页面(录像、预览也是它):标题(已转义)填进去;mode 见 KidPageMode,不给 = 孩子端 */
|
|
3717
|
+
export function kidPage(title, mode = {}, stageV = '0') {
|
|
3718
|
+
return KID_PAGE.replaceAll('__TITLE__', title).replace('__SHORT__', title).replace('__MODE__', JSON.stringify(mode)).replace('__STAGE_V__', stageV);
|
|
3719
|
+
}
|