1537 lines
60 KiB
JavaScript
1537 lines
60 KiB
JavaScript
/* =====================================================================
|
||
* 框子(OrderBox)- 运动提醒宝 Web 端核心业务逻辑
|
||
* 原生 JS 实现,无框架、无构建工具
|
||
* 模块分区:
|
||
* 主题常量 / 双模式控制 / 工具函数 / API 封装层 / Toast 系统 /
|
||
* 弹窗系统 / 交互追踪 / 按钮 Loading / 页面路由 / Hub 逻辑 /
|
||
* Tab 切换 / 卡片列表 / 卡片编辑器 / 执行器 / 语音播报 /
|
||
* 提示音 / 屏幕常亮 / 历史列表 / 初始化
|
||
* ===================================================================== */
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ==================== 主题常量 ====================
|
||
const THEME = {
|
||
bg: '#080b12', surface: '#121826', surfaceSoft: '#1a2234', surfaceStrong: '#202b42',
|
||
primary: '#f0b35f', primarySoft: 'rgba(240,179,95,0.16)',
|
||
text: '#f6f7fb', textSecondary: '#9ba7bc', textDisabled: '#5a6578',
|
||
border: '#2a3449', success: '#4ade80', warning: '#fbbf24', error: '#ef4444'
|
||
};
|
||
|
||
// ==================== 双模式控制 ====================
|
||
// debug:所有交互弹窗完整显示,3s 自动关闭
|
||
// normal:查看类操作降级为 Toast,普通弹窗 2s 关闭
|
||
const UI_MODE_KEY = 'order_box_ui_mode';
|
||
|
||
function getUiMode() {
|
||
try {
|
||
const mode = localStorage.getItem(UI_MODE_KEY);
|
||
if (mode === 'normal' || mode === 'debug') return mode;
|
||
} catch (e) { /* localStorage 不可用时按 debug 处理 */ }
|
||
return 'debug';
|
||
}
|
||
|
||
function setUiMode(mode) {
|
||
try {
|
||
localStorage.setItem(UI_MODE_KEY, mode === 'normal' ? 'normal' : 'debug');
|
||
} catch (e) { /* 静默失败 */ }
|
||
}
|
||
|
||
// ==================== 工具函数 ====================
|
||
|
||
/** mm:ss 格式,如 "01:30" */
|
||
function formatDuration(seconds) {
|
||
const safe = Math.max(0, Math.floor(Number(seconds) || 0));
|
||
const mm = Math.floor(safe / 60);
|
||
const ss = safe % 60;
|
||
return String(mm).padStart(2, '0') + ':' + String(ss).padStart(2, '0');
|
||
}
|
||
|
||
/** "N分N秒" 或 "N秒" */
|
||
function formatDurationLong(seconds) {
|
||
const safe = Math.max(0, Math.floor(Number(seconds) || 0));
|
||
const mm = Math.floor(safe / 60);
|
||
const ss = safe % 60;
|
||
if (mm > 0) return mm + '分' + ss + '秒';
|
||
return ss + '秒';
|
||
}
|
||
|
||
/** 语音播报专用时长文案:"N分钟N秒" */
|
||
function formatSpeechDuration(seconds) {
|
||
const safe = Math.max(0, Math.floor(Number(seconds) || 0));
|
||
const mm = Math.floor(safe / 60);
|
||
const ss = safe % 60;
|
||
const parts = [];
|
||
if (mm > 0) parts.push(mm + '分钟');
|
||
if (ss > 0) parts.push(ss + '秒');
|
||
return parts.length ? parts.join('') : '0秒';
|
||
}
|
||
|
||
/** 北京时间格式 "yyyy/M/d HH:mm:ss"(项目规则:所有时间展示一律 Asia/Shanghai) */
|
||
function formatDateTime(isoString) {
|
||
if (!isoString) return '—';
|
||
const date = new Date(isoString);
|
||
if (Number.isNaN(date.getTime())) return String(isoString);
|
||
return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||
}
|
||
|
||
/** 循环模式文案 */
|
||
function formatLoopText(loopMode, loopCount) {
|
||
if (loopMode === 'once') return '单次执行';
|
||
if (loopMode === 'repeat_forever') return '无限循环';
|
||
return '循环 ' + Math.max(1, Number(loopCount || 1)) + ' 次';
|
||
}
|
||
|
||
/** UUID 生成(crypto.randomUUID 优先,降级手写 v4) */
|
||
function generateId() {
|
||
try {
|
||
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||
return window.crypto.randomUUID();
|
||
}
|
||
} catch (e) { /* 降级 */ }
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||
const r = (Math.random() * 16) | 0;
|
||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||
return v.toString(16);
|
||
});
|
||
}
|
||
|
||
/** HTML 转义 */
|
||
function escHtml(s) {
|
||
return String(s === null || s === undefined ? '' : s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/** JSON 安全序列化(超长截断),用于交互详情展示 */
|
||
function safeJsonStringify(value) {
|
||
try {
|
||
let text = JSON.stringify(value, null, 2);
|
||
if (text === undefined) return String(value);
|
||
if (text.length > 4000) text = text.slice(0, 4000) + '\n...(内容过长已截断)';
|
||
return text;
|
||
} catch (e) {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
// ==================== API 封装层 ====================
|
||
const API_TIMEOUT_MS = 15000;
|
||
|
||
function formatApiError(json, status, rawText) {
|
||
if (json && json.error) return String(json.error);
|
||
if (rawText) return 'HTTP ' + status + ':' + String(rawText).slice(0, 200);
|
||
return 'HTTP ' + status;
|
||
}
|
||
|
||
/**
|
||
* 通用请求封装
|
||
* 返回统一结构:{ ok, data, error, status, duration, url, method, requestBody, responseBody }
|
||
*/
|
||
async function apiFetch(method, path, body) {
|
||
const startedAt = Date.now();
|
||
const result = {
|
||
ok: false,
|
||
data: null,
|
||
error: '',
|
||
status: 0,
|
||
duration: 0,
|
||
url: path,
|
||
method: method,
|
||
requestBody: body === undefined ? null : body,
|
||
responseBody: null
|
||
};
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(function () { controller.abort(); }, API_TIMEOUT_MS);
|
||
try {
|
||
const options = { method: method, headers: {}, signal: controller.signal };
|
||
if (body !== undefined && body !== null) {
|
||
options.headers['Content-Type'] = 'application/json';
|
||
options.body = JSON.stringify(body);
|
||
}
|
||
const resp = await fetch(path, options);
|
||
result.status = resp.status;
|
||
const text = await resp.text();
|
||
let json = null;
|
||
try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; }
|
||
result.responseBody = json !== null ? json : (text || null);
|
||
if (resp.ok && json && json.ok !== false) {
|
||
result.ok = true;
|
||
result.data = (json && json.data !== undefined) ? json.data : json;
|
||
} else {
|
||
result.error = formatApiError(json, resp.status, text);
|
||
}
|
||
} catch (e) {
|
||
if (e && e.name === 'AbortError') {
|
||
result.error = '请求超时(' + API_TIMEOUT_MS / 1000 + ' 秒)';
|
||
} else {
|
||
result.error = '网络请求失败:' + String((e && e.message) || e);
|
||
}
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
result.duration = Date.now() - startedAt;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ==================== Toast 系统 ====================
|
||
// info=蓝 / success=绿 / error=红,默认 2s 自动消失,不可点击关闭
|
||
let toastTimer = null;
|
||
|
||
function showToast(msg, type, duration) {
|
||
const el = document.getElementById('toast');
|
||
if (!el) return;
|
||
const toastType = (type === 'success' || type === 'error') ? type : 'info';
|
||
el.textContent = msg;
|
||
el.className = 'toast toast-show toast-' + toastType;
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(function () {
|
||
el.className = 'toast';
|
||
}, Number(duration) > 0 ? Number(duration) : 2000);
|
||
}
|
||
|
||
// ==================== 弹窗系统 ====================
|
||
|
||
/** 通用弹窗。actions: [{ text, style: 'default'|'primary'|'danger', closeOnClick, onPress(btn) }] */
|
||
function showDialog(title, bodyHtml, actions) {
|
||
document.getElementById('dialogTitle').textContent = title;
|
||
document.getElementById('dialogBody').innerHTML = bodyHtml;
|
||
const actionsEl = document.getElementById('dialogActions');
|
||
actionsEl.innerHTML = '';
|
||
const list = (Array.isArray(actions) && actions.length) ? actions : [{ text: '知道了', style: 'primary' }];
|
||
list.forEach(function (action) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'dialog-btn' + (action.style ? ' dialog-btn-' + action.style : '');
|
||
btn.textContent = action.text || '确定';
|
||
btn.addEventListener('click', function () {
|
||
if (action.closeOnClick !== false) closeDialog();
|
||
if (typeof action.onPress === 'function') action.onPress(btn);
|
||
});
|
||
actionsEl.appendChild(btn);
|
||
});
|
||
document.getElementById('dialogOverlay').style.display = 'flex';
|
||
}
|
||
|
||
function closeDialog() {
|
||
document.getElementById('dialogOverlay').style.display = 'none';
|
||
}
|
||
|
||
/** 确认弹窗:双模式均保留,永不降级。onConfirm(btn) 需自行调用 closeDialog() */
|
||
function showConfirm(title, bodyHtml, onConfirm) {
|
||
showDialog(title, bodyHtml, [
|
||
{ text: '取消', style: 'default' },
|
||
{
|
||
text: '确认',
|
||
style: 'danger',
|
||
closeOnClick: false,
|
||
onPress: function (btn) { if (typeof onConfirm === 'function') onConfirm(btn); }
|
||
}
|
||
]);
|
||
}
|
||
|
||
function showAlert(title, bodyHtml) {
|
||
showDialog(title, bodyHtml, [{ text: '知道了', style: 'primary' }]);
|
||
}
|
||
|
||
// ---- 交互详情弹窗(带复制按钮与自动关闭倒计时) ----
|
||
let interactionCloseTimer = null;
|
||
let interactionCountdown = 0;
|
||
|
||
function buildInteractionText(d) {
|
||
const reqText = (d.requestBody === null || d.requestBody === undefined) ? '—' : safeJsonStringify(d.requestBody);
|
||
let respText = '—';
|
||
if (d.responseBody !== null && d.responseBody !== undefined) {
|
||
respText = typeof d.responseBody === 'string' ? d.responseBody : safeJsonStringify(d.responseBody);
|
||
}
|
||
return [
|
||
'操作:' + d.label,
|
||
'时间:' + d.time + '(北京时间)',
|
||
'接口:' + d.method + ' ' + d.url,
|
||
'HTTP 状态:' + (d.status || '—'),
|
||
'耗时:' + d.duration + ' ms',
|
||
'当前状态:' + (d.success ? '成功' : '失败'),
|
||
'',
|
||
'请求参数:',
|
||
reqText,
|
||
'',
|
||
'返回结果:',
|
||
respText,
|
||
'',
|
||
'错误信息:' + (d.error || '—')
|
||
].join('\n');
|
||
}
|
||
|
||
function clearInteractionTimer() {
|
||
if (interactionCloseTimer) {
|
||
clearInterval(interactionCloseTimer);
|
||
interactionCloseTimer = null;
|
||
}
|
||
}
|
||
|
||
function hideInteractionDialog() {
|
||
clearInteractionTimer();
|
||
const overlay = document.getElementById('interactionOverlay');
|
||
if (overlay) overlay.style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* 交互详情弹窗
|
||
* autoCloseMs > 0:显示倒计时并自动关闭;为 0/null:停留(异常排障场景)
|
||
*/
|
||
function showInteractionDialog(detail, autoCloseMs) {
|
||
clearInteractionTimer();
|
||
const box = document.getElementById('interactionBox');
|
||
document.getElementById('interactionTitle').textContent = detail.success ? '交互详情' : '交互异常';
|
||
document.getElementById('interactionContent').textContent = buildInteractionText(detail);
|
||
box.classList.toggle('dialog-error', !detail.success);
|
||
|
||
const actionsEl = document.getElementById('interactionActions');
|
||
actionsEl.innerHTML = '';
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.className = 'dialog-btn dialog-btn-primary';
|
||
closeBtn.textContent = '关闭';
|
||
closeBtn.addEventListener('click', hideInteractionDialog);
|
||
actionsEl.appendChild(closeBtn);
|
||
|
||
document.getElementById('interactionOverlay').style.display = 'flex';
|
||
|
||
if (Number(autoCloseMs) > 0) {
|
||
interactionCountdown = Math.ceil(Number(autoCloseMs) / 1000);
|
||
closeBtn.textContent = '关闭(' + interactionCountdown + 's)';
|
||
interactionCloseTimer = setInterval(function () {
|
||
interactionCountdown -= 1;
|
||
if (interactionCountdown <= 0) {
|
||
hideInteractionDialog();
|
||
} else {
|
||
closeBtn.textContent = '关闭(' + interactionCountdown + 's)';
|
||
}
|
||
}, 1000);
|
||
}
|
||
}
|
||
|
||
/** 复制交互日志到剪贴板 */
|
||
async function handleCopyInteraction() {
|
||
const contentEl = document.getElementById('interactionContent');
|
||
const text = contentEl ? (contentEl.textContent || '') : '';
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
} else {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
showToast('已复制交互日志', 'success');
|
||
} catch (e) {
|
||
showToast('复制失败', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 交互追踪 ====================
|
||
// 所有 API 调用统一经由此函数包装:
|
||
// debug 模式:成功 → 交互详情弹窗 3s 自动关闭
|
||
// normal 模式:options.mode === 'view' → Toast;其余 → 弹窗 2s 关闭
|
||
// 失败/异常:弹窗停留(中断自动关闭),便于排障
|
||
// options.silent:高频后台同步(执行器逐步 sync)静默记录,不打扰前台
|
||
let lastInteractionLog = null;
|
||
|
||
async function runTrackedInteraction(label, fn, options) {
|
||
const opts = options || {};
|
||
const startedAt = Date.now();
|
||
let res = null;
|
||
let thrown = null;
|
||
try {
|
||
res = await fn();
|
||
} catch (e) {
|
||
thrown = e;
|
||
}
|
||
const detail = {
|
||
label: label,
|
||
time: formatDateTime(new Date(startedAt).toISOString()),
|
||
method: (res && res.method) || '—',
|
||
url: (res && res.url) || '—',
|
||
requestBody: res ? res.requestBody : null,
|
||
responseBody: res ? res.responseBody : null,
|
||
duration: (res && typeof res.duration === 'number') ? res.duration : (Date.now() - startedAt),
|
||
status: (res && res.status) || 0,
|
||
error: thrown ? String((thrown && thrown.message) || thrown) : (res && res.ok ? '' : ((res && res.error) || '未知错误')),
|
||
success: !thrown && !!(res && res.ok)
|
||
};
|
||
lastInteractionLog = detail;
|
||
|
||
if (opts.silent) {
|
||
// 高频后台同步:仅记录日志,不弹窗、不 Toast
|
||
console.log('[interaction:silent]', detail);
|
||
} else if (!detail.success) {
|
||
showInteractionDialog(detail, 0); // 异常:停留弹窗,中断自动关闭
|
||
} else if (getUiMode() === 'debug') {
|
||
showInteractionDialog(detail, 3000);
|
||
} else if (opts.mode === 'view') {
|
||
showToast(label + '成功', 'success', 2000);
|
||
} else {
|
||
showInteractionDialog(detail, 2000);
|
||
}
|
||
|
||
if (thrown) {
|
||
return {
|
||
ok: false, data: null, error: detail.error, status: 0,
|
||
duration: detail.duration, url: detail.url, method: detail.method,
|
||
requestBody: null, responseBody: null
|
||
};
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// ==================== 按钮 Loading ====================
|
||
// 异步操作期间:disabled + 文字切换(如"保存中…"),完成后恢复
|
||
|
||
function getButtonTextEl(btn) {
|
||
if (!btn) return null;
|
||
return btn.querySelector('.btn-text') || btn.querySelector('span') || btn;
|
||
}
|
||
|
||
function setButtonLoading(btn, loadingText) {
|
||
if (!btn) return;
|
||
const textEl = getButtonTextEl(btn);
|
||
if (textEl.dataset.originalText === undefined) {
|
||
textEl.dataset.originalText = textEl.textContent;
|
||
}
|
||
btn.disabled = true;
|
||
btn.classList.add('btn-loading');
|
||
if (loadingText) textEl.textContent = loadingText;
|
||
}
|
||
|
||
function resetButton(btn, originalText) {
|
||
if (!btn) return;
|
||
const textEl = getButtonTextEl(btn);
|
||
btn.disabled = false;
|
||
btn.classList.remove('btn-loading');
|
||
const restore = (originalText !== undefined) ? originalText : textEl.dataset.originalText;
|
||
if (restore !== undefined) textEl.textContent = restore;
|
||
delete textEl.dataset.originalText;
|
||
}
|
||
|
||
// ==================== 页面路由 ====================
|
||
let currentView = 'hub'; // 'hub' | 'motion'
|
||
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;
|
||
}
|
||
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) {
|
||
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) { /* 不支持时静默 */ }
|
||
}
|
||
|
||
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;
|
||
|
||
function playBeep() {
|
||
try {
|
||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioContextCtor) return;
|
||
if (!audioCtx) audioCtx = new AudioContextCtor();
|
||
if (audioCtx.state === 'suspended') { void audioCtx.resume(); }
|
||
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
|
||
};
|
||
})(); |