From 87f5554f85a2d525902a35c72d19fc379cbba874 Mon Sep 17 00:00:00 2001 From: keanu77 <20503845+keanu77@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:12:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E8=99=95=E7=90=86=20PR=20#3?= =?UTF-8?q?=EF=BC=8F#4=20=E7=9A=84=20Codex=20=E5=AF=A9=E6=9F=A5=E6=84=8F?= =?UTF-8?q?=E8=A6=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 中位數偶數筆取中間兩筆平均(worker 抽出 stats.js 並加測試;模擬模式同步) - 投影片端換頁競態:控制請求排隊、換頁序號擋掉過期的開題 - 手機頁重連:取消排定的重連、已在連線就不再開新 socket - 模擬模式啟動即設定加入頁人數 - qrcode 改為 skill 自帶套件(resolve.mjs SKILL_LOCAL) - README 部署指令改用子 shell;live-poll README 補 cd .. - Live Demo 改為 Node 20+,重新 build docs/index.html - CI 執行 worker 的 node --test --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 1 + README.en.md | 4 +- README.md | 4 +- demo/src/index.html | 2 +- docs/index.html | 23 +- skills/html-slide-deck/package-lock.json | 310 +++++++++++++++++- skills/html-slide-deck/package.json | 5 +- .../references/live-poll/README.md | 3 +- .../references/live-poll/screen/live-poll.js | 21 +- .../references/live-poll/worker/package.json | 3 +- .../references/live-poll/worker/src/index.js | 3 +- .../references/live-poll/worker/src/phone.js | 16 +- .../references/live-poll/worker/src/stats.js | 7 + .../live-poll/worker/test/stats.test.js | 17 + skills/html-slide-deck/scripts/resolve.mjs | 4 +- 16 files changed, 385 insertions(+), 39 deletions(-) create mode 100644 skills/html-slide-deck/references/live-poll/worker/src/stats.js create mode 100644 skills/html-slide-deck/references/live-poll/worker/test/stats.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97db8c0..fbb98cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: grep -q 'POLL_CFG={"sim"' docs/index.html && ! grep -q '"key":' docs/index.html # Live Demo 必須是模擬模式、不含 key for f in skills/html-slide-deck/references/live-poll/*.mjs skills/html-slide-deck/references/live-poll/screen/*.js skills/html-slide-deck/references/live-poll/worker/src/*.js; do node --check "$f"; done node -e "JSON.parse(require('fs').readFileSync('skills/html-slide-deck/references/live-poll/worker/src/polls.json','utf8'))" + (cd skills/html-slide-deck/references/live-poll/worker && node --test test/stats.test.js) python -m py_compile skills/html-slide-deck/scripts/montage.py examples/pptx-edits-back-to-html.py bash -n install.sh skills/html-slide-deck/scripts/*.sh pip install ruff && ruff check skills/html-slide-deck/scripts/montage.py examples/pptx-edits-back-to-html.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 757f27f..6d6af0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 手機投票新增**模擬模式**:`poll-slides.mjs --polls --sim [人數]`,不連後端、不含 key,可公開;`live-poll.js` 依 `POLL_CFG.sim` 自動跑出作答過程。 - 新增 `README.en.md`(英文完整說明)、`SECURITY.md`(漏洞回報、ADMIN_KEY 處理與輪替、QA 會執行 JS);README 開頭放 Live Demo 連結與 45 秒流程 GIF(`docs/demo.gif`)。 - 乾淨環境安裝測試(OrbStack Ubuntu 24.04,照 README 從 clone 到離線 QA、講義、PDF、做法 B 全程):發現 **Node 18 跑不了 Playwright**,需求改為 Node 20+(README 徽章、install.sh 檢查、SKILL.md、engines),疑難排解補上;`build-handout.mjs` 找不到 render 目錄時改成明確錯誤訊息。 +- 修正 PR #3/#4 的 Codex 審查意見:數字題中位數偶數筆改取中間兩筆平均(抽出 `worker/src/stats.js` 並加 `node --test` 測試,CI 一併執行);投影片端快速換頁不再重開已離開的題目(控制請求排隊+換頁序號);手機頁回前景時不會多開一條 WebSocket;模擬模式開在加入頁時就顯示模擬人數;`qrcode` 改為 skill 自帶套件,照文件安裝即可跑 `poll-slides.mjs`;README 部署指令改用子 shell,不會停在 `my-poll` 裡;Live Demo 安裝頁改為 Node 20+。 ## 1.3.0 — 2026-10-18 diff --git a/README.en.md b/README.en.md index 8139109..4b341e0 100644 --- a/README.en.md +++ b/README.en.md @@ -103,8 +103,8 @@ Used by 60 people at once at a medical conference talk in October 2026, and load
``` ```bash -cp -R $SKILL/references/live-poll/worker my-poll && cd my-poll && npm install -npx wrangler login && npx wrangler secret put ADMIN_KEY && npx wrangler deploy # put the same key in .dev.vars +cp -R $SKILL/references/live-poll/worker my-poll +(cd my-poll && npm install && npx wrangler login && npx wrangler secret put ADMIN_KEY && npx wrangler deploy) # put the same key in my-poll/.dev.vars node $SKILL/scripts/poll-slides.mjs index.html index-live.html --worker my-poll --url https://slide-poll..workers.dev ``` diff --git a/README.md b/README.md index 8ee22ad..689edea 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,8 @@ QA:qa-screenshots.mjs ─ 截圖、溢出、JS 錯誤、對外請求、QR 逐
``` ```bash -cp -R $SKILL/references/live-poll/worker my-poll && cd my-poll && npm install -npx wrangler login && npx wrangler secret put ADMIN_KEY && npx wrangler deploy # 同一把 key 也寫進 .dev.vars +cp -R $SKILL/references/live-poll/worker my-poll +(cd my-poll && npm install && npx wrangler login && npx wrangler secret put ADMIN_KEY && npx wrangler deploy) # 同一把 key 也寫進 my-poll/.dev.vars node $SKILL/scripts/poll-slides.mjs index.html index-live.html --worker my-poll --url https://slide-poll.<子網域>.workers.dev ``` diff --git a/demo/src/index.html b/demo/src/index.html index 65ca41d..8a5fa8c 100644 --- a/demo/src/index.html +++ b/demo/src/index.html @@ -243,7 +243,7 @@

30 秒安裝

# 重開 CLI 後直接說 「做一份 HTML 簡報」 -
支援 Claude Code、Codex CLI、Gemini CLI、Grok Build。需要 Node 18+;QA 用 Playwright,講義用 Python。
+
支援 Claude Code、Codex CLI、Gemini CLI、Grok Build。需要 Node 20+;QA 用 Playwright,講義用 Python。
__QR__
github.com/keanu77/htmlpptskill
diff --git a/docs/index.html b/docs/index.html index c160334..5ef5963 100644 --- a/docs/index.html +++ b/docs/index.html @@ -651,7 +651,7 @@

30 秒安裝

# 重開 CLI 後直接說 「做一份 HTML 簡報」 -
支援 Claude Code、Codex CLI、Gemini CLI、Grok Build。需要 Node 18+;QA 用 Playwright,講義用 Python。
+
支援 Claude Code、Codex CLI、Gemini CLI、Grok Build。需要 Node 20+;QA 用 Playwright,講義用 Python。
github.com/keanu77/htmlpptskill
@@ -818,7 +818,7 @@

謝謝觀看

const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const pollSec = id => document.querySelector(`section[data-poll="${id}"]`); const results = {}; - let current = null, ws = null, wsRetry = 0, poller = null; + let current = null, ws = null, wsRetry = 0, poller = null, navSeq = 0; /* ---------------------------------------------------------------- 模擬模式 */ const SIM = CFG.sim ? (CFG.sim === true ? 60 : CFG.sim) : 0; @@ -831,7 +831,8 @@

謝謝觀看

} else { const sorted = [...v].sort((a, b) => a - b); out.counts = def.bins.map(([lo, hi]) => v.filter(x => x >= lo && x <= hi).length); - out.median = sorted.length ? sorted[Math.floor((sorted.length - 1) / 2)] : null; + const mid = sorted.length >> 1; // 偶數筆取中間兩筆平均 + out.median = !sorted.length ? null : sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; out.close = v.filter(x => Math.abs(x - def.answer) <= def.answer * 0.2).length; } return out; @@ -849,7 +850,6 @@

謝謝觀看

const def = CFG.polls[id]; if (sims[id]) return render(id); sims[id] = { votes: [], timer: null }; - $$('.lp-phones').forEach(el => { el.textContent = SIM; }); sims[id].timer = setInterval(() => { const s = sims[id]; for (let k = 0; k < 1 + Math.floor(Math.random() * 4) && s.votes.length < SIM; k++) s.votes.push(simVote(def)); @@ -874,10 +874,12 @@

謝謝觀看

return await r.json(); } finally { clearTimeout(timer); } } - const control = body => api('/api/control', { + // 控制請求排隊依序送出,快速換頁時「開題」不會晚於「離開」抵達後端 + let ctlChain = Promise.resolve(); + const control = body => (ctlChain = ctlChain.catch(() => {}).then(() => api('/api/control', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + CFG.key }, body: JSON.stringify(body), - }); + }))); function setNet(ok, msg) { document.documentElement.classList.toggle('lp-live', ok); @@ -960,12 +962,13 @@

謝謝觀看

$$('.ok', sec).forEach(el => el.classList.remove('ok')); } - async function enterPoll(id) { + async function enterPoll(id, seq) { current = id; if (SIM) return simStart(id); try { const res = await api('/api/results?poll=' + id); results[id] = res; + if (seq !== navSeq) return; // 等待期間已換頁:不要再開這題 if (res.phase !== 'revealed') { const r = await control({ poll: id, phase: 'open' }); results[id] = r.results; @@ -994,8 +997,8 @@

謝謝觀看

} function onShow(sec) { - const id = sec && sec.dataset.poll; - if (id) return enterPoll(id); + const id = sec && sec.dataset.poll, seq = ++navSeq; + if (id) return enterPoll(id, seq); if (current) { current = null; if (!SIM) control({ poll: null }).catch(() => {}); } } @@ -1007,7 +1010,7 @@

謝謝觀看

}, true); function start() { - if (SIM) setNet(true); + if (SIM) { setNet(true); $$('.lp-phones').forEach(el => { el.textContent = SIM; }); } else if ($$('section[data-poll], .lp-joinslide').length) connect(); Reveal.on('slidechanged', e => onShow(e.currentSlide)); onShow(Reveal.getCurrentSlide()); diff --git a/skills/html-slide-deck/package-lock.json b/skills/html-slide-deck/package-lock.json index 31350e8..c1ae79c 100644 --- a/skills/html-slide-deck/package-lock.json +++ b/skills/html-slide-deck/package-lock.json @@ -7,10 +7,125 @@ "name": "html-slide-deck-skill", "dependencies": { "jsqr": "^1.4.0", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "qrcode": "^1.5.4" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/jsqr": { @@ -19,6 +134,63 @@ "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", "license": "Apache-2.0" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pngjs": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", @@ -27,6 +199,140 @@ "engines": { "node": ">=14.19.0" } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/skills/html-slide-deck/package.json b/skills/html-slide-deck/package.json index 35aa73c..2c44998 100644 --- a/skills/html-slide-deck/package.json +++ b/skills/html-slide-deck/package.json @@ -1,10 +1,11 @@ { "name": "html-slide-deck-skill", "private": true, - "description": "QA 用的純 JS 小套件(QR 解碼)。playwright/pptxgenjs 由專案或全域提供。", + "description": "純 JS 小套件:QA 的 QR 解碼(jsqr/pngjs)、poll-slides 的 QR 產生(qrcode)。playwright/pptxgenjs 由專案或全域提供。", "dependencies": { "jsqr": "^1.4.0", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "qrcode": "^1.5.4" }, "engines": { "node": ">=20" diff --git a/skills/html-slide-deck/references/live-poll/README.md b/skills/html-slide-deck/references/live-poll/README.md index 5d519aa..8d0003b 100644 --- a/skills/html-slide-deck/references/live-poll/README.md +++ b/skills/html-slide-deck/references/live-poll/README.md @@ -34,6 +34,7 @@ npx wrangler login openssl rand -hex 24 | tee /dev/stderr | npx wrangler secret put ADMIN_KEY printf 'ADMIN_KEY=%s\n' '<上一行印出的 key>' > .dev.vars && chmod 600 .dev.vars npx wrangler deploy # 印出 https://slide-poll.<子網域>.workers.dev +cd .. # 回到簡報目錄,下面的 --worker my-poll 才找得到 ``` - `.dev.vars` 已在 `.gitignore`,**不要 commit**;`wrangler dev` 本機測試也讀它。 @@ -68,7 +69,7 @@ npx wrangler deploy # 印出 https://slide-poll.<子 node $SKILL/scripts/poll-slides.mjs index.html index-live.html --worker my-poll --url https://slide-poll.<子網域>.workers.dev ``` -- 腳本把占位換成完整投票頁(每頁右側 QR 都有 `data-url`,QA 會解碼比對),並在**最後一個** `` 前注入樣式、設定與 `live-poll.js`。需要 npm `qrcode`。 +- 腳本把占位換成完整投票頁(每頁右側 QR 都有 `data-url`,QA 會解碼比對),並在**最後一個** `` 前注入樣式、設定與 `live-poll.js`。需要 npm `qrcode`:skill 目錄自帶(`install.sh` 或在 skill 目錄 `npm install` 會裝好)。 - ADMIN_KEY 取環境變數 `ADMIN_KEY` 或 `my-poll/.dev.vars`,**會寫進 `index-live.html`**:這份只放講者電腦,不上傳、不寄出、不 commit。要分享給觀眾的版本用沒跑過這支腳本的 `index.html`。 - 做法 B(content.js):讓 build.js 對某個 type 輸出同樣的占位 section,build 完再跑這支。 - 從 PPTX 轉出的 HTML 也一樣:在轉檔程式插頁時插入占位 section。插頁後若投影片上印有頁碼,要重編。 diff --git a/skills/html-slide-deck/references/live-poll/screen/live-poll.js b/skills/html-slide-deck/references/live-poll/screen/live-poll.js index 398aa73..2ec9799 100644 --- a/skills/html-slide-deck/references/live-poll/screen/live-poll.js +++ b/skills/html-slide-deck/references/live-poll/screen/live-poll.js @@ -11,7 +11,7 @@ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const pollSec = id => document.querySelector(`section[data-poll="${id}"]`); const results = {}; - let current = null, ws = null, wsRetry = 0, poller = null; + let current = null, ws = null, wsRetry = 0, poller = null, navSeq = 0; /* ---------------------------------------------------------------- 模擬模式 */ const SIM = CFG.sim ? (CFG.sim === true ? 60 : CFG.sim) : 0; @@ -24,7 +24,8 @@ } else { const sorted = [...v].sort((a, b) => a - b); out.counts = def.bins.map(([lo, hi]) => v.filter(x => x >= lo && x <= hi).length); - out.median = sorted.length ? sorted[Math.floor((sorted.length - 1) / 2)] : null; + const mid = sorted.length >> 1; // 偶數筆取中間兩筆平均 + out.median = !sorted.length ? null : sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; out.close = v.filter(x => Math.abs(x - def.answer) <= def.answer * 0.2).length; } return out; @@ -42,7 +43,6 @@ const def = CFG.polls[id]; if (sims[id]) return render(id); sims[id] = { votes: [], timer: null }; - $$('.lp-phones').forEach(el => { el.textContent = SIM; }); sims[id].timer = setInterval(() => { const s = sims[id]; for (let k = 0; k < 1 + Math.floor(Math.random() * 4) && s.votes.length < SIM; k++) s.votes.push(simVote(def)); @@ -67,10 +67,12 @@ return await r.json(); } finally { clearTimeout(timer); } } - const control = body => api('/api/control', { + // 控制請求排隊依序送出,快速換頁時「開題」不會晚於「離開」抵達後端 + let ctlChain = Promise.resolve(); + const control = body => (ctlChain = ctlChain.catch(() => {}).then(() => api('/api/control', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + CFG.key }, body: JSON.stringify(body), - }); + }))); function setNet(ok, msg) { document.documentElement.classList.toggle('lp-live', ok); @@ -153,12 +155,13 @@ $$('.ok', sec).forEach(el => el.classList.remove('ok')); } - async function enterPoll(id) { + async function enterPoll(id, seq) { current = id; if (SIM) return simStart(id); try { const res = await api('/api/results?poll=' + id); results[id] = res; + if (seq !== navSeq) return; // 等待期間已換頁:不要再開這題 if (res.phase !== 'revealed') { const r = await control({ poll: id, phase: 'open' }); results[id] = r.results; @@ -187,8 +190,8 @@ } function onShow(sec) { - const id = sec && sec.dataset.poll; - if (id) return enterPoll(id); + const id = sec && sec.dataset.poll, seq = ++navSeq; + if (id) return enterPoll(id, seq); if (current) { current = null; if (!SIM) control({ poll: null }).catch(() => {}); } } @@ -200,7 +203,7 @@ }, true); function start() { - if (SIM) setNet(true); + if (SIM) { setNet(true); $$('.lp-phones').forEach(el => { el.textContent = SIM; }); } else if ($$('section[data-poll], .lp-joinslide').length) connect(); Reveal.on('slidechanged', e => onShow(e.currentSlide)); onShow(Reveal.getCurrentSlide()); diff --git a/skills/html-slide-deck/references/live-poll/worker/package.json b/skills/html-slide-deck/references/live-poll/worker/package.json index ffb4d48..bb0327c 100644 --- a/skills/html-slide-deck/references/live-poll/worker/package.json +++ b/skills/html-slide-deck/references/live-poll/worker/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "dev": "wrangler dev", - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "test": "node --test test/stats.test.js" }, "devDependencies": { "wrangler": "^4" diff --git a/skills/html-slide-deck/references/live-poll/worker/src/index.js b/skills/html-slide-deck/references/live-poll/worker/src/index.js index 1c154af..71b03c9 100644 --- a/skills/html-slide-deck/references/live-poll/worker/src/index.js +++ b/skills/html-slide-deck/references/live-poll/worker/src/index.js @@ -4,6 +4,7 @@ import { DurableObject } from "cloudflare:workers"; import POLLS from "./polls.json"; import { phonePage } from "./phone.js"; +import { median } from "./stats.js"; const DEFAULT_ROOM = "default"; // 同一個 Worker 要給不同場次用,設 vars.ROOM_NAME const PHASES = ["closed", "open", "revealed"]; @@ -77,7 +78,7 @@ export class Room extends DurableObject { } else { const vals = rows.map(r => Number(r.value)).sort((a, b) => a - b); out.counts = def.bins.map(([lo, hi]) => vals.filter(v => v >= lo && v <= hi).length); - out.median = vals.length ? vals[Math.floor((vals.length - 1) / 2)] : null; + out.median = median(vals); const tol = def.answer * 0.2; out.close = vals.filter(v => Math.abs(v - def.answer) <= tol).length; } diff --git a/skills/html-slide-deck/references/live-poll/worker/src/phone.js b/skills/html-slide-deck/references/live-poll/worker/src/phone.js index f6d3bd8..9d483d9 100644 --- a/skills/html-slide-deck/references/live-poll/worker/src/phone.js +++ b/skills/html-slide-deck/references/live-poll/worker/src/phone.js @@ -133,17 +133,21 @@ async function send(value) { function apply(s) { if (s && s.type === "state") { state = s; render(); } } -let ws = null, retry = 0, pollTimer = null; +let ws = null, retry = 0, pollTimer = null, retryTimer = null; function connect() { + clearTimeout(retryTimer); retryTimer = null; // 回到前景時先取消排定的重連,避免開兩條 socket + if (ws && ws.readyState < 2) return; // 已在連線中或已連上 + let sock; try { - ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws?role=phone"); + sock = ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws?role=phone"); } catch (e) { return fallback(); } - ws.onopen = () => { retry = 0; conn.textContent = "已連線"; clearInterval(pollTimer); pollTimer = null; }; - ws.onmessage = e => { if (e.data !== "pong") apply(JSON.parse(e.data)); }; - ws.onclose = () => { + sock.onopen = () => { retry = 0; conn.textContent = "已連線"; clearInterval(pollTimer); pollTimer = null; }; + sock.onmessage = e => { if (e.data !== "pong") apply(JSON.parse(e.data)); }; + sock.onclose = () => { + if (ws !== sock) return; // 舊 socket 晚到的 close conn.textContent = "重新連線中…"; fallback(); - setTimeout(connect, Math.min(15000, 1000 * 2 ** retry++)); + retryTimer = setTimeout(connect, Math.min(15000, 1000 * 2 ** retry++)); }; } function fallback() { diff --git a/skills/html-slide-deck/references/live-poll/worker/src/stats.js b/skills/html-slide-deck/references/live-poll/worker/src/stats.js new file mode 100644 index 0000000..3fbc742 --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/src/stats.js @@ -0,0 +1,7 @@ +// 純函式統計,方便在 Node 直接測試(npm test)。 +// 中位數:輸入需已由小到大排序;偶數筆取中間兩筆平均,沒有資料回傳 null。 +export function median(sorted) { + const n = sorted.length, mid = n >> 1; + if (!n) return null; + return n % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} diff --git a/skills/html-slide-deck/references/live-poll/worker/test/stats.test.js b/skills/html-slide-deck/references/live-poll/worker/test/stats.test.js new file mode 100644 index 0000000..2520389 --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/test/stats.test.js @@ -0,0 +1,17 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { median } from "../src/stats.js"; + +test("median:奇數筆取中間", () => { + assert.equal(median([1, 5, 9]), 5); + assert.equal(median([7]), 7); +}); + +test("median:偶數筆取中間兩筆平均", () => { + assert.equal(median([0, 100]), 50); + assert.equal(median([1, 2, 3, 10]), 2.5); +}); + +test("median:沒有資料回傳 null", () => { + assert.equal(median([]), null); +}); diff --git a/skills/html-slide-deck/scripts/resolve.mjs b/skills/html-slide-deck/scripts/resolve.mjs index 9dd465e..272973b 100644 --- a/skills/html-slide-deck/scripts/resolve.mjs +++ b/skills/html-slide-deck/scripts/resolve.mjs @@ -1,5 +1,5 @@ // 共用:解析 playwright / pptxgenjs / jsqr 等套件。 -// 順序:SKILL_PKG_ROOT 環境變數 → skill 目錄自帶 node_modules(只給 jsqr/pngjs 這類小套件)→ 執行目錄往上 → npm 全域。 +// 順序:SKILL_PKG_ROOT 環境變數 → skill 目錄自帶 node_modules(只給 jsqr/pngjs/qrcode 這類小套件)→ 執行目錄往上 → npm 全域。 import { createRequire } from 'node:module'; import { execSync } from 'node:child_process'; import { dirname, join } from 'node:path'; @@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const SKILL_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const SKILL_LOCAL = new Set(['jsqr', 'pngjs']); // 其他套件不從 skill 目錄取,避免撿到缺瀏覽器的舊版 playwright +const SKILL_LOCAL = new Set(['jsqr', 'pngjs', 'qrcode']); // 其他套件不從 skill 目錄取,避免撿到缺瀏覽器的舊版 playwright function fromDir(dir, name) { return existsSync(join(dir, 'node_modules', name)) ? createRequire(join(dir, 'package.json'))(name) : null; From fc0af18d2195f9b257a3dee5881840ce9ff9be2e Mon Sep 17 00:00:00 2001 From: keanu77 <20503845+keanu77@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:37:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=8E=A7=E5=88=B6=E8=AB=8B=E6=B1=82?= =?UTF-8?q?=E6=8E=92=E9=9A=8A=E6=99=82=E7=95=A5=E9=81=8E=E9=81=8E=E6=9C=9F?= =?UTF-8?q?=E7=9A=84=E6=8F=9B=E9=A0=81=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 斷線時 /api/control 卡住、講者連換多頁,積壓的換頁指令輪到時若已換過頁就略過, 只送最新狀態;不會重播舊題、也不會疊加多次逾時。揭曉/清空照送。 重新 build docs/index.html。 --- docs/index.html | 21 ++++++++++--------- .../references/live-poll/screen/live-poll.js | 21 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/docs/index.html b/docs/index.html index 5ef5963..d74d65c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -874,12 +874,14 @@

謝謝觀看

return await r.json(); } finally { clearTimeout(timer); } } - // 控制請求排隊依序送出,快速換頁時「開題」不會晚於「離開」抵達後端 + // 控制請求排隊依序送出,快速換頁時「開題」不會晚於「離開」抵達後端。 + // 換頁指令帶 seq:輪到時若已換過頁就略過(回傳 null),斷線後只送最新狀態,不會重播積壓的舊題。 let ctlChain = Promise.resolve(); - const control = body => (ctlChain = ctlChain.catch(() => {}).then(() => api('/api/control', { - method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + CFG.key }, - body: JSON.stringify(body), - }))); + const control = (body, seq) => (ctlChain = ctlChain.catch(() => {}).then(() => + seq !== undefined && seq !== navSeq ? null : api('/api/control', { + method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + CFG.key }, + body: JSON.stringify(body), + }))); function setNet(ok, msg) { document.documentElement.classList.toggle('lp-live', ok); @@ -970,11 +972,10 @@

謝謝觀看

results[id] = res; if (seq !== navSeq) return; // 等待期間已換頁:不要再開這題 if (res.phase !== 'revealed') { - const r = await control({ poll: id, phase: 'open' }); + const r = await control({ poll: id, phase: 'open' }, seq); + if (!r) return; // 排隊期間已換頁 results[id] = r.results; - } else { - await control({ poll: id, phase: 'revealed' }); // 回到已揭曉的題:讓手機也顯示答案 - } + } else if (!await control({ poll: id, phase: 'revealed' }, seq)) return; // 回到已揭曉的題:讓手機也顯示答案 render(id); } catch (e) { setNet(false, '連不到投票伺服器(按 A 仍可揭曉)'); @@ -999,7 +1000,7 @@

謝謝觀看

function onShow(sec) { const id = sec && sec.dataset.poll, seq = ++navSeq; if (id) return enterPoll(id, seq); - if (current) { current = null; if (!SIM) control({ poll: null }).catch(() => {}); } + if (current) { current = null; if (!SIM) control({ poll: null }, seq).catch(() => {}); } } // capture 階段攔截,避免和 reveal.js 內建鍵衝突;只在投票頁生效 diff --git a/skills/html-slide-deck/references/live-poll/screen/live-poll.js b/skills/html-slide-deck/references/live-poll/screen/live-poll.js index 2ec9799..dd712b6 100644 --- a/skills/html-slide-deck/references/live-poll/screen/live-poll.js +++ b/skills/html-slide-deck/references/live-poll/screen/live-poll.js @@ -67,12 +67,14 @@ return await r.json(); } finally { clearTimeout(timer); } } - // 控制請求排隊依序送出,快速換頁時「開題」不會晚於「離開」抵達後端 + // 控制請求排隊依序送出,快速換頁時「開題」不會晚於「離開」抵達後端。 + // 換頁指令帶 seq:輪到時若已換過頁就略過(回傳 null),斷線後只送最新狀態,不會重播積壓的舊題。 let ctlChain = Promise.resolve(); - const control = body => (ctlChain = ctlChain.catch(() => {}).then(() => api('/api/control', { - method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + CFG.key }, - body: JSON.stringify(body), - }))); + const control = (body, seq) => (ctlChain = ctlChain.catch(() => {}).then(() => + seq !== undefined && seq !== navSeq ? null : api('/api/control', { + method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + CFG.key }, + body: JSON.stringify(body), + }))); function setNet(ok, msg) { document.documentElement.classList.toggle('lp-live', ok); @@ -163,11 +165,10 @@ results[id] = res; if (seq !== navSeq) return; // 等待期間已換頁:不要再開這題 if (res.phase !== 'revealed') { - const r = await control({ poll: id, phase: 'open' }); + const r = await control({ poll: id, phase: 'open' }, seq); + if (!r) return; // 排隊期間已換頁 results[id] = r.results; - } else { - await control({ poll: id, phase: 'revealed' }); // 回到已揭曉的題:讓手機也顯示答案 - } + } else if (!await control({ poll: id, phase: 'revealed' }, seq)) return; // 回到已揭曉的題:讓手機也顯示答案 render(id); } catch (e) { setNet(false, '連不到投票伺服器(按 A 仍可揭曉)'); @@ -192,7 +193,7 @@ function onShow(sec) { const id = sec && sec.dataset.poll, seq = ++navSeq; if (id) return enterPoll(id, seq); - if (current) { current = null; if (!SIM) control({ poll: null }).catch(() => {}); } + if (current) { current = null; if (!SIM) control({ poll: null }, seq).catch(() => {}); } } // capture 階段攔截,避免和 reveal.js 內建鍵衝突;只在投票頁生效