a3e8911deb
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
2847 lines
117 KiB
JavaScript
2847 lines
117 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' | 'schedule'
|
||
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';
|
||
document.getElementById('viewSchedule').style.display = (viewName === 'schedule') ? '' : 'none';
|
||
// 离开作息报时器即停止秒级 tick(待机播报引擎只在报时器页面上运行)
|
||
if (viewName !== 'schedule') stopSchedTick();
|
||
}
|
||
|
||
function switchTab(tabName) {
|
||
currentTab = tabName;
|
||
document.querySelectorAll('#motionTabBar .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;
|
||
document.getElementById('btnEnterSchedule').disabled = false;
|
||
|
||
// activeKit 已指向某个 kit:跳过 Hub 直接进入
|
||
if (appState && appState.activeKit === 'motion_reminder') {
|
||
showView('motion');
|
||
switchTab('cards');
|
||
} else if (appState && appState.activeKit === 'schedule_reminder') {
|
||
showView('schedule');
|
||
switchSchedTab('schedules');
|
||
// 无用户手势时不能探测语音,仅同步时钟并启动 tick
|
||
syncSchedClock().then(function () { startSchedTick(); });
|
||
}
|
||
}
|
||
|
||
async function handleEnterMotion() {
|
||
const btn = document.getElementById('btnEnterMotion');
|
||
// 必须在 await 之前(用户手势上下文中)解锁音频与语音,供后续探测与播报使用
|
||
unlockAudio();
|
||
unlockSpeech();
|
||
setButtonLoading(btn, '进入中…');
|
||
const res = await runTrackedInteraction('设置激活 kit(motion_reminder)', function () {
|
||
return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'motion_reminder' });
|
||
});
|
||
if (!res.ok) {
|
||
resetButton(btn);
|
||
showAlert('进入失败', escHtml(res.error || '请稍后重试'));
|
||
return;
|
||
}
|
||
resetButton(btn);
|
||
await refreshState(true);
|
||
showView('motion');
|
||
switchTab('cards');
|
||
// 交互详情弹窗(debug 3s / normal 2s 自动关闭)会盖住探测弹窗,等它先收起再探测语音
|
||
setTimeout(function () { probeSpeech(); }, getUiMode() === 'debug' ? 3200 : 2200);
|
||
}
|
||
|
||
async function handleBackHub() {
|
||
await runTrackedInteraction('返回框子(清除激活 kit)', function () {
|
||
return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: '' });
|
||
}, { mode: 'view' });
|
||
showView('hub');
|
||
await refreshState(true);
|
||
}
|
||
|
||
// ==================== Tab 切换 ====================
|
||
// (switchTab 已在"页面路由"分区实现,此处负责静态事件绑定的入口见 init)
|
||
|
||
// ==================== 卡片列表 ====================
|
||
|
||
function updateStaleWarning() {
|
||
const el = document.getElementById('staleWarning');
|
||
if (!el) return;
|
||
const show = !!(appState && appState.runningSession && !runnerState);
|
||
el.style.display = show ? '' : 'none';
|
||
}
|
||
|
||
function renderCards() {
|
||
const listEl = document.getElementById('cardsList');
|
||
const emptyEl = document.getElementById('cardsEmpty');
|
||
if (!cardsCache.length) {
|
||
listEl.innerHTML = '';
|
||
listEl.style.display = 'none';
|
||
emptyEl.style.display = '';
|
||
return;
|
||
}
|
||
emptyEl.style.display = 'none';
|
||
listEl.style.display = '';
|
||
listEl.innerHTML = cardsCache.map(function (card) {
|
||
const steps = Array.isArray(card.steps) ? card.steps : [];
|
||
const previews = steps.slice(0, 4).map(function (s, i) {
|
||
return '<div class="step-preview-line">' + (i + 1) + '. ' + escHtml(s.name) + ' · ' + formatDuration(Number(s.durationSec) || 0) + '</div>';
|
||
}).join('');
|
||
const more = steps.length > 4
|
||
? '<div class="step-preview-more">还有 ' + (steps.length - 4) + ' 个步骤…</div>'
|
||
: '';
|
||
const perRound = steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0);
|
||
return '' +
|
||
'<div class="card-item" data-id="' + escHtml(card.id) + '">' +
|
||
'<div class="card-item-header">' +
|
||
'<div class="card-title">' + escHtml(card.title) + '</div>' +
|
||
'<div class="card-meta">' + escHtml(formatLoopText(card.loopMode, card.loopCount)) + '</div>' +
|
||
'</div>' +
|
||
'<div class="card-sub">' + steps.length + ' 个步骤 · 单轮 ' + formatDurationLong(perRound) + '</div>' +
|
||
'<div class="step-preview">' + previews + more + '</div>' +
|
||
'<div class="card-actions">' +
|
||
'<button type="button" class="btn-card-primary btn-start-card">' +
|
||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>' +
|
||
'<span class="btn-text">启动</span>' +
|
||
'</button>' +
|
||
'<button type="button" class="btn-card-ghost btn-edit-card"><span class="btn-text">编辑</span></button>' +
|
||
'<button type="button" class="btn-card-ghost btn-delete-card"><span class="btn-text btn-text-danger">删除</span></button>' +
|
||
'<button type="button" class="btn-card-ghost btn-regen-tts"><span class="btn-text">重生语音</span></button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
async function loadCards() {
|
||
const listEl = document.getElementById('cardsList');
|
||
const emptyEl = document.getElementById('cardsEmpty');
|
||
const loadingEl = document.getElementById('cardsLoading');
|
||
loadingEl.style.display = '';
|
||
listEl.style.display = 'none';
|
||
listEl.innerHTML = '';
|
||
emptyEl.style.display = 'none';
|
||
|
||
const res = await runTrackedInteraction('加载卡片列表', function () {
|
||
return apiFetch('GET', '/api/app_order_box/kits/motion_reminder/cards');
|
||
}, { mode: 'view' });
|
||
|
||
loadingEl.style.display = 'none';
|
||
if (!res.ok) {
|
||
showAlert('加载失败', escHtml(res.error || '卡片列表加载失败'));
|
||
return;
|
||
}
|
||
cardsCache = Array.isArray(res.data) ? res.data : [];
|
||
renderCards();
|
||
updateStaleWarning();
|
||
}
|
||
|
||
// ---- 卡片操作(事件委托) ----
|
||
|
||
function handleCardsListClick(e) {
|
||
const btn = e.target.closest ? e.target.closest('button') : null;
|
||
if (!btn) return;
|
||
const item = btn.closest('.card-item');
|
||
if (!item) return;
|
||
const card = cardsCache.find(function (c) { return c.id === item.dataset.id; });
|
||
if (!card) return;
|
||
if (btn.classList.contains('btn-start-card')) handleStartCard(card, btn);
|
||
else if (btn.classList.contains('btn-edit-card')) openEditor(card);
|
||
else if (btn.classList.contains('btn-delete-card')) handleDeleteCard(card);
|
||
else if (btn.classList.contains('btn-regen-tts')) handleRegenCardTTS(card, btn);
|
||
}
|
||
|
||
/** 手动补救:强制重建该卡片的云端语音缓存;平时无需点击,编辑后遇到没声音时用 */
|
||
async function handleRegenCardTTS(card, btn) {
|
||
if (!Array.isArray(card.steps) || !card.steps.length) {
|
||
showAlert('无法生成', '当前卡片没有可执行步骤');
|
||
return;
|
||
}
|
||
setButtonLoading(btn, '生成中…');
|
||
const res = await runTrackedInteraction('重生卡片语音', function () {
|
||
return prepareCardTTS(card, true);
|
||
});
|
||
resetButton(btn);
|
||
if (!res || !res.ok) return;
|
||
const d = res.data || {};
|
||
showToast('语音已重新生成(' + (Number(d.success) || 0) + '/' + (Number(d.total) || 0) + ')', 'success');
|
||
}
|
||
|
||
function handleDeleteCard(card) {
|
||
showConfirm('确认删除', '确定删除「' + escHtml(card.title) + '」吗?<br>删除后不可恢复。', async function (confirmBtn) {
|
||
setButtonLoading(confirmBtn, '删除中…');
|
||
const res = await runTrackedInteraction('删除卡片', function () {
|
||
return apiFetch('DELETE', '/api/app_order_box/kits/motion_reminder/cards?id=' + encodeURIComponent(card.id));
|
||
});
|
||
closeDialog();
|
||
if (!res.ok) {
|
||
resetButton(confirmBtn);
|
||
return; // 失败详情已由交互弹窗停留展示
|
||
}
|
||
await loadCards();
|
||
});
|
||
}
|
||
|
||
async function handleStartCard(card, btn) {
|
||
if (!Array.isArray(card.steps) || !card.steps.length) {
|
||
showAlert('无法启动', '当前卡片没有可执行步骤');
|
||
return;
|
||
}
|
||
if (appState && appState.runningSession) {
|
||
showAlert('无法启动', '服务端存在运行中的 session,请先清理占用或等待其结束。');
|
||
updateStaleWarning();
|
||
return;
|
||
}
|
||
// 必须在 await 之前(用户手势上下文中)解锁音频和语音
|
||
unlockAudio();
|
||
unlockSpeech();
|
||
// 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS)
|
||
prepareCardTTS(card);
|
||
setButtonLoading(btn, '启动中…');
|
||
const res = await runTrackedInteraction('启动执行会话', function () {
|
||
return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/start', { card: card });
|
||
});
|
||
if (!res.ok) {
|
||
resetButton(btn);
|
||
if (res.status === 409) {
|
||
showToast('已有运行中的会话,请先清理占用', 'error');
|
||
await refreshState(true);
|
||
updateStaleWarning();
|
||
}
|
||
return;
|
||
}
|
||
resetButton(btn);
|
||
const session = res.data;
|
||
await refreshState(true);
|
||
updateStaleWarning();
|
||
openRunner(session, (session && session.cardSnapshot) || card);
|
||
}
|
||
|
||
async function handleCleanStale() {
|
||
const btn = document.getElementById('btnCleanStale');
|
||
setButtonLoading(btn, '清理中…');
|
||
const res = await runTrackedInteraction('清理残留 session', function () {
|
||
return apiFetch('POST', '/api/app_order_box/state/reset-running');
|
||
});
|
||
resetButton(btn);
|
||
if (!res.ok) return;
|
||
await refreshState(true);
|
||
updateStaleWarning();
|
||
}
|
||
|
||
// ==================== 卡片编辑器 ====================
|
||
let editorOpen = false;
|
||
let editorDraft = null;
|
||
let editorDragIndex = null;
|
||
|
||
function createDefaultStep(type) {
|
||
const isRest = type === 'rest';
|
||
return {
|
||
id: generateId(),
|
||
type: isRest ? 'rest' : 'action',
|
||
name: isRest ? '休息' : '',
|
||
durationSec: isRest ? 30 : 60,
|
||
voiceText: ''
|
||
};
|
||
}
|
||
|
||
/** 默认草稿:1 个动作(60s) + 1 个休息(30s),循环 3 次 */
|
||
function createDraftCard() {
|
||
return {
|
||
id: '',
|
||
title: '',
|
||
steps: [createDefaultStep('action'), createDefaultStep('rest')],
|
||
loopMode: 'repeat_count',
|
||
loopCount: 3
|
||
};
|
||
}
|
||
|
||
function openEditor(card) {
|
||
if (card) {
|
||
editorDraft = {
|
||
id: card.id || '',
|
||
title: card.title || '',
|
||
steps: (Array.isArray(card.steps) ? card.steps : []).map(function (s) {
|
||
return {
|
||
id: s.id || generateId(),
|
||
type: s.type === 'rest' ? 'rest' : 'action',
|
||
name: s.name || '',
|
||
durationSec: Number(s.durationSec) || 0,
|
||
voiceText: s.voiceText || ''
|
||
};
|
||
}),
|
||
loopMode: ['once', 'repeat_count', 'repeat_forever'].indexOf(card.loopMode) >= 0 ? card.loopMode : 'once',
|
||
loopCount: card.loopCount === null || card.loopCount === undefined ? 3 : Number(card.loopCount)
|
||
};
|
||
if (!editorDraft.steps.length) editorDraft.steps = [createDefaultStep('action')];
|
||
} else {
|
||
editorDraft = createDraftCard();
|
||
}
|
||
document.getElementById('editorTitle').textContent = card ? '编辑流程卡片' : '新建流程卡片';
|
||
renderEditor();
|
||
document.getElementById('editorModal').style.display = 'flex';
|
||
editorOpen = true;
|
||
}
|
||
|
||
function closeEditor() {
|
||
document.getElementById('editorModal').style.display = 'none';
|
||
editorOpen = false;
|
||
editorDraft = null;
|
||
editorDragIndex = null;
|
||
}
|
||
|
||
function editorTotalDuration() {
|
||
return editorDraft.steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0);
|
||
}
|
||
|
||
function updateEditorStats() {
|
||
if (!editorDraft) return;
|
||
const total = editorTotalDuration();
|
||
const previewEl = document.getElementById('editorTotalPreview');
|
||
if (previewEl) {
|
||
previewEl.innerHTML =
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/></svg>' +
|
||
'<span>总时长:' + formatDurationLong(total) + '</span>';
|
||
}
|
||
const perRoundEl = document.getElementById('editorPerRound');
|
||
if (perRoundEl) perRoundEl.textContent = formatDurationLong(total);
|
||
const globalEl = document.getElementById('editorGlobal');
|
||
if (globalEl) {
|
||
if (editorDraft.loopMode === 'repeat_forever') {
|
||
globalEl.textContent = '无限循环(单轮 ' + formatDurationLong(total) + ')';
|
||
} else if (editorDraft.loopMode === 'repeat_count') {
|
||
const loops = Math.max(1, Number(editorDraft.loopCount || 1));
|
||
globalEl.textContent = formatDurationLong(total * loops);
|
||
} else {
|
||
globalEl.textContent = formatDurationLong(total);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderStepEditor(step, index, total) {
|
||
const isAction = step.type !== 'rest';
|
||
const canRemove = total > 1;
|
||
return '' +
|
||
'<div class="step-editor ' + (isAction ? 'step-action' : 'step-rest') + '" data-step-id="' + escHtml(step.id) + '">' +
|
||
'<div class="step-editor-header">' +
|
||
'<div class="step-title-row">' +
|
||
'<span class="step-badge">' + (isAction ? '动作' : '休息') + '</span>' +
|
||
'<span class="step-index">步骤 ' + (index + 1) + '</span>' +
|
||
'</div>' +
|
||
'<div class="step-actions">' +
|
||
'<button type="button" class="btn-step-icon btn-step-copy" title="复制步骤">' +
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>' +
|
||
'</button>' +
|
||
'<button type="button" class="btn-step-icon btn-step-remove' + (canRemove ? '' : ' disabled') + '" title="删除步骤"' + (canRemove ? '' : ' disabled') + '>' +
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' +
|
||
'</button>' +
|
||
'<span class="step-drag-handle" title="拖拽排序">' +
|
||
'<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>' +
|
||
'</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<input class="editor-input step-name-input" type="text" placeholder="' + (isAction ? '例如:俯卧撑' : '例如:休息') + '" value="' + escHtml(step.name) + '">' +
|
||
'<input class="editor-input step-duration-input" type="text" inputmode="numeric" placeholder="时长(秒)" value="' + escHtml(String(step.durationSec || '')) + '">' +
|
||
'<div class="step-duration-hint">时长:' + formatDurationLong(Number(step.durationSec) || 0) + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderEditor() {
|
||
if (!editorDraft) return;
|
||
const d = editorDraft;
|
||
const body = document.getElementById('editorBody');
|
||
const segments = [
|
||
{ label: '单次', value: 'once' },
|
||
{ label: 'N 次', value: 'repeat_count' },
|
||
{ label: '无限', value: 'repeat_forever' }
|
||
];
|
||
const stepsHtml = d.steps.map(function (step, index) {
|
||
return renderStepEditor(step, index, d.steps.length);
|
||
}).join('');
|
||
|
||
body.innerHTML = '' +
|
||
'<div class="editor-field">' +
|
||
'<label class="editor-label" for="editorCardTitle">卡片名称</label>' +
|
||
'<input id="editorCardTitle" class="editor-input" type="text" placeholder="例如:晨练循环" value="' + escHtml(d.title) + '">' +
|
||
'</div>' +
|
||
'<div class="editor-field">' +
|
||
'<label class="editor-label">循环方式</label>' +
|
||
'<div class="segment-row">' +
|
||
segments.map(function (s) {
|
||
return '<button type="button" class="segment-btn' + (d.loopMode === s.value ? ' active' : '') + '" data-loop="' + s.value + '">' + s.label + '</button>';
|
||
}).join('') +
|
||
'</div>' +
|
||
'</div>' +
|
||
(d.loopMode === 'repeat_count' ? (
|
||
'<div class="editor-field">' +
|
||
'<label class="editor-label" for="editorLoopCount">循环次数</label>' +
|
||
'<input id="editorLoopCount" class="editor-input" type="text" inputmode="numeric" placeholder="输入循环次数" value="' + escHtml(String(d.loopCount || '')) + '">' +
|
||
'</div>'
|
||
) : '') +
|
||
'<div class="editor-steps-header">' +
|
||
'<span class="editor-section-title">流程步骤</span>' +
|
||
'<div class="editor-steps-buttons">' +
|
||
'<button type="button" class="btn-small" id="btnAddAction">+ 加动作</button>' +
|
||
'<button type="button" class="btn-small" id="btnAddRest">+ 加休息</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="editor-total-duration" id="editorTotalPreview"></div>' +
|
||
'<div class="editor-steps-list" id="editorStepsList">' + stepsHtml + '</div>' +
|
||
'<div class="editor-duration-info">' +
|
||
'<div class="duration-info-row"><span class="duration-info-label">单轮时长</span><span class="duration-info-value" id="editorPerRound"></span></div>' +
|
||
'<div class="duration-info-row"><span class="duration-info-label">全局总时长</span><span class="duration-info-value duration-info-highlight" id="editorGlobal"></span></div>' +
|
||
'</div>' +
|
||
'<div class="editor-footer">' +
|
||
'<button type="button" class="btn-primary btn-editor-save" id="btnEditorSave"><span class="btn-text">保存</span></button>' +
|
||
'</div>';
|
||
|
||
bindEditorEvents();
|
||
updateEditorStats();
|
||
}
|
||
|
||
function bindEditorEvents() {
|
||
const body = document.getElementById('editorBody');
|
||
|
||
const titleInput = document.getElementById('editorCardTitle');
|
||
titleInput.addEventListener('input', function () { editorDraft.title = titleInput.value; });
|
||
|
||
body.querySelectorAll('.segment-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
if (editorDraft.loopMode === btn.dataset.loop) return;
|
||
editorDraft.loopMode = btn.dataset.loop;
|
||
renderEditor();
|
||
});
|
||
});
|
||
|
||
const loopCountInput = document.getElementById('editorLoopCount');
|
||
if (loopCountInput) {
|
||
loopCountInput.addEventListener('input', function () {
|
||
const digits = loopCountInput.value.replace(/[^0-9]/g, '');
|
||
if (digits !== loopCountInput.value) loopCountInput.value = digits;
|
||
editorDraft.loopCount = digits === '' ? 0 : parseInt(digits, 10);
|
||
updateEditorStats();
|
||
});
|
||
}
|
||
|
||
document.getElementById('btnAddAction').addEventListener('click', function () {
|
||
editorDraft.steps.push(createDefaultStep('action'));
|
||
renderEditor();
|
||
});
|
||
document.getElementById('btnAddRest').addEventListener('click', function () {
|
||
editorDraft.steps.push(createDefaultStep('rest'));
|
||
renderEditor();
|
||
});
|
||
document.getElementById('btnEditorSave').addEventListener('click', handleEditorSave);
|
||
|
||
body.querySelectorAll('.step-editor').forEach(function (stepEl) {
|
||
const stepId = stepEl.dataset.stepId;
|
||
const step = editorDraft.steps.find(function (s) { return s.id === stepId; });
|
||
if (!step) return;
|
||
const index = editorDraft.steps.indexOf(step);
|
||
|
||
const nameInput = stepEl.querySelector('.step-name-input');
|
||
nameInput.addEventListener('input', function () { step.name = nameInput.value; });
|
||
|
||
const durationInput = stepEl.querySelector('.step-duration-input');
|
||
durationInput.addEventListener('input', function () {
|
||
const digits = durationInput.value.replace(/[^0-9]/g, '');
|
||
if (digits !== durationInput.value) durationInput.value = digits;
|
||
step.durationSec = digits === '' ? 0 : parseInt(digits, 10);
|
||
const hintEl = stepEl.querySelector('.step-duration-hint');
|
||
if (hintEl) hintEl.textContent = '时长:' + formatDurationLong(step.durationSec);
|
||
updateEditorStats();
|
||
});
|
||
|
||
stepEl.querySelector('.btn-step-copy').addEventListener('click', function () {
|
||
const idx = editorDraft.steps.findIndex(function (s) { return s.id === stepId; });
|
||
if (idx < 0) return;
|
||
const src = editorDraft.steps[idx];
|
||
editorDraft.steps.splice(idx + 1, 0, {
|
||
id: generateId(),
|
||
type: src.type,
|
||
name: (src.name || '') + ' (副本)',
|
||
durationSec: src.durationSec,
|
||
voiceText: src.voiceText || ''
|
||
});
|
||
renderEditor();
|
||
});
|
||
|
||
stepEl.querySelector('.btn-step-remove').addEventListener('click', function () {
|
||
if (editorDraft.steps.length <= 1) return;
|
||
editorDraft.steps = editorDraft.steps.filter(function (s) { return s.id !== stepId; });
|
||
renderEditor();
|
||
});
|
||
|
||
bindStepDrag(stepEl, index);
|
||
});
|
||
}
|
||
|
||
// ---- HTML5 拖拽排序:仅拖拽手柄可发起拖放 ----
|
||
function bindStepDrag(stepEl, index) {
|
||
const handle = stepEl.querySelector('.step-drag-handle');
|
||
|
||
handle.addEventListener('mousedown', function () { stepEl.draggable = true; });
|
||
handle.addEventListener('mouseup', function () { stepEl.draggable = false; });
|
||
|
||
stepEl.addEventListener('dragstart', function (e) {
|
||
editorDragIndex = index;
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
try { e.dataTransfer.setData('text/plain', String(index)); } catch (err) { /* 兼容旧内核 */ }
|
||
requestAnimationFrame(function () { stepEl.classList.add('step-dragging'); });
|
||
});
|
||
|
||
stepEl.addEventListener('dragend', function () {
|
||
stepEl.draggable = false;
|
||
editorDragIndex = null;
|
||
document.querySelectorAll('.step-editor').forEach(function (el) {
|
||
el.classList.remove('step-dragging', 'step-drag-over', 'drag-over-before', 'drag-over-after');
|
||
});
|
||
});
|
||
|
||
stepEl.addEventListener('dragover', function (e) {
|
||
if (editorDragIndex === null || editorDragIndex === index) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
const rect = stepEl.getBoundingClientRect();
|
||
const before = (e.clientY - rect.top) < rect.height / 2;
|
||
stepEl.classList.add('step-drag-over');
|
||
stepEl.classList.toggle('drag-over-before', before);
|
||
stepEl.classList.toggle('drag-over-after', !before);
|
||
});
|
||
|
||
stepEl.addEventListener('dragleave', function () {
|
||
stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after');
|
||
});
|
||
|
||
stepEl.addEventListener('drop', function (e) {
|
||
e.preventDefault();
|
||
const from = editorDragIndex;
|
||
stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after');
|
||
if (from === null || from === index) return;
|
||
const rect = stepEl.getBoundingClientRect();
|
||
const before = (e.clientY - rect.top) < rect.height / 2;
|
||
let to = before ? index : index + 1;
|
||
if (from < to) to -= 1;
|
||
if (to === from) return;
|
||
const moved = editorDraft.steps.splice(from, 1)[0];
|
||
editorDraft.steps.splice(to, 0, moved);
|
||
editorDragIndex = null;
|
||
renderEditor();
|
||
});
|
||
}
|
||
|
||
async function handleEditorSave() {
|
||
if (!editorDraft) return;
|
||
const saveBtn = document.getElementById('btnEditorSave');
|
||
|
||
// 保存校验
|
||
const title = String(editorDraft.title || '').trim();
|
||
if (!title) {
|
||
showAlert('无法保存', '请输入卡片名称');
|
||
return;
|
||
}
|
||
const invalidSteps = editorDraft.steps.filter(function (s) {
|
||
return !(Number(s.durationSec) > 0);
|
||
});
|
||
if (invalidSteps.length) {
|
||
showAlert('无法保存', invalidSteps.length + ' 个步骤的时长无效,请输入大于 0 的数字');
|
||
return;
|
||
}
|
||
// 保存前清洗:trim 名称、durationSec 取 floor 最小 1、过滤空名称步骤
|
||
const cleanSteps = editorDraft.steps
|
||
.map(function (s) {
|
||
return {
|
||
id: s.id,
|
||
type: s.type === 'rest' ? 'rest' : 'action',
|
||
name: String(s.name || '').trim(),
|
||
durationSec: Math.max(1, Math.floor(Number(s.durationSec) || 0)),
|
||
voiceText: String(s.voiceText || '').trim()
|
||
};
|
||
})
|
||
.filter(function (s) { return s.name !== ''; });
|
||
if (!cleanSteps.length) {
|
||
showAlert('无法保存', '至少保留一个有效步骤(步骤名称不能为空)');
|
||
return;
|
||
}
|
||
let loopCount = null;
|
||
if (editorDraft.loopMode === 'repeat_count') {
|
||
loopCount = Math.floor(Number(editorDraft.loopCount || 0));
|
||
if (loopCount < 1) {
|
||
showAlert('无法保存', '循环次数至少为 1');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const isEdit = !!editorDraft.id;
|
||
const payload = {
|
||
title: title,
|
||
steps: cleanSteps,
|
||
loopMode: editorDraft.loopMode,
|
||
loopCount: loopCount
|
||
};
|
||
if (isEdit) payload.id = editorDraft.id;
|
||
|
||
setButtonLoading(saveBtn, '保存中…');
|
||
const res = await runTrackedInteraction(isEdit ? '更新流程卡片' : '新建流程卡片', function () {
|
||
return apiFetch(isEdit ? 'PUT' : 'POST', '/api/app_order_box/kits/motion_reminder/cards', payload);
|
||
});
|
||
if (!res.ok) {
|
||
resetButton(saveBtn);
|
||
return; // 失败详情已由交互弹窗停留展示
|
||
}
|
||
resetButton(saveBtn);
|
||
closeEditor();
|
||
await loadCards();
|
||
}
|
||
|
||
// ==================== 执行器 ====================
|
||
let runnerState = null;
|
||
let runnerTimerId = null;
|
||
let runnerAnnounceKey = '';
|
||
let runnerFinishing = false;
|
||
|
||
function perRoundSec(card) {
|
||
return ((card && card.steps) || []).reduce(function (sum, s) {
|
||
return sum + Math.max(1, Number(s.durationSec) || 0);
|
||
}, 0);
|
||
}
|
||
|
||
function runnerTotalLoops() {
|
||
if (!runnerState) return null;
|
||
return runnerState.card.loopMode === 'repeat_count'
|
||
? Math.max(1, Number(runnerState.card.loopCount || 1))
|
||
: null;
|
||
}
|
||
|
||
/** 预计剩余总秒数:当前步骤剩余 + 本轮剩余步骤 + 剩余整轮 */
|
||
function computeRemainingSec(rs) {
|
||
const steps = rs.card.steps;
|
||
let total = Math.max(0, Number(rs.remainingSec) || 0);
|
||
for (let i = rs.currentStepIndex + 1; i < steps.length; i++) {
|
||
total += Math.max(1, Number(steps[i].durationSec) || 0);
|
||
}
|
||
const totalLoops = rs.card.loopMode === 'repeat_count'
|
||
? Math.max(1, Number(rs.card.loopCount || 1))
|
||
: null;
|
||
if (totalLoops !== null) {
|
||
const roundsLeft = Math.max(0, totalLoops - rs.currentLoop);
|
||
total += roundsLeft * perRoundSec(rs.card);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
function openRunner(session, card) {
|
||
unlockAudio(); // 用户手势中解锁音频,确保后续定时器触发的提示音能播放
|
||
const steps = (card && Array.isArray(card.steps)) ? card.steps : [];
|
||
if (!steps.length) {
|
||
showAlert('无法启动', '当前卡片没有可执行步骤');
|
||
return;
|
||
}
|
||
const startIndex = Math.min(Math.max(0, Number(session.currentStepIndex) || 0), steps.length - 1);
|
||
runnerState = {
|
||
sessionId: session.id,
|
||
card: card,
|
||
status: 'running',
|
||
currentLoop: Math.max(1, Number(session.currentLoop) || 1),
|
||
completedLoops: Math.max(0, Number(session.completedLoops) || 0),
|
||
currentStepIndex: startIndex,
|
||
remainingSec: Math.max(1, Number(steps[startIndex].durationSec) || 1),
|
||
manualStopped: false
|
||
};
|
||
runnerAnnounceKey = '';
|
||
runnerFinishing = false;
|
||
document.getElementById('runnerModal').style.display = 'flex';
|
||
requestWakeLock();
|
||
onRunnerStepChanged(true);
|
||
startRunnerTimer();
|
||
}
|
||
|
||
function closeRunner() {
|
||
stopRunnerTimer();
|
||
runnerState = null;
|
||
runnerAnnounceKey = '';
|
||
stopSpeech();
|
||
releaseWakeLock();
|
||
document.getElementById('runnerModal').style.display = 'none';
|
||
}
|
||
|
||
function startRunnerTimer() {
|
||
stopRunnerTimer();
|
||
runnerTimerId = setInterval(runnerTick, 1000);
|
||
}
|
||
|
||
function stopRunnerTimer() {
|
||
if (runnerTimerId) {
|
||
clearInterval(runnerTimerId);
|
||
runnerTimerId = null;
|
||
}
|
||
}
|
||
|
||
/** 每秒递减;5/3 秒播报;到 0 自动切下一步 */
|
||
function runnerTick() {
|
||
if (!runnerState || runnerState.status !== 'running' || runnerFinishing) return;
|
||
const step = runnerState.card.steps[runnerState.currentStepIndex];
|
||
const pitch = (step && step.type === 'action') ? 1.1 : 0.9;
|
||
if (runnerState.remainingSec === 5 || runnerState.remainingSec === 3) {
|
||
speakText('还有 ' + runnerState.remainingSec + ' 秒', pitch);
|
||
}
|
||
if (runnerState.remainingSec <= 1) {
|
||
advanceStep();
|
||
return;
|
||
}
|
||
runnerState.remainingSec -= 1;
|
||
updateRunnerDynamic();
|
||
}
|
||
|
||
/** 步骤推进:非末步→下一步;末步→下一轮或完成 */
|
||
function advanceStep() {
|
||
if (!runnerState || runnerFinishing) return;
|
||
const steps = runnerState.card.steps;
|
||
const isLastStep = runnerState.currentStepIndex >= steps.length - 1;
|
||
|
||
if (!isLastStep) {
|
||
runnerState.currentStepIndex += 1;
|
||
runnerState.remainingSec = Math.max(1, Number(steps[runnerState.currentStepIndex].durationSec) || 1);
|
||
onRunnerStepChanged(false);
|
||
return;
|
||
}
|
||
|
||
const nextCompletedLoops = runnerState.completedLoops + 1;
|
||
const totalLoops = runnerTotalLoops();
|
||
if (runnerState.card.loopMode === 'once' || (totalLoops !== null && nextCompletedLoops >= totalLoops)) {
|
||
void finishRunner('completed', false);
|
||
return;
|
||
}
|
||
|
||
runnerState.completedLoops = nextCompletedLoops;
|
||
runnerState.currentLoop += 1;
|
||
runnerState.currentStepIndex = 0;
|
||
runnerState.remainingSec = Math.max(1, Number(steps[0].durationSec) || 1);
|
||
onRunnerStepChanged(false);
|
||
}
|
||
|
||
/** 步骤切换统一入口:渲染 + 播报 + 提示音 + 振动 + 服务端同步 */
|
||
function onRunnerStepChanged(isFirst) {
|
||
renderRunner();
|
||
updateRunnerDynamic();
|
||
announceStep();
|
||
playBeep();
|
||
vibrate([50]);
|
||
if (!isFirst) syncRunnerSilent();
|
||
}
|
||
|
||
/** 静默同步进度到服务端(高频调用,不弹窗) */
|
||
function syncRunnerSilent() {
|
||
if (!runnerState) return Promise.resolve(null);
|
||
const rs = runnerState;
|
||
return runTrackedInteraction('同步执行进度', function () {
|
||
return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/sync', {
|
||
id: rs.sessionId,
|
||
status: rs.status,
|
||
currentLoop: rs.currentLoop,
|
||
completedLoops: rs.completedLoops,
|
||
currentStepIndex: rs.currentStepIndex,
|
||
manualStopped: rs.manualStopped
|
||
});
|
||
}, { silent: true });
|
||
}
|
||
|
||
async function finishRunner(status, manualStopped) {
|
||
if (!runnerState || runnerFinishing) return;
|
||
runnerFinishing = true;
|
||
const rs = runnerState;
|
||
stopRunnerTimer();
|
||
const body = {
|
||
id: rs.sessionId,
|
||
status: status,
|
||
currentLoop: rs.currentLoop,
|
||
completedLoops: rs.completedLoops,
|
||
currentStepIndex: rs.currentStepIndex,
|
||
manualStopped: !!manualStopped
|
||
};
|
||
const res = await runTrackedInteraction(
|
||
status === 'completed' ? '会话正常完成' : '手动结束会话',
|
||
function () {
|
||
return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/finish', body);
|
||
}
|
||
);
|
||
closeRunner();
|
||
runnerFinishing = false;
|
||
if (res.ok) {
|
||
showToast(status === 'completed' ? '流程已完成' : '已提前结束', 'success');
|
||
}
|
||
await refreshState(true);
|
||
updateStaleWarning();
|
||
if (currentView === 'motion' && currentTab === 'cards') {
|
||
await loadCards();
|
||
}
|
||
}
|
||
|
||
// ---- 执行器控制按钮 ----
|
||
|
||
async function handleRunnerPause() {
|
||
if (!runnerState || runnerFinishing) return;
|
||
vibrate([50]);
|
||
const pausing = runnerState.status === 'running';
|
||
runnerState.status = pausing ? 'paused' : 'running';
|
||
const container = document.getElementById('runnerContainer');
|
||
container.classList.toggle('is-paused', runnerState.status === 'paused');
|
||
const btn = document.getElementById('btnRunnerPause');
|
||
if (btn) {
|
||
const textEl = getButtonTextEl(btn);
|
||
textEl.textContent = runnerState.status === 'paused' ? '继续' : '暂停';
|
||
}
|
||
if (runnerState.status === 'paused') {
|
||
stopSpeech();
|
||
}
|
||
await syncRunnerSilent();
|
||
}
|
||
|
||
function handleRunnerSkip() {
|
||
if (!runnerState || runnerFinishing) return;
|
||
vibrate([50]);
|
||
advanceStep();
|
||
}
|
||
|
||
function handleRunnerStop() {
|
||
if (!runnerState || runnerFinishing) return;
|
||
vibrate([30]);
|
||
showConfirm('结束执行', '确定提前结束本次执行吗?这会记录为手动提前结束。', async function () {
|
||
closeDialog();
|
||
await finishRunner('stopped', true);
|
||
});
|
||
}
|
||
|
||
// ---- 执行器渲染 ----
|
||
|
||
function renderRunner() {
|
||
const rs = runnerState;
|
||
if (!rs) return;
|
||
const container = document.getElementById('runnerContainer');
|
||
const step = rs.card.steps[rs.currentStepIndex] || {};
|
||
const nextStep = rs.card.steps[rs.currentStepIndex + 1] || null;
|
||
const isAction = step.type !== 'rest';
|
||
const totalSteps = rs.card.steps.length;
|
||
const totalLoops = runnerTotalLoops();
|
||
const globalDurationSec = totalLoops !== null ? perRoundSec(rs.card) * totalLoops : null;
|
||
|
||
container.innerHTML = '' +
|
||
'<div class="runner-header">' +
|
||
'<span class="runner-badge ' + (isAction ? 'badge-action' : 'badge-rest') + '">' + (isAction ? '动作' : '休息') + '</span>' +
|
||
'<div class="runner-title">' + escHtml(rs.card.title) + '</div>' +
|
||
'<div class="runner-loop-text">第 ' + rs.currentLoop + ' 轮 ' + (totalLoops ? '/ 共 ' + totalLoops + ' 轮' : '/ 无限循环') + ' · 步骤 ' + (rs.currentStepIndex + 1) + ' / ' + totalSteps + '</div>' +
|
||
'</div>' +
|
||
'<div class="runner-progress"><div class="runner-progress-bar" id="runnerStepBar"></div></div>' +
|
||
'<div class="runner-split-layout">' +
|
||
'<div class="runner-col-left runner-main">' +
|
||
'<div class="runner-label">当前动作</div>' +
|
||
'<div class="runner-current ' + (isAction ? 'current-action' : 'current-rest') + '">' + escHtml(step.name || '—') + '</div>' +
|
||
'<div class="runner-timer" id="runnerTimer">--:--</div>' +
|
||
'<div class="runner-next-card">' +
|
||
'<div class="runner-label">下一个动作</div>' +
|
||
'<div class="runner-next">' + (nextStep ? escHtml(nextStep.name) : '本轮结束') + '</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="runner-col-right">' +
|
||
'<div class="runner-footer">' +
|
||
'<div class="runner-info-card">' +
|
||
'<div class="runner-info-row"><span class="runner-info-label">已执行</span><span class="runner-info-value">第 ' + rs.currentLoop + ' 轮,步骤 ' + (rs.currentStepIndex + 1) + '/' + totalSteps + '</span></div>' +
|
||
'<div class="runner-info-row"><span class="runner-info-label">预计剩余</span><span class="runner-info-value runner-info-highlight" id="runnerInfoRemaining">—</span></div>' +
|
||
'<div class="runner-info-row"><span class="runner-info-label">全局时长</span><span class="runner-info-value runner-info-highlight">' + (globalDurationSec !== null ? '约 ' + formatDuration(globalDurationSec) : '无限循环') + '</span></div>' +
|
||
'</div>' +
|
||
'<div class="runner-buttons">' +
|
||
'<button type="button" class="runner-btn" id="btnRunnerPause"><span class="btn-text">' + (rs.status === 'paused' ? '继续' : '暂停') + '</span></button>' +
|
||
'<button type="button" class="runner-btn" id="btnRunnerSkip"><span class="btn-text">跳过</span></button>' +
|
||
'<button type="button" class="runner-btn runner-btn-danger" id="btnRunnerStop"><span class="btn-text">结束</span></button>' +
|
||
'</div>' +
|
||
(globalDurationSec !== null && globalDurationSec > 0 ? (
|
||
'<div class="runner-global">' +
|
||
'<div class="runner-global-header"><span class="runner-global-label">执行进度</span><span class="runner-global-percent" id="runnerGlobalPercent">0%</span></div>' +
|
||
'<div class="runner-global-bar-wrap"><div class="runner-global-bar" id="runnerGlobalBar"></div></div>' +
|
||
'<div class="runner-global-stats">' +
|
||
'<span id="runnerGlobalExecuted">已执行 ' + formatDuration(0) + '</span>' +
|
||
'<span class="runner-global-divider">/</span>' +
|
||
'<span>总时长 ' + formatDuration(globalDurationSec) + '</span>' +
|
||
'<span class="runner-global-divider">·</span>' +
|
||
'<span class="runner-global-remaining" id="runnerGlobalRemaining">剩余 ' + formatDuration(0) + '</span>' +
|
||
'</div>' +
|
||
'</div>'
|
||
) : '') +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
container.classList.toggle('is-paused', rs.status === 'paused');
|
||
document.getElementById('btnRunnerPause').addEventListener('click', handleRunnerPause);
|
||
document.getElementById('btnRunnerSkip').addEventListener('click', handleRunnerSkip);
|
||
document.getElementById('btnRunnerStop').addEventListener('click', handleRunnerStop);
|
||
}
|
||
|
||
/** 每 tick 局部刷新:倒计时、进度条、预计剩余、全局进度 */
|
||
function updateRunnerDynamic() {
|
||
const rs = runnerState;
|
||
if (!rs) return;
|
||
const step = rs.card.steps[rs.currentStepIndex];
|
||
const stepDuration = Math.max(1, Number((step && step.durationSec) || 1));
|
||
const stepProgress = Math.max(0, Math.min(1, 1 - (rs.remainingSec / stepDuration)));
|
||
|
||
const timerEl = document.getElementById('runnerTimer');
|
||
if (timerEl) timerEl.textContent = formatDuration(rs.remainingSec);
|
||
const barEl = document.getElementById('runnerStepBar');
|
||
if (barEl) barEl.style.width = (stepProgress * 100).toFixed(2) + '%';
|
||
|
||
const remainingSec = computeRemainingSec(rs);
|
||
const remEl = document.getElementById('runnerInfoRemaining');
|
||
if (remEl) remEl.textContent = '约 ' + formatDuration(remainingSec);
|
||
|
||
const totalLoops = runnerTotalLoops();
|
||
const globalDurationSec = totalLoops !== null ? perRoundSec(rs.card) * totalLoops : null;
|
||
if (globalDurationSec !== null && globalDurationSec > 0) {
|
||
const executedSec = Math.max(0, globalDurationSec - remainingSec);
|
||
const progress = Math.max(0, Math.min(1, executedSec / globalDurationSec));
|
||
const pctEl = document.getElementById('runnerGlobalPercent');
|
||
if (pctEl) pctEl.textContent = Math.round(progress * 100) + '%';
|
||
const gBarEl = document.getElementById('runnerGlobalBar');
|
||
if (gBarEl) gBarEl.style.width = (progress * 100).toFixed(2) + '%';
|
||
const exEl = document.getElementById('runnerGlobalExecuted');
|
||
if (exEl) exEl.textContent = '已执行 ' + formatDuration(executedSec);
|
||
const reEl = document.getElementById('runnerGlobalRemaining');
|
||
if (reEl) reEl.textContent = '剩余 ' + formatDuration(remainingSec);
|
||
}
|
||
}
|
||
|
||
// ==================== 语音播报 ====================
|
||
// 双通道策略(解决"有时能读、有时不能读、安卓平板完全不能读"):
|
||
// 通道1 系统 TTS(Web Speech API):音质自然,iPhone 静音键下仍能发声,优先生效;
|
||
// 通道2 云端 mp3(服务端 DashScope 预合成):系统 TTS 无引擎 / 静默失败时兜底。
|
||
// 判定链路:进入 kit 播欢迎语探测 → 用户确认 → 失败切云端 → 两者皆不可用则显式预警。
|
||
// 去重:同一步骤(sessionId:loop:stepIndex)不重复播报。
|
||
|
||
const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!';
|
||
// 部分安卓 Chrome 无语音引擎时既不触发 onstart 也不触发 onerror,只能靠超时兜底
|
||
const SPEECH_PROBE_TIMEOUT_MS = 1200;
|
||
|
||
// 'unknown' 未判定 | 'system' 系统 TTS | 'cloud' 云端 mp3 | 'none' 两者皆不可用
|
||
let speechEngineMode = 'unknown';
|
||
let speechProbeDone = false;
|
||
let voiceWarned = false;
|
||
|
||
// 文本 → 云端 mp3 URL 映射,由 prepareCardTTS 预合成填充
|
||
let ttsUrlMap = {};
|
||
// 已预合成卡片的内容指纹(不是 card.id):编辑流程后内容变化即视为新内容,需重新预合成
|
||
let ttsPreparedCardKey = '';
|
||
|
||
// 云端音频播放器(单实例串行队列,避免多条播报叠在一起)
|
||
let speechAudioEl = null;
|
||
let cloudQueue = [];
|
||
let cloudQueueBusy = false;
|
||
|
||
function showVoiceWarning(msg) {
|
||
// 运动提醒宝与作息报时器各有一份预警横幅,统一在此处同步显示
|
||
[['voiceWarning', 'voiceWarningDesc'], ['schedVoiceWarning', 'schedVoiceWarningDesc']].forEach(function (pair) {
|
||
const el = document.getElementById(pair[0]);
|
||
if (!el) return;
|
||
const descEl = document.getElementById(pair[1]);
|
||
if (descEl && msg) descEl.textContent = msg;
|
||
el.style.display = '';
|
||
});
|
||
}
|
||
|
||
function hideVoiceWarning() {
|
||
['voiceWarning', 'schedVoiceWarning'].forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
/** 语音彻底不可用:显式预警,避免用户误以为正在静默播报 */
|
||
function markVoiceUnavailable(reason) {
|
||
speechEngineMode = 'none';
|
||
showVoiceWarning(reason || '本机无法播放语音,请检查系统语音引擎与音量设置。');
|
||
if (!voiceWarned) {
|
||
voiceWarned = true;
|
||
showToast('本机无法播放语音,请检查系统语音引擎或音量', 'error', 4000);
|
||
}
|
||
}
|
||
|
||
/** 系统 TTS 单句播报;用 onstart / onerror / 超时判断引擎是否真的启动 */
|
||
function speakSystem(text, pitch) {
|
||
return new Promise(function (resolve) {
|
||
let settled = false;
|
||
let timer = null;
|
||
const finish = function (ok, error) {
|
||
if (settled) return;
|
||
settled = true;
|
||
if (timer) clearTimeout(timer);
|
||
resolve({ ok: ok, error: error || '' });
|
||
};
|
||
try {
|
||
if (!('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') {
|
||
finish(false, 'unsupported');
|
||
return;
|
||
}
|
||
const synth = window.speechSynthesis;
|
||
try { synth.cancel(); } catch (e) { /* 静默 */ }
|
||
const utterance = new SpeechSynthesisUtterance(String(text || ''));
|
||
utterance.lang = 'zh-CN';
|
||
utterance.pitch = Number(pitch) || 1;
|
||
utterance.rate = 0.95;
|
||
timer = setTimeout(function () { finish(false, 'start-timeout'); }, SPEECH_PROBE_TIMEOUT_MS);
|
||
utterance.onstart = function () { finish(true, ''); };
|
||
utterance.onerror = function (ev) { finish(false, (ev && ev.error) || 'utterance-error'); };
|
||
// cancel() 紧跟 speak() 在 Chrome 上存在竞态会丢句,延后一拍再播
|
||
setTimeout(function () {
|
||
try { synth.speak(utterance); } catch (e) { finish(false, String((e && e.message) || e)); }
|
||
}, 60);
|
||
} catch (e) {
|
||
finish(false, String((e && e.message) || e));
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 云端 mp3 单条播放,Promise<boolean> */
|
||
function playCloudUrl(url) {
|
||
return new Promise(function (resolve) {
|
||
let settled = false;
|
||
const finish = function (ok) {
|
||
if (settled) return;
|
||
settled = true;
|
||
resolve(ok);
|
||
};
|
||
try {
|
||
if (speechAudioEl) { try { speechAudioEl.pause(); } catch (e) { /* 静默 */ } }
|
||
const audio = new Audio(url);
|
||
audio.preload = 'auto';
|
||
speechAudioEl = audio;
|
||
audio.onended = function () { finish(true); };
|
||
audio.onerror = function () { finish(false); };
|
||
const playPromise = audio.play();
|
||
// 自动播放策略拦截 / 静音键 / 解码失败:play() 会 reject
|
||
if (playPromise && typeof playPromise.catch === 'function') {
|
||
playPromise.catch(function () { finish(false); });
|
||
}
|
||
} catch (e) {
|
||
finish(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
function stopCloudSpeech() {
|
||
cloudQueue = [];
|
||
cloudQueueBusy = false;
|
||
if (speechAudioEl) {
|
||
try { speechAudioEl.pause(); } catch (e) { /* 静默 */ }
|
||
speechAudioEl = null;
|
||
}
|
||
}
|
||
|
||
function runCloudQueue() {
|
||
if (!cloudQueueBusy) return;
|
||
const url = cloudQueue.shift();
|
||
if (!url) {
|
||
cloudQueueBusy = false;
|
||
return;
|
||
}
|
||
playCloudUrl(url).then(function (ok) {
|
||
if (!ok) {
|
||
cloudQueueBusy = false;
|
||
markVoiceUnavailable('云端语音播放失败,请检查音量与静音键。');
|
||
return;
|
||
}
|
||
runCloudQueue();
|
||
});
|
||
}
|
||
|
||
/** 顺序播放一批云端 mp3;返回是否存在可播的缓存音频。
|
||
* map 可选:作息报时器使用自己的预合成映射,缺省时用运动卡片的映射 */
|
||
function playCloudAtoms(atoms, map) {
|
||
const src = map || ttsUrlMap;
|
||
const list = (Array.isArray(atoms) ? atoms : [atoms])
|
||
.map(function (t) { return String(t || '').trim(); })
|
||
.filter(Boolean);
|
||
const urls = list.map(function (t) { return src[t]; }).filter(Boolean);
|
||
if (!urls.length) return false;
|
||
stopCloudSpeech();
|
||
cloudQueue = urls.slice();
|
||
cloudQueueBusy = true;
|
||
runCloudQueue();
|
||
return true;
|
||
}
|
||
|
||
/** 统一播报入口:优先系统 TTS,静默失败时自动降级到云端 mp3。
|
||
* map 可选:指定云端 mp3 的文本→URL 映射(作息报时器传入自己的映射) */
|
||
function speakText(text, pitch, map) {
|
||
const t = String(text || '').trim();
|
||
if (!t) return;
|
||
if (speechEngineMode === 'cloud' && playCloudAtoms(t, map)) return;
|
||
speakSystem(t, pitch).then(function (r) {
|
||
if (r.ok) return;
|
||
// 系统 TTS 本次失败:有云端音频就切过去,否则显式预警
|
||
if (playCloudAtoms(t, map)) {
|
||
if (speechEngineMode !== 'cloud') {
|
||
speechEngineMode = 'cloud';
|
||
showToast('已自动切换到云端语音播报', 'info', 2000);
|
||
}
|
||
return;
|
||
}
|
||
markVoiceUnavailable('系统语音与云端语音均不可用,请检查系统语音引擎或音量设置。');
|
||
});
|
||
}
|
||
|
||
/** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */
|
||
function unlockSpeech() {
|
||
try {
|
||
if (!('speechSynthesis' in window)) return;
|
||
const u = new SpeechSynthesisUtterance('');
|
||
u.volume = 0;
|
||
window.speechSynthesis.speak(u);
|
||
window.speechSynthesis.cancel();
|
||
} catch (e) { /* 静默 */ }
|
||
}
|
||
|
||
function stopSpeech() {
|
||
try {
|
||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||
} catch (e) { /* 静默 */ }
|
||
stopCloudSpeech();
|
||
}
|
||
|
||
/**
|
||
* 进入 kit 时的语音探测:用提示语本身当探测信号(无需另造探测音),
|
||
* 引擎是否启动由 onstart/onerror 判定,「喇叭有没有真响」由用户点按钮确认。
|
||
* 必须在用户手势上下文中调用;force=true 时忽略"已探测过"强制重测。
|
||
* probeText 可选:指定探测句(作息报时器传入自己的待机提示语)。
|
||
*/
|
||
function probeSpeech(force, probeText) {
|
||
if (speechProbeDone && !force) return;
|
||
speechProbeDone = true;
|
||
const text = String(probeText || WELCOME_TEXT).trim() || WELCOME_TEXT;
|
||
let answered = false;
|
||
const settle = function (mode, tip) {
|
||
if (answered) return;
|
||
answered = true;
|
||
speechEngineMode = mode;
|
||
// 必须收起探测弹窗:否则模态遮罩会一直盖住页面,导致后面所有按钮都点不动
|
||
closeDialog();
|
||
if (tip) showVoiceWarning(tip); else hideVoiceWarning();
|
||
};
|
||
speakSystem(text, 1).then(function (r) {
|
||
if (answered) return;
|
||
if (r.ok) return; // 引擎已启动,等用户确认是否真的听到
|
||
settle('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。');
|
||
});
|
||
showDialog('语音检查',
|
||
'正在播报提示语:<br><span class="voice-probe-text">' + escHtml(text) + '</span><br>请确认是否听到声音。',
|
||
[
|
||
{
|
||
text: '没听到',
|
||
style: 'default',
|
||
onPress: function () { settle('cloud', '已切换到云端语音兜底(需先启动一次流程完成预合成)。'); }
|
||
},
|
||
{
|
||
text: '听到了',
|
||
style: 'primary',
|
||
onPress: function () { settle('system', ''); }
|
||
}
|
||
]);
|
||
// 兜底:用户一直不点确认时自动收起弹窗,避免模态遮罩长期挡住页面
|
||
setTimeout(function () {
|
||
if (answered) return;
|
||
answered = true;
|
||
closeDialog();
|
||
}, 8000);
|
||
}
|
||
|
||
/** 卡片内容指纹:步骤/循环任一变化即视为新内容;与 card.id 无关 */
|
||
function cardTTSFingerprint(card) {
|
||
try {
|
||
return JSON.stringify({
|
||
steps: (card.steps || []).map(function (s) {
|
||
return [
|
||
String(s.name || ''),
|
||
Number(s.durationSec) || 0,
|
||
String(s.voiceText || ''),
|
||
String(s.type || '')
|
||
];
|
||
}),
|
||
loopMode: card.loopMode,
|
||
loopCount: card.loopCount
|
||
});
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
/** 会话启动时批量预合成整卡语音;失败不阻塞流程,运行期回退系统 TTS。force=true 用于手动重生 */
|
||
function prepareCardTTS(card, force) {
|
||
if (!card || !card.id) return Promise.resolve({ ok: false, error: 'no card' });
|
||
const key = cardTTSFingerprint(card);
|
||
if (!force && key && ttsPreparedCardKey === key) return Promise.resolve({ ok: true, skipped: true });
|
||
ttsPreparedCardKey = key;
|
||
const payload = {
|
||
card: {
|
||
id: card.id,
|
||
title: card.title,
|
||
steps: card.steps,
|
||
loopMode: card.loopMode,
|
||
loopCount: card.loopCount
|
||
}
|
||
};
|
||
return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/tts/prepare', payload).then(function (res) {
|
||
if (res.ok && res.data && res.data.map) {
|
||
Object.keys(res.data.map).forEach(function (text) { ttsUrlMap[text] = res.data.map[text]; });
|
||
console.log('[order_box tts] 预合成完成 ' + res.data.success + '/' + res.data.total);
|
||
if (speechEngineMode === 'cloud' && Object.keys(ttsUrlMap).length) hideVoiceWarning();
|
||
} else {
|
||
ttsPreparedCardKey = '';
|
||
console.warn('[order_box tts] 预合成失败:' + (res.error || 'unknown'));
|
||
}
|
||
return res;
|
||
}).catch(function (e) {
|
||
ttsPreparedCardKey = '';
|
||
console.warn('[order_box tts] 预合成异常:' + String((e && e.message) || e));
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
});
|
||
}
|
||
|
||
/** 步骤播报的最小原子单元,必须与后端 buildCardTTSBatch 的取词逐字一致(云端缓存查表依赖) */
|
||
function stepSpeechAtoms(loop, step, nextStep, loopMode) {
|
||
const atoms = [];
|
||
if (loopMode !== 'once') atoms.push('第' + loop + '轮');
|
||
atoms.push((step.voiceText && String(step.voiceText).trim())
|
||
? String(step.voiceText).trim()
|
||
: (step.name + ',' + formatSpeechDuration(step.durationSec)));
|
||
if (nextStep) atoms.push('下一步,' + (String(nextStep.name || '').trim() || '下一步'));
|
||
return atoms;
|
||
}
|
||
|
||
/** 步骤切换播报:"第N轮,{名称},{时长}。下一步,{nextName}" */
|
||
function announceStep() {
|
||
if (!runnerState) return;
|
||
const key = runnerState.sessionId + ':' + runnerState.currentLoop + ':' + runnerState.currentStepIndex;
|
||
if (runnerAnnounceKey === key) return; // 去重
|
||
runnerAnnounceKey = key;
|
||
|
||
const step = runnerState.card.steps[runnerState.currentStepIndex];
|
||
if (!step) return;
|
||
const nextStep = runnerState.card.steps[runnerState.currentStepIndex + 1] || null;
|
||
const pitch = step.type === 'action' ? 1.1 : 0.9;
|
||
const atoms = stepSpeechAtoms(runnerState.currentLoop, step, nextStep, runnerState.card.loopMode);
|
||
if (speechEngineMode === 'cloud' && playCloudAtoms(atoms)) return;
|
||
// 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气
|
||
const merged = atoms.map(function (t, i) {
|
||
return (i > 0 && t.indexOf('下一步') === 0) ? '。' + t : t;
|
||
}).join(',');
|
||
speakText(merged, pitch);
|
||
}
|
||
|
||
// ==================== 提示音 ====================
|
||
// Web Audio API:步骤切换时播放短促蜂鸣(200ms, 880Hz)
|
||
|
||
let audioCtx = null;
|
||
|
||
/** 在用户手势中调用,解锁 AudioContext(解决浏览器自动播放策略静音问题) */
|
||
function unlockAudio() {
|
||
try {
|
||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioContextCtor) return;
|
||
if (!audioCtx) audioCtx = new AudioContextCtor();
|
||
if (audioCtx.state === 'suspended') {
|
||
audioCtx.resume().catch(function () {});
|
||
}
|
||
} catch (e) { /* 不支持时静默 */ }
|
||
}
|
||
|
||
function playBeep() {
|
||
try {
|
||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioContextCtor) return;
|
||
if (!audioCtx) audioCtx = new AudioContextCtor();
|
||
// 如果仍处于 suspended(无用户手势解锁),跳过本次播放
|
||
if (audioCtx.state === 'suspended') return;
|
||
const osc = audioCtx.createOscillator();
|
||
const gain = audioCtx.createGain();
|
||
osc.type = 'sine';
|
||
osc.frequency.value = 880;
|
||
const now = audioCtx.currentTime;
|
||
gain.gain.setValueAtTime(0.0001, now);
|
||
gain.gain.exponentialRampToValueAtTime(0.22, now + 0.02);
|
||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2);
|
||
osc.connect(gain);
|
||
gain.connect(audioCtx.destination);
|
||
osc.start(now);
|
||
osc.stop(now + 0.2);
|
||
} catch (e) { /* 不支持时静默 */ }
|
||
}
|
||
|
||
/** 振动反馈(不支持时静默) */
|
||
function vibrate(pattern) {
|
||
try {
|
||
if (navigator.vibrate) navigator.vibrate(pattern);
|
||
} catch (e) { /* 静默 */ }
|
||
}
|
||
|
||
// ==================== 屏幕常亮 ====================
|
||
// Wake Lock API:执行期间保持屏幕不灭;页面恢复可见时自动重新申请
|
||
|
||
let wakeLockSentinel = null;
|
||
|
||
async function requestWakeLock() {
|
||
try {
|
||
if ('wakeLock' in navigator) {
|
||
wakeLockSentinel = await navigator.wakeLock.request('screen');
|
||
wakeLockSentinel.addEventListener('release', function () { wakeLockSentinel = null; });
|
||
}
|
||
} catch (e) { /* 不支持或权限受限时静默 */ }
|
||
}
|
||
|
||
function releaseWakeLock() {
|
||
try {
|
||
if (wakeLockSentinel) { void wakeLockSentinel.release(); }
|
||
} catch (e) { /* 静默 */ }
|
||
wakeLockSentinel = null;
|
||
}
|
||
|
||
function handleVisibilityChange() {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (runnerState) requestWakeLock();
|
||
// 恢复到前台立即补一次 tick:后台被冻结期间到点的条目在此刻被判定为补播或错过
|
||
if (currentView === 'schedule' && schedStandbyOn) {
|
||
requestWakeLock();
|
||
schedTick();
|
||
}
|
||
}
|
||
|
||
// ==================== 历史列表 ====================
|
||
|
||
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 : []);
|
||
}
|
||
|
||
// ==================== 作息报时器 ====================
|
||
// 与运动提醒宝并列的第二个 kit:按「时:分」绝对时刻触发播报。
|
||
// 到点后按「重复次数 × 间隔秒」循环播报,用户点「我知道了」即提前结束本条;
|
||
// 但下一条时刻不受影响,照常触发(互不干扰)。
|
||
// 待机播报引擎只在报时器页面上运行;页面被冻结期间到点的条目,在恢复前台时判定为错过。
|
||
|
||
const SCHED_STANDBY_TEXT = '作息报时器已开始待机';
|
||
const SCHED_WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']; // index 0 => 周一(ISO 1)
|
||
const SCHED_DEFAULT_REPEAT = 5;
|
||
const SCHED_DEFAULT_INTERVAL = 30;
|
||
// 超过该延迟才算「错过」:正常每秒 tick 的误差远小于此值,只用于识别页面被冻结的情况
|
||
const SCHED_MISSED_THRESHOLD_MS = 90 * 1000;
|
||
const SCHED_TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||
const SCHED_MAX_PREVIEW_ITEMS = 6;
|
||
|
||
let schedList = [];
|
||
let schedActiveId = null; // 当前选中的日程(待机用)
|
||
let schedStandbyOn = false;
|
||
let schedTickTimer = null;
|
||
let schedClockOffsetMs = 0; // 服务端时间 - 本机时间,抵消旧手机时钟漂移
|
||
let schedClockSynced = false;
|
||
let schedDayKey = ''; // 当前北京日期(跨天时重置触发标记)
|
||
let schedFiredKeys = {}; // 'YYYY-MM-DD|itemId' -> true
|
||
let schedRepeat = null; // 正在循环播报的那一条
|
||
let schedMissedList = [];
|
||
let schedTab = 'schedules';
|
||
let schedDraft = null;
|
||
let schedTtsUrlMap = {}; // 作息报时器独立的云端 mp3 映射
|
||
let schedTtsPreparedKey = '';
|
||
|
||
const schedPad2 = function (n) { return String(n).padStart(2, '0'); };
|
||
|
||
const SCHED_TRASH_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>';
|
||
|
||
/** 指定 epochMs 对应的北京时间字段(项目规则:时间一律按 Asia/Shanghai 处理) */
|
||
function beijingParts(epochMs) {
|
||
const out = {};
|
||
try {
|
||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: 'Asia/Shanghai', hour12: false,
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||
});
|
||
fmt.formatToParts(new Date(epochMs)).forEach(function (p) {
|
||
if (p.type !== 'literal') out[p.type] = p.value;
|
||
});
|
||
} catch (e) { /* 极端环境下退回本机时间 */ }
|
||
const year = out.year || String(new Date(epochMs).getFullYear());
|
||
const month = out.month || schedPad2(new Date(epochMs).getMonth() + 1);
|
||
const day = out.day || schedPad2(new Date(epochMs).getDate());
|
||
let hour = out.hour === undefined ? String(new Date(epochMs).getHours()) : out.hour;
|
||
if (hour === '24') hour = '00'; // 部分实现午夜返回 24
|
||
const minute = out.minute === undefined ? schedPad2(new Date(epochMs).getMinutes()) : out.minute;
|
||
const second = out.second === undefined ? schedPad2(new Date(epochMs).getSeconds()) : out.second;
|
||
const dateKey = year + '-' + month + '-' + day;
|
||
const dayIdx = new Date(dateKey + 'T00:00:00+08:00').getDay(); // 0=周日
|
||
return {
|
||
dateKey: dateKey,
|
||
weekday: dayIdx === 0 ? 7 : dayIdx,
|
||
hour: Number(hour),
|
||
minute: Number(minute),
|
||
second: Number(second),
|
||
hhmm: hour + ':' + minute
|
||
};
|
||
}
|
||
|
||
function schedNowMs() { return Date.now() + schedClockOffsetMs; }
|
||
|
||
/** 北京时间日期文案,如 "2026年9月30日星期三" */
|
||
function schedDateText(epochMs) {
|
||
try {
|
||
return new Date(epochMs).toLocaleDateString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||
});
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
function schedWeekdayText(weekdays) {
|
||
const list = Array.isArray(weekdays) ? weekdays.slice().sort(function (a, b) { return a - b; }) : [];
|
||
if (!list.length) return '未设置';
|
||
if (list.length === 7) return '每天';
|
||
if (list.join(',') === '1,2,3,4,5') return '工作日';
|
||
if (list.join(',') === '6,7') return '周末';
|
||
return list.map(function (w) { return '周' + (SCHED_WEEKDAY_LABELS[w - 1] || ''); }).join(' ');
|
||
}
|
||
|
||
function schedFormatCountdown(ms) {
|
||
const total = Math.max(0, Math.floor(ms / 1000));
|
||
const h = Math.floor(total / 3600);
|
||
const m = Math.floor((total % 3600) / 60);
|
||
const s = total % 60;
|
||
if (h > 0) return h + ' 小时 ' + m + ' 分钟';
|
||
if (m > 0) return m + ' 分钟 ' + s + ' 秒';
|
||
return s + ' 秒';
|
||
}
|
||
|
||
/** 时刻文案:"07:30" -> "7点30分"(必须与后端 tts.speechTimeText 逐字一致) */
|
||
function schedSpeechTimeText(time) {
|
||
const parts = String(time || '').split(':');
|
||
const h = Number(parts[0]);
|
||
const m = Number(parts[1]);
|
||
const safeH = Number.isFinite(h) ? h : 0;
|
||
const safeM = Number.isFinite(m) ? m : 0;
|
||
return safeH + '点' + (safeM > 0 ? safeM + '分' : '');
|
||
}
|
||
|
||
/** 单条时刻的播报整句(必须与后端 tts.itemSpeechText 逐字一致,云端缓存查表依赖) */
|
||
function schedItemSpeechText(item) {
|
||
const voiceText = String(item && item.voiceText || '').trim();
|
||
if (voiceText) return voiceText;
|
||
const name = String(item && item.name || '').trim() || '提醒';
|
||
return '现在是' + schedSpeechTimeText(item && item.time) + ',' + name;
|
||
}
|
||
|
||
/** 编辑器内单条时刻的播报提示文案(随输入实时更新) */
|
||
function schedItemHintText(item) {
|
||
return '到点播报「' + schedItemSpeechText(item) + '」,共 ' +
|
||
Number(item.repeatTimes) + ' 次,每 ' + Number(item.intervalSec) + ' 秒一次';
|
||
}
|
||
|
||
/** 某条目在指定日期对应的计划触发时刻(epochMs,按北京时间解析) */
|
||
function schedPlannedMs(dateKey, time) {
|
||
const ms = Date.parse(dateKey + 'T' + String(time || '') + ':00+08:00');
|
||
return Number.isFinite(ms) ? ms : NaN;
|
||
}
|
||
|
||
function schedCurrentSchedule() {
|
||
if (!schedActiveId) return null;
|
||
return schedList.find(function (s) { return s.id === schedActiveId; }) || null;
|
||
}
|
||
|
||
// ---------- 校时 ----------
|
||
async function syncSchedClock() {
|
||
const res = await runTrackedInteraction('校时(服务端时间)', function () {
|
||
return apiFetch('GET', '/api/app_order_box/kits/schedule_reminder/time');
|
||
}, { mode: 'view', silent: true });
|
||
if (res && res.ok && res.data && Number.isFinite(Number(res.data.epochMs))) {
|
||
schedClockOffsetMs = Number(res.data.epochMs) - Date.now();
|
||
schedClockSynced = true;
|
||
}
|
||
}
|
||
|
||
// ---------- 待机播报引擎 ----------
|
||
function startSchedTick() {
|
||
const p = beijingParts(schedNowMs());
|
||
if (schedDayKey !== p.dateKey) { schedDayKey = p.dateKey; schedFiredKeys = {}; }
|
||
schedTick();
|
||
if (schedTickTimer) return;
|
||
schedTickTimer = setInterval(schedTick, 1000);
|
||
}
|
||
|
||
function stopSchedTick() {
|
||
if (schedTickTimer) { clearInterval(schedTickTimer); schedTickTimer = null; }
|
||
}
|
||
|
||
function schedTick() {
|
||
const nowMs = schedNowMs();
|
||
const p = beijingParts(nowMs);
|
||
const clockEl = document.getElementById('schedClock');
|
||
if (clockEl) clockEl.textContent = p.hhmm + ':' + schedPad2(p.second);
|
||
const dateEl = document.getElementById('schedDate');
|
||
if (dateEl) dateEl.textContent = schedDateText(nowMs);
|
||
if (!schedStandbyOn) return;
|
||
// 跨天:重置当日触发标记
|
||
if (schedDayKey !== p.dateKey) { schedDayKey = p.dateKey; schedFiredKeys = {}; }
|
||
const schedule = schedCurrentSchedule();
|
||
if (!schedule || !schedule.enabled) return;
|
||
if (schedule.weekdays.indexOf(p.weekday) < 0) {
|
||
const nextEl = document.getElementById('schedNext');
|
||
if (nextEl) nextEl.textContent = '今天不是该日程的生效日(' + schedWeekdayText(schedule.weekdays) + ')';
|
||
return;
|
||
}
|
||
schedCheckDueItems(schedule, p, nowMs);
|
||
schedUpdateNext(schedule, p, nowMs);
|
||
}
|
||
|
||
function schedCheckDueItems(schedule, p, nowMs) {
|
||
const items = Array.isArray(schedule.items) ? schedule.items : [];
|
||
items.forEach(function (item) {
|
||
if (!item || item.enabled === false || !item.time) return;
|
||
const key = p.dateKey + '|' + item.id;
|
||
if (schedFiredKeys[key]) return;
|
||
const plannedMs = schedPlannedMs(p.dateKey, item.time);
|
||
if (!Number.isFinite(plannedMs)) return;
|
||
if (nowMs < plannedMs) return; // 还没到点
|
||
schedFiredKeys[key] = true;
|
||
if (nowMs - plannedMs > SCHED_MISSED_THRESHOLD_MS) {
|
||
// 判定为错过:只进提示条,不自动出声,避免页面恢复时突然炸响
|
||
schedPushMissed(schedule, item, plannedMs);
|
||
return;
|
||
}
|
||
fireSchedItem(schedule, item, plannedMs);
|
||
});
|
||
}
|
||
|
||
/** 启动待机时,把今天已过去较久的时刻直接标记为已完成,避免"一开就补播一堆" */
|
||
function markSchedPastItemsDone(schedule, p, nowMs) {
|
||
const items = Array.isArray(schedule.items) ? schedule.items : [];
|
||
items.forEach(function (item) {
|
||
if (!item || !item.time) return;
|
||
const plannedMs = schedPlannedMs(p.dateKey, item.time);
|
||
if (!Number.isFinite(plannedMs)) return;
|
||
if (nowMs - plannedMs > SCHED_MISSED_THRESHOLD_MS) {
|
||
schedFiredKeys[p.dateKey + '|' + item.id] = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
function schedUpdateNext(schedule, p, nowMs) {
|
||
const el = document.getElementById('schedNext');
|
||
if (!el) return;
|
||
const items = (Array.isArray(schedule.items) ? schedule.items : [])
|
||
.filter(function (i) { return i && i.enabled !== false && i.time; });
|
||
let next = null;
|
||
for (let i = 0; i < items.length; i++) {
|
||
const plannedMs = schedPlannedMs(p.dateKey, items[i].time);
|
||
if (!Number.isFinite(plannedMs)) continue;
|
||
if (nowMs < plannedMs) { next = { item: items[i], plannedMs: plannedMs }; break; }
|
||
}
|
||
if (!next) { el.textContent = '今天没有后续时刻了'; return; }
|
||
el.textContent = '下一条:' + next.item.time + ' ' + (next.item.name || '') +
|
||
' · 还有 ' + schedFormatCountdown(next.plannedMs - nowMs);
|
||
}
|
||
|
||
// ---------- 到点触发 + 循环播报 ----------
|
||
function fireSchedItem(schedule, item, plannedMs) {
|
||
// 上一条尚未播完的重复直接让位,避免两条语音叠在一起(下一条时刻本身照常触发)
|
||
stopSchedRepeat();
|
||
const state = {
|
||
schedule: schedule,
|
||
item: item,
|
||
times: Math.max(1, Number(item.repeatTimes) || SCHED_DEFAULT_REPEAT),
|
||
intervalSec: Math.max(5, Number(item.intervalSec) || SCHED_DEFAULT_INTERVAL),
|
||
done: 1,
|
||
logId: 0,
|
||
timerId: null,
|
||
text: schedItemSpeechText(item)
|
||
};
|
||
schedRepeat = state;
|
||
|
||
postSchedLog(schedule, item, plannedMs, 'fired').then(function (logId) {
|
||
if (schedRepeat === state) state.logId = logId;
|
||
});
|
||
|
||
showSchedAlert(item);
|
||
updateSchedAlertRepeat(state);
|
||
speakText(state.text, 1, schedTtsUrlMap);
|
||
if (state.done >= state.times) {
|
||
schedFinishRepeat(state);
|
||
return;
|
||
}
|
||
state.timerId = setTimeout(function () { schedRepeatNext(state); }, state.intervalSec * 1000);
|
||
}
|
||
|
||
function schedRepeatNext(state) {
|
||
if (schedRepeat !== state) return;
|
||
state.done += 1;
|
||
updateSchedAlertRepeat(state);
|
||
speakText(state.text, 1, schedTtsUrlMap);
|
||
if (state.done >= state.times) { schedFinishRepeat(state); return; }
|
||
state.timerId = setTimeout(function () { schedRepeatNext(state); }, state.intervalSec * 1000);
|
||
}
|
||
|
||
/** 播满设定次数(用户未点确认):稍后自动收起弹窗,记录仍保持"已播报" */
|
||
function schedFinishRepeat(state) {
|
||
state.timerId = setTimeout(function () {
|
||
if (schedRepeat !== state) return;
|
||
schedRepeat = null;
|
||
hideSchedAlert();
|
||
}, 2000);
|
||
}
|
||
|
||
function stopSchedRepeat() {
|
||
const state = schedRepeat;
|
||
if (!state) return;
|
||
if (state.timerId) { clearTimeout(state.timerId); state.timerId = null; }
|
||
schedRepeat = null;
|
||
}
|
||
|
||
/** 用户点「我知道了」:提前结束本条重复,并回写日志为已确认 */
|
||
function ackSchedRepeat() {
|
||
const state = schedRepeat;
|
||
stopSchedRepeat();
|
||
hideSchedAlert();
|
||
stopSpeech();
|
||
if (state && state.logId) {
|
||
apiFetch('POST', '/api/app_order_box/kits/schedule_reminder/logs/ack', { id: state.logId })
|
||
.then(function (res) {
|
||
if (!res || !res.ok) console.warn('[order_box schedule] 确认回写失败:' + ((res && res.error) || 'unknown'));
|
||
})
|
||
.catch(function () { /* 静默 */ });
|
||
}
|
||
if (state) showToast('已确认「' + (state.item.name || '') + '」', 'success', 2000);
|
||
}
|
||
|
||
function showSchedAlert(item) {
|
||
const modal = document.getElementById('schedAlertModal');
|
||
const timeEl = document.getElementById('schedAlertTime');
|
||
const nameEl = document.getElementById('schedAlertName');
|
||
if (timeEl) timeEl.textContent = String(item.time || '--:--');
|
||
if (nameEl) nameEl.textContent = String(item.name || '');
|
||
if (modal) modal.style.display = 'flex';
|
||
requestWakeLock();
|
||
}
|
||
|
||
function hideSchedAlert() {
|
||
const modal = document.getElementById('schedAlertModal');
|
||
if (modal) modal.style.display = 'none';
|
||
}
|
||
|
||
function updateSchedAlertRepeat(state) {
|
||
const el = document.getElementById('schedAlertRepeat');
|
||
if (!el) return;
|
||
el.textContent = '第 ' + state.done + ' / ' + state.times + ' 次播报 · 每 ' + state.intervalSec + ' 秒一次';
|
||
}
|
||
|
||
// ---------- 播报日志 ----------
|
||
function postSchedLog(schedule, item, plannedMs, status) {
|
||
const body = {
|
||
scheduleId: schedule ? schedule.id : '',
|
||
scheduleTitle: schedule ? schedule.title : '',
|
||
itemId: item ? item.id : '',
|
||
itemName: item ? item.name : '',
|
||
plannedAt: Number.isFinite(plannedMs) ? new Date(plannedMs).toISOString() : '',
|
||
status: status
|
||
};
|
||
return apiFetch('POST', '/api/app_order_box/kits/schedule_reminder/logs', body).then(function (res) {
|
||
if (res && res.ok && res.data) return Number(res.data.id) || 0;
|
||
console.warn('[order_box schedule] 写入播报记录失败:' + ((res && res.error) || 'unknown'));
|
||
return 0;
|
||
}).catch(function () { return 0; });
|
||
}
|
||
|
||
// ---------- 错过显示条 ----------
|
||
function schedPushMissed(schedule, item, plannedMs) {
|
||
const key = (schedule ? schedule.id : '') + '|' + item.id + '|' + item.time;
|
||
if (!schedMissedList.some(function (m) { return m.key === key; })) {
|
||
schedMissedList.push({ key: key, time: String(item.time || ''), name: String(item.name || ''), plannedMs: plannedMs });
|
||
}
|
||
postSchedLog(schedule, item, plannedMs, 'missed');
|
||
renderSchedMissed();
|
||
}
|
||
|
||
function renderSchedMissed() {
|
||
const el = document.getElementById('schedMissed');
|
||
const descEl = document.getElementById('schedMissedDesc');
|
||
if (!el || !descEl) return;
|
||
if (!schedMissedList.length) { el.style.display = 'none'; descEl.innerHTML = ''; return; }
|
||
el.style.display = '';
|
||
descEl.innerHTML = schedMissedList.map(function (m) {
|
||
const late = Number.isFinite(m.plannedMs) ? ('(已过 ' + schedFormatCountdown(schedNowMs() - m.plannedMs) + ')') : '';
|
||
return '<div class="missed-line">' + escHtml(m.time) + ' ' + escHtml(m.name) + late + '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
function clearSchedMissed() {
|
||
schedMissedList = [];
|
||
renderSchedMissed();
|
||
}
|
||
|
||
// ---------- 待机开关 ----------
|
||
function startSchedStandby() {
|
||
const schedule = schedCurrentSchedule();
|
||
if (!schedule) { showAlert('无法待机', '请先新建并选择一套作息日程。'); return; }
|
||
if (!Array.isArray(schedule.items) || !schedule.items.length) {
|
||
showAlert('无法待机', '这套日程还没有任何时刻,请先编辑补充。');
|
||
return;
|
||
}
|
||
const nowMs = schedNowMs();
|
||
const p = beijingParts(nowMs);
|
||
schedDayKey = p.dateKey;
|
||
markSchedPastItemsDone(schedule, p, nowMs);
|
||
schedStandbyOn = true;
|
||
refreshSchedStandbyUI();
|
||
renderSchedules();
|
||
schedUpdateNext(schedule, p, nowMs);
|
||
requestWakeLock();
|
||
startSchedTick();
|
||
showToast('已开始待机,到点自动播报', 'success', 2000);
|
||
prepareScheduleTTS(schedule, false); // 预合成语音,不阻塞待机
|
||
}
|
||
|
||
function stopSchedStandby(reason) {
|
||
schedStandbyOn = false;
|
||
stopSchedRepeat();
|
||
hideSchedAlert();
|
||
stopSpeech();
|
||
refreshSchedStandbyUI();
|
||
renderSchedules();
|
||
if (reason) showToast(reason, 'info', 2000);
|
||
}
|
||
|
||
function toggleSchedStandby() {
|
||
if (!schedList.length) { showAlert('无法待机', '请先新建一套作息日程。'); return; }
|
||
if (schedStandbyOn) { stopSchedStandby('已停止待机'); return; }
|
||
if (!schedActiveId || !schedList.some(function (s) { return s.id === schedActiveId; })) {
|
||
schedActiveId = schedList[0].id;
|
||
}
|
||
startSchedStandby();
|
||
}
|
||
|
||
function selectSchedForStandby(schedule) {
|
||
if (schedStandbyOn && schedActiveId === schedule.id) { stopSchedStandby('已停止待机'); return; }
|
||
if (schedStandbyOn) { schedStandbyOn = false; stopSchedRepeat(); hideSchedAlert(); stopSpeech(); }
|
||
schedActiveId = schedule.id;
|
||
schedFiredKeys = {};
|
||
startSchedStandby();
|
||
}
|
||
|
||
function refreshSchedStandbyUI() {
|
||
const panel = document.getElementById('schedStandbyPanel');
|
||
const statusEl = document.getElementById('schedStatus');
|
||
const btn = document.getElementById('btnSchedStandby');
|
||
const schedule = schedCurrentSchedule();
|
||
if (panel) panel.classList.toggle('is-live', !!schedStandbyOn);
|
||
if (statusEl) {
|
||
if (!schedule) statusEl.textContent = '请先选择一套日程';
|
||
else if (!schedStandbyOn) statusEl.textContent = '未待机 · 当前日程「' + schedule.title + '」';
|
||
else statusEl.textContent = '待机中 · ' + schedule.title + '(' + schedWeekdayText(schedule.weekdays) + ')' +
|
||
(schedClockSynced ? '' : ' · 未校时');
|
||
}
|
||
if (btn) {
|
||
const textEl = getButtonTextEl(btn);
|
||
if (textEl) textEl.textContent = schedStandbyOn ? '停止待机' : '开始待机';
|
||
}
|
||
}
|
||
|
||
// ---------- 语音预合成(本 kit 独立映射) ----------
|
||
function schedFingerprint(schedule) {
|
||
try {
|
||
return JSON.stringify({
|
||
items: (schedule.items || []).map(function (i) {
|
||
return [String(i.time || ''), String(i.name || ''), String(i.voiceText || ''), i.enabled === false ? 0 : 1];
|
||
})
|
||
});
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
function prepareScheduleTTS(schedule, force) {
|
||
if (!schedule || !schedule.id) return Promise.resolve({ ok: false, error: 'no schedule' });
|
||
const key = schedFingerprint(schedule);
|
||
if (!force && key && schedTtsPreparedKey === key) return Promise.resolve({ ok: true, skipped: true });
|
||
schedTtsPreparedKey = key;
|
||
return apiFetch('POST', '/api/app_order_box/kits/schedule_reminder/tts/prepare', { scheduleId: schedule.id })
|
||
.then(function (res) {
|
||
if (res.ok && res.data && res.data.map) {
|
||
Object.keys(res.data.map).forEach(function (text) { schedTtsUrlMap[text] = res.data.map[text]; });
|
||
console.log('[order_box schedule tts] 预合成完成 ' + res.data.success + '/' + res.data.total);
|
||
if (speechEngineMode === 'cloud' && Object.keys(schedTtsUrlMap).length) hideVoiceWarning();
|
||
} else {
|
||
schedTtsPreparedKey = '';
|
||
console.warn('[order_box schedule tts] 预合成失败:' + (res.error || 'unknown'));
|
||
}
|
||
return res;
|
||
})
|
||
.catch(function (e) {
|
||
schedTtsPreparedKey = '';
|
||
console.warn('[order_box schedule tts] 预合成异常:' + String((e && e.message) || e));
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
});
|
||
}
|
||
|
||
async function handleRegenScheduleTTS(schedule, btn) {
|
||
if (btn) btn.disabled = true;
|
||
const res = await runTrackedInteraction('重生日程语音', function () {
|
||
return prepareScheduleTTS(schedule, true);
|
||
});
|
||
if (btn) btn.disabled = false;
|
||
if (res && res.ok) {
|
||
const d = res.data || {};
|
||
showToast('语音已重新生成(' + (d.success || 0) + '/' + (d.total || 0) + ')', 'success', 2000);
|
||
}
|
||
}
|
||
|
||
// ---------- 列表与 Tab ----------
|
||
function switchSchedTab(tabName) {
|
||
schedTab = (tabName === 'logs') ? 'logs' : 'schedules';
|
||
document.querySelectorAll('#schedTabBar .tab-item').forEach(function (el) {
|
||
el.classList.toggle('active', el.dataset.schedTab === schedTab);
|
||
});
|
||
document.getElementById('pageSchedules').style.display = (schedTab === 'schedules') ? '' : 'none';
|
||
document.getElementById('pageSchedLogs').style.display = (schedTab === 'logs') ? '' : 'none';
|
||
if (schedTab === 'schedules') loadSchedules();
|
||
else loadSchedLogs();
|
||
}
|
||
|
||
function renderSchedules() {
|
||
const listEl = document.getElementById('schedulesList');
|
||
const emptyEl = document.getElementById('schedulesEmpty');
|
||
if (!listEl || !emptyEl) return;
|
||
if (!schedList.length) {
|
||
listEl.innerHTML = '';
|
||
listEl.style.display = 'none';
|
||
emptyEl.style.display = '';
|
||
return;
|
||
}
|
||
emptyEl.style.display = 'none';
|
||
listEl.style.display = '';
|
||
listEl.innerHTML = schedList.map(function (schedule) {
|
||
const items = Array.isArray(schedule.items) ? schedule.items : [];
|
||
const previews = items.slice(0, SCHED_MAX_PREVIEW_ITEMS).map(function (i) {
|
||
return '<div class="sched-preview-line"><span class="sched-preview-time">' + escHtml(i.time) + '</span>' + escHtml(i.name) + '</div>';
|
||
}).join('');
|
||
const more = items.length > SCHED_MAX_PREVIEW_ITEMS
|
||
? '<div class="sched-preview-more">还有 ' + (items.length - SCHED_MAX_PREVIEW_ITEMS) + ' 个时刻…</div>'
|
||
: '';
|
||
const isActive = (schedActiveId === schedule.id);
|
||
const activeTag = isActive ? '<span class="sched-active-tag">' + (schedStandbyOn ? '待机中' : '已选中') + '</span>' : '';
|
||
const standbyLabel = (isActive && schedStandbyOn) ? '停止待机' : '用这套待机';
|
||
return '' +
|
||
'<div class="card-item" data-id="' + escHtml(schedule.id) + '">' +
|
||
'<div class="card-item-header">' +
|
||
'<div class="card-title">' + escHtml(schedule.title) + activeTag + '</div>' +
|
||
'</div>' +
|
||
'<div class="card-meta">' + schedWeekdayText(schedule.weekdays) + ' · ' + items.length + ' 个时刻</div>' +
|
||
'<div class="sched-item-preview">' + previews + more + '</div>' +
|
||
'<div class="card-actions">' +
|
||
'<button class="btn-card-primary" data-action="standby"><span class="btn-text">' + standbyLabel + '</span></button>' +
|
||
'<button class="btn-card-ghost" data-action="edit"><span class="btn-text">编辑</span></button>' +
|
||
'<button class="btn-card-ghost" data-action="regen"><span class="btn-text">重生语音</span></button>' +
|
||
'<button class="btn-card-ghost btn-text-danger" data-action="delete"><span class="btn-text">删除</span></button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
async function loadSchedules() {
|
||
const listEl = document.getElementById('schedulesList');
|
||
const emptyEl = document.getElementById('schedulesEmpty');
|
||
const loadingEl = document.getElementById('schedulesLoading');
|
||
if (loadingEl) loadingEl.style.display = '';
|
||
if (listEl) { listEl.style.display = 'none'; listEl.innerHTML = ''; }
|
||
if (emptyEl) emptyEl.style.display = 'none';
|
||
|
||
const res = await runTrackedInteraction('加载作息日程', function () {
|
||
return apiFetch('GET', '/api/app_order_box/kits/schedule_reminder/schedules');
|
||
}, { mode: 'view' });
|
||
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
if (!res.ok) {
|
||
showAlert('加载失败', escHtml(res.error || '作息日程加载失败'));
|
||
return;
|
||
}
|
||
schedList = Array.isArray(res.data) ? res.data : [];
|
||
if (schedActiveId && !schedList.some(function (s) { return s.id === schedActiveId; })) schedActiveId = null;
|
||
renderSchedules();
|
||
refreshSchedStandbyUI();
|
||
if (schedStandbyOn && !schedCurrentSchedule()) stopSchedStandby('日程已不存在,已停止待机');
|
||
}
|
||
|
||
function handleSchedulesListClick(e) {
|
||
const btn = e.target.closest ? e.target.closest('button') : null;
|
||
if (!btn) return;
|
||
const itemEl = btn.closest('.card-item');
|
||
if (!itemEl) return;
|
||
const schedule = schedList.find(function (s) { return s.id === itemEl.dataset.id; });
|
||
if (!schedule) return;
|
||
const action = btn.dataset.action;
|
||
if (action === 'standby') { selectSchedForStandby(schedule); return; }
|
||
if (action === 'edit') { openSchedEditor(schedule); return; }
|
||
if (action === 'regen') { handleRegenScheduleTTS(schedule, btn); return; }
|
||
if (action === 'delete') { handleDeleteSchedule(schedule); return; }
|
||
}
|
||
|
||
function handleDeleteSchedule(schedule) {
|
||
showConfirm('删除日程', '确定删除作息日程「' + escHtml(schedule.title) + '」吗?此操作不可撤销。', async function () {
|
||
closeDialog();
|
||
const res = await runTrackedInteraction('删除作息日程', function () {
|
||
return apiFetch('DELETE', '/api/app_order_box/kits/schedule_reminder/schedules?id=' + encodeURIComponent(schedule.id));
|
||
});
|
||
if (!res.ok) { showAlert('删除失败', escHtml(res.error || '删除失败')); return; }
|
||
if (schedActiveId === schedule.id) {
|
||
stopSchedStandby();
|
||
schedActiveId = null;
|
||
}
|
||
await loadSchedules();
|
||
showToast('已删除', 'success', 2000);
|
||
});
|
||
}
|
||
|
||
// ---------- 日程编辑器 ----------
|
||
function createDefaultSchedItem() {
|
||
return {
|
||
id: generateId(),
|
||
time: '07:30',
|
||
name: '起床',
|
||
voiceText: '',
|
||
repeatTimes: SCHED_DEFAULT_REPEAT,
|
||
intervalSec: SCHED_DEFAULT_INTERVAL,
|
||
enabled: true
|
||
};
|
||
}
|
||
|
||
function createDefaultSchedDraft() {
|
||
return {
|
||
id: '',
|
||
title: '',
|
||
weekdays: [1, 2, 3, 4, 5, 6, 7],
|
||
enabled: true,
|
||
items: [createDefaultSchedItem()]
|
||
};
|
||
}
|
||
|
||
function cloneSchedDraft(schedule) {
|
||
return {
|
||
id: schedule.id,
|
||
title: schedule.title,
|
||
weekdays: Array.isArray(schedule.weekdays) ? schedule.weekdays.slice() : [],
|
||
enabled: schedule.enabled !== false,
|
||
items: (Array.isArray(schedule.items) ? schedule.items : []).map(function (i) {
|
||
return {
|
||
id: i.id,
|
||
time: String(i.time || ''),
|
||
name: String(i.name || ''),
|
||
voiceText: String(i.voiceText || ''),
|
||
repeatTimes: Math.max(1, Number(i.repeatTimes) || SCHED_DEFAULT_REPEAT),
|
||
intervalSec: Math.max(5, Number(i.intervalSec) || SCHED_DEFAULT_INTERVAL),
|
||
enabled: i.enabled !== false
|
||
};
|
||
})
|
||
};
|
||
}
|
||
|
||
function openSchedEditor(schedule) {
|
||
schedDraft = schedule ? cloneSchedDraft(schedule) : createDefaultSchedDraft();
|
||
const titleEl = document.getElementById('schedEditorTitle');
|
||
if (titleEl) titleEl.textContent = schedule ? '编辑作息日程' : '新建作息日程';
|
||
renderSchedEditor();
|
||
const modal = document.getElementById('schedEditorModal');
|
||
if (modal) modal.style.display = 'flex';
|
||
}
|
||
|
||
function closeSchedEditor() {
|
||
const modal = document.getElementById('schedEditorModal');
|
||
if (modal) modal.style.display = 'none';
|
||
schedDraft = null;
|
||
}
|
||
|
||
function renderSchedEditorStats() {
|
||
const body = document.getElementById('schedEditorBody');
|
||
if (!body || !schedDraft) return;
|
||
const el = body.querySelector('#schedEditorStats');
|
||
if (!el) return;
|
||
const items = schedDraft.items
|
||
.filter(function (i) { return SCHED_TIME_RE.test(String(i.time || '')); })
|
||
.sort(function (a, b) { return String(a.time).localeCompare(String(b.time)); });
|
||
const range = items.length ? (items[0].time + ' ~ ' + items[items.length - 1].time) : '—';
|
||
el.innerHTML = '' +
|
||
'<div class="duration-info-row"><span class="duration-info-label">有效时刻</span>' +
|
||
'<span class="duration-info-value duration-info-highlight">' + items.length + ' 个</span></div>' +
|
||
'<div class="duration-info-row"><span class="duration-info-label">首 / 末</span>' +
|
||
'<span class="duration-info-value">' + escHtml(range) + '</span></div>' +
|
||
'<div class="duration-info-row"><span class="duration-info-label">生效星期</span>' +
|
||
'<span class="duration-info-value">' + escHtml(schedWeekdayText(schedDraft.weekdays)) + '</span></div>';
|
||
}
|
||
|
||
/** 星期选择 chip 的文案:生效的加 "√" 前缀,避免只靠底色判断选中态 */
|
||
function schedWeekdayChipLabel(weekday, active) {
|
||
return (active ? '√' : '') + '周' + (SCHED_WEEKDAY_LABELS[weekday - 1] || '');
|
||
}
|
||
|
||
function renderSchedEditor() {
|
||
const body = document.getElementById('schedEditorBody');
|
||
if (!body || !schedDraft) return;
|
||
const d = schedDraft;
|
||
let weekdaysHtml = '';
|
||
for (let w = 1; w <= 7; w += 1) {
|
||
const active = d.weekdays.indexOf(w) >= 0;
|
||
weekdaysHtml += '<button type="button" class="weekday-chip' + (active ? ' active' : '') + '" data-weekday="' + w + '">' +
|
||
escHtml(schedWeekdayChipLabel(w, active)) + '</button>';
|
||
}
|
||
|
||
const itemsHtml = d.items.map(function (item, index) {
|
||
const canRemove = d.items.length > 1;
|
||
return '' +
|
||
'<div class="sched-item-editor" data-item-id="' + escHtml(item.id) + '">' +
|
||
'<div class="step-editor-header">' +
|
||
'<div class="step-title-row">' +
|
||
'<span class="step-badge">时刻</span>' +
|
||
'<span class="step-index">#' + (index + 1) + '</span>' +
|
||
'</div>' +
|
||
'<div class="step-actions">' +
|
||
'<button type="button" class="btn-step-icon btn-step-remove' + (canRemove ? '' : ' disabled') + '" data-item-action="remove" title="删除该时刻">' +
|
||
SCHED_TRASH_SVG +
|
||
'</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<input type="time" class="editor-input sched-time-input" data-field="time" value="' + escHtml(item.time) + '">' +
|
||
'<input type="text" class="editor-input step-name-input" data-field="name" placeholder="事项名称,如:起床" value="' + escHtml(item.name) + '">' +
|
||
'<input type="text" class="editor-input step-name-input" data-field="voiceText" placeholder="语音文案(留空自动:现在是7点30分,起床)" value="' + escHtml(item.voiceText) + '">' +
|
||
'<div class="sched-item-row-2">' +
|
||
'<div class="sched-field">' +
|
||
'<label class="sched-field-label">重复次数(1~20)</label>' +
|
||
'<input type="number" class="editor-input sched-number-input" data-field="repeatTimes" min="1" max="20" value="' + Number(item.repeatTimes) + '">' +
|
||
'</div>' +
|
||
'<div class="sched-field">' +
|
||
'<label class="sched-field-label">间隔秒数(5~600)</label>' +
|
||
'<input type="number" class="editor-input sched-number-input" data-field="intervalSec" min="5" max="600" value="' + Number(item.intervalSec) + '">' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="sched-item-hint">' + escHtml(schedItemHintText(item)) + '</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
body.innerHTML = '' +
|
||
'<div class="editor-field">' +
|
||
'<label class="editor-label">日程名称</label>' +
|
||
'<input type="text" class="editor-input" id="schedEditorTitleInput" data-field="title" placeholder="例如:周末在家" value="' + escHtml(d.title) + '">' +
|
||
'</div>' +
|
||
'<div class="editor-field">' +
|
||
'<label class="editor-label">生效星期</label>' +
|
||
'<div class="weekday-row">' + weekdaysHtml + '</div>' +
|
||
'</div>' +
|
||
'<div class="editor-steps-header">' +
|
||
'<h3 class="editor-section-title">时刻列表</h3>' +
|
||
'<div class="editor-steps-buttons">' +
|
||
'<button type="button" class="btn-small" id="btnSchedAddItem">+ 加时刻</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="editor-steps-list">' + itemsHtml + '</div>' +
|
||
'<div class="editor-duration-info" id="schedEditorStats"></div>' +
|
||
'<div class="editor-footer">' +
|
||
'<button class="btn-primary" id="btnSchedEditorSave"><span class="btn-text">保存日程</span></button>' +
|
||
'</div>';
|
||
|
||
renderSchedEditorStats();
|
||
}
|
||
|
||
function handleSchedEditorInput(e) {
|
||
const target = e.target;
|
||
if (!target || !target.dataset || !schedDraft) return;
|
||
const field = target.dataset.field;
|
||
if (!field) return;
|
||
if (field === 'title') { schedDraft.title = String(target.value || ''); return; }
|
||
const itemEl = target.closest ? target.closest('.sched-item-editor') : null;
|
||
if (!itemEl) return;
|
||
const item = schedDraft.items.find(function (i) { return i.id === itemEl.dataset.itemId; });
|
||
if (!item) return;
|
||
if (field === 'time') {
|
||
item.time = String(target.value || '');
|
||
} else if (field === 'repeatTimes') {
|
||
item.repeatTimes = Math.min(Math.max(1, Math.floor(Number(target.value) || 1)), 20);
|
||
} else if (field === 'intervalSec') {
|
||
item.intervalSec = Math.min(Math.max(5, Math.floor(Number(target.value) || 5)), 600);
|
||
} else if (field === 'name' || field === 'voiceText') {
|
||
item[field] = String(target.value || '');
|
||
}
|
||
// 只更新该条的播报提示与底部统计,不整体重渲染,避免打断连续输入
|
||
const hintEl = itemEl.querySelector('.sched-item-hint');
|
||
if (hintEl) hintEl.textContent = schedItemHintText(item);
|
||
renderSchedEditorStats();
|
||
}
|
||
|
||
function handleSchedEditorClick(e) {
|
||
const btn = e.target.closest ? e.target.closest('button') : null;
|
||
if (!btn || !schedDraft) return;
|
||
if (btn.dataset.weekday) {
|
||
const w = Number(btn.dataset.weekday);
|
||
const idx = schedDraft.weekdays.indexOf(w);
|
||
if (idx >= 0) schedDraft.weekdays.splice(idx, 1);
|
||
else schedDraft.weekdays.push(w);
|
||
schedDraft.weekdays.sort(function (a, b) { return a - b; });
|
||
// 只切换按钮样式与文字,不整体重渲染,避免打断用户正在输入的内容
|
||
const active = schedDraft.weekdays.indexOf(w) >= 0;
|
||
btn.classList.toggle('active', active);
|
||
btn.textContent = schedWeekdayChipLabel(w, active);
|
||
renderSchedEditorStats();
|
||
return;
|
||
}
|
||
if (btn.dataset.itemAction === 'remove') {
|
||
if (schedDraft.items.length <= 1) return;
|
||
const itemEl = btn.closest('.sched-item-editor');
|
||
if (!itemEl) return;
|
||
const idx = schedDraft.items.findIndex(function (i) { return i.id === itemEl.dataset.itemId; });
|
||
if (idx < 0) return;
|
||
schedDraft.items.splice(idx, 1);
|
||
renderSchedEditor();
|
||
return;
|
||
}
|
||
if (btn.id === 'btnSchedAddItem') {
|
||
const items = schedDraft.items;
|
||
let time = '09:00';
|
||
const last = items.length ? String(items[items.length - 1].time || '') : '';
|
||
const m = SCHED_TIME_RE.exec(last);
|
||
if (m) {
|
||
const total = (Number(m[1]) * 60 + Number(m[2]) + 60) % (24 * 60);
|
||
time = schedPad2(Math.floor(total / 60)) + ':' + schedPad2(total % 60);
|
||
}
|
||
items.push({
|
||
id: generateId(), time: time, name: '新事项', voiceText: '',
|
||
repeatTimes: SCHED_DEFAULT_REPEAT, intervalSec: SCHED_DEFAULT_INTERVAL, enabled: true
|
||
});
|
||
renderSchedEditor();
|
||
return;
|
||
}
|
||
if (btn.id === 'btnSchedEditorSave') {
|
||
handleSchedEditorSave();
|
||
return;
|
||
}
|
||
}
|
||
|
||
async function handleSchedEditorSave() {
|
||
if (!schedDraft) return;
|
||
const saveBtn = document.getElementById('btnSchedEditorSave');
|
||
const title = String(schedDraft.title || '').trim();
|
||
if (!title) { showAlert('无法保存', '请填写日程名称。'); return; }
|
||
if (!schedDraft.weekdays.length) { showAlert('无法保存', '请至少选择一个生效星期。'); return; }
|
||
const items = schedDraft.items
|
||
.filter(function (i) { return SCHED_TIME_RE.test(String(i.time || '')); })
|
||
.map(function (i, index) {
|
||
return {
|
||
id: i.id,
|
||
time: String(i.time),
|
||
name: String(i.name || '').trim() || ('时刻' + (index + 1)),
|
||
voiceText: String(i.voiceText || '').trim(),
|
||
repeatTimes: Math.min(Math.max(1, Math.floor(Number(i.repeatTimes) || SCHED_DEFAULT_REPEAT)), 20),
|
||
intervalSec: Math.min(Math.max(5, Math.floor(Number(i.intervalSec) || SCHED_DEFAULT_INTERVAL)), 600),
|
||
enabled: true
|
||
};
|
||
})
|
||
.sort(function (a, b) { return String(a.time).localeCompare(String(b.time)); });
|
||
if (!items.length) { showAlert('无法保存', '请至少添加一个有效时刻(时:分 不能为空)。'); return; }
|
||
|
||
const isEdit = !!schedDraft.id;
|
||
const payload = {
|
||
id: schedDraft.id,
|
||
title: title,
|
||
weekdays: schedDraft.weekdays,
|
||
items: items,
|
||
enabled: true
|
||
};
|
||
|
||
setButtonLoading(saveBtn, '保存中…');
|
||
const res = await runTrackedInteraction(isEdit ? '更新作息日程' : '新建作息日程', function () {
|
||
return apiFetch(isEdit ? 'PUT' : 'POST', '/api/app_order_box/kits/schedule_reminder/schedules', payload);
|
||
});
|
||
resetButton(saveBtn);
|
||
if (!res.ok) { showAlert('保存失败', escHtml(res.error || '请稍后重试')); return; }
|
||
|
||
closeSchedEditor();
|
||
await loadSchedules();
|
||
showToast(isEdit ? '日程已更新' : '日程已创建', 'success', 2000);
|
||
}
|
||
|
||
// ---------- 播报记录 ----------
|
||
function renderSchedLogs(list) {
|
||
const listEl = document.getElementById('schedLogsList');
|
||
const emptyEl = document.getElementById('schedLogsEmpty');
|
||
if (!listEl || !emptyEl) return;
|
||
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 status = String(item.status || '');
|
||
const statusText = status === 'acked' ? '已确认' : (status === 'missed' ? '错过' : '已播报');
|
||
const badgeClass = 'badge-' + (status === 'acked' ? 'acked' : (status === 'missed' ? 'missed' : 'fired'));
|
||
return '' +
|
||
'<div class="history-item">' +
|
||
'<div class="history-title">' + escHtml(item.itemName || '未知时刻') +
|
||
' <span class="sched-log-badge ' + badgeClass + '">' + statusText + '</span></div>' +
|
||
'<div class="history-line">日程:' + escHtml(item.scheduleTitle || '—') + '</div>' +
|
||
'<div class="history-line">计划时刻:' + formatDateTime(item.plannedAt) + '</div>' +
|
||
'<div class="history-line">触发时间:' + formatDateTime(item.firedAt) + '</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
async function loadSchedLogs() {
|
||
const listEl = document.getElementById('schedLogsList');
|
||
const emptyEl = document.getElementById('schedLogsEmpty');
|
||
const loadingEl = document.getElementById('schedLogsLoading');
|
||
if (loadingEl) loadingEl.style.display = '';
|
||
if (listEl) { listEl.style.display = 'none'; listEl.innerHTML = ''; }
|
||
if (emptyEl) emptyEl.style.display = 'none';
|
||
|
||
const res = await runTrackedInteraction('加载播报记录', function () {
|
||
return apiFetch('GET', '/api/app_order_box/kits/schedule_reminder/logs');
|
||
}, { mode: 'view' });
|
||
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
if (!res.ok) {
|
||
showAlert('加载失败', escHtml(res.error || '播报记录加载失败'));
|
||
return;
|
||
}
|
||
renderSchedLogs(Array.isArray(res.data) ? res.data : []);
|
||
}
|
||
|
||
// ---------- 进入 / 返回 ----------
|
||
async function handleEnterSchedule() {
|
||
const btn = document.getElementById('btnEnterSchedule');
|
||
// 必须在 await 之前(用户手势上下文中)解锁音频与语音
|
||
unlockAudio();
|
||
unlockSpeech();
|
||
setButtonLoading(btn, '进入中…');
|
||
const res = await runTrackedInteraction('设置激活 kit(schedule_reminder)', function () {
|
||
return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'schedule_reminder' });
|
||
});
|
||
if (!res.ok) {
|
||
resetButton(btn);
|
||
showAlert('进入失败', escHtml(res.error || '请稍后重试'));
|
||
return;
|
||
}
|
||
resetButton(btn);
|
||
await refreshState(true);
|
||
showView('schedule');
|
||
switchSchedTab('schedules');
|
||
await syncSchedClock();
|
||
startSchedTick();
|
||
renderSchedMissed();
|
||
// 交互详情弹窗会盖住探测弹窗,等它先收起再探测语音
|
||
setTimeout(function () { probeSpeech(false, SCHED_STANDBY_TEXT); }, getUiMode() === 'debug' ? 3200 : 2200);
|
||
}
|
||
|
||
function doBackHubFromSchedule() {
|
||
stopSchedTick();
|
||
showView('hub');
|
||
refreshState(true);
|
||
}
|
||
|
||
function handleBackHubFromSchedule() {
|
||
if (schedStandbyOn) {
|
||
showConfirm('停止待机', '返回框子会停止当前待机,之后的时刻将不再自动播报。确定返回吗?', function () {
|
||
closeDialog();
|
||
stopSchedStandby('已停止待机');
|
||
requestStateClearActiveKit().then(function () { doBackHubFromSchedule(); });
|
||
});
|
||
return;
|
||
}
|
||
requestStateClearActiveKit().then(function () { doBackHubFromSchedule(); });
|
||
}
|
||
|
||
function requestStateClearActiveKit() {
|
||
return runTrackedInteraction('返回框子(清除激活 kit)', function () {
|
||
return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: '' });
|
||
}, { mode: 'view' });
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
/** 键盘快捷键:执行器内 空格=暂停/继续,Esc=结束 */
|
||
function handleGlobalKeydown(e) {
|
||
if (!runnerState) return;
|
||
const tag = String((e.target && e.target.tagName) || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea') return;
|
||
if (e.code === 'Space') {
|
||
e.preventDefault();
|
||
handleRunnerPause();
|
||
} else if (e.key === 'Escape') {
|
||
handleRunnerStop();
|
||
}
|
||
}
|
||
|
||
function bindStaticEvents() {
|
||
document.getElementById('btnEnterMotion').addEventListener('click', handleEnterMotion);
|
||
document.getElementById('btnBackHub').addEventListener('click', handleBackHub);
|
||
document.getElementById('btnNewCard').addEventListener('click', function () { openEditor(null); });
|
||
document.getElementById('btnCleanStale').addEventListener('click', handleCleanStale);
|
||
// 逐元素判空绑定:任何一个元素缺失都不应该中断后续绑定(否则整页按钮会全部失效)
|
||
const retryVoiceEl = document.getElementById('btnRetryVoice');
|
||
if (retryVoiceEl) retryVoiceEl.addEventListener('click', function () {
|
||
// 按当前所在 kit 选择探测句:报时器用待机提示语,运动提醒宝用欢迎语
|
||
probeSpeech(true, currentView === 'schedule' ? SCHED_STANDBY_TEXT : WELCOME_TEXT);
|
||
});
|
||
document.getElementById('btnEditorClose').addEventListener('click', closeEditor);
|
||
document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction);
|
||
document.getElementById('cardsList').addEventListener('click', handleCardsListClick);
|
||
|
||
document.querySelectorAll('#motionTabBar .tab-item').forEach(function (el) {
|
||
el.addEventListener('click', function () { switchTab(el.dataset.tab); });
|
||
});
|
||
|
||
// 作息报时器:逐元素判空绑定,避免单个元素缺失中断整页事件注册
|
||
const bindClick = function (id, handler) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('click', handler);
|
||
};
|
||
bindClick('btnEnterSchedule', handleEnterSchedule);
|
||
bindClick('btnBackHubFromSchedule', handleBackHubFromSchedule);
|
||
bindClick('btnSchedStandby', toggleSchedStandby);
|
||
bindClick('btnNewSchedule', function () { openSchedEditor(null); });
|
||
bindClick('btnSchedEditorClose', closeSchedEditor);
|
||
bindClick('btnSchedAck', ackSchedRepeat);
|
||
bindClick('btnSchedMissedClear', clearSchedMissed);
|
||
bindClick('btnSchedRetryVoice', function () { probeSpeech(true, SCHED_STANDBY_TEXT); });
|
||
document.querySelectorAll('#schedTabBar .tab-item').forEach(function (el) {
|
||
el.addEventListener('click', function () { switchSchedTab(el.dataset.schedTab); });
|
||
});
|
||
const schedulesListEl = document.getElementById('schedulesList');
|
||
if (schedulesListEl) schedulesListEl.addEventListener('click', handleSchedulesListClick);
|
||
const schedEditorBodyEl = document.getElementById('schedEditorBody');
|
||
if (schedEditorBodyEl) {
|
||
schedEditorBodyEl.addEventListener('click', handleSchedEditorClick);
|
||
schedEditorBodyEl.addEventListener('input', handleSchedEditorInput);
|
||
}
|
||
|
||
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
|
||
};
|
||
})(); |