/* ===================================================================== * 框子(OrderBox)- 运动提醒宝 Web 端核心业务逻辑 * 原生 JS 实现,无框架、无构建工具 * 模块分区: * 主题常量 / 双模式控制 / 工具函数 / API 封装层 / Toast 系统 / * 弹窗系统 / 交互追踪 / 按钮 Loading / 页面路由 / Hub 逻辑 / * Tab 切换 / 卡片列表 / 卡片编辑器 / 执行器 / 语音播报 / * 提示音 / 屏幕常亮 / 历史列表 / 初始化 * ===================================================================== */ (function () { 'use strict'; // ==================== 主题常量 ==================== const THEME = { bg: '#080b12', surface: '#121826', surfaceSoft: '#1a2234', surfaceStrong: '#202b42', primary: '#f0b35f', primarySoft: 'rgba(240,179,95,0.16)', text: '#f6f7fb', textSecondary: '#9ba7bc', textDisabled: '#5a6578', border: '#2a3449', success: '#4ade80', warning: '#fbbf24', error: '#ef4444' }; // ==================== 双模式控制 ==================== // debug:所有交互弹窗完整显示,3s 自动关闭 // normal:查看类操作降级为 Toast,普通弹窗 2s 关闭 const UI_MODE_KEY = 'order_box_ui_mode'; function getUiMode() { try { const mode = localStorage.getItem(UI_MODE_KEY); if (mode === 'normal' || mode === 'debug') return mode; } catch (e) { /* localStorage 不可用时按 debug 处理 */ } return 'debug'; } function setUiMode(mode) { try { localStorage.setItem(UI_MODE_KEY, mode === 'normal' ? 'normal' : 'debug'); } catch (e) { /* 静默失败 */ } } // ==================== 工具函数 ==================== /** mm:ss 格式,如 "01:30" */ function formatDuration(seconds) { const safe = Math.max(0, Math.floor(Number(seconds) || 0)); const mm = Math.floor(safe / 60); const ss = safe % 60; return String(mm).padStart(2, '0') + ':' + String(ss).padStart(2, '0'); } /** "N分N秒" 或 "N秒" */ function formatDurationLong(seconds) { const safe = Math.max(0, Math.floor(Number(seconds) || 0)); const mm = Math.floor(safe / 60); const ss = safe % 60; if (mm > 0) return mm + '分' + ss + '秒'; return ss + '秒'; } /** 语音播报专用时长文案:"N分钟N秒" */ function formatSpeechDuration(seconds) { const safe = Math.max(0, Math.floor(Number(seconds) || 0)); const mm = Math.floor(safe / 60); const ss = safe % 60; const parts = []; if (mm > 0) parts.push(mm + '分钟'); if (ss > 0) parts.push(ss + '秒'); return parts.length ? parts.join('') : '0秒'; } /** 北京时间格式 "yyyy/M/d HH:mm:ss"(项目规则:所有时间展示一律 Asia/Shanghai) */ function formatDateTime(isoString) { if (!isoString) return '—'; const date = new Date(isoString); if (Number.isNaN(date.getTime())) return String(isoString); return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); } /** 循环模式文案 */ function formatLoopText(loopMode, loopCount) { if (loopMode === 'once') return '单次执行'; if (loopMode === 'repeat_forever') return '无限循环'; return '循环 ' + Math.max(1, Number(loopCount || 1)) + ' 次'; } /** UUID 生成(crypto.randomUUID 优先,降级手写 v4) */ function generateId() { try { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID(); } } catch (e) { /* 降级 */ } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** HTML 转义 */ function escHtml(s) { return String(s === null || s === undefined ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** JSON 安全序列化(超长截断),用于交互详情展示 */ function safeJsonStringify(value) { try { let text = JSON.stringify(value, null, 2); if (text === undefined) return String(value); if (text.length > 4000) text = text.slice(0, 4000) + '\n...(内容过长已截断)'; return text; } catch (e) { return String(value); } } // ==================== API 封装层 ==================== const API_TIMEOUT_MS = 15000; function formatApiError(json, status, rawText) { if (json && json.error) return String(json.error); if (rawText) return 'HTTP ' + status + ':' + String(rawText).slice(0, 200); return 'HTTP ' + status; } /** * 通用请求封装 * 返回统一结构:{ ok, data, error, status, duration, url, method, requestBody, responseBody } */ async function apiFetch(method, path, body) { const startedAt = Date.now(); const result = { ok: false, data: null, error: '', status: 0, duration: 0, url: path, method: method, requestBody: body === undefined ? null : body, responseBody: null }; const controller = new AbortController(); const timeoutId = setTimeout(function () { controller.abort(); }, API_TIMEOUT_MS); try { const options = { method: method, headers: {}, signal: controller.signal }; if (body !== undefined && body !== null) { options.headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } const resp = await fetch(path, options); result.status = resp.status; const text = await resp.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; } result.responseBody = json !== null ? json : (text || null); if (resp.ok && json && json.ok !== false) { result.ok = true; result.data = (json && json.data !== undefined) ? json.data : json; } else { result.error = formatApiError(json, resp.status, text); } } catch (e) { if (e && e.name === 'AbortError') { result.error = '请求超时(' + API_TIMEOUT_MS / 1000 + ' 秒)'; } else { result.error = '网络请求失败:' + String((e && e.message) || e); } } finally { clearTimeout(timeoutId); result.duration = Date.now() - startedAt; } return result; } // ==================== Toast 系统 ==================== // info=蓝 / success=绿 / error=红,默认 2s 自动消失,不可点击关闭 let toastTimer = null; function showToast(msg, type, duration) { const el = document.getElementById('toast'); if (!el) return; const toastType = (type === 'success' || type === 'error') ? type : 'info'; el.textContent = msg; el.className = 'toast toast-show toast-' + toastType; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.className = 'toast'; }, Number(duration) > 0 ? Number(duration) : 2000); } // ==================== 弹窗系统 ==================== /** 通用弹窗。actions: [{ text, style: 'default'|'primary'|'danger', closeOnClick, onPress(btn) }] */ function showDialog(title, bodyHtml, actions) { document.getElementById('dialogTitle').textContent = title; document.getElementById('dialogBody').innerHTML = bodyHtml; const actionsEl = document.getElementById('dialogActions'); actionsEl.innerHTML = ''; const list = (Array.isArray(actions) && actions.length) ? actions : [{ text: '知道了', style: 'primary' }]; list.forEach(function (action) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dialog-btn' + (action.style ? ' dialog-btn-' + action.style : ''); btn.textContent = action.text || '确定'; btn.addEventListener('click', function () { if (action.closeOnClick !== false) closeDialog(); if (typeof action.onPress === 'function') action.onPress(btn); }); actionsEl.appendChild(btn); }); document.getElementById('dialogOverlay').style.display = 'flex'; } function closeDialog() { document.getElementById('dialogOverlay').style.display = 'none'; } /** 确认弹窗:双模式均保留,永不降级。onConfirm(btn) 需自行调用 closeDialog() */ function showConfirm(title, bodyHtml, onConfirm) { showDialog(title, bodyHtml, [ { text: '取消', style: 'default' }, { text: '确认', style: 'danger', closeOnClick: false, onPress: function (btn) { if (typeof onConfirm === 'function') onConfirm(btn); } } ]); } function showAlert(title, bodyHtml) { showDialog(title, bodyHtml, [{ text: '知道了', style: 'primary' }]); } // ---- 交互详情弹窗(带复制按钮与自动关闭倒计时) ---- let interactionCloseTimer = null; let interactionCountdown = 0; function buildInteractionText(d) { const reqText = (d.requestBody === null || d.requestBody === undefined) ? '—' : safeJsonStringify(d.requestBody); let respText = '—'; if (d.responseBody !== null && d.responseBody !== undefined) { respText = typeof d.responseBody === 'string' ? d.responseBody : safeJsonStringify(d.responseBody); } return [ '操作:' + d.label, '时间:' + d.time + '(北京时间)', '接口:' + d.method + ' ' + d.url, 'HTTP 状态:' + (d.status || '—'), '耗时:' + d.duration + ' ms', '当前状态:' + (d.success ? '成功' : '失败'), '', '请求参数:', reqText, '', '返回结果:', respText, '', '错误信息:' + (d.error || '—') ].join('\n'); } function clearInteractionTimer() { if (interactionCloseTimer) { clearInterval(interactionCloseTimer); interactionCloseTimer = null; } } function hideInteractionDialog() { clearInteractionTimer(); const overlay = document.getElementById('interactionOverlay'); if (overlay) overlay.style.display = 'none'; } /** * 交互详情弹窗 * autoCloseMs > 0:显示倒计时并自动关闭;为 0/null:停留(异常排障场景) */ function showInteractionDialog(detail, autoCloseMs) { clearInteractionTimer(); const box = document.getElementById('interactionBox'); document.getElementById('interactionTitle').textContent = detail.success ? '交互详情' : '交互异常'; document.getElementById('interactionContent').textContent = buildInteractionText(detail); box.classList.toggle('dialog-error', !detail.success); const actionsEl = document.getElementById('interactionActions'); actionsEl.innerHTML = ''; const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'dialog-btn dialog-btn-primary'; closeBtn.textContent = '关闭'; closeBtn.addEventListener('click', hideInteractionDialog); actionsEl.appendChild(closeBtn); document.getElementById('interactionOverlay').style.display = 'flex'; if (Number(autoCloseMs) > 0) { interactionCountdown = Math.ceil(Number(autoCloseMs) / 1000); closeBtn.textContent = '关闭(' + interactionCountdown + 's)'; interactionCloseTimer = setInterval(function () { interactionCountdown -= 1; if (interactionCountdown <= 0) { hideInteractionDialog(); } else { closeBtn.textContent = '关闭(' + interactionCountdown + 's)'; } }, 1000); } } /** 复制交互日志到剪贴板 */ async function handleCopyInteraction() { const contentEl = document.getElementById('interactionContent'); const text = contentEl ? (contentEl.textContent || '') : ''; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } showToast('已复制交互日志', 'success'); } catch (e) { showToast('复制失败', 'error'); } } // ==================== 交互追踪 ==================== // 所有 API 调用统一经由此函数包装: // debug 模式:成功 → 交互详情弹窗 3s 自动关闭 // normal 模式:options.mode === 'view' → Toast;其余 → 弹窗 2s 关闭 // 失败/异常:弹窗停留(中断自动关闭),便于排障 // options.silent:高频后台同步(执行器逐步 sync)静默记录,不打扰前台 let lastInteractionLog = null; async function runTrackedInteraction(label, fn, options) { const opts = options || {}; const startedAt = Date.now(); let res = null; let thrown = null; try { res = await fn(); } catch (e) { thrown = e; } const detail = { label: label, time: formatDateTime(new Date(startedAt).toISOString()), method: (res && res.method) || '—', url: (res && res.url) || '—', requestBody: res ? res.requestBody : null, responseBody: res ? res.responseBody : null, duration: (res && typeof res.duration === 'number') ? res.duration : (Date.now() - startedAt), status: (res && res.status) || 0, error: thrown ? String((thrown && thrown.message) || thrown) : (res && res.ok ? '' : ((res && res.error) || '未知错误')), success: !thrown && !!(res && res.ok) }; lastInteractionLog = detail; if (opts.silent) { // 高频后台同步:仅记录日志,不弹窗、不 Toast console.log('[interaction:silent]', detail); } else if (!detail.success) { showInteractionDialog(detail, 0); // 异常:停留弹窗,中断自动关闭 } else if (getUiMode() === 'debug') { showInteractionDialog(detail, 3000); } else if (opts.mode === 'view') { showToast(label + '成功', 'success', 2000); } else { showInteractionDialog(detail, 2000); } if (thrown) { return { ok: false, data: null, error: detail.error, status: 0, duration: detail.duration, url: detail.url, method: detail.method, requestBody: null, responseBody: null }; } return res; } // ==================== 按钮 Loading ==================== // 异步操作期间:disabled + 文字切换(如"保存中…"),完成后恢复 function getButtonTextEl(btn) { if (!btn) return null; return btn.querySelector('.btn-text') || btn.querySelector('span') || btn; } function setButtonLoading(btn, loadingText) { if (!btn) return; const textEl = getButtonTextEl(btn); if (textEl.dataset.originalText === undefined) { textEl.dataset.originalText = textEl.textContent; } btn.disabled = true; btn.classList.add('btn-loading'); if (loadingText) textEl.textContent = loadingText; } function resetButton(btn, originalText) { if (!btn) return; const textEl = getButtonTextEl(btn); btn.disabled = false; btn.classList.remove('btn-loading'); const restore = (originalText !== undefined) ? originalText : textEl.dataset.originalText; if (restore !== undefined) textEl.textContent = restore; delete textEl.dataset.originalText; } // ==================== 页面路由 ==================== let currentView = 'hub'; // 'hub' | 'motion' | 'schedule' let currentTab = 'cards'; // 'cards' | 'history' function showView(viewName) { currentView = viewName; document.getElementById('viewHub').style.display = (viewName === 'hub') ? '' : 'none'; document.getElementById('viewMotion').style.display = (viewName === 'motion') ? '' : 'none'; document.getElementById('viewSchedule').style.display = (viewName === 'schedule') ? '' : 'none'; // 离开作息报时器即停止秒级 tick(待机播报引擎只在报时器页面上运行) if (viewName !== 'schedule') stopSchedTick(); } function switchTab(tabName) { currentTab = tabName; document.querySelectorAll('#motionTabBar .tab-item').forEach(function (el) { el.classList.toggle('active', el.dataset.tab === tabName); }); document.getElementById('pageCards').style.display = (tabName === 'cards') ? '' : 'none'; document.getElementById('pageHistory').style.display = (tabName === 'history') ? '' : 'none'; if (tabName === 'cards') { loadCards(); } else { loadHistory(); } } // ==================== Hub 逻辑 ==================== let appMeta = null; let appState = null; let cardsCache = []; /** 静默刷新服务端全局状态(activeKit / runningSession) */ async function refreshState(silent) { const res = await runTrackedInteraction('刷新全局状态', function () { return apiFetch('GET', '/api/app_order_box/state'); }, { mode: 'view', silent: silent !== false }); if (res && res.ok) appState = res.data; return appState; } async function initHub() { const metaRes = await runTrackedInteraction('获取应用元信息', function () { return apiFetch('GET', '/api/app_order_box/meta'); }, { mode: 'view' }); if (!metaRes.ok) { showAlert('初始化失败', escHtml(metaRes.error || '无法获取应用元信息,请刷新重试')); return; } appMeta = metaRes.data; const stateRes = await runTrackedInteraction('获取全局状态', function () { return apiFetch('GET', '/api/app_order_box/state'); }, { mode: 'view' }); if (!stateRes.ok) { showAlert('初始化失败', escHtml(stateRes.error || '无法获取全局状态,请刷新重试')); return; } appState = stateRes.data; const enterBtn = document.getElementById('btnEnterMotion'); enterBtn.disabled = false; document.getElementById('btnEnterSchedule').disabled = false; // activeKit 已指向某个 kit:跳过 Hub 直接进入 if (appState && appState.activeKit === 'motion_reminder') { showView('motion'); switchTab('cards'); } else if (appState && appState.activeKit === 'schedule_reminder') { showView('schedule'); switchSchedTab('schedules'); // 无用户手势时不能探测语音,仅同步时钟并启动 tick syncSchedClock().then(function () { startSchedTick(); }); } } async function handleEnterMotion() { const btn = document.getElementById('btnEnterMotion'); // 必须在 await 之前(用户手势上下文中)解锁音频与语音,供后续探测与播报使用 unlockAudio(); unlockSpeech(); setButtonLoading(btn, '进入中…'); const res = await runTrackedInteraction('设置激活 kit(motion_reminder)', function () { return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'motion_reminder' }); }); if (!res.ok) { resetButton(btn); showAlert('进入失败', escHtml(res.error || '请稍后重试')); return; } resetButton(btn); await refreshState(true); showView('motion'); switchTab('cards'); // 交互详情弹窗(debug 3s / normal 2s 自动关闭)会盖住探测弹窗,等它先收起再探测语音 setTimeout(function () { probeSpeech(); }, getUiMode() === 'debug' ? 3200 : 2200); } async function handleBackHub() { await runTrackedInteraction('返回框子(清除激活 kit)', function () { return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: '' }); }, { mode: 'view' }); showView('hub'); await refreshState(true); } // ==================== Tab 切换 ==================== // (switchTab 已在"页面路由"分区实现,此处负责静态事件绑定的入口见 init) // ==================== 卡片列表 ==================== function updateStaleWarning() { const el = document.getElementById('staleWarning'); if (!el) return; const show = !!(appState && appState.runningSession && !runnerState); el.style.display = show ? '' : 'none'; } function renderCards() { const listEl = document.getElementById('cardsList'); const emptyEl = document.getElementById('cardsEmpty'); if (!cardsCache.length) { listEl.innerHTML = ''; listEl.style.display = 'none'; emptyEl.style.display = ''; return; } emptyEl.style.display = 'none'; listEl.style.display = ''; listEl.innerHTML = cardsCache.map(function (card) { const steps = Array.isArray(card.steps) ? card.steps : []; const previews = steps.slice(0, 4).map(function (s, i) { return '
' + (i + 1) + '. ' + escHtml(s.name) + ' · ' + formatDuration(Number(s.durationSec) || 0) + '
'; }).join(''); const more = steps.length > 4 ? '
还有 ' + (steps.length - 4) + ' 个步骤…
' : ''; const perRound = steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); return '' + '
' + '
' + '
' + escHtml(card.title) + '
' + '
' + escHtml(formatLoopText(card.loopMode, card.loopCount)) + '
' + '
' + '
' + steps.length + ' 个步骤 · 单轮 ' + formatDurationLong(perRound) + '
' + '
' + previews + more + '
' + '
' + '' + '' + '' + '' + '
' + '
'; }).join(''); } async function loadCards() { const listEl = document.getElementById('cardsList'); const emptyEl = document.getElementById('cardsEmpty'); const loadingEl = document.getElementById('cardsLoading'); loadingEl.style.display = ''; listEl.style.display = 'none'; listEl.innerHTML = ''; emptyEl.style.display = 'none'; const res = await runTrackedInteraction('加载卡片列表', function () { return apiFetch('GET', '/api/app_order_box/kits/motion_reminder/cards'); }, { mode: 'view' }); loadingEl.style.display = 'none'; if (!res.ok) { showAlert('加载失败', escHtml(res.error || '卡片列表加载失败')); return; } cardsCache = Array.isArray(res.data) ? res.data : []; renderCards(); updateStaleWarning(); } // ---- 卡片操作(事件委托) ---- function handleCardsListClick(e) { const btn = e.target.closest ? e.target.closest('button') : null; if (!btn) return; const item = btn.closest('.card-item'); if (!item) return; const card = cardsCache.find(function (c) { return c.id === item.dataset.id; }); if (!card) return; if (btn.classList.contains('btn-start-card')) handleStartCard(card, btn); else if (btn.classList.contains('btn-edit-card')) openEditor(card); else if (btn.classList.contains('btn-delete-card')) handleDeleteCard(card); else if (btn.classList.contains('btn-regen-tts')) handleRegenCardTTS(card, btn); } /** 手动补救:强制重建该卡片的云端语音缓存;平时无需点击,编辑后遇到没声音时用 */ async function handleRegenCardTTS(card, btn) { if (!Array.isArray(card.steps) || !card.steps.length) { showAlert('无法生成', '当前卡片没有可执行步骤'); return; } setButtonLoading(btn, '生成中…'); const res = await runTrackedInteraction('重生卡片语音', function () { return prepareCardTTS(card, true); }); resetButton(btn); if (!res || !res.ok) return; const d = res.data || {}; showToast('语音已重新生成(' + (Number(d.success) || 0) + '/' + (Number(d.total) || 0) + ')', 'success'); } function handleDeleteCard(card) { showConfirm('确认删除', '确定删除「' + escHtml(card.title) + '」吗?
删除后不可恢复。', async function (confirmBtn) { setButtonLoading(confirmBtn, '删除中…'); const res = await runTrackedInteraction('删除卡片', function () { return apiFetch('DELETE', '/api/app_order_box/kits/motion_reminder/cards?id=' + encodeURIComponent(card.id)); }); closeDialog(); if (!res.ok) { resetButton(confirmBtn); return; // 失败详情已由交互弹窗停留展示 } await loadCards(); }); } async function handleStartCard(card, btn) { if (!Array.isArray(card.steps) || !card.steps.length) { showAlert('无法启动', '当前卡片没有可执行步骤'); return; } if (appState && appState.runningSession) { showAlert('无法启动', '服务端存在运行中的 session,请先清理占用或等待其结束。'); updateStaleWarning(); return; } // 必须在 await 之前(用户手势上下文中)解锁音频和语音 unlockAudio(); unlockSpeech(); // 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS) prepareCardTTS(card); setButtonLoading(btn, '启动中…'); const res = await runTrackedInteraction('启动执行会话', function () { return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/start', { card: card }); }); if (!res.ok) { resetButton(btn); if (res.status === 409) { showToast('已有运行中的会话,请先清理占用', 'error'); await refreshState(true); updateStaleWarning(); } return; } resetButton(btn); const session = res.data; await refreshState(true); updateStaleWarning(); openRunner(session, (session && session.cardSnapshot) || card); } async function handleCleanStale() { const btn = document.getElementById('btnCleanStale'); setButtonLoading(btn, '清理中…'); const res = await runTrackedInteraction('清理残留 session', function () { return apiFetch('POST', '/api/app_order_box/state/reset-running'); }); resetButton(btn); if (!res.ok) return; await refreshState(true); updateStaleWarning(); } // ==================== 卡片编辑器 ==================== let editorOpen = false; let editorDraft = null; let editorDragIndex = null; function createDefaultStep(type) { const isRest = type === 'rest'; return { id: generateId(), type: isRest ? 'rest' : 'action', name: isRest ? '休息' : '', durationSec: isRest ? 30 : 60, voiceText: '' }; } /** 默认草稿:1 个动作(60s) + 1 个休息(30s),循环 3 次 */ function createDraftCard() { return { id: '', title: '', steps: [createDefaultStep('action'), createDefaultStep('rest')], loopMode: 'repeat_count', loopCount: 3 }; } function openEditor(card) { if (card) { editorDraft = { id: card.id || '', title: card.title || '', steps: (Array.isArray(card.steps) ? card.steps : []).map(function (s) { return { id: s.id || generateId(), type: s.type === 'rest' ? 'rest' : 'action', name: s.name || '', durationSec: Number(s.durationSec) || 0, voiceText: s.voiceText || '' }; }), loopMode: ['once', 'repeat_count', 'repeat_forever'].indexOf(card.loopMode) >= 0 ? card.loopMode : 'once', loopCount: card.loopCount === null || card.loopCount === undefined ? 3 : Number(card.loopCount) }; if (!editorDraft.steps.length) editorDraft.steps = [createDefaultStep('action')]; } else { editorDraft = createDraftCard(); } document.getElementById('editorTitle').textContent = card ? '编辑流程卡片' : '新建流程卡片'; renderEditor(); document.getElementById('editorModal').style.display = 'flex'; editorOpen = true; } function closeEditor() { document.getElementById('editorModal').style.display = 'none'; editorOpen = false; editorDraft = null; editorDragIndex = null; } function editorTotalDuration() { return editorDraft.steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); } function updateEditorStats() { if (!editorDraft) return; const total = editorTotalDuration(); const previewEl = document.getElementById('editorTotalPreview'); if (previewEl) { previewEl.innerHTML = '' + '总时长:' + formatDurationLong(total) + ''; } const perRoundEl = document.getElementById('editorPerRound'); if (perRoundEl) perRoundEl.textContent = formatDurationLong(total); const globalEl = document.getElementById('editorGlobal'); if (globalEl) { if (editorDraft.loopMode === 'repeat_forever') { globalEl.textContent = '无限循环(单轮 ' + formatDurationLong(total) + ')'; } else if (editorDraft.loopMode === 'repeat_count') { const loops = Math.max(1, Number(editorDraft.loopCount || 1)); globalEl.textContent = formatDurationLong(total * loops); } else { globalEl.textContent = formatDurationLong(total); } } } function renderStepEditor(step, index, total) { const isAction = step.type !== 'rest'; const canRemove = total > 1; return '' + '
' + '
' + '
' + '' + (isAction ? '动作' : '休息') + '' + '步骤 ' + (index + 1) + '' + '
' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '' + '' + '
时长:' + formatDurationLong(Number(step.durationSec) || 0) + '
' + '
'; } function renderEditor() { if (!editorDraft) return; const d = editorDraft; const body = document.getElementById('editorBody'); const segments = [ { label: '单次', value: 'once' }, { label: 'N 次', value: 'repeat_count' }, { label: '无限', value: 'repeat_forever' } ]; const stepsHtml = d.steps.map(function (step, index) { return renderStepEditor(step, index, d.steps.length); }).join(''); body.innerHTML = '' + '
' + '' + '' + '
' + '
' + '' + '
' + segments.map(function (s) { return ''; }).join('') + '
' + '
' + (d.loopMode === 'repeat_count' ? ( '
' + '' + '' + '
' ) : '') + '
' + '流程步骤' + '
' + '' + '' + '
' + '
' + '
' + '
' + stepsHtml + '
' + '
' + '
单轮时长
' + '
全局总时长
' + '
' + ''; bindEditorEvents(); updateEditorStats(); } function bindEditorEvents() { const body = document.getElementById('editorBody'); const titleInput = document.getElementById('editorCardTitle'); titleInput.addEventListener('input', function () { editorDraft.title = titleInput.value; }); body.querySelectorAll('.segment-btn').forEach(function (btn) { btn.addEventListener('click', function () { if (editorDraft.loopMode === btn.dataset.loop) return; editorDraft.loopMode = btn.dataset.loop; renderEditor(); }); }); const loopCountInput = document.getElementById('editorLoopCount'); if (loopCountInput) { loopCountInput.addEventListener('input', function () { const digits = loopCountInput.value.replace(/[^0-9]/g, ''); if (digits !== loopCountInput.value) loopCountInput.value = digits; editorDraft.loopCount = digits === '' ? 0 : parseInt(digits, 10); updateEditorStats(); }); } document.getElementById('btnAddAction').addEventListener('click', function () { editorDraft.steps.push(createDefaultStep('action')); renderEditor(); }); document.getElementById('btnAddRest').addEventListener('click', function () { editorDraft.steps.push(createDefaultStep('rest')); renderEditor(); }); document.getElementById('btnEditorSave').addEventListener('click', handleEditorSave); body.querySelectorAll('.step-editor').forEach(function (stepEl) { const stepId = stepEl.dataset.stepId; const step = editorDraft.steps.find(function (s) { return s.id === stepId; }); if (!step) return; const index = editorDraft.steps.indexOf(step); const nameInput = stepEl.querySelector('.step-name-input'); nameInput.addEventListener('input', function () { step.name = nameInput.value; }); const durationInput = stepEl.querySelector('.step-duration-input'); durationInput.addEventListener('input', function () { const digits = durationInput.value.replace(/[^0-9]/g, ''); if (digits !== durationInput.value) durationInput.value = digits; step.durationSec = digits === '' ? 0 : parseInt(digits, 10); const hintEl = stepEl.querySelector('.step-duration-hint'); if (hintEl) hintEl.textContent = '时长:' + formatDurationLong(step.durationSec); updateEditorStats(); }); stepEl.querySelector('.btn-step-copy').addEventListener('click', function () { const idx = editorDraft.steps.findIndex(function (s) { return s.id === stepId; }); if (idx < 0) return; const src = editorDraft.steps[idx]; editorDraft.steps.splice(idx + 1, 0, { id: generateId(), type: src.type, name: (src.name || '') + ' (副本)', durationSec: src.durationSec, voiceText: src.voiceText || '' }); renderEditor(); }); stepEl.querySelector('.btn-step-remove').addEventListener('click', function () { if (editorDraft.steps.length <= 1) return; editorDraft.steps = editorDraft.steps.filter(function (s) { return s.id !== stepId; }); renderEditor(); }); bindStepDrag(stepEl, index); }); } // ---- HTML5 拖拽排序:仅拖拽手柄可发起拖放 ---- function bindStepDrag(stepEl, index) { const handle = stepEl.querySelector('.step-drag-handle'); handle.addEventListener('mousedown', function () { stepEl.draggable = true; }); handle.addEventListener('mouseup', function () { stepEl.draggable = false; }); stepEl.addEventListener('dragstart', function (e) { editorDragIndex = index; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', String(index)); } catch (err) { /* 兼容旧内核 */ } requestAnimationFrame(function () { stepEl.classList.add('step-dragging'); }); }); stepEl.addEventListener('dragend', function () { stepEl.draggable = false; editorDragIndex = null; document.querySelectorAll('.step-editor').forEach(function (el) { el.classList.remove('step-dragging', 'step-drag-over', 'drag-over-before', 'drag-over-after'); }); }); stepEl.addEventListener('dragover', function (e) { if (editorDragIndex === null || editorDragIndex === index) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = stepEl.getBoundingClientRect(); const before = (e.clientY - rect.top) < rect.height / 2; stepEl.classList.add('step-drag-over'); stepEl.classList.toggle('drag-over-before', before); stepEl.classList.toggle('drag-over-after', !before); }); stepEl.addEventListener('dragleave', function () { stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after'); }); stepEl.addEventListener('drop', function (e) { e.preventDefault(); const from = editorDragIndex; stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after'); if (from === null || from === index) return; const rect = stepEl.getBoundingClientRect(); const before = (e.clientY - rect.top) < rect.height / 2; let to = before ? index : index + 1; if (from < to) to -= 1; if (to === from) return; const moved = editorDraft.steps.splice(from, 1)[0]; editorDraft.steps.splice(to, 0, moved); editorDragIndex = null; renderEditor(); }); } async function handleEditorSave() { if (!editorDraft) return; const saveBtn = document.getElementById('btnEditorSave'); // 保存校验 const title = String(editorDraft.title || '').trim(); if (!title) { showAlert('无法保存', '请输入卡片名称'); return; } const invalidSteps = editorDraft.steps.filter(function (s) { return !(Number(s.durationSec) > 0); }); if (invalidSteps.length) { showAlert('无法保存', invalidSteps.length + ' 个步骤的时长无效,请输入大于 0 的数字'); return; } // 保存前清洗:trim 名称、durationSec 取 floor 最小 1、过滤空名称步骤 const cleanSteps = editorDraft.steps .map(function (s) { return { id: s.id, type: s.type === 'rest' ? 'rest' : 'action', name: String(s.name || '').trim(), durationSec: Math.max(1, Math.floor(Number(s.durationSec) || 0)), voiceText: String(s.voiceText || '').trim() }; }) .filter(function (s) { return s.name !== ''; }); if (!cleanSteps.length) { showAlert('无法保存', '至少保留一个有效步骤(步骤名称不能为空)'); return; } let loopCount = null; if (editorDraft.loopMode === 'repeat_count') { loopCount = Math.floor(Number(editorDraft.loopCount || 0)); if (loopCount < 1) { showAlert('无法保存', '循环次数至少为 1'); return; } } const isEdit = !!editorDraft.id; const payload = { title: title, steps: cleanSteps, loopMode: editorDraft.loopMode, loopCount: loopCount }; if (isEdit) payload.id = editorDraft.id; setButtonLoading(saveBtn, '保存中…'); const res = await runTrackedInteraction(isEdit ? '更新流程卡片' : '新建流程卡片', function () { return apiFetch(isEdit ? 'PUT' : 'POST', '/api/app_order_box/kits/motion_reminder/cards', payload); }); if (!res.ok) { resetButton(saveBtn); return; // 失败详情已由交互弹窗停留展示 } resetButton(saveBtn); closeEditor(); await loadCards(); } // ==================== 执行器 ==================== let runnerState = null; let runnerTimerId = null; let runnerAnnounceKey = ''; let runnerFinishing = false; function perRoundSec(card) { return ((card && card.steps) || []).reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); } function runnerTotalLoops() { if (!runnerState) return null; return runnerState.card.loopMode === 'repeat_count' ? Math.max(1, Number(runnerState.card.loopCount || 1)) : null; } /** 预计剩余总秒数:当前步骤剩余 + 本轮剩余步骤 + 剩余整轮 */ function computeRemainingSec(rs) { const steps = rs.card.steps; let total = Math.max(0, Number(rs.remainingSec) || 0); for (let i = rs.currentStepIndex + 1; i < steps.length; i++) { total += Math.max(1, Number(steps[i].durationSec) || 0); } const totalLoops = rs.card.loopMode === 'repeat_count' ? Math.max(1, Number(rs.card.loopCount || 1)) : null; if (totalLoops !== null) { const roundsLeft = Math.max(0, totalLoops - rs.currentLoop); total += roundsLeft * perRoundSec(rs.card); } return total; } function openRunner(session, card) { unlockAudio(); // 用户手势中解锁音频,确保后续定时器触发的提示音能播放 const steps = (card && Array.isArray(card.steps)) ? card.steps : []; if (!steps.length) { showAlert('无法启动', '当前卡片没有可执行步骤'); return; } const startIndex = Math.min(Math.max(0, Number(session.currentStepIndex) || 0), steps.length - 1); runnerState = { sessionId: session.id, card: card, status: 'running', currentLoop: Math.max(1, Number(session.currentLoop) || 1), completedLoops: Math.max(0, Number(session.completedLoops) || 0), currentStepIndex: startIndex, remainingSec: Math.max(1, Number(steps[startIndex].durationSec) || 1), manualStopped: false }; runnerAnnounceKey = ''; runnerFinishing = false; document.getElementById('runnerModal').style.display = 'flex'; requestWakeLock(); onRunnerStepChanged(true); startRunnerTimer(); } function closeRunner() { stopRunnerTimer(); runnerState = null; runnerAnnounceKey = ''; stopSpeech(); releaseWakeLock(); document.getElementById('runnerModal').style.display = 'none'; } function startRunnerTimer() { stopRunnerTimer(); runnerTimerId = setInterval(runnerTick, 1000); } function stopRunnerTimer() { if (runnerTimerId) { clearInterval(runnerTimerId); runnerTimerId = null; } } /** 每秒递减;5/3 秒播报;到 0 自动切下一步 */ function runnerTick() { if (!runnerState || runnerState.status !== 'running' || runnerFinishing) return; const step = runnerState.card.steps[runnerState.currentStepIndex]; const pitch = (step && step.type === 'action') ? 1.1 : 0.9; if (runnerState.remainingSec === 5 || runnerState.remainingSec === 3) { speakText('还有 ' + runnerState.remainingSec + ' 秒', pitch); } if (runnerState.remainingSec <= 1) { advanceStep(); return; } runnerState.remainingSec -= 1; updateRunnerDynamic(); } /** 步骤推进:非末步→下一步;末步→下一轮或完成 */ function advanceStep() { if (!runnerState || runnerFinishing) return; const steps = runnerState.card.steps; const isLastStep = runnerState.currentStepIndex >= steps.length - 1; if (!isLastStep) { runnerState.currentStepIndex += 1; runnerState.remainingSec = Math.max(1, Number(steps[runnerState.currentStepIndex].durationSec) || 1); onRunnerStepChanged(false); return; } const nextCompletedLoops = runnerState.completedLoops + 1; const totalLoops = runnerTotalLoops(); if (runnerState.card.loopMode === 'once' || (totalLoops !== null && nextCompletedLoops >= totalLoops)) { void finishRunner('completed', false); return; } runnerState.completedLoops = nextCompletedLoops; runnerState.currentLoop += 1; runnerState.currentStepIndex = 0; runnerState.remainingSec = Math.max(1, Number(steps[0].durationSec) || 1); onRunnerStepChanged(false); } /** 步骤切换统一入口:渲染 + 播报 + 提示音 + 振动 + 服务端同步 */ function onRunnerStepChanged(isFirst) { renderRunner(); updateRunnerDynamic(); announceStep(); playBeep(); vibrate([50]); if (!isFirst) syncRunnerSilent(); } /** 静默同步进度到服务端(高频调用,不弹窗) */ function syncRunnerSilent() { if (!runnerState) return Promise.resolve(null); const rs = runnerState; return runTrackedInteraction('同步执行进度', function () { return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/sync', { id: rs.sessionId, status: rs.status, currentLoop: rs.currentLoop, completedLoops: rs.completedLoops, currentStepIndex: rs.currentStepIndex, manualStopped: rs.manualStopped }); }, { silent: true }); } async function finishRunner(status, manualStopped) { if (!runnerState || runnerFinishing) return; runnerFinishing = true; const rs = runnerState; stopRunnerTimer(); const body = { id: rs.sessionId, status: status, currentLoop: rs.currentLoop, completedLoops: rs.completedLoops, currentStepIndex: rs.currentStepIndex, manualStopped: !!manualStopped }; const res = await runTrackedInteraction( status === 'completed' ? '会话正常完成' : '手动结束会话', function () { return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/finish', body); } ); closeRunner(); runnerFinishing = false; if (res.ok) { showToast(status === 'completed' ? '流程已完成' : '已提前结束', 'success'); } await refreshState(true); updateStaleWarning(); if (currentView === 'motion' && currentTab === 'cards') { await loadCards(); } } // ---- 执行器控制按钮 ---- async function handleRunnerPause() { if (!runnerState || runnerFinishing) return; vibrate([50]); const pausing = runnerState.status === 'running'; runnerState.status = pausing ? 'paused' : 'running'; const container = document.getElementById('runnerContainer'); container.classList.toggle('is-paused', runnerState.status === 'paused'); const btn = document.getElementById('btnRunnerPause'); if (btn) { const textEl = getButtonTextEl(btn); textEl.textContent = runnerState.status === 'paused' ? '继续' : '暂停'; } if (runnerState.status === 'paused') { stopSpeech(); } await syncRunnerSilent(); } function handleRunnerSkip() { if (!runnerState || runnerFinishing) return; vibrate([50]); advanceStep(); } function handleRunnerStop() { if (!runnerState || runnerFinishing) return; vibrate([30]); showConfirm('结束执行', '确定提前结束本次执行吗?这会记录为手动提前结束。', async function () { closeDialog(); await finishRunner('stopped', true); }); } // ---- 执行器渲染 ---- function renderRunner() { const rs = runnerState; if (!rs) return; const container = document.getElementById('runnerContainer'); const step = rs.card.steps[rs.currentStepIndex] || {}; const nextStep = rs.card.steps[rs.currentStepIndex + 1] || null; const isAction = step.type !== 'rest'; const totalSteps = rs.card.steps.length; const totalLoops = runnerTotalLoops(); const globalDurationSec = totalLoops !== null ? perRoundSec(rs.card) * totalLoops : null; container.innerHTML = '' + '
' + '' + (isAction ? '动作' : '休息') + '' + '
' + escHtml(rs.card.title) + '
' + '
第 ' + rs.currentLoop + ' 轮 ' + (totalLoops ? '/ 共 ' + totalLoops + ' 轮' : '/ 无限循环') + ' · 步骤 ' + (rs.currentStepIndex + 1) + ' / ' + totalSteps + '
' + '
' + '
' + '
' + '
' + '
当前动作
' + '
' + escHtml(step.name || '—') + '
' + '
--:--
' + '
' + '
下一个动作
' + '
' + (nextStep ? escHtml(nextStep.name) : '本轮结束') + '
' + '
' + '
' + '
' + '' + '
' + '
'; container.classList.toggle('is-paused', rs.status === 'paused'); document.getElementById('btnRunnerPause').addEventListener('click', handleRunnerPause); document.getElementById('btnRunnerSkip').addEventListener('click', handleRunnerSkip); document.getElementById('btnRunnerStop').addEventListener('click', handleRunnerStop); } /** 每 tick 局部刷新:倒计时、进度条、预计剩余、全局进度 */ function updateRunnerDynamic() { const rs = runnerState; if (!rs) return; const step = rs.card.steps[rs.currentStepIndex]; const stepDuration = Math.max(1, Number((step && step.durationSec) || 1)); const stepProgress = Math.max(0, Math.min(1, 1 - (rs.remainingSec / stepDuration))); const timerEl = document.getElementById('runnerTimer'); if (timerEl) timerEl.textContent = formatDuration(rs.remainingSec); const barEl = document.getElementById('runnerStepBar'); if (barEl) barEl.style.width = (stepProgress * 100).toFixed(2) + '%'; const remainingSec = computeRemainingSec(rs); const remEl = document.getElementById('runnerInfoRemaining'); if (remEl) remEl.textContent = '约 ' + formatDuration(remainingSec); const totalLoops = runnerTotalLoops(); const globalDurationSec = totalLoops !== null ? perRoundSec(rs.card) * totalLoops : null; if (globalDurationSec !== null && globalDurationSec > 0) { const executedSec = Math.max(0, globalDurationSec - remainingSec); const progress = Math.max(0, Math.min(1, executedSec / globalDurationSec)); const pctEl = document.getElementById('runnerGlobalPercent'); if (pctEl) pctEl.textContent = Math.round(progress * 100) + '%'; const gBarEl = document.getElementById('runnerGlobalBar'); if (gBarEl) gBarEl.style.width = (progress * 100).toFixed(2) + '%'; const exEl = document.getElementById('runnerGlobalExecuted'); if (exEl) exEl.textContent = '已执行 ' + formatDuration(executedSec); const reEl = document.getElementById('runnerGlobalRemaining'); if (reEl) reEl.textContent = '剩余 ' + formatDuration(remainingSec); } } // ==================== 语音播报 ==================== // 双通道策略(解决"有时能读、有时不能读、安卓平板完全不能读"): // 通道1 系统 TTS(Web Speech API):音质自然,iPhone 静音键下仍能发声,优先生效; // 通道2 云端 mp3(服务端 DashScope 预合成):系统 TTS 无引擎 / 静默失败时兜底。 // 判定链路:进入 kit 播欢迎语探测 → 用户确认 → 失败切云端 → 两者皆不可用则显式预警。 // 去重:同一步骤(sessionId:loop:stepIndex)不重复播报。 const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!'; // 部分安卓 Chrome 无语音引擎时既不触发 onstart 也不触发 onerror,只能靠超时兜底 const SPEECH_PROBE_TIMEOUT_MS = 1200; // 'unknown' 未判定 | 'system' 系统 TTS | 'cloud' 云端 mp3 | 'none' 两者皆不可用 let speechEngineMode = 'unknown'; let speechProbeDone = false; let voiceWarned = false; // 文本 → 云端 mp3 URL 映射,由 prepareCardTTS 预合成填充 let ttsUrlMap = {}; // 已预合成卡片的内容指纹(不是 card.id):编辑流程后内容变化即视为新内容,需重新预合成 let ttsPreparedCardKey = ''; // 云端音频播放器(单实例串行队列,避免多条播报叠在一起) let speechAudioEl = null; let cloudQueue = []; let cloudQueueBusy = false; function showVoiceWarning(msg) { // 运动提醒宝与作息报时器各有一份预警横幅,统一在此处同步显示 [['voiceWarning', 'voiceWarningDesc'], ['schedVoiceWarning', 'schedVoiceWarningDesc']].forEach(function (pair) { const el = document.getElementById(pair[0]); if (!el) return; const descEl = document.getElementById(pair[1]); if (descEl && msg) descEl.textContent = msg; el.style.display = ''; }); } function hideVoiceWarning() { ['voiceWarning', 'schedVoiceWarning'].forEach(function (id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); } /** 语音彻底不可用:显式预警,避免用户误以为正在静默播报 */ function markVoiceUnavailable(reason) { speechEngineMode = 'none'; showVoiceWarning(reason || '本机无法播放语音,请检查系统语音引擎与音量设置。'); if (!voiceWarned) { voiceWarned = true; showToast('本机无法播放语音,请检查系统语音引擎或音量', 'error', 4000); } } /** 系统 TTS 单句播报;用 onstart / onerror / 超时判断引擎是否真的启动 */ function speakSystem(text, pitch) { return new Promise(function (resolve) { let settled = false; let timer = null; const finish = function (ok, error) { if (settled) return; settled = true; if (timer) clearTimeout(timer); resolve({ ok: ok, error: error || '' }); }; try { if (!('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') { finish(false, 'unsupported'); return; } const synth = window.speechSynthesis; try { synth.cancel(); } catch (e) { /* 静默 */ } const utterance = new SpeechSynthesisUtterance(String(text || '')); utterance.lang = 'zh-CN'; utterance.pitch = Number(pitch) || 1; utterance.rate = 0.95; timer = setTimeout(function () { finish(false, 'start-timeout'); }, SPEECH_PROBE_TIMEOUT_MS); utterance.onstart = function () { finish(true, ''); }; utterance.onerror = function (ev) { finish(false, (ev && ev.error) || 'utterance-error'); }; // cancel() 紧跟 speak() 在 Chrome 上存在竞态会丢句,延后一拍再播 setTimeout(function () { try { synth.speak(utterance); } catch (e) { finish(false, String((e && e.message) || e)); } }, 60); } catch (e) { finish(false, String((e && e.message) || e)); } }); } /** 云端 mp3 单条播放,Promise */ function playCloudUrl(url) { return new Promise(function (resolve) { let settled = false; const finish = function (ok) { if (settled) return; settled = true; resolve(ok); }; try { if (speechAudioEl) { try { speechAudioEl.pause(); } catch (e) { /* 静默 */ } } const audio = new Audio(url); audio.preload = 'auto'; speechAudioEl = audio; audio.onended = function () { finish(true); }; audio.onerror = function () { finish(false); }; const playPromise = audio.play(); // 自动播放策略拦截 / 静音键 / 解码失败:play() 会 reject if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch(function () { finish(false); }); } } catch (e) { finish(false); } }); } function stopCloudSpeech() { cloudQueue = []; cloudQueueBusy = false; if (speechAudioEl) { try { speechAudioEl.pause(); } catch (e) { /* 静默 */ } speechAudioEl = null; } } function runCloudQueue() { if (!cloudQueueBusy) return; const url = cloudQueue.shift(); if (!url) { cloudQueueBusy = false; return; } playCloudUrl(url).then(function (ok) { if (!ok) { cloudQueueBusy = false; markVoiceUnavailable('云端语音播放失败,请检查音量与静音键。'); return; } runCloudQueue(); }); } /** 顺序播放一批云端 mp3;返回是否存在可播的缓存音频。 * map 可选:作息报时器使用自己的预合成映射,缺省时用运动卡片的映射 */ function playCloudAtoms(atoms, map) { const src = map || ttsUrlMap; const list = (Array.isArray(atoms) ? atoms : [atoms]) .map(function (t) { return String(t || '').trim(); }) .filter(Boolean); const urls = list.map(function (t) { return src[t]; }).filter(Boolean); if (!urls.length) return false; stopCloudSpeech(); cloudQueue = urls.slice(); cloudQueueBusy = true; runCloudQueue(); return true; } /** 统一播报入口:优先系统 TTS,静默失败时自动降级到云端 mp3。 * map 可选:指定云端 mp3 的文本→URL 映射(作息报时器传入自己的映射) */ function speakText(text, pitch, map) { const t = String(text || '').trim(); if (!t) return; if (speechEngineMode === 'cloud' && playCloudAtoms(t, map)) return; speakSystem(t, pitch).then(function (r) { if (r.ok) return; // 系统 TTS 本次失败:有云端音频就切过去,否则显式预警 if (playCloudAtoms(t, map)) { if (speechEngineMode !== 'cloud') { speechEngineMode = 'cloud'; showToast('已自动切换到云端语音播报', 'info', 2000); } return; } markVoiceUnavailable('系统语音与云端语音均不可用,请检查系统语音引擎或音量设置。'); }); } /** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */ function unlockSpeech() { try { if (!('speechSynthesis' in window)) return; const u = new SpeechSynthesisUtterance(''); u.volume = 0; window.speechSynthesis.speak(u); window.speechSynthesis.cancel(); } catch (e) { /* 静默 */ } } function stopSpeech() { try { if ('speechSynthesis' in window) window.speechSynthesis.cancel(); } catch (e) { /* 静默 */ } stopCloudSpeech(); } /** * 进入 kit 时的语音探测:用提示语本身当探测信号(无需另造探测音), * 引擎是否启动由 onstart/onerror 判定,「喇叭有没有真响」由用户点按钮确认。 * 必须在用户手势上下文中调用;force=true 时忽略"已探测过"强制重测。 * probeText 可选:指定探测句(作息报时器传入自己的待机提示语)。 */ function probeSpeech(force, probeText) { if (speechProbeDone && !force) return; speechProbeDone = true; const text = String(probeText || WELCOME_TEXT).trim() || WELCOME_TEXT; let answered = false; const settle = function (mode, tip) { if (answered) return; answered = true; speechEngineMode = mode; // 必须收起探测弹窗:否则模态遮罩会一直盖住页面,导致后面所有按钮都点不动 closeDialog(); if (tip) showVoiceWarning(tip); else hideVoiceWarning(); }; speakSystem(text, 1).then(function (r) { if (answered) return; if (r.ok) return; // 引擎已启动,等用户确认是否真的听到 settle('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。'); }); showDialog('语音检查', '正在播报提示语:
' + escHtml(text) + '
请确认是否听到声音。', [ { text: '没听到', style: 'default', onPress: function () { settle('cloud', '已切换到云端语音兜底(需先启动一次流程完成预合成)。'); } }, { text: '听到了', style: 'primary', onPress: function () { settle('system', ''); } } ]); // 兜底:用户一直不点确认时自动收起弹窗,避免模态遮罩长期挡住页面 setTimeout(function () { if (answered) return; answered = true; closeDialog(); }, 8000); } /** 卡片内容指纹:步骤/循环任一变化即视为新内容;与 card.id 无关 */ function cardTTSFingerprint(card) { try { return JSON.stringify({ steps: (card.steps || []).map(function (s) { return [ String(s.name || ''), Number(s.durationSec) || 0, String(s.voiceText || ''), String(s.type || '') ]; }), loopMode: card.loopMode, loopCount: card.loopCount }); } catch (e) { return ''; } } /** 会话启动时批量预合成整卡语音;失败不阻塞流程,运行期回退系统 TTS。force=true 用于手动重生 */ function prepareCardTTS(card, force) { if (!card || !card.id) return Promise.resolve({ ok: false, error: 'no card' }); const key = cardTTSFingerprint(card); if (!force && key && ttsPreparedCardKey === key) return Promise.resolve({ ok: true, skipped: true }); ttsPreparedCardKey = key; const payload = { card: { id: card.id, title: card.title, steps: card.steps, loopMode: card.loopMode, loopCount: card.loopCount } }; return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/tts/prepare', payload).then(function (res) { if (res.ok && res.data && res.data.map) { Object.keys(res.data.map).forEach(function (text) { ttsUrlMap[text] = res.data.map[text]; }); console.log('[order_box tts] 预合成完成 ' + res.data.success + '/' + res.data.total); if (speechEngineMode === 'cloud' && Object.keys(ttsUrlMap).length) hideVoiceWarning(); } else { ttsPreparedCardKey = ''; console.warn('[order_box tts] 预合成失败:' + (res.error || 'unknown')); } return res; }).catch(function (e) { ttsPreparedCardKey = ''; console.warn('[order_box tts] 预合成异常:' + String((e && e.message) || e)); return { ok: false, error: String((e && e.message) || e) }; }); } /** 步骤播报的最小原子单元,必须与后端 buildCardTTSBatch 的取词逐字一致(云端缓存查表依赖) */ function stepSpeechAtoms(loop, step, nextStep, loopMode) { const atoms = []; if (loopMode !== 'once') atoms.push('第' + loop + '轮'); atoms.push((step.voiceText && String(step.voiceText).trim()) ? String(step.voiceText).trim() : (step.name + ',' + formatSpeechDuration(step.durationSec))); if (nextStep) atoms.push('下一步,' + (String(nextStep.name || '').trim() || '下一步')); return atoms; } /** 步骤切换播报:"第N轮,{名称},{时长}。下一步,{nextName}" */ function announceStep() { if (!runnerState) return; const key = runnerState.sessionId + ':' + runnerState.currentLoop + ':' + runnerState.currentStepIndex; if (runnerAnnounceKey === key) return; // 去重 runnerAnnounceKey = key; const step = runnerState.card.steps[runnerState.currentStepIndex]; if (!step) return; const nextStep = runnerState.card.steps[runnerState.currentStepIndex + 1] || null; const pitch = step.type === 'action' ? 1.1 : 0.9; const atoms = stepSpeechAtoms(runnerState.currentLoop, step, nextStep, runnerState.card.loopMode); if (speechEngineMode === 'cloud' && playCloudAtoms(atoms)) return; // 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气 const merged = atoms.map(function (t, i) { return (i > 0 && t.indexOf('下一步') === 0) ? '。' + t : t; }).join(','); speakText(merged, pitch); } // ==================== 提示音 ==================== // Web Audio API:步骤切换时播放短促蜂鸣(200ms, 880Hz) let audioCtx = null; /** 在用户手势中调用,解锁 AudioContext(解决浏览器自动播放策略静音问题) */ function unlockAudio() { try { const AudioContextCtor = window.AudioContext || window.webkitAudioContext; if (!AudioContextCtor) return; if (!audioCtx) audioCtx = new AudioContextCtor(); if (audioCtx.state === 'suspended') { audioCtx.resume().catch(function () {}); } } catch (e) { /* 不支持时静默 */ } } function playBeep() { try { const AudioContextCtor = window.AudioContext || window.webkitAudioContext; if (!AudioContextCtor) return; if (!audioCtx) audioCtx = new AudioContextCtor(); // 如果仍处于 suspended(无用户手势解锁),跳过本次播放 if (audioCtx.state === 'suspended') return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = 880; const now = audioCtx.currentTime; gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.22, now + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(now); osc.stop(now + 0.2); } catch (e) { /* 不支持时静默 */ } } /** 振动反馈(不支持时静默) */ function vibrate(pattern) { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch (e) { /* 静默 */ } } // ==================== 屏幕常亮 ==================== // Wake Lock API:执行期间保持屏幕不灭;页面恢复可见时自动重新申请 let wakeLockSentinel = null; async function requestWakeLock() { try { if ('wakeLock' in navigator) { wakeLockSentinel = await navigator.wakeLock.request('screen'); wakeLockSentinel.addEventListener('release', function () { wakeLockSentinel = null; }); } } catch (e) { /* 不支持或权限受限时静默 */ } } function releaseWakeLock() { try { if (wakeLockSentinel) { void wakeLockSentinel.release(); } } catch (e) { /* 静默 */ } wakeLockSentinel = null; } function handleVisibilityChange() { if (document.visibilityState !== 'visible') return; if (runnerState) requestWakeLock(); // 恢复到前台立即补一次 tick:后台被冻结期间到点的条目在此刻被判定为补播或错过 if (currentView === 'schedule' && schedStandbyOn) { requestWakeLock(); schedTick(); } } // ==================== 历史列表 ==================== function renderHistory(list) { const listEl = document.getElementById('historyList'); const emptyEl = document.getElementById('historyEmpty'); if (!list.length) { listEl.innerHTML = ''; listEl.style.display = 'none'; emptyEl.style.display = ''; return; } emptyEl.style.display = 'none'; listEl.style.display = ''; listEl.innerHTML = list.map(function (item) { const title = (item.cardSnapshot && item.cardSnapshot.title) || '未知流程'; const endText = item.manualStopped ? '手动提前结束' : (item.status === 'completed' ? '正常完成' : '停止'); const badgeClass = item.manualStopped ? 'badge-warning' : (item.status === 'completed' ? 'badge-success' : 'badge-muted'); return '' + '
' + '
' + escHtml(title) + '
' + '
开始:' + formatDateTime(item.startedAt) + '
' + '
结束:' + formatDateTime(item.endedAt) + '
' + '
完成轮次:' + Math.max(0, Number(item.completedLoops) || 0) + '
' + '
结束方式:' + endText + '
' + '
'; }).join(''); } async function loadHistory() { const listEl = document.getElementById('historyList'); const emptyEl = document.getElementById('historyEmpty'); const loadingEl = document.getElementById('historyLoading'); loadingEl.style.display = ''; listEl.style.display = 'none'; listEl.innerHTML = ''; emptyEl.style.display = 'none'; const res = await runTrackedInteraction('加载执行历史', function () { return apiFetch('GET', '/api/app_order_box/kits/motion_reminder/history'); }, { mode: 'view' }); loadingEl.style.display = 'none'; if (!res.ok) { showAlert('加载失败', escHtml(res.error || '执行历史加载失败')); return; } renderHistory(Array.isArray(res.data) ? res.data : []); } // ==================== 作息报时器 ==================== // 与运动提醒宝并列的第二个 kit:按「时:分」绝对时刻触发播报。 // 到点后按「重复次数 × 间隔秒」循环播报,用户点「我知道了」即提前结束本条; // 但下一条时刻不受影响,照常触发(互不干扰)。 // 待机播报引擎只在报时器页面上运行;页面被冻结期间到点的条目,在恢复前台时判定为错过。 const SCHED_STANDBY_TEXT = '作息报时器已开始待机'; const SCHED_WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']; // index 0 => 周一(ISO 1) const SCHED_DEFAULT_REPEAT = 5; const SCHED_DEFAULT_INTERVAL = 30; // 超过该延迟才算「错过」:正常每秒 tick 的误差远小于此值,只用于识别页面被冻结的情况 const SCHED_MISSED_THRESHOLD_MS = 90 * 1000; const SCHED_TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; const SCHED_MAX_PREVIEW_ITEMS = 6; let schedList = []; let schedActiveId = null; // 当前选中的日程(待机用) let schedStandbyOn = false; let schedTickTimer = null; let schedClockOffsetMs = 0; // 服务端时间 - 本机时间,抵消旧手机时钟漂移 let schedClockSynced = false; let schedDayKey = ''; // 当前北京日期(跨天时重置触发标记) let schedFiredKeys = {}; // 'YYYY-MM-DD|itemId' -> true let schedRepeat = null; // 正在循环播报的那一条 let schedMissedList = []; let schedTab = 'schedules'; let schedDraft = null; let schedTtsUrlMap = {}; // 作息报时器独立的云端 mp3 映射 let schedTtsPreparedKey = ''; const schedPad2 = function (n) { return String(n).padStart(2, '0'); }; const SCHED_TRASH_SVG = ''; /** 指定 epochMs 对应的北京时间字段(项目规则:时间一律按 Asia/Shanghai 处理) */ function beijingParts(epochMs) { const out = {}; try { const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); fmt.formatToParts(new Date(epochMs)).forEach(function (p) { if (p.type !== 'literal') out[p.type] = p.value; }); } catch (e) { /* 极端环境下退回本机时间 */ } const year = out.year || String(new Date(epochMs).getFullYear()); const month = out.month || schedPad2(new Date(epochMs).getMonth() + 1); const day = out.day || schedPad2(new Date(epochMs).getDate()); let hour = out.hour === undefined ? String(new Date(epochMs).getHours()) : out.hour; if (hour === '24') hour = '00'; // 部分实现午夜返回 24 const minute = out.minute === undefined ? schedPad2(new Date(epochMs).getMinutes()) : out.minute; const second = out.second === undefined ? schedPad2(new Date(epochMs).getSeconds()) : out.second; const dateKey = year + '-' + month + '-' + day; const dayIdx = new Date(dateKey + 'T00:00:00+08:00').getDay(); // 0=周日 return { dateKey: dateKey, weekday: dayIdx === 0 ? 7 : dayIdx, hour: Number(hour), minute: Number(minute), second: Number(second), hhmm: hour + ':' + minute }; } function schedNowMs() { return Date.now() + schedClockOffsetMs; } /** 北京时间日期文案,如 "2026年9月30日星期三" */ function schedDateText(epochMs) { try { return new Date(epochMs).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); } catch (e) { return ''; } } function schedWeekdayText(weekdays) { const list = Array.isArray(weekdays) ? weekdays.slice().sort(function (a, b) { return a - b; }) : []; if (!list.length) return '未设置'; if (list.length === 7) return '每天'; if (list.join(',') === '1,2,3,4,5') return '工作日'; if (list.join(',') === '6,7') return '周末'; return list.map(function (w) { return '周' + (SCHED_WEEKDAY_LABELS[w - 1] || ''); }).join(' '); } function schedFormatCountdown(ms) { const total = Math.max(0, Math.floor(ms / 1000)); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; if (h > 0) return h + ' 小时 ' + m + ' 分钟'; if (m > 0) return m + ' 分钟 ' + s + ' 秒'; return s + ' 秒'; } /** 时刻文案:"07:30" -> "7点30分"(必须与后端 tts.speechTimeText 逐字一致) */ function schedSpeechTimeText(time) { const parts = String(time || '').split(':'); const h = Number(parts[0]); const m = Number(parts[1]); const safeH = Number.isFinite(h) ? h : 0; const safeM = Number.isFinite(m) ? m : 0; return safeH + '点' + (safeM > 0 ? safeM + '分' : ''); } /** 单条时刻的播报整句(必须与后端 tts.itemSpeechText 逐字一致,云端缓存查表依赖) */ function schedItemSpeechText(item) { const voiceText = String(item && item.voiceText || '').trim(); if (voiceText) return voiceText; const name = String(item && item.name || '').trim() || '提醒'; return '现在是' + schedSpeechTimeText(item && item.time) + ',' + name; } /** 编辑器内单条时刻的播报提示文案(随输入实时更新) */ function schedItemHintText(item) { return '到点播报「' + schedItemSpeechText(item) + '」,共 ' + Number(item.repeatTimes) + ' 次,每 ' + Number(item.intervalSec) + ' 秒一次'; } /** 某条目在指定日期对应的计划触发时刻(epochMs,按北京时间解析) */ function schedPlannedMs(dateKey, time) { const ms = Date.parse(dateKey + 'T' + String(time || '') + ':00+08:00'); return Number.isFinite(ms) ? ms : NaN; } function schedCurrentSchedule() { if (!schedActiveId) return null; return schedList.find(function (s) { return s.id === schedActiveId; }) || null; } // ---------- 校时 ---------- async function syncSchedClock() { const res = await runTrackedInteraction('校时(服务端时间)', function () { return apiFetch('GET', '/api/app_order_box/kits/schedule_reminder/time'); }, { mode: 'view', silent: true }); if (res && res.ok && res.data && Number.isFinite(Number(res.data.epochMs))) { schedClockOffsetMs = Number(res.data.epochMs) - Date.now(); schedClockSynced = true; } } // ---------- 待机播报引擎 ---------- function startSchedTick() { const p = beijingParts(schedNowMs()); if (schedDayKey !== p.dateKey) { schedDayKey = p.dateKey; schedFiredKeys = {}; } schedTick(); if (schedTickTimer) return; schedTickTimer = setInterval(schedTick, 1000); } function stopSchedTick() { if (schedTickTimer) { clearInterval(schedTickTimer); schedTickTimer = null; } } function schedTick() { const nowMs = schedNowMs(); const p = beijingParts(nowMs); const clockEl = document.getElementById('schedClock'); if (clockEl) clockEl.textContent = p.hhmm + ':' + schedPad2(p.second); const dateEl = document.getElementById('schedDate'); if (dateEl) dateEl.textContent = schedDateText(nowMs); if (!schedStandbyOn) return; // 跨天:重置当日触发标记 if (schedDayKey !== p.dateKey) { schedDayKey = p.dateKey; schedFiredKeys = {}; } const schedule = schedCurrentSchedule(); if (!schedule || !schedule.enabled) return; if (schedule.weekdays.indexOf(p.weekday) < 0) { const nextEl = document.getElementById('schedNext'); if (nextEl) nextEl.textContent = '今天不是该日程的生效日(' + schedWeekdayText(schedule.weekdays) + ')'; return; } schedCheckDueItems(schedule, p, nowMs); schedUpdateNext(schedule, p, nowMs); } function schedCheckDueItems(schedule, p, nowMs) { const items = Array.isArray(schedule.items) ? schedule.items : []; items.forEach(function (item) { if (!item || item.enabled === false || !item.time) return; const key = p.dateKey + '|' + item.id; if (schedFiredKeys[key]) return; const plannedMs = schedPlannedMs(p.dateKey, item.time); if (!Number.isFinite(plannedMs)) return; if (nowMs < plannedMs) return; // 还没到点 schedFiredKeys[key] = true; if (nowMs - plannedMs > SCHED_MISSED_THRESHOLD_MS) { // 判定为错过:只进提示条,不自动出声,避免页面恢复时突然炸响 schedPushMissed(schedule, item, plannedMs); return; } fireSchedItem(schedule, item, plannedMs); }); } /** 启动待机时,把今天已过去较久的时刻直接标记为已完成,避免"一开就补播一堆" */ function markSchedPastItemsDone(schedule, p, nowMs) { const items = Array.isArray(schedule.items) ? schedule.items : []; items.forEach(function (item) { if (!item || !item.time) return; const plannedMs = schedPlannedMs(p.dateKey, item.time); if (!Number.isFinite(plannedMs)) return; if (nowMs - plannedMs > SCHED_MISSED_THRESHOLD_MS) { schedFiredKeys[p.dateKey + '|' + item.id] = true; } }); } function schedUpdateNext(schedule, p, nowMs) { const el = document.getElementById('schedNext'); if (!el) return; const items = (Array.isArray(schedule.items) ? schedule.items : []) .filter(function (i) { return i && i.enabled !== false && i.time; }); let next = null; for (let i = 0; i < items.length; i++) { const plannedMs = schedPlannedMs(p.dateKey, items[i].time); if (!Number.isFinite(plannedMs)) continue; if (nowMs < plannedMs) { next = { item: items[i], plannedMs: plannedMs }; break; } } if (!next) { el.textContent = '今天没有后续时刻了'; return; } el.textContent = '下一条:' + next.item.time + ' ' + (next.item.name || '') + ' · 还有 ' + schedFormatCountdown(next.plannedMs - nowMs); } // ---------- 到点触发 + 循环播报 ---------- function fireSchedItem(schedule, item, plannedMs) { // 上一条尚未播完的重复直接让位,避免两条语音叠在一起(下一条时刻本身照常触发) stopSchedRepeat(); const state = { schedule: schedule, item: item, times: Math.max(1, Number(item.repeatTimes) || SCHED_DEFAULT_REPEAT), intervalSec: Math.max(5, Number(item.intervalSec) || SCHED_DEFAULT_INTERVAL), done: 1, logId: 0, timerId: null, text: schedItemSpeechText(item) }; schedRepeat = state; postSchedLog(schedule, item, plannedMs, 'fired').then(function (logId) { if (schedRepeat === state) state.logId = logId; }); showSchedAlert(item); updateSchedAlertRepeat(state); speakText(state.text, 1, schedTtsUrlMap); if (state.done >= state.times) { schedFinishRepeat(state); return; } state.timerId = setTimeout(function () { schedRepeatNext(state); }, state.intervalSec * 1000); } function schedRepeatNext(state) { if (schedRepeat !== state) return; state.done += 1; updateSchedAlertRepeat(state); speakText(state.text, 1, schedTtsUrlMap); if (state.done >= state.times) { schedFinishRepeat(state); return; } state.timerId = setTimeout(function () { schedRepeatNext(state); }, state.intervalSec * 1000); } /** 播满设定次数(用户未点确认):稍后自动收起弹窗,记录仍保持"已播报" */ function schedFinishRepeat(state) { state.timerId = setTimeout(function () { if (schedRepeat !== state) return; schedRepeat = null; hideSchedAlert(); }, 2000); } function stopSchedRepeat() { const state = schedRepeat; if (!state) return; if (state.timerId) { clearTimeout(state.timerId); state.timerId = null; } schedRepeat = null; } /** 用户点「我知道了」:提前结束本条重复,并回写日志为已确认 */ function ackSchedRepeat() { const state = schedRepeat; stopSchedRepeat(); hideSchedAlert(); stopSpeech(); if (state && state.logId) { apiFetch('POST', '/api/app_order_box/kits/schedule_reminder/logs/ack', { id: state.logId }) .then(function (res) { if (!res || !res.ok) console.warn('[order_box schedule] 确认回写失败:' + ((res && res.error) || 'unknown')); }) .catch(function () { /* 静默 */ }); } if (state) showToast('已确认「' + (state.item.name || '') + '」', 'success', 2000); } function showSchedAlert(item) { const modal = document.getElementById('schedAlertModal'); const timeEl = document.getElementById('schedAlertTime'); const nameEl = document.getElementById('schedAlertName'); if (timeEl) timeEl.textContent = String(item.time || '--:--'); if (nameEl) nameEl.textContent = String(item.name || ''); if (modal) modal.style.display = 'flex'; requestWakeLock(); } function hideSchedAlert() { const modal = document.getElementById('schedAlertModal'); if (modal) modal.style.display = 'none'; } function updateSchedAlertRepeat(state) { const el = document.getElementById('schedAlertRepeat'); if (!el) return; el.textContent = '第 ' + state.done + ' / ' + state.times + ' 次播报 · 每 ' + state.intervalSec + ' 秒一次'; } // ---------- 播报日志 ---------- function postSchedLog(schedule, item, plannedMs, status) { const body = { scheduleId: schedule ? schedule.id : '', scheduleTitle: schedule ? schedule.title : '', itemId: item ? item.id : '', itemName: item ? item.name : '', plannedAt: Number.isFinite(plannedMs) ? new Date(plannedMs).toISOString() : '', status: status }; return apiFetch('POST', '/api/app_order_box/kits/schedule_reminder/logs', body).then(function (res) { if (res && res.ok && res.data) return Number(res.data.id) || 0; console.warn('[order_box schedule] 写入播报记录失败:' + ((res && res.error) || 'unknown')); return 0; }).catch(function () { return 0; }); } // ---------- 错过显示条 ---------- function schedPushMissed(schedule, item, plannedMs) { const key = (schedule ? schedule.id : '') + '|' + item.id + '|' + item.time; if (!schedMissedList.some(function (m) { return m.key === key; })) { schedMissedList.push({ key: key, time: String(item.time || ''), name: String(item.name || ''), plannedMs: plannedMs }); } postSchedLog(schedule, item, plannedMs, 'missed'); renderSchedMissed(); } function renderSchedMissed() { const el = document.getElementById('schedMissed'); const descEl = document.getElementById('schedMissedDesc'); if (!el || !descEl) return; if (!schedMissedList.length) { el.style.display = 'none'; descEl.innerHTML = ''; return; } el.style.display = ''; descEl.innerHTML = schedMissedList.map(function (m) { const late = Number.isFinite(m.plannedMs) ? ('(已过 ' + schedFormatCountdown(schedNowMs() - m.plannedMs) + ')') : ''; return '
' + escHtml(m.time) + ' ' + escHtml(m.name) + late + '
'; }).join(''); } function clearSchedMissed() { schedMissedList = []; renderSchedMissed(); } // ---------- 待机开关 ---------- function startSchedStandby() { const schedule = schedCurrentSchedule(); if (!schedule) { showAlert('无法待机', '请先新建并选择一套作息日程。'); return; } if (!Array.isArray(schedule.items) || !schedule.items.length) { showAlert('无法待机', '这套日程还没有任何时刻,请先编辑补充。'); return; } const nowMs = schedNowMs(); const p = beijingParts(nowMs); schedDayKey = p.dateKey; markSchedPastItemsDone(schedule, p, nowMs); schedStandbyOn = true; refreshSchedStandbyUI(); renderSchedules(); schedUpdateNext(schedule, p, nowMs); requestWakeLock(); startSchedTick(); showToast('已开始待机,到点自动播报', 'success', 2000); prepareScheduleTTS(schedule, false); // 预合成语音,不阻塞待机 } function stopSchedStandby(reason) { schedStandbyOn = false; stopSchedRepeat(); hideSchedAlert(); stopSpeech(); refreshSchedStandbyUI(); renderSchedules(); if (reason) showToast(reason, 'info', 2000); } function toggleSchedStandby() { if (!schedList.length) { showAlert('无法待机', '请先新建一套作息日程。'); return; } if (schedStandbyOn) { stopSchedStandby('已停止待机'); return; } if (!schedActiveId || !schedList.some(function (s) { return s.id === schedActiveId; })) { schedActiveId = schedList[0].id; } startSchedStandby(); } function selectSchedForStandby(schedule) { if (schedStandbyOn && schedActiveId === schedule.id) { stopSchedStandby('已停止待机'); return; } if (schedStandbyOn) { schedStandbyOn = false; stopSchedRepeat(); hideSchedAlert(); stopSpeech(); } schedActiveId = schedule.id; schedFiredKeys = {}; startSchedStandby(); } function refreshSchedStandbyUI() { const panel = document.getElementById('schedStandbyPanel'); const statusEl = document.getElementById('schedStatus'); const btn = document.getElementById('btnSchedStandby'); const schedule = schedCurrentSchedule(); if (panel) panel.classList.toggle('is-live', !!schedStandbyOn); if (statusEl) { if (!schedule) statusEl.textContent = '请先选择一套日程'; else if (!schedStandbyOn) statusEl.textContent = '未待机 · 当前日程「' + schedule.title + '」'; else statusEl.textContent = '待机中 · ' + schedule.title + '(' + schedWeekdayText(schedule.weekdays) + ')' + (schedClockSynced ? '' : ' · 未校时'); } if (btn) { const textEl = getButtonTextEl(btn); if (textEl) textEl.textContent = schedStandbyOn ? '停止待机' : '开始待机'; } } // ---------- 语音预合成(本 kit 独立映射) ---------- function schedFingerprint(schedule) { try { return JSON.stringify({ items: (schedule.items || []).map(function (i) { return [String(i.time || ''), String(i.name || ''), String(i.voiceText || ''), i.enabled === false ? 0 : 1]; }) }); } catch (e) { return ''; } } function prepareScheduleTTS(schedule, force) { if (!schedule || !schedule.id) return Promise.resolve({ ok: false, error: 'no schedule' }); const key = schedFingerprint(schedule); if (!force && key && schedTtsPreparedKey === key) return Promise.resolve({ ok: true, skipped: true }); schedTtsPreparedKey = key; return apiFetch('POST', '/api/app_order_box/kits/schedule_reminder/tts/prepare', { scheduleId: schedule.id }) .then(function (res) { if (res.ok && res.data && res.data.map) { Object.keys(res.data.map).forEach(function (text) { schedTtsUrlMap[text] = res.data.map[text]; }); console.log('[order_box schedule tts] 预合成完成 ' + res.data.success + '/' + res.data.total); if (speechEngineMode === 'cloud' && Object.keys(schedTtsUrlMap).length) hideVoiceWarning(); } else { schedTtsPreparedKey = ''; console.warn('[order_box schedule tts] 预合成失败:' + (res.error || 'unknown')); } return res; }) .catch(function (e) { schedTtsPreparedKey = ''; console.warn('[order_box schedule tts] 预合成异常:' + String((e && e.message) || e)); return { ok: false, error: String((e && e.message) || e) }; }); } async function handleRegenScheduleTTS(schedule, btn) { if (btn) btn.disabled = true; const res = await runTrackedInteraction('重生日程语音', function () { return prepareScheduleTTS(schedule, true); }); if (btn) btn.disabled = false; if (res && res.ok) { const d = res.data || {}; showToast('语音已重新生成(' + (d.success || 0) + '/' + (d.total || 0) + ')', 'success', 2000); } } // ---------- 列表与 Tab ---------- function switchSchedTab(tabName) { schedTab = (tabName === 'logs') ? 'logs' : 'schedules'; document.querySelectorAll('#schedTabBar .tab-item').forEach(function (el) { el.classList.toggle('active', el.dataset.schedTab === schedTab); }); document.getElementById('pageSchedules').style.display = (schedTab === 'schedules') ? '' : 'none'; document.getElementById('pageSchedLogs').style.display = (schedTab === 'logs') ? '' : 'none'; if (schedTab === 'schedules') loadSchedules(); else loadSchedLogs(); } function renderSchedules() { const listEl = document.getElementById('schedulesList'); const emptyEl = document.getElementById('schedulesEmpty'); if (!listEl || !emptyEl) return; if (!schedList.length) { listEl.innerHTML = ''; listEl.style.display = 'none'; emptyEl.style.display = ''; return; } emptyEl.style.display = 'none'; listEl.style.display = ''; listEl.innerHTML = schedList.map(function (schedule) { const items = Array.isArray(schedule.items) ? schedule.items : []; const previews = items.slice(0, SCHED_MAX_PREVIEW_ITEMS).map(function (i) { return '
' + escHtml(i.time) + '' + escHtml(i.name) + '
'; }).join(''); const more = items.length > SCHED_MAX_PREVIEW_ITEMS ? '
还有 ' + (items.length - SCHED_MAX_PREVIEW_ITEMS) + ' 个时刻…
' : ''; const isActive = (schedActiveId === schedule.id); const activeTag = isActive ? '' + (schedStandbyOn ? '待机中' : '已选中') + '' : ''; const standbyLabel = (isActive && schedStandbyOn) ? '停止待机' : '用这套待机'; return '' + '
' + '
' + '
' + escHtml(schedule.title) + activeTag + '
' + '
' + '
' + schedWeekdayText(schedule.weekdays) + ' · ' + items.length + ' 个时刻
' + '
' + previews + more + '
' + '
' + '' + '' + '' + '' + '
' + '
'; }).join(''); } async function loadSchedules() { const listEl = document.getElementById('schedulesList'); const emptyEl = document.getElementById('schedulesEmpty'); const loadingEl = document.getElementById('schedulesLoading'); if (loadingEl) loadingEl.style.display = ''; if (listEl) { listEl.style.display = 'none'; listEl.innerHTML = ''; } if (emptyEl) emptyEl.style.display = 'none'; const res = await runTrackedInteraction('加载作息日程', function () { return apiFetch('GET', '/api/app_order_box/kits/schedule_reminder/schedules'); }, { mode: 'view' }); if (loadingEl) loadingEl.style.display = 'none'; if (!res.ok) { showAlert('加载失败', escHtml(res.error || '作息日程加载失败')); return; } schedList = Array.isArray(res.data) ? res.data : []; if (schedActiveId && !schedList.some(function (s) { return s.id === schedActiveId; })) schedActiveId = null; renderSchedules(); refreshSchedStandbyUI(); if (schedStandbyOn && !schedCurrentSchedule()) stopSchedStandby('日程已不存在,已停止待机'); } function handleSchedulesListClick(e) { const btn = e.target.closest ? e.target.closest('button') : null; if (!btn) return; const itemEl = btn.closest('.card-item'); if (!itemEl) return; const schedule = schedList.find(function (s) { return s.id === itemEl.dataset.id; }); if (!schedule) return; const action = btn.dataset.action; if (action === 'standby') { selectSchedForStandby(schedule); return; } if (action === 'edit') { openSchedEditor(schedule); return; } if (action === 'regen') { handleRegenScheduleTTS(schedule, btn); return; } if (action === 'delete') { handleDeleteSchedule(schedule); return; } } function handleDeleteSchedule(schedule) { showConfirm('删除日程', '确定删除作息日程「' + escHtml(schedule.title) + '」吗?此操作不可撤销。', async function () { closeDialog(); const res = await runTrackedInteraction('删除作息日程', function () { return apiFetch('DELETE', '/api/app_order_box/kits/schedule_reminder/schedules?id=' + encodeURIComponent(schedule.id)); }); if (!res.ok) { showAlert('删除失败', escHtml(res.error || '删除失败')); return; } if (schedActiveId === schedule.id) { stopSchedStandby(); schedActiveId = null; } await loadSchedules(); showToast('已删除', 'success', 2000); }); } // ---------- 日程编辑器 ---------- function createDefaultSchedItem() { return { id: generateId(), time: '07:30', name: '起床', voiceText: '', repeatTimes: SCHED_DEFAULT_REPEAT, intervalSec: SCHED_DEFAULT_INTERVAL, enabled: true }; } function createDefaultSchedDraft() { return { id: '', title: '', weekdays: [1, 2, 3, 4, 5, 6, 7], enabled: true, items: [createDefaultSchedItem()] }; } function cloneSchedDraft(schedule) { return { id: schedule.id, title: schedule.title, weekdays: Array.isArray(schedule.weekdays) ? schedule.weekdays.slice() : [], enabled: schedule.enabled !== false, items: (Array.isArray(schedule.items) ? schedule.items : []).map(function (i) { return { id: i.id, time: String(i.time || ''), name: String(i.name || ''), voiceText: String(i.voiceText || ''), repeatTimes: Math.max(1, Number(i.repeatTimes) || SCHED_DEFAULT_REPEAT), intervalSec: Math.max(5, Number(i.intervalSec) || SCHED_DEFAULT_INTERVAL), enabled: i.enabled !== false }; }) }; } function openSchedEditor(schedule) { schedDraft = schedule ? cloneSchedDraft(schedule) : createDefaultSchedDraft(); const titleEl = document.getElementById('schedEditorTitle'); if (titleEl) titleEl.textContent = schedule ? '编辑作息日程' : '新建作息日程'; renderSchedEditor(); const modal = document.getElementById('schedEditorModal'); if (modal) modal.style.display = 'flex'; } function closeSchedEditor() { const modal = document.getElementById('schedEditorModal'); if (modal) modal.style.display = 'none'; schedDraft = null; } function renderSchedEditorStats() { const body = document.getElementById('schedEditorBody'); if (!body || !schedDraft) return; const el = body.querySelector('#schedEditorStats'); if (!el) return; const items = schedDraft.items .filter(function (i) { return SCHED_TIME_RE.test(String(i.time || '')); }) .sort(function (a, b) { return String(a.time).localeCompare(String(b.time)); }); const range = items.length ? (items[0].time + ' ~ ' + items[items.length - 1].time) : '—'; el.innerHTML = '' + '
有效时刻' + '' + items.length + ' 个
' + '
首 / 末' + '' + escHtml(range) + '
' + '
生效星期' + '' + escHtml(schedWeekdayText(schedDraft.weekdays)) + '
'; } /** 星期选择 chip 的文案:生效的加 "√" 前缀,避免只靠底色判断选中态 */ function schedWeekdayChipLabel(weekday, active) { return (active ? '√' : '') + '周' + (SCHED_WEEKDAY_LABELS[weekday - 1] || ''); } function renderSchedEditor() { const body = document.getElementById('schedEditorBody'); if (!body || !schedDraft) return; const d = schedDraft; let weekdaysHtml = ''; for (let w = 1; w <= 7; w += 1) { const active = d.weekdays.indexOf(w) >= 0; weekdaysHtml += ''; } const itemsHtml = d.items.map(function (item, index) { const canRemove = d.items.length > 1; return '' + '
' + '
' + '
' + '时刻' + '#' + (index + 1) + '' + '
' + '
' + '' + '
' + '
' + '' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '
' + escHtml(schedItemHintText(item)) + '
' + '
'; }).join(''); body.innerHTML = '' + '
' + '' + '' + '
' + '
' + '' + '
' + weekdaysHtml + '
' + '
' + '
' + '

