/* =====================================================================
* 框子(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 '' +
'' +
'' +
'
' + 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 '' +
'';
}
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 = '' +
'' +
'' +
'' +
'
' +
'
当前动作
' +
'
' + 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 '' +
'' +
'' +
'
' + 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 '' +
'';
}).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
};
})();