/* ===================================================================== * 框子(OrderBox)- 运动提醒宝 Web 端核心业务逻辑 * 原生 JS 实现,无框架、无构建工具 * 模块分区: * 主题常量 / 双模式控制 / 工具函数 / API 封装层 / Toast 系统 / * 弹窗系统 / 交互追踪 / 按钮 Loading / 页面路由 / Hub 逻辑 / * Tab 切换 / 卡片列表 / 卡片编辑器 / 执行器 / 语音播报 / * 提示音 / 屏幕常亮 / 历史列表 / 初始化 * ===================================================================== */ (function () { 'use strict'; // ==================== 主题常量 ==================== const THEME = { bg: '#080b12', surface: '#121826', surfaceSoft: '#1a2234', surfaceStrong: '#202b42', primary: '#f0b35f', primarySoft: 'rgba(240,179,95,0.16)', text: '#f6f7fb', textSecondary: '#9ba7bc', textDisabled: '#5a6578', border: '#2a3449', success: '#4ade80', warning: '#fbbf24', error: '#ef4444' }; // ==================== 双模式控制 ==================== // debug:所有交互弹窗完整显示,3s 自动关闭 // normal:查看类操作降级为 Toast,普通弹窗 2s 关闭 const UI_MODE_KEY = 'order_box_ui_mode'; function getUiMode() { try { const mode = localStorage.getItem(UI_MODE_KEY); if (mode === 'normal' || mode === 'debug') return mode; } catch (e) { /* localStorage 不可用时按 debug 处理 */ } return 'debug'; } function setUiMode(mode) { try { localStorage.setItem(UI_MODE_KEY, mode === 'normal' ? 'normal' : 'debug'); } catch (e) { /* 静默失败 */ } } // ==================== 工具函数 ==================== /** mm:ss 格式,如 "01:30" */ function formatDuration(seconds) { const safe = Math.max(0, Math.floor(Number(seconds) || 0)); const mm = Math.floor(safe / 60); const ss = safe % 60; return String(mm).padStart(2, '0') + ':' + String(ss).padStart(2, '0'); } /** "N分N秒" 或 "N秒" */ function formatDurationLong(seconds) { const safe = Math.max(0, Math.floor(Number(seconds) || 0)); const mm = Math.floor(safe / 60); const ss = safe % 60; if (mm > 0) return mm + '分' + ss + '秒'; return ss + '秒'; } /** 语音播报专用时长文案:"N分钟N秒" */ function formatSpeechDuration(seconds) { const safe = Math.max(0, Math.floor(Number(seconds) || 0)); const mm = Math.floor(safe / 60); const ss = safe % 60; const parts = []; if (mm > 0) parts.push(mm + '分钟'); if (ss > 0) parts.push(ss + '秒'); return parts.length ? parts.join('') : '0秒'; } /** 北京时间格式 "yyyy/M/d HH:mm:ss"(项目规则:所有时间展示一律 Asia/Shanghai) */ function formatDateTime(isoString) { if (!isoString) return '—'; const date = new Date(isoString); if (Number.isNaN(date.getTime())) return String(isoString); return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); } /** 循环模式文案 */ function formatLoopText(loopMode, loopCount) { if (loopMode === 'once') return '单次执行'; if (loopMode === 'repeat_forever') return '无限循环'; return '循环 ' + Math.max(1, Number(loopCount || 1)) + ' 次'; } /** UUID 生成(crypto.randomUUID 优先,降级手写 v4) */ function generateId() { try { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID(); } } catch (e) { /* 降级 */ } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** HTML 转义 */ function escHtml(s) { return String(s === null || s === undefined ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** JSON 安全序列化(超长截断),用于交互详情展示 */ function safeJsonStringify(value) { try { let text = JSON.stringify(value, null, 2); if (text === undefined) return String(value); if (text.length > 4000) text = text.slice(0, 4000) + '\n...(内容过长已截断)'; return text; } catch (e) { return String(value); } } // ==================== API 封装层 ==================== const API_TIMEOUT_MS = 15000; function formatApiError(json, status, rawText) { if (json && json.error) return String(json.error); if (rawText) return 'HTTP ' + status + ':' + String(rawText).slice(0, 200); return 'HTTP ' + status; } /** * 通用请求封装 * 返回统一结构:{ ok, data, error, status, duration, url, method, requestBody, responseBody } */ async function apiFetch(method, path, body) { const startedAt = Date.now(); const result = { ok: false, data: null, error: '', status: 0, duration: 0, url: path, method: method, requestBody: body === undefined ? null : body, responseBody: null }; const controller = new AbortController(); const timeoutId = setTimeout(function () { controller.abort(); }, API_TIMEOUT_MS); try { const options = { method: method, headers: {}, signal: controller.signal }; if (body !== undefined && body !== null) { options.headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } const resp = await fetch(path, options); result.status = resp.status; const text = await resp.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; } result.responseBody = json !== null ? json : (text || null); if (resp.ok && json && json.ok !== false) { result.ok = true; result.data = (json && json.data !== undefined) ? json.data : json; } else { result.error = formatApiError(json, resp.status, text); } } catch (e) { if (e && e.name === 'AbortError') { result.error = '请求超时(' + API_TIMEOUT_MS / 1000 + ' 秒)'; } else { result.error = '网络请求失败:' + String((e && e.message) || e); } } finally { clearTimeout(timeoutId); result.duration = Date.now() - startedAt; } return result; } // ==================== Toast 系统 ==================== // info=蓝 / success=绿 / error=红,默认 2s 自动消失,不可点击关闭 let toastTimer = null; function showToast(msg, type, duration) { const el = document.getElementById('toast'); if (!el) return; const toastType = (type === 'success' || type === 'error') ? type : 'info'; el.textContent = msg; el.className = 'toast toast-show toast-' + toastType; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.className = 'toast'; }, Number(duration) > 0 ? Number(duration) : 2000); } // ==================== 弹窗系统 ==================== /** 通用弹窗。actions: [{ text, style: 'default'|'primary'|'danger', closeOnClick, onPress(btn) }] */ function showDialog(title, bodyHtml, actions) { document.getElementById('dialogTitle').textContent = title; document.getElementById('dialogBody').innerHTML = bodyHtml; const actionsEl = document.getElementById('dialogActions'); actionsEl.innerHTML = ''; const list = (Array.isArray(actions) && actions.length) ? actions : [{ text: '知道了', style: 'primary' }]; list.forEach(function (action) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dialog-btn' + (action.style ? ' dialog-btn-' + action.style : ''); btn.textContent = action.text || '确定'; btn.addEventListener('click', function () { if (action.closeOnClick !== false) closeDialog(); if (typeof action.onPress === 'function') action.onPress(btn); }); actionsEl.appendChild(btn); }); document.getElementById('dialogOverlay').style.display = 'flex'; } function closeDialog() { document.getElementById('dialogOverlay').style.display = 'none'; } /** 确认弹窗:双模式均保留,永不降级。onConfirm(btn) 需自行调用 closeDialog() */ function showConfirm(title, bodyHtml, onConfirm) { showDialog(title, bodyHtml, [ { text: '取消', style: 'default' }, { text: '确认', style: 'danger', closeOnClick: false, onPress: function (btn) { if (typeof onConfirm === 'function') onConfirm(btn); } } ]); } function showAlert(title, bodyHtml) { showDialog(title, bodyHtml, [{ text: '知道了', style: 'primary' }]); } // ---- 交互详情弹窗(带复制按钮与自动关闭倒计时) ---- let interactionCloseTimer = null; let interactionCountdown = 0; function buildInteractionText(d) { const reqText = (d.requestBody === null || d.requestBody === undefined) ? '—' : safeJsonStringify(d.requestBody); let respText = '—'; if (d.responseBody !== null && d.responseBody !== undefined) { respText = typeof d.responseBody === 'string' ? d.responseBody : safeJsonStringify(d.responseBody); } return [ '操作:' + d.label, '时间:' + d.time + '(北京时间)', '接口:' + d.method + ' ' + d.url, 'HTTP 状态:' + (d.status || '—'), '耗时:' + d.duration + ' ms', '当前状态:' + (d.success ? '成功' : '失败'), '', '请求参数:', reqText, '', '返回结果:', respText, '', '错误信息:' + (d.error || '—') ].join('\n'); } function clearInteractionTimer() { if (interactionCloseTimer) { clearInterval(interactionCloseTimer); interactionCloseTimer = null; } } function hideInteractionDialog() { clearInteractionTimer(); const overlay = document.getElementById('interactionOverlay'); if (overlay) overlay.style.display = 'none'; } /** * 交互详情弹窗 * autoCloseMs > 0:显示倒计时并自动关闭;为 0/null:停留(异常排障场景) */ function showInteractionDialog(detail, autoCloseMs) { clearInteractionTimer(); const box = document.getElementById('interactionBox'); document.getElementById('interactionTitle').textContent = detail.success ? '交互详情' : '交互异常'; document.getElementById('interactionContent').textContent = buildInteractionText(detail); box.classList.toggle('dialog-error', !detail.success); const actionsEl = document.getElementById('interactionActions'); actionsEl.innerHTML = ''; const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'dialog-btn dialog-btn-primary'; closeBtn.textContent = '关闭'; closeBtn.addEventListener('click', hideInteractionDialog); actionsEl.appendChild(closeBtn); document.getElementById('interactionOverlay').style.display = 'flex'; if (Number(autoCloseMs) > 0) { interactionCountdown = Math.ceil(Number(autoCloseMs) / 1000); closeBtn.textContent = '关闭(' + interactionCountdown + 's)'; interactionCloseTimer = setInterval(function () { interactionCountdown -= 1; if (interactionCountdown <= 0) { hideInteractionDialog(); } else { closeBtn.textContent = '关闭(' + interactionCountdown + 's)'; } }, 1000); } } /** 复制交互日志到剪贴板 */ async function handleCopyInteraction() { const contentEl = document.getElementById('interactionContent'); const text = contentEl ? (contentEl.textContent || '') : ''; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } showToast('已复制交互日志', 'success'); } catch (e) { showToast('复制失败', 'error'); } } // ==================== 交互追踪 ==================== // 所有 API 调用统一经由此函数包装: // debug 模式:成功 → 交互详情弹窗 3s 自动关闭 // normal 模式:options.mode === 'view' → Toast;其余 → 弹窗 2s 关闭 // 失败/异常:弹窗停留(中断自动关闭),便于排障 // options.silent:高频后台同步(执行器逐步 sync)静默记录,不打扰前台 let lastInteractionLog = null; async function runTrackedInteraction(label, fn, options) { const opts = options || {}; const startedAt = Date.now(); let res = null; let thrown = null; try { res = await fn(); } catch (e) { thrown = e; } const detail = { label: label, time: formatDateTime(new Date(startedAt).toISOString()), method: (res && res.method) || '—', url: (res && res.url) || '—', requestBody: res ? res.requestBody : null, responseBody: res ? res.responseBody : null, duration: (res && typeof res.duration === 'number') ? res.duration : (Date.now() - startedAt), status: (res && res.status) || 0, error: thrown ? String((thrown && thrown.message) || thrown) : (res && res.ok ? '' : ((res && res.error) || '未知错误')), success: !thrown && !!(res && res.ok) }; lastInteractionLog = detail; if (opts.silent) { // 高频后台同步:仅记录日志,不弹窗、不 Toast console.log('[interaction:silent]', detail); } else if (!detail.success) { showInteractionDialog(detail, 0); // 异常:停留弹窗,中断自动关闭 } else if (getUiMode() === 'debug') { showInteractionDialog(detail, 3000); } else if (opts.mode === 'view') { showToast(label + '成功', 'success', 2000); } else { showInteractionDialog(detail, 2000); } if (thrown) { return { ok: false, data: null, error: detail.error, status: 0, duration: detail.duration, url: detail.url, method: detail.method, requestBody: null, responseBody: null }; } return res; } // ==================== 按钮 Loading ==================== // 异步操作期间:disabled + 文字切换(如"保存中…"),完成后恢复 function getButtonTextEl(btn) { if (!btn) return null; return btn.querySelector('.btn-text') || btn.querySelector('span') || btn; } function setButtonLoading(btn, loadingText) { if (!btn) return; const textEl = getButtonTextEl(btn); if (textEl.dataset.originalText === undefined) { textEl.dataset.originalText = textEl.textContent; } btn.disabled = true; btn.classList.add('btn-loading'); if (loadingText) textEl.textContent = loadingText; } function resetButton(btn, originalText) { if (!btn) return; const textEl = getButtonTextEl(btn); btn.disabled = false; btn.classList.remove('btn-loading'); const restore = (originalText !== undefined) ? originalText : textEl.dataset.originalText; if (restore !== undefined) textEl.textContent = restore; delete textEl.dataset.originalText; } // ==================== 页面路由 ==================== let currentView = 'hub'; // 'hub' | 'motion' 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 '
' + (i + 1) + '. ' + escHtml(s.name) + ' · ' + formatDuration(Number(s.durationSec) || 0) + '
'; }).join(''); const more = steps.length > 4 ? '
还有 ' + (steps.length - 4) + ' 个步骤…
' : ''; const perRound = steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); return '' + '
' + '
' + '
' + escHtml(card.title) + '
' + '
' + escHtml(formatLoopText(card.loopMode, card.loopCount)) + '
' + '
' + '
' + steps.length + ' 个步骤 · 单轮 ' + formatDurationLong(perRound) + '
' + '
' + previews + more + '
' + '
' + '' + '' + '' + '
' + '
'; }).join(''); } async function loadCards() { const listEl = document.getElementById('cardsList'); const emptyEl = document.getElementById('cardsEmpty'); const loadingEl = document.getElementById('cardsLoading'); loadingEl.style.display = ''; listEl.style.display = 'none'; listEl.innerHTML = ''; emptyEl.style.display = 'none'; const res = await runTrackedInteraction('加载卡片列表', function () { return apiFetch('GET', '/api/app_order_box/kits/motion_reminder/cards'); }, { mode: 'view' }); loadingEl.style.display = 'none'; if (!res.ok) { showAlert('加载失败', escHtml(res.error || '卡片列表加载失败')); return; } cardsCache = Array.isArray(res.data) ? res.data : []; renderCards(); updateStaleWarning(); } // ---- 卡片操作(事件委托) ---- function handleCardsListClick(e) { const btn = e.target.closest ? e.target.closest('button') : null; if (!btn) return; const item = btn.closest('.card-item'); if (!item) return; const card = cardsCache.find(function (c) { return c.id === item.dataset.id; }); if (!card) return; if (btn.classList.contains('btn-start-card')) handleStartCard(card, btn); else if (btn.classList.contains('btn-edit-card')) openEditor(card); else if (btn.classList.contains('btn-delete-card')) handleDeleteCard(card); } function handleDeleteCard(card) { showConfirm('确认删除', '确定删除「' + escHtml(card.title) + '」吗?
删除后不可恢复。', async function (confirmBtn) { setButtonLoading(confirmBtn, '删除中…'); const res = await runTrackedInteraction('删除卡片', function () { return apiFetch('DELETE', '/api/app_order_box/kits/motion_reminder/cards?id=' + encodeURIComponent(card.id)); }); closeDialog(); if (!res.ok) { resetButton(confirmBtn); return; // 失败详情已由交互弹窗停留展示 } await loadCards(); }); } async function handleStartCard(card, btn) { if (!Array.isArray(card.steps) || !card.steps.length) { showAlert('无法启动', '当前卡片没有可执行步骤'); return; } if (appState && appState.runningSession) { showAlert('无法启动', '服务端存在运行中的 session,请先清理占用或等待其结束。'); updateStaleWarning(); return; } setButtonLoading(btn, '启动中…'); const res = await runTrackedInteraction('启动执行会话', function () { return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/start', { card: card }); }); if (!res.ok) { resetButton(btn); if (res.status === 409) { showToast('已有运行中的会话,请先清理占用', 'error'); await refreshState(true); updateStaleWarning(); } return; } resetButton(btn); const session = res.data; await refreshState(true); updateStaleWarning(); openRunner(session, (session && session.cardSnapshot) || card); } async function handleCleanStale() { const btn = document.getElementById('btnCleanStale'); setButtonLoading(btn, '清理中…'); const res = await runTrackedInteraction('清理残留 session', function () { return apiFetch('POST', '/api/app_order_box/state/reset-running'); }); resetButton(btn); if (!res.ok) return; await refreshState(true); updateStaleWarning(); } // ==================== 卡片编辑器 ==================== let editorOpen = false; let editorDraft = null; let editorDragIndex = null; function createDefaultStep(type) { const isRest = type === 'rest'; return { id: generateId(), type: isRest ? 'rest' : 'action', name: isRest ? '休息' : '', durationSec: isRest ? 30 : 60, voiceText: '' }; } /** 默认草稿:1 个动作(60s) + 1 个休息(30s),循环 3 次 */ function createDraftCard() { return { id: '', title: '', steps: [createDefaultStep('action'), createDefaultStep('rest')], loopMode: 'repeat_count', loopCount: 3 }; } function openEditor(card) { if (card) { editorDraft = { id: card.id || '', title: card.title || '', steps: (Array.isArray(card.steps) ? card.steps : []).map(function (s) { return { id: s.id || generateId(), type: s.type === 'rest' ? 'rest' : 'action', name: s.name || '', durationSec: Number(s.durationSec) || 0, voiceText: s.voiceText || '' }; }), loopMode: ['once', 'repeat_count', 'repeat_forever'].indexOf(card.loopMode) >= 0 ? card.loopMode : 'once', loopCount: card.loopCount === null || card.loopCount === undefined ? 3 : Number(card.loopCount) }; if (!editorDraft.steps.length) editorDraft.steps = [createDefaultStep('action')]; } else { editorDraft = createDraftCard(); } document.getElementById('editorTitle').textContent = card ? '编辑流程卡片' : '新建流程卡片'; renderEditor(); document.getElementById('editorModal').style.display = 'flex'; editorOpen = true; } function closeEditor() { document.getElementById('editorModal').style.display = 'none'; editorOpen = false; editorDraft = null; editorDragIndex = null; } function editorTotalDuration() { return editorDraft.steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); } function updateEditorStats() { if (!editorDraft) return; const total = editorTotalDuration(); const previewEl = document.getElementById('editorTotalPreview'); if (previewEl) { previewEl.innerHTML = '' + '总时长:' + formatDurationLong(total) + ''; } const perRoundEl = document.getElementById('editorPerRound'); if (perRoundEl) perRoundEl.textContent = formatDurationLong(total); const globalEl = document.getElementById('editorGlobal'); if (globalEl) { if (editorDraft.loopMode === 'repeat_forever') { globalEl.textContent = '无限循环(单轮 ' + formatDurationLong(total) + ')'; } else if (editorDraft.loopMode === 'repeat_count') { const loops = Math.max(1, Number(editorDraft.loopCount || 1)); globalEl.textContent = formatDurationLong(total * loops); } else { globalEl.textContent = formatDurationLong(total); } } } function renderStepEditor(step, index, total) { const isAction = step.type !== 'rest'; const canRemove = total > 1; return '' + '
' + '
' + '
' + '' + (isAction ? '动作' : '休息') + '' + '步骤 ' + (index + 1) + '' + '
' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '' + '' + '
时长:' + formatDurationLong(Number(step.durationSec) || 0) + '
' + '
'; } function renderEditor() { if (!editorDraft) return; const d = editorDraft; const body = document.getElementById('editorBody'); const segments = [ { label: '单次', value: 'once' }, { label: 'N 次', value: 'repeat_count' }, { label: '无限', value: 'repeat_forever' } ]; const stepsHtml = d.steps.map(function (step, index) { return renderStepEditor(step, index, d.steps.length); }).join(''); body.innerHTML = '' + '
' + '' + '' + '
' + '
' + '' + '
' + segments.map(function (s) { return ''; }).join('') + '
' + '
' + (d.loopMode === 'repeat_count' ? ( '
' + '' + '' + '
' ) : '') + '
' + '流程步骤' + '
' + '' + '' + '
' + '
' + '
' + '
' + stepsHtml + '
' + '
' + '
单轮时长
' + '
全局总时长
' + '
' + ''; bindEditorEvents(); updateEditorStats(); } function bindEditorEvents() { const body = document.getElementById('editorBody'); const titleInput = document.getElementById('editorCardTitle'); titleInput.addEventListener('input', function () { editorDraft.title = titleInput.value; }); body.querySelectorAll('.segment-btn').forEach(function (btn) { btn.addEventListener('click', function () { if (editorDraft.loopMode === btn.dataset.loop) return; editorDraft.loopMode = btn.dataset.loop; renderEditor(); }); }); const loopCountInput = document.getElementById('editorLoopCount'); if (loopCountInput) { loopCountInput.addEventListener('input', function () { const digits = loopCountInput.value.replace(/[^0-9]/g, ''); if (digits !== loopCountInput.value) loopCountInput.value = digits; editorDraft.loopCount = digits === '' ? 0 : parseInt(digits, 10); updateEditorStats(); }); } document.getElementById('btnAddAction').addEventListener('click', function () { editorDraft.steps.push(createDefaultStep('action')); renderEditor(); }); document.getElementById('btnAddRest').addEventListener('click', function () { editorDraft.steps.push(createDefaultStep('rest')); renderEditor(); }); document.getElementById('btnEditorSave').addEventListener('click', handleEditorSave); body.querySelectorAll('.step-editor').forEach(function (stepEl) { const stepId = stepEl.dataset.stepId; const step = editorDraft.steps.find(function (s) { return s.id === stepId; }); if (!step) return; const index = editorDraft.steps.indexOf(step); const nameInput = stepEl.querySelector('.step-name-input'); nameInput.addEventListener('input', function () { step.name = nameInput.value; }); const durationInput = stepEl.querySelector('.step-duration-input'); durationInput.addEventListener('input', function () { const digits = durationInput.value.replace(/[^0-9]/g, ''); if (digits !== durationInput.value) durationInput.value = digits; step.durationSec = digits === '' ? 0 : parseInt(digits, 10); const hintEl = stepEl.querySelector('.step-duration-hint'); if (hintEl) hintEl.textContent = '时长:' + formatDurationLong(step.durationSec); updateEditorStats(); }); stepEl.querySelector('.btn-step-copy').addEventListener('click', function () { const idx = editorDraft.steps.findIndex(function (s) { return s.id === stepId; }); if (idx < 0) return; const src = editorDraft.steps[idx]; editorDraft.steps.splice(idx + 1, 0, { id: generateId(), type: src.type, name: (src.name || '') + ' (副本)', durationSec: src.durationSec, voiceText: src.voiceText || '' }); renderEditor(); }); stepEl.querySelector('.btn-step-remove').addEventListener('click', function () { if (editorDraft.steps.length <= 1) return; editorDraft.steps = editorDraft.steps.filter(function (s) { return s.id !== stepId; }); renderEditor(); }); bindStepDrag(stepEl, index); }); } // ---- HTML5 拖拽排序:仅拖拽手柄可发起拖放 ---- function bindStepDrag(stepEl, index) { const handle = stepEl.querySelector('.step-drag-handle'); handle.addEventListener('mousedown', function () { stepEl.draggable = true; }); handle.addEventListener('mouseup', function () { stepEl.draggable = false; }); stepEl.addEventListener('dragstart', function (e) { editorDragIndex = index; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', String(index)); } catch (err) { /* 兼容旧内核 */ } requestAnimationFrame(function () { stepEl.classList.add('step-dragging'); }); }); stepEl.addEventListener('dragend', function () { stepEl.draggable = false; editorDragIndex = null; document.querySelectorAll('.step-editor').forEach(function (el) { el.classList.remove('step-dragging', 'step-drag-over', 'drag-over-before', 'drag-over-after'); }); }); stepEl.addEventListener('dragover', function (e) { if (editorDragIndex === null || editorDragIndex === index) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = stepEl.getBoundingClientRect(); const before = (e.clientY - rect.top) < rect.height / 2; stepEl.classList.add('step-drag-over'); stepEl.classList.toggle('drag-over-before', before); stepEl.classList.toggle('drag-over-after', !before); }); stepEl.addEventListener('dragleave', function () { stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after'); }); stepEl.addEventListener('drop', function (e) { e.preventDefault(); const from = editorDragIndex; stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after'); if (from === null || from === index) return; const rect = stepEl.getBoundingClientRect(); const before = (e.clientY - rect.top) < rect.height / 2; let to = before ? index : index + 1; if (from < to) to -= 1; if (to === from) return; const moved = editorDraft.steps.splice(from, 1)[0]; editorDraft.steps.splice(to, 0, moved); editorDragIndex = null; renderEditor(); }); } async function handleEditorSave() { if (!editorDraft) return; const saveBtn = document.getElementById('btnEditorSave'); // 保存校验 const title = String(editorDraft.title || '').trim(); if (!title) { showAlert('无法保存', '请输入卡片名称'); return; } const invalidSteps = editorDraft.steps.filter(function (s) { return !(Number(s.durationSec) > 0); }); if (invalidSteps.length) { showAlert('无法保存', invalidSteps.length + ' 个步骤的时长无效,请输入大于 0 的数字'); return; } // 保存前清洗:trim 名称、durationSec 取 floor 最小 1、过滤空名称步骤 const cleanSteps = editorDraft.steps .map(function (s) { return { id: s.id, type: s.type === 'rest' ? 'rest' : 'action', name: String(s.name || '').trim(), durationSec: Math.max(1, Math.floor(Number(s.durationSec) || 0)), voiceText: String(s.voiceText || '').trim() }; }) .filter(function (s) { return s.name !== ''; }); if (!cleanSteps.length) { showAlert('无法保存', '至少保留一个有效步骤(步骤名称不能为空)'); return; } let loopCount = null; if (editorDraft.loopMode === 'repeat_count') { loopCount = Math.floor(Number(editorDraft.loopCount || 0)); if (loopCount < 1) { showAlert('无法保存', '循环次数至少为 1'); return; } } const isEdit = !!editorDraft.id; const payload = { title: title, steps: cleanSteps, loopMode: editorDraft.loopMode, loopCount: loopCount }; if (isEdit) payload.id = editorDraft.id; setButtonLoading(saveBtn, '保存中…'); const res = await runTrackedInteraction(isEdit ? '更新流程卡片' : '新建流程卡片', function () { return apiFetch(isEdit ? 'PUT' : 'POST', '/api/app_order_box/kits/motion_reminder/cards', payload); }); if (!res.ok) { resetButton(saveBtn); return; // 失败详情已由交互弹窗停留展示 } resetButton(saveBtn); closeEditor(); await loadCards(); } // ==================== 执行器 ==================== let runnerState = null; let runnerTimerId = null; let runnerAnnounceKey = ''; let runnerFinishing = false; function perRoundSec(card) { return ((card && card.steps) || []).reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); } function runnerTotalLoops() { if (!runnerState) return null; return runnerState.card.loopMode === 'repeat_count' ? Math.max(1, Number(runnerState.card.loopCount || 1)) : null; } /** 预计剩余总秒数:当前步骤剩余 + 本轮剩余步骤 + 剩余整轮 */ function computeRemainingSec(rs) { const steps = rs.card.steps; let total = Math.max(0, Number(rs.remainingSec) || 0); for (let i = rs.currentStepIndex + 1; i < steps.length; i++) { total += Math.max(1, Number(steps[i].durationSec) || 0); } const totalLoops = rs.card.loopMode === 'repeat_count' ? Math.max(1, Number(rs.card.loopCount || 1)) : null; if (totalLoops !== null) { const roundsLeft = Math.max(0, totalLoops - rs.currentLoop); total += roundsLeft * perRoundSec(rs.card); } return total; } function openRunner(session, card) { 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 = '' + '
' + '' + (isAction ? '动作' : '休息') + '' + '
' + escHtml(rs.card.title) + '
' + '
第 ' + rs.currentLoop + ' 轮 ' + (totalLoops ? '/ 共 ' + totalLoops + ' 轮' : '/ 无限循环') + ' · 步骤 ' + (rs.currentStepIndex + 1) + ' / ' + totalSteps + '
' + '
' + '
' + '
' + '
' + '
当前动作
' + '
' + escHtml(step.name || '—') + '
' + '
--:--
' + '
' + '
下一个动作
' + '
' + (nextStep ? escHtml(nextStep.name) : '本轮结束') + '
' + '
' + '
' + '
' + '' + '
' + '
'; container.classList.toggle('is-paused', rs.status === 'paused'); document.getElementById('btnRunnerPause').addEventListener('click', handleRunnerPause); document.getElementById('btnRunnerSkip').addEventListener('click', handleRunnerSkip); document.getElementById('btnRunnerStop').addEventListener('click', handleRunnerStop); } /** 每 tick 局部刷新:倒计时、进度条、预计剩余、全局进度 */ function updateRunnerDynamic() { const rs = runnerState; if (!rs) return; const step = rs.card.steps[rs.currentStepIndex]; const stepDuration = Math.max(1, Number((step && step.durationSec) || 1)); const stepProgress = Math.max(0, Math.min(1, 1 - (rs.remainingSec / stepDuration))); const timerEl = document.getElementById('runnerTimer'); if (timerEl) timerEl.textContent = formatDuration(rs.remainingSec); const barEl = document.getElementById('runnerStepBar'); if (barEl) barEl.style.width = (stepProgress * 100).toFixed(2) + '%'; const remainingSec = computeRemainingSec(rs); const remEl = document.getElementById('runnerInfoRemaining'); if (remEl) remEl.textContent = '约 ' + formatDuration(remainingSec); const totalLoops = runnerTotalLoops(); const globalDurationSec = totalLoops !== null ? perRoundSec(rs.card) * totalLoops : null; if (globalDurationSec !== null && globalDurationSec > 0) { const executedSec = Math.max(0, globalDurationSec - remainingSec); const progress = Math.max(0, Math.min(1, executedSec / globalDurationSec)); const pctEl = document.getElementById('runnerGlobalPercent'); if (pctEl) pctEl.textContent = Math.round(progress * 100) + '%'; const gBarEl = document.getElementById('runnerGlobalBar'); if (gBarEl) gBarEl.style.width = (progress * 100).toFixed(2) + '%'; const exEl = document.getElementById('runnerGlobalExecuted'); if (exEl) exEl.textContent = '已执行 ' + formatDuration(executedSec); const reEl = document.getElementById('runnerGlobalRemaining'); if (reEl) reEl.textContent = '剩余 ' + formatDuration(remainingSec); } } // ==================== 语音播报 ==================== // 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 '' + '
' + '
' + escHtml(title) + '
' + '
开始:' + formatDateTime(item.startedAt) + '
' + '
结束:' + formatDateTime(item.endedAt) + '
' + '
完成轮次:' + Math.max(0, Number(item.completedLoops) || 0) + '
' + '
结束方式:' + endText + '
' + '
'; }).join(''); } async function loadHistory() { const listEl = document.getElementById('historyList'); const emptyEl = document.getElementById('historyEmpty'); const loadingEl = document.getElementById('historyLoading'); loadingEl.style.display = ''; listEl.style.display = 'none'; listEl.innerHTML = ''; emptyEl.style.display = 'none'; const res = await runTrackedInteraction('加载执行历史', function () { return apiFetch('GET', '/api/app_order_box/kits/motion_reminder/history'); }, { mode: 'view' }); loadingEl.style.display = 'none'; if (!res.ok) { showAlert('加载失败', escHtml(res.error || '执行历史加载失败')); return; } renderHistory(Array.isArray(res.data) ? res.data : []); } // ==================== 初始化 ==================== /** 键盘快捷键:执行器内 空格=暂停/继续,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 }; })();