feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作 - 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数 - 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放 - 新增语音引擎探测与异常预警UI,覆盖web端与移动端 - 优化web端订单盒页面布局,修复卡片操作按钮换行问题 - 新增调试探针脚本、ESLint配置与相关lint脚本 - 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user