Files
yangxiangyuan bbc7ca6523 feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
2026-09-17 16:26:27 +08:00

1855 lines
73 KiB
JavaScript
Raw Permalink 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'
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';
}
function switchTab(tabName) {
currentTab = tabName;
document.querySelectorAll('.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;
// activeKit 已指向运动提醒宝:跳过 Hub 直接进入
if (appState && appState.activeKit === 'motion_reminder') {
showView('motion');
switchTab('cards');
}
}
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) {
const el = document.getElementById('voiceWarning');
if (!el) return;
const descEl = document.getElementById('voiceWarningDesc');
if (descEl && msg) descEl.textContent = msg;
el.style.display = '';
}
function hideVoiceWarning() {
const el = document.getElementById('voiceWarning');
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;返回是否存在可播的缓存音频 */
function playCloudAtoms(atoms) {
const list = (Array.isArray(atoms) ? atoms : [atoms])
.map(function (t) { return String(t || '').trim(); })
.filter(Boolean);
const urls = list.map(function (t) { return ttsUrlMap[t]; }).filter(Boolean);
if (!urls.length) return false;
stopCloudSpeech();
cloudQueue = urls.slice();
cloudQueueBusy = true;
runCloudQueue();
return true;
}
/** 统一播报入口:优先系统 TTS,静默失败时自动降级到云端 mp3 */
function speakText(text, pitch) {
const t = String(text || '').trim();
if (!t) return;
if (speechEngineMode === 'cloud' && playCloudAtoms(t)) return;
speakSystem(t, pitch).then(function (r) {
if (r.ok) return;
// 系统 TTS 本次失败:有云端音频就切过去,否则显式预警
if (playCloudAtoms(t)) {
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 时忽略"已探测过"强制重测。
*/
function probeSpeech(force) {
if (speechProbeDone && !force) return;
speechProbeDone = true;
let answered = false;
const settle = function (mode, tip) {
if (answered) return;
answered = true;
speechEngineMode = mode;
// 必须收起探测弹窗:否则模态遮罩会一直盖住页面,导致后面所有按钮都点不动
closeDialog();
if (tip) showVoiceWarning(tip); else hideVoiceWarning();
};
speakSystem(WELCOME_TEXT, 1).then(function (r) {
if (answered) return;
if (r.ok) return; // 引擎已启动,等用户确认是否真的听到
settle('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。');
});
showDialog('语音检查',
'正在播报欢迎语:<br><span class="voice-probe-text">' + escHtml(WELCOME_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' && runnerState) {
requestWakeLock();
}
}
// ==================== 历史列表 ====================
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 : []);
}
// ==================== 初始化 ====================
/** 键盘快捷键:执行器内 空格=暂停/继续,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 () { probeSpeech(true); });
document.getElementById('btnEditorClose').addEventListener('click', closeEditor);
document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction);
document.getElementById('cardsList').addEventListener('click', handleCardsListClick);
document.querySelectorAll('.tab-item').forEach(function (el) {
el.addEventListener('click', function () { switchTab(el.dataset.tab); });
});
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
};
})();