Files
Toolbox/public/tools/web_order_box/order_box.js
T
yangxiangyuan 88ad9a5f06 fix(web_order_box): 修复移动端音频和语音播放限制问题
1. 调整响应式布局的最小高度阈值从420px改为300px
2. 添加unlockAudio和unlockSpeech函数,在用户手势上下文解锁浏览器自动播放限制
3. 优化播放逻辑,当音频上下文未解锁时跳过蜂鸣音播放
2026-07-30 16:31:38 +08:00

1565 lines
62 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'
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');
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');
}
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>' +
'</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);
}
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();
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);
}
}
// ==================== 语音播报 ====================
// Web Speech API:动作 pitch=1.1,休息 pitch=0.9,rate=0.95,lang=zh-CN
// 去重:同一步骤(sessionId:loop:stepIndex)不重复播报
function speakText(text, pitch) {
try {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-CN';
utterance.pitch = Number(pitch) || 1;
utterance.rate = 0.95;
window.speechSynthesis.speak(utterance);
} catch (e) { /* 不支持时静默 */ }
}
/** 在用户手势中调用,解锁 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) { /* 静默 */ }
}
/** 步骤切换播报:"第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 title = (step.voiceText && String(step.voiceText).trim())
? String(step.voiceText).trim()
: (step.name + ',' + formatSpeechDuration(step.durationSec));
const nextText = nextStep ? '。下一步,' + nextStep.name : '';
speakText('第 ' + runnerState.currentLoop + ' 轮,' + title + nextText, 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);
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
};
})();