/* =====================================================================
* 框子(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 '' +
'' +
'' +
'
' + 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 '' +
'';
}
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 = '' +
'' +
'' +
'' +
'
当前动作
' +
'
' + 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
};
})();