Files
Toolbox/public/tools/web_order_box/order_box.js
T
yangxiangyuan a3e8911deb feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
2026-10-09 14:05:36 +08:00

2847 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 框子(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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 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 '<div class="step-preview-line">' + (i + 1) + '. ' + escHtml(s.name) + ' · ' + formatDuration(Number(s.durationSec) || 0) + '</div>';
}).join('');
const more = steps.length > 4
? '<div class="step-preview-more">还有 ' + (steps.length - 4) + ' 个步骤…</div>'
: '';
const perRound = steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0);
return '' +
'<div class="card-item" data-id="' + escHtml(card.id) + '">' +
'<div class="card-item-header">' +
'<div class="card-title">' + escHtml(card.title) + '</div>' +
'<div class="card-meta">' + escHtml(formatLoopText(card.loopMode, card.loopCount)) + '</div>' +
'</div>' +
'<div class="card-sub">' + steps.length + ' 个步骤 · 单轮 ' + formatDurationLong(perRound) + '</div>' +
'<div class="step-preview">' + previews + more + '</div>' +
'<div class="card-actions">' +
'<button type="button" class="btn-card-primary btn-start-card">' +
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>' +
'<span class="btn-text">启动</span>' +
'</button>' +
'<button type="button" class="btn-card-ghost btn-edit-card"><span class="btn-text">编辑</span></button>' +
'<button type="button" class="btn-card-ghost btn-delete-card"><span class="btn-text btn-text-danger">删除</span></button>' +
'<button type="button" class="btn-card-ghost btn-regen-tts"><span class="btn-text">重生语音</span></button>' +
'</div>' +
'</div>';
}).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) + '」吗?<br>删除后不可恢复。', 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 =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/></svg>' +
'<span>总时长:' + formatDurationLong(total) + '</span>';
}
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 '' +
'<div class="step-editor ' + (isAction ? 'step-action' : 'step-rest') + '" data-step-id="' + escHtml(step.id) + '">' +
'<div class="step-editor-header">' +
'<div class="step-title-row">' +
'<span class="step-badge">' + (isAction ? '动作' : '休息') + '</span>' +
'<span class="step-index">步骤 ' + (index + 1) + '</span>' +
'</div>' +
'<div class="step-actions">' +
'<button type="button" class="btn-step-icon btn-step-copy" title="复制步骤">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>' +
'</button>' +
'<button type="button" class="btn-step-icon btn-step-remove' + (canRemove ? '' : ' disabled') + '" title="删除步骤"' + (canRemove ? '' : ' disabled') + '>' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' +
'</button>' +
'<span class="step-drag-handle" title="拖拽排序">' +
'<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>' +
'</span>' +
'</div>' +
'</div>' +
'<input class="editor-input step-name-input" type="text" placeholder="' + (isAction ? '例如:俯卧撑' : '例如:休息') + '" value="' + escHtml(step.name) + '">' +
'<input class="editor-input step-duration-input" type="text" inputmode="numeric" placeholder="时长(秒)" value="' + escHtml(String(step.durationSec || '')) + '">' +
'<div class="step-duration-hint">时长:' + formatDurationLong(Number(step.durationSec) || 0) + '</div>' +
'</div>';
}
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 = '' +
'<div class="editor-field">' +
'<label class="editor-label" for="editorCardTitle">卡片名称</label>' +
'<input id="editorCardTitle" class="editor-input" type="text" placeholder="例如:晨练循环" value="' + escHtml(d.title) + '">' +
'</div>' +
'<div class="editor-field">' +
'<label class="editor-label">循环方式</label>' +
'<div class="segment-row">' +
segments.map(function (s) {
return '<button type="button" class="segment-btn' + (d.loopMode === s.value ? ' active' : '') + '" data-loop="' + s.value + '">' + s.label + '</button>';
}).join('') +
'</div>' +
'</div>' +
(d.loopMode === 'repeat_count' ? (
'<div class="editor-field">' +
'<label class="editor-label" for="editorLoopCount">循环次数</label>' +
'<input id="editorLoopCount" class="editor-input" type="text" inputmode="numeric" placeholder="输入循环次数" value="' + escHtml(String(d.loopCount || '')) + '">' +
'</div>'
) : '') +
'<div class="editor-steps-header">' +
'<span class="editor-section-title">流程步骤</span>' +
'<div class="editor-steps-buttons">' +
'<button type="button" class="btn-small" id="btnAddAction">+ 加动作</button>' +
'<button type="button" class="btn-small" id="btnAddRest">+ 加休息</button>' +
'</div>' +
'</div>' +
'<div class="editor-total-duration" id="editorTotalPreview"></div>' +
'<div class="editor-steps-list" id="editorStepsList">' + stepsHtml + '</div>' +
'<div class="editor-duration-info">' +
'<div class="duration-info-row"><span class="duration-info-label">单轮时长</span><span class="duration-info-value" id="editorPerRound"></span></div>' +
'<div class="duration-info-row"><span class="duration-info-label">全局总时长</span><span class="duration-info-value duration-info-highlight" id="editorGlobal"></span></div>' +
'</div>' +
'<div class="editor-footer">' +
'<button type="button" class="btn-primary btn-editor-save" id="btnEditorSave"><span class="btn-text">保存</span></button>' +
'</div>';
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 = '' +
'<div class="runner-header">' +
'<span class="runner-badge ' + (isAction ? 'badge-action' : 'badge-rest') + '">' + (isAction ? '动作' : '休息') + '</span>' +
'<div class="runner-title">' + escHtml(rs.card.title) + '</div>' +
'<div class="runner-loop-text">第 ' + rs.currentLoop + ' 轮 ' + (totalLoops ? '/ 共 ' + totalLoops + ' 轮' : '/ 无限循环') + ' · 步骤 ' + (rs.currentStepIndex + 1) + ' / ' + totalSteps + '</div>' +
'</div>' +
'<div class="runner-progress"><div class="runner-progress-bar" id="runnerStepBar"></div></div>' +
'<div class="runner-split-layout">' +
'<div class="runner-col-left runner-main">' +
'<div class="runner-label">当前动作</div>' +
'<div class="runner-current ' + (isAction ? 'current-action' : 'current-rest') + '">' + escHtml(step.name || '—') + '</div>' +
'<div class="runner-timer" id="runnerTimer">--:--</div>' +
'<div class="runner-next-card">' +
'<div class="runner-label">下一个动作</div>' +
'<div class="runner-next">' + (nextStep ? escHtml(nextStep.name) : '本轮结束') + '</div>' +
'</div>' +
'</div>' +
'<div class="runner-col-right">' +
'<div class="runner-footer">' +
'<div class="runner-info-card">' +
'<div class="runner-info-row"><span class="runner-info-label">已执行</span><span class="runner-info-value">第 ' + rs.currentLoop + ' 轮,步骤 ' + (rs.currentStepIndex + 1) + '/' + totalSteps + '</span></div>' +
'<div class="runner-info-row"><span class="runner-info-label">预计剩余</span><span class="runner-info-value runner-info-highlight" id="runnerInfoRemaining">—</span></div>' +
'<div class="runner-info-row"><span class="runner-info-label">全局时长</span><span class="runner-info-value runner-info-highlight">' + (globalDurationSec !== null ? '约 ' + formatDuration(globalDurationSec) : '无限循环') + '</span></div>' +
'</div>' +
'<div class="runner-buttons">' +
'<button type="button" class="runner-btn" id="btnRunnerPause"><span class="btn-text">' + (rs.status === 'paused' ? '继续' : '暂停') + '</span></button>' +
'<button type="button" class="runner-btn" id="btnRunnerSkip"><span class="btn-text">跳过</span></button>' +
'<button type="button" class="runner-btn runner-btn-danger" id="btnRunnerStop"><span class="btn-text">结束</span></button>' +
'</div>' +
(globalDurationSec !== null && globalDurationSec > 0 ? (
'<div class="runner-global">' +
'<div class="runner-global-header"><span class="runner-global-label">执行进度</span><span class="runner-global-percent" id="runnerGlobalPercent">0%</span></div>' +
'<div class="runner-global-bar-wrap"><div class="runner-global-bar" id="runnerGlobalBar"></div></div>' +
'<div class="runner-global-stats">' +
'<span id="runnerGlobalExecuted">已执行 ' + formatDuration(0) + '</span>' +
'<span class="runner-global-divider">/</span>' +
'<span>总时长 ' + formatDuration(globalDurationSec) + '</span>' +
'<span class="runner-global-divider">·</span>' +
'<span class="runner-global-remaining" id="runnerGlobalRemaining">剩余 ' + formatDuration(0) + '</span>' +
'</div>' +
'</div>'
) : '') +
'</div>' +
'</div>' +
'</div>';
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<boolean> */
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('语音检查',
'正在播报提示语:<br><span class="voice-probe-text">' + escHtml(text) + '</span><br>请确认是否听到声音。',
[
{
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 '' +
'<div class="history-item">' +
'<div class="history-title">' + escHtml(title) + '</div>' +
'<div class="history-line">开始:' + formatDateTime(item.startedAt) + '</div>' +
'<div class="history-line">结束:' + formatDateTime(item.endedAt) + '</div>' +
'<div class="history-line">完成轮次:' + Math.max(0, Number(item.completedLoops) || 0) + '</div>' +
'<div class="history-line">结束方式:<span class="history-badge ' + badgeClass + '">' + endText + '</span></div>' +
'</div>';
}).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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></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 '<div class="missed-line">' + escHtml(m.time) + ' ' + escHtml(m.name) + late + '</div>';
}).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 '<div class="sched-preview-line"><span class="sched-preview-time">' + escHtml(i.time) + '</span>' + escHtml(i.name) + '</div>';
}).join('');
const more = items.length > SCHED_MAX_PREVIEW_ITEMS
? '<div class="sched-preview-more">还有 ' + (items.length - SCHED_MAX_PREVIEW_ITEMS) + ' 个时刻…</div>'
: '';
const isActive = (schedActiveId === schedule.id);
const activeTag = isActive ? '<span class="sched-active-tag">' + (schedStandbyOn ? '待机中' : '已选中') + '</span>' : '';
const standbyLabel = (isActive && schedStandbyOn) ? '停止待机' : '用这套待机';
return '' +
'<div class="card-item" data-id="' + escHtml(schedule.id) + '">' +
'<div class="card-item-header">' +
'<div class="card-title">' + escHtml(schedule.title) + activeTag + '</div>' +
'</div>' +
'<div class="card-meta">' + schedWeekdayText(schedule.weekdays) + ' · ' + items.length + ' 个时刻</div>' +
'<div class="sched-item-preview">' + previews + more + '</div>' +
'<div class="card-actions">' +
'<button class="btn-card-primary" data-action="standby"><span class="btn-text">' + standbyLabel + '</span></button>' +
'<button class="btn-card-ghost" data-action="edit"><span class="btn-text">编辑</span></button>' +
'<button class="btn-card-ghost" data-action="regen"><span class="btn-text">重生语音</span></button>' +
'<button class="btn-card-ghost btn-text-danger" data-action="delete"><span class="btn-text">删除</span></button>' +
'</div>' +
'</div>';
}).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 = '' +
'<div class="duration-info-row"><span class="duration-info-label">有效时刻</span>' +
'<span class="duration-info-value duration-info-highlight">' + items.length + ' 个</span></div>' +
'<div class="duration-info-row"><span class="duration-info-label">首 / 末</span>' +
'<span class="duration-info-value">' + escHtml(range) + '</span></div>' +
'<div class="duration-info-row"><span class="duration-info-label">生效星期</span>' +
'<span class="duration-info-value">' + escHtml(schedWeekdayText(schedDraft.weekdays)) + '</span></div>';
}
/** 星期选择 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 += '<button type="button" class="weekday-chip' + (active ? ' active' : '') + '" data-weekday="' + w + '">' +
escHtml(schedWeekdayChipLabel(w, active)) + '</button>';
}
const itemsHtml = d.items.map(function (item, index) {
const canRemove = d.items.length > 1;
return '' +
'<div class="sched-item-editor" data-item-id="' + escHtml(item.id) + '">' +
'<div class="step-editor-header">' +
'<div class="step-title-row">' +
'<span class="step-badge">时刻</span>' +
'<span class="step-index">#' + (index + 1) + '</span>' +
'</div>' +
'<div class="step-actions">' +
'<button type="button" class="btn-step-icon btn-step-remove' + (canRemove ? '' : ' disabled') + '" data-item-action="remove" title="删除该时刻">' +
SCHED_TRASH_SVG +
'</button>' +
'</div>' +
'</div>' +
'<input type="time" class="editor-input sched-time-input" data-field="time" value="' + escHtml(item.time) + '">' +
'<input type="text" class="editor-input step-name-input" data-field="name" placeholder="事项名称,如:起床" value="' + escHtml(item.name) + '">' +
'<input type="text" class="editor-input step-name-input" data-field="voiceText" placeholder="语音文案(留空自动:现在是7点30分,起床)" value="' + escHtml(item.voiceText) + '">' +
'<div class="sched-item-row-2">' +
'<div class="sched-field">' +
'<label class="sched-field-label">重复次数(1~20)</label>' +
'<input type="number" class="editor-input sched-number-input" data-field="repeatTimes" min="1" max="20" value="' + Number(item.repeatTimes) + '">' +
'</div>' +
'<div class="sched-field">' +
'<label class="sched-field-label">间隔秒数(5~600)</label>' +
'<input type="number" class="editor-input sched-number-input" data-field="intervalSec" min="5" max="600" value="' + Number(item.intervalSec) + '">' +
'</div>' +
'</div>' +
'<div class="sched-item-hint">' + escHtml(schedItemHintText(item)) + '</div>' +
'</div>';
}).join('');
body.innerHTML = '' +
'<div class="editor-field">' +
'<label class="editor-label">日程名称</label>' +
'<input type="text" class="editor-input" id="schedEditorTitleInput" data-field="title" placeholder="例如:周末在家" value="' + escHtml(d.title) + '">' +
'</div>' +
'<div class="editor-field">' +
'<label class="editor-label">生效星期</label>' +
'<div class="weekday-row">' + weekdaysHtml + '</div>' +
'</div>' +
'<div class="editor-steps-header">' +
'<h3 class="editor-section-title">时刻列表</h3>' +
'<div class="editor-steps-buttons">' +
'<button type="button" class="btn-small" id="btnSchedAddItem">+ 加时刻</button>' +
'</div>' +
'</div>' +
'<div class="editor-steps-list">' + itemsHtml + '</div>' +
'<div class="editor-duration-info" id="schedEditorStats"></div>' +
'<div class="editor-footer">' +
'<button class="btn-primary" id="btnSchedEditorSave"><span class="btn-text">保存日程</span></button>' +
'</div>';
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 '' +
'<div class="history-item">' +
'<div class="history-title">' + escHtml(item.itemName || '未知时刻') +
' <span class="sched-log-badge ' + badgeClass + '">' + statusText + '</span></div>' +
'<div class="history-line">日程:' + escHtml(item.scheduleTitle || '—') + '</div>' +
'<div class="history-line">计划时刻:' + formatDateTime(item.plannedAt) + '</div>' +
'<div class="history-line">触发时间:' + formatDateTime(item.firedAt) + '</div>' +
'</div>';
}).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
};
})();