feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能

- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
yangxiangyuan
2026-09-17 16:26:27 +08:00
parent d4bf3627fd
commit bbc7ca6523
29 changed files with 3228 additions and 163 deletions
+6
View File
@@ -51,6 +51,12 @@
<div class="stale-desc">如果这是上次异常退出遗留,可以先清理占用再重新启动。</div>
<button class="btn-stale-clean" id="btnCleanStale">清理占用</button>
</div>
<!-- 语音不可用预警 -->
<div id="voiceWarning" class="stale-warning voice-warning" style="display:none">
<div class="stale-title">语音播报可能不可用</div>
<div class="stale-desc" id="voiceWarningDesc">本机系统语音未启动,已尝试云端语音兜底。</div>
<button class="btn-stale-clean" id="btnRetryVoice">重新检测</button>
</div>
<!-- 新建按钮 -->
<div class="cards-toolbar">
<button class="btn-primary" id="btnNewCard">
+7 -1
View File
@@ -120,6 +120,12 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
.btn-stale-clean:hover { filter: brightness(1.08); }
.btn-stale-clean:active { transform: scale(0.96); }
/* 语音不可用预警:沿用 stale-warning 结构,改用错误色以区别于残留 session 警告 */
.voice-warning { border-color: var(--error); background: rgba(239,68,68,0.07); }
.voice-warning .stale-title { color: var(--error); }
.voice-warning .btn-stale-clean { background: var(--error); color: #fff; }
.voice-probe-text { color: var(--primary); font-weight: 600; }
/* ============================================================
卡片页
============================================================ */
@@ -154,7 +160,7 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
.step-preview-line { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
.step-preview-more { font-size: 13px; color: var(--text-disabled); font-style: italic; line-height: 1.6; }
.card-actions { display: flex; flex-direction: row; gap: 8px; margin-top: 12px; }
.card-actions { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
/* 卡片按钮 — JS 用 .btn-card-primary / .btn-card-ghost */
.btn-card-primary {
+306 -16
View File
@@ -490,6 +490,9 @@
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' });
@@ -503,6 +506,8 @@
await refreshState(true);
showView('motion');
switchTab('cards');
// 交互详情弹窗(debug 3s / normal 2s 自动关闭)会盖住探测弹窗,等它先收起再探测语音
setTimeout(function () { probeSpeech(); }, getUiMode() === 'debug' ? 3200 : 2200);
}
async function handleBackHub() {
@@ -560,6 +565,7 @@
'</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('');
@@ -600,6 +606,23 @@
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) {
@@ -630,6 +653,8 @@
// 必须在 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 });
@@ -1344,19 +1369,173 @@
}
// ==================== 语音播报 ====================
// Web Speech API:动作 pitch=1.1,休息 pitch=0.9,rate=0.95,lang=zh-CN
// 去重:同一步骤(sessionId:loop:stepIndex)不重复播报
// 双通道策略(解决"有时能读、有时不能读、安卓平板完全不能读"):
// 通道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) {
const el = document.getElementById('voiceWarning');
if (!el) return;
const descEl = document.getElementById('voiceWarningDesc');
if (descEl && msg) descEl.textContent = msg;
el.style.display = '';
}
function hideVoiceWarning() {
const el = document.getElementById('voiceWarning');
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;返回是否存在可播的缓存音频 */
function playCloudAtoms(atoms) {
const list = (Array.isArray(atoms) ? atoms : [atoms])
.map(function (t) { return String(t || '').trim(); })
.filter(Boolean);
const urls = list.map(function (t) { return ttsUrlMap[t]; }).filter(Boolean);
if (!urls.length) return false;
stopCloudSpeech();
cloudQueue = urls.slice();
cloudQueueBusy = true;
runCloudQueue();
return true;
}
/** 统一播报入口:优先系统 TTS,静默失败时自动降级到云端 mp3 */
function speakText(text, pitch) {
try {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-CN';
utterance.pitch = Number(pitch) || 1;
utterance.rate = 0.95;
window.speechSynthesis.speak(utterance);
} catch (e) { /* 不支持时静默 */ }
const t = String(text || '').trim();
if (!t) return;
if (speechEngineMode === 'cloud' && playCloudAtoms(t)) return;
speakSystem(t, pitch).then(function (r) {
if (r.ok) return;
// 系统 TTS 本次失败:有云端音频就切过去,否则显式预警
if (playCloudAtoms(t)) {
if (speechEngineMode !== 'cloud') {
speechEngineMode = 'cloud';
showToast('已自动切换到云端语音播报', 'info', 2000);
}
return;
}
markVoiceUnavailable('系统语音与云端语音均不可用,请检查系统语音引擎或音量设置。');
});
}
/** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */
@@ -1374,6 +1553,112 @@
try {
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
} catch (e) { /* 静默 */ }
stopCloudSpeech();
}
/**
* 进入运动 kit 时的语音探测:用欢迎语本身当探测信号(无需另造探测音),
* 引擎是否启动由 onstart/onerror 判定,「喇叭有没有真响」由用户点按钮确认。
* 必须在用户手势上下文中调用;force=true 时忽略"已探测过"强制重测。
*/
function probeSpeech(force) {
if (speechProbeDone && !force) return;
speechProbeDone = true;
let answered = false;
const settle = function (mode, tip) {
if (answered) return;
answered = true;
speechEngineMode = mode;
// 必须收起探测弹窗:否则模态遮罩会一直盖住页面,导致后面所有按钮都点不动
closeDialog();
if (tip) showVoiceWarning(tip); else hideVoiceWarning();
};
speakSystem(WELCOME_TEXT, 1).then(function (r) {
if (answered) return;
if (r.ok) return; // 引擎已启动,等用户确认是否真的听到
settle('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。');
});
showDialog('语音检查',
'正在播报欢迎语:<br><span class="voice-probe-text">' + escHtml(WELCOME_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}" */
@@ -1387,11 +1672,13 @@
if (!step) return;
const nextStep = runnerState.card.steps[runnerState.currentStepIndex + 1] || null;
const pitch = step.type === 'action' ? 1.1 : 0.9;
const title = (step.voiceText && String(step.voiceText).trim())
? String(step.voiceText).trim()
: (step.name + ',' + formatSpeechDuration(step.durationSec));
const nextText = nextStep ? '。下一步,' + nextStep.name : '';
speakText('第 ' + runnerState.currentLoop + ' 轮,' + title + nextText, pitch);
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);
}
// ==================== 提示音 ====================
@@ -1536,6 +1823,9 @@
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 () { probeSpeech(true); });
document.getElementById('btnEditorClose').addEventListener('click', closeEditor);
document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction);
document.getElementById('cardsList').addEventListener('click', handleCardsListClick);