时刻列表

' + '
' + '' + '
' + '
' + '
' + itemsHtml + '
' + '
' + ''; renderSchedEditorStats(); } function handleSchedEditorInput(e) { const target = e.target; if (!target || !target.dataset || !schedDraft) return; const field = target.dataset.field; if (!field) return; if (field === 'title') { schedDraft.title = String(target.value || ''); return; } const itemEl = target.closest ? target.closest('.sched-item-editor') : null; if (!itemEl) return; const item = schedDraft.items.find(function (i) { return i.id === itemEl.dataset.itemId; }); if (!item) return; if (field === 'time') { item.time = String(target.value || ''); } else if (field === 'repeatTimes') { item.repeatTimes = Math.min(Math.max(1, Math.floor(Number(target.value) || 1)), 20); } else if (field === 'intervalSec') { item.intervalSec = Math.min(Math.max(5, Math.floor(Number(target.value) || 5)), 600); } else if (field === 'name' || field === 'voiceText') { item[field] = String(target.value || ''); } // 只更新该条的播报提示与底部统计,不整体重渲染,避免打断连续输入 const hintEl = itemEl.querySelector('.sched-item-hint'); if (hintEl) hintEl.textContent = schedItemHintText(item); renderSchedEditorStats(); } function handleSchedEditorClick(e) { const btn = e.target.closest ? e.target.closest('button') : null; if (!btn || !schedDraft) return; if (btn.dataset.weekday) { const w = Number(btn.dataset.weekday); const idx = schedDraft.weekdays.indexOf(w); if (idx >= 0) schedDraft.weekdays.splice(idx, 1); else schedDraft.weekdays.push(w); schedDraft.weekdays.sort(function (a, b) { return a - b; }); // 只切换按钮样式与文字,不整体重渲染,避免打断用户正在输入的内容 const active = schedDraft.weekdays.indexOf(w) >= 0; btn.classList.toggle('active', active); btn.textContent = schedWeekdayChipLabel(w, active); renderSchedEditorStats(); return; } if (btn.dataset.itemAction === 'remove') { if (schedDraft.items.length <= 1) return; const itemEl = btn.closest('.sched-item-editor'); if (!itemEl) return; const idx = schedDraft.items.findIndex(function (i) { return i.id === itemEl.dataset.itemId; }); if (idx < 0) return; schedDraft.items.splice(idx, 1); renderSchedEditor(); return; } if (btn.id === 'btnSchedAddItem') { const items = schedDraft.items; let time = '09:00'; const last = items.length ? String(items[items.length - 1].time || '') : ''; const m = SCHED_TIME_RE.exec(last); if (m) { const total = (Number(m[1]) * 60 + Number(m[2]) + 60) % (24 * 60); time = schedPad2(Math.floor(total / 60)) + ':' + schedPad2(total % 60); } items.push({ id: generateId(), time: time, name: '新事项', voiceText: '', repeatTimes: SCHED_DEFAULT_REPEAT, intervalSec: SCHED_DEFAULT_INTERVAL, enabled: true }); renderSchedEditor(); return; } if (btn.id === 'btnSchedEditorSave') { handleSchedEditorSave(); return; } } async function handleSchedEditorSave() { if (!schedDraft) return; const saveBtn = document.getElementById('btnSchedEditorSave'); const title = String(schedDraft.title || '').trim(); if (!title) { showAlert('无法保存', '请填写日程名称。'); return; } if (!schedDraft.weekdays.length) { showAlert('无法保存', '请至少选择一个生效星期。'); return; } const items = schedDraft.items .filter(function (i) { return SCHED_TIME_RE.test(String(i.time || '')); }) .map(function (i, index) { return { id: i.id, time: String(i.time), name: String(i.name || '').trim() || ('时刻' + (index + 1)), voiceText: String(i.voiceText || '').trim(), repeatTimes: Math.min(Math.max(1, Math.floor(Number(i.repeatTimes) || SCHED_DEFAULT_REPEAT)), 20), intervalSec: Math.min(Math.max(5, Math.floor(Number(i.intervalSec) || SCHED_DEFAULT_INTERVAL)), 600), enabled: true }; }) .sort(function (a, b) { return String(a.time).localeCompare(String(b.time)); }); if (!items.length) { showAlert('无法保存', '请至少添加一个有效时刻(时:分 不能为空)。'); return; } const isEdit = !!schedDraft.id; const payload = { id: schedDraft.id, title: title, weekdays: schedDraft.weekdays, items: items, enabled: true }; setButtonLoading(saveBtn, '保存中…'); const res = await runTrackedInteraction(isEdit ? '更新作息日程' : '新建作息日程', function () { return apiFetch(isEdit ? 'PUT' : 'POST', '/api/app_order_box/kits/schedule_reminder/schedules', payload); }); resetButton(saveBtn); if (!res.ok) { showAlert('保存失败', escHtml(res.error || '请稍后重试')); return; } closeSchedEditor(); await loadSchedules(); showToast(isEdit ? '日程已更新' : '日程已创建', 'success', 2000); } // ---------- 播报记录 ---------- function renderSchedLogs(list) { const listEl = document.getElementById('schedLogsList'); const emptyEl = document.getElementById('schedLogsEmpty'); if (!listEl || !emptyEl) return; if (!list.length) { listEl.innerHTML = ''; listEl.style.display = 'none'; emptyEl.style.display = ''; return; } emptyEl.style.display = 'none'; listEl.style.display = ''; listEl.innerHTML = list.map(function (item) { const status = String(item.status || ''); const statusText = status === 'acked' ? '已确认' : (status === 'missed' ? '错过' : '已播报'); const badgeClass = 'badge-' + (status === 'acked' ? 'acked' : (status === 'missed' ? 'missed' : 'fired')); return '' + '
' + '
' + escHtml(item.itemName || '未知时刻') + ' ' + statusText + '
' + '
日程:' + escHtml(item.scheduleTitle || '—') + '
' + '
计划时刻:' + formatDateTime(item.plannedAt) + '
' + '
触发时间:' + formatDateTime(item.firedAt) + '
' + '
'; }).join(''); } async function loadSchedLogs() { const listEl = document.getElementById('schedLogsList'); const emptyEl = document.getElementById('schedLogsEmpty'); const loadingEl = document.getElementById('schedLogsLoading'); if (loadingEl) loadingEl.style.display = ''; if (listEl) { listEl.style.display = 'none'; listEl.innerHTML = ''; } if (emptyEl) emptyEl.style.display = 'none'; const res = await runTrackedInteraction('加载播报记录', function () { return apiFetch('GET', '/api/app_order_box/kits/schedule_reminder/logs'); }, { mode: 'view' }); if (loadingEl) loadingEl.style.display = 'none'; if (!res.ok) { showAlert('加载失败', escHtml(res.error || '播报记录加载失败')); return; } renderSchedLogs(Array.isArray(res.data) ? res.data : []); } // ---------- 进入 / 返回 ---------- async function handleEnterSchedule() { const btn = document.getElementById('btnEnterSchedule'); // 必须在 await 之前(用户手势上下文中)解锁音频与语音 unlockAudio(); unlockSpeech(); setButtonLoading(btn, '进入中…'); const res = await runTrackedInteraction('设置激活 kit(schedule_reminder)', function () { return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'schedule_reminder' }); }); if (!res.ok) { resetButton(btn); showAlert('进入失败', escHtml(res.error || '请稍后重试')); return; } resetButton(btn); await refreshState(true); showView('schedule'); switchSchedTab('schedules'); await syncSchedClock(); startSchedTick(); renderSchedMissed(); // 交互详情弹窗会盖住探测弹窗,等它先收起再探测语音 setTimeout(function () { probeSpeech(false, SCHED_STANDBY_TEXT); }, getUiMode() === 'debug' ? 3200 : 2200); } function doBackHubFromSchedule() { stopSchedTick(); showView('hub'); refreshState(true); } function handleBackHubFromSchedule() { if (schedStandbyOn) { showConfirm('停止待机', '返回框子会停止当前待机,之后的时刻将不再自动播报。确定返回吗?', function () { closeDialog(); stopSchedStandby('已停止待机'); requestStateClearActiveKit().then(function () { doBackHubFromSchedule(); }); }); return; } requestStateClearActiveKit().then(function () { doBackHubFromSchedule(); }); } function requestStateClearActiveKit() { return runTrackedInteraction('返回框子(清除激活 kit)', function () { return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: '' }); }, { mode: 'view' }); } // ==================== 初始化 ==================== /** 键盘快捷键:执行器内 空格=暂停/继续,Esc=结束 */ function handleGlobalKeydown(e) { if (!runnerState) return; const tag = String((e.target && e.target.tagName) || '').toLowerCase(); if (tag === 'input' || tag === 'textarea') return; if (e.code === 'Space') { e.preventDefault(); handleRunnerPause(); } else if (e.key === 'Escape') { handleRunnerStop(); } } function bindStaticEvents() { document.getElementById('btnEnterMotion').addEventListener('click', handleEnterMotion); document.getElementById('btnBackHub').addEventListener('click', handleBackHub); document.getElementById('btnNewCard').addEventListener('click', function () { openEditor(null); }); document.getElementById('btnCleanStale').addEventListener('click', handleCleanStale); // 逐元素判空绑定:任何一个元素缺失都不应该中断后续绑定(否则整页按钮会全部失效) const retryVoiceEl = document.getElementById('btnRetryVoice'); if (retryVoiceEl) retryVoiceEl.addEventListener('click', function () { // 按当前所在 kit 选择探测句:报时器用待机提示语,运动提醒宝用欢迎语 probeSpeech(true, currentView === 'schedule' ? SCHED_STANDBY_TEXT : WELCOME_TEXT); }); document.getElementById('btnEditorClose').addEventListener('click', closeEditor); document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction); document.getElementById('cardsList').addEventListener('click', handleCardsListClick); document.querySelectorAll('#motionTabBar .tab-item').forEach(function (el) { el.addEventListener('click', function () { switchTab(el.dataset.tab); }); }); // 作息报时器:逐元素判空绑定,避免单个元素缺失中断整页事件注册 const bindClick = function (id, handler) { const el = document.getElementById(id); if (el) el.addEventListener('click', handler); }; bindClick('btnEnterSchedule', handleEnterSchedule); bindClick('btnBackHubFromSchedule', handleBackHubFromSchedule); bindClick('btnSchedStandby', toggleSchedStandby); bindClick('btnNewSchedule', function () { openSchedEditor(null); }); bindClick('btnSchedEditorClose', closeSchedEditor); bindClick('btnSchedAck', ackSchedRepeat); bindClick('btnSchedMissedClear', clearSchedMissed); bindClick('btnSchedRetryVoice', function () { probeSpeech(true, SCHED_STANDBY_TEXT); }); document.querySelectorAll('#schedTabBar .tab-item').forEach(function (el) { el.addEventListener('click', function () { switchSchedTab(el.dataset.schedTab); }); }); const schedulesListEl = document.getElementById('schedulesList'); if (schedulesListEl) schedulesListEl.addEventListener('click', handleSchedulesListClick); const schedEditorBodyEl = document.getElementById('schedEditorBody'); if (schedEditorBodyEl) { schedEditorBodyEl.addEventListener('click', handleSchedEditorClick); schedEditorBodyEl.addEventListener('input', handleSchedEditorInput); } document.addEventListener('keydown', handleGlobalKeydown); document.addEventListener('visibilitychange', handleVisibilityChange); } async function init() { bindStaticEvents(); await initHub(); } document.addEventListener('DOMContentLoaded', init); // 调试辅助:控制台查看当前 UI 模式与最近一次交互日志 window.__orderBox = { getUiMode: getUiMode, setUiMode: setUiMode, get lastInteraction() { return lastInteractionLog; }, THEME: THEME }; })();