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
@@ -0,0 +1,349 @@
// 运动提醒宝(motion_reminder)语音播报:系统 TTS + 云端 mp3 双通道兜底
// 说明:项目 Rule 6 要求每个 tool 独立维护自己的第三方能力封装,
// 因此本文件不引用其他 kit 的语音实现,只依赖本 kit 的 ./api 与 expo-av / expo-speech。
// 场景:安卓设备无系统 TTS 引擎、引擎被回收、或系统 TTS 静默失败时,改播服务端预合成的 mp3。
// 语义与 Web 端 order_box.js 语音区保持一致:探测 → 预合成 → 运行期自动降级 → 不可用预警。
import { Audio } from 'expo-av';
import * as Speech from 'expo-speech';
import { API_BASE_URL, motionReminderApi } from './api';
import { MotionCard, MotionStep, TTSPrepareResult } from './types';
export type SpeechEngineMode = 'unknown' | 'system' | 'cloud' | 'none';
export interface SpeechState {
mode: SpeechEngineMode;
/** 非空即在卡片列表展示语音预警横幅 */
warning: string;
}
/** 欢迎语,同时承担「系统 TTS 是否真的出声」的探测信号;必须与服务端 tts.js 的 WELCOME_TEXT 逐字一致 */
export const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!';
// 系统语音引擎启动判定超时:超过该时间既没 onStart 也没 onError,视为引擎没起来
const PROBE_TIMEOUT_MS = 1200;
// 云端 mp3 单条播放的兜底上限,避免回调异常导致播放队列卡死
const CLOUD_PLAY_TIMEOUT_MS = 20000;
const formatSpeechDuration = (seconds: number | null | undefined) => {
const safe = Math.max(0, Math.floor(Number(seconds) || 0));
const mm = Math.floor(safe / 60);
const ss = safe % 60;
const parts: string[] = [];
if (mm > 0) parts.push(`${mm}分钟`);
if (ss > 0) parts.push(`${ss}秒`);
return parts.length ? parts.join('') : '0秒';
};
// ==================== 状态(供页面订阅展示预警) ====================
let speechState: SpeechState = { mode: 'unknown', warning: '' };
const stateListeners = new Set<(state: SpeechState) => void>();
const setSpeechState = (next: Partial<SpeechState>) => {
const merged = { ...speechState, ...next };
if (merged.mode === speechState.mode && merged.warning === speechState.warning) return;
speechState = merged;
stateListeners.forEach((listener) => {
try {
listener(speechState);
} catch {}
});
};
export const getSpeechState = (): SpeechState => speechState;
export const subscribeSpeechState = (listener: (state: SpeechState) => void) => {
stateListeners.add(listener);
return () => {
stateListeners.delete(listener);
};
};
export const setSpeechMode = (mode: SpeechEngineMode, warning = '') => setSpeechState({ mode, warning });
export const setSpeechWarning = (warning: string) => setSpeechState({ warning });
// ==================== 音频会话与云端播放 ====================
let audioModeConfigured = false;
/** iOS 静音键下也要能播云端 mp3,与系统 TTS 不受静音键影响的语义对齐 */
export const configureSpeechAudioMode = async () => {
if (audioModeConfigured) return;
audioModeConfigured = true;
try {
await Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
staysActiveInBackground: false,
shouldDuckAndroid: true,
});
} catch (error) {
console.warn('[order_box tts] 音频模式设置失败:', error);
}
};
let ttsUrlMap: Record<string, string> = {};
let activeSound: Audio.Sound | null = null;
let playToken = 0;
let cloudChain: Promise<void> = Promise.resolve();
let assetOriginWarned = false;
/** 服务端返回的是 /uploads/... 相对路径,播放器需要绝对 URL(外网走后端注入的 https 基址) */
const resolveAssetUrl = (url: string): string => {
const raw = String(url || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
const origin = API_BASE_URL.replace(/\/api\/app_order_box\/?$/i, '').replace(/\/+$/, '');
if (!/^https?:\/\//i.test(origin)) {
if (!assetOriginWarned) {
assetOriginWarned = true;
console.warn('[order_box tts] EXPO_PUBLIC_API_BASE_URL 不是绝对地址,云端语音无法播放,将回退系统 TTS');
}
return '';
}
return origin + (raw.startsWith('/') ? raw : `/${raw}`);
};
const enqueueCloud = (task: () => Promise<void>) => {
const run = cloudChain.then(task, task);
cloudChain = run.catch(() => {});
return run;
};
const unloadActiveSound = async () => {
const sound = activeSound;
activeSound = null;
if (!sound) return;
try {
await sound.stopAsync();
} catch {}
try {
await sound.unloadAsync();
} catch {}
};
/** 云端 mp3 播放/加载失败的统一预警,与 Web 端一致:不静默失败 */
const notifyCloudPlayFailed = () =>
setSpeechWarning('云端语音播放失败,请检查网络后重试,或点「重新检测」。');
/** 播放一条 mp3 并等它播完;被 stopSpeech 打断时立即返回,避免队列卡住 */
const playOneUrl = (url: string, token: number) =>
new Promise<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve();
};
timer = setTimeout(finish, CLOUD_PLAY_TIMEOUT_MS);
Audio.Sound.createAsync(
{ uri: url },
{ shouldPlay: true },
(status) => {
if (token !== playToken) {
finish();
return;
}
if (!status.isLoaded) {
if (status.error) {
console.warn('[order_box tts] 云端语音播放失败:', status.error);
notifyCloudPlayFailed();
finish();
}
return;
}
if (status.didJustFinish) finish();
}
)
.then(({ sound }) => {
if (token !== playToken) {
void sound.unloadAsync().catch(() => {});
finish();
return;
}
activeSound = sound;
})
.catch((error) => {
console.warn('[order_box tts] 云端语音加载失败:', error);
notifyCloudPlayFailed();
finish();
});
});
/**
* 依次播放一组原子文本对应的 mp3。
* 全部原子都命中缓存才返回 true;只要有一条查不到就整体回退系统 TTS,避免只念半句。
*/
const playCloudAtoms = (atoms: string[]): boolean => {
const urls = atoms.map((text) => resolveAssetUrl(ttsUrlMap[text] || ''));
if (!urls.length || urls.some((url) => !url)) return false;
playToken += 1;
const token = playToken;
void unloadActiveSound().then(() => {
urls.forEach((url) => {
void enqueueCloud(() => (token === playToken ? playOneUrl(url, token) : Promise.resolve()));
});
});
return true;
};
// ==================== 系统 TTS ====================
/** 运行期系统 TTS 播报(不关心是否启动,失败由降级链兜底) */
export const speakText = (text: string, pitch = 1) => {
const content = String(text || '').trim();
if (!content) return;
try {
void Speech.stop();
} catch {}
try {
Speech.speak(content, { language: 'zh-CN', pitch, rate: 0.95 });
} catch (error) {
console.warn('[order_box tts] 系统语音播报异常:', error);
}
};
/** 带启动判定的系统 TTS:onStart 视为引擎已启动,onError / 超时视为没起来(探测专用) */
export const speakSystem = (text: string, pitch = 1): Promise<boolean> =>
new Promise((resolve) => {
const content = String(text || '').trim();
if (!content) {
resolve(false);
return;
}
let settled = false;
const settle = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
try {
void Speech.stop();
} catch {}
try {
Speech.speak(content, {
language: 'zh-CN',
pitch,
rate: 0.95,
onStart: () => settle(true),
onError: () => settle(false),
});
} catch {
settle(false);
return;
}
setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
});
/** 统一播报入口:云端通道优先(命中缓存时),否则回退系统 TTS */
export const speakAtoms = (atoms: string[], pitch = 1) => {
const list = atoms.map((text) => String(text || '').trim()).filter(Boolean);
if (!list.length) return;
if (speechState.mode === 'cloud') {
if (playCloudAtoms(list)) return;
if (!Object.keys(ttsUrlMap).length) {
setSpeechWarning('云端语音尚未预合成,请回卡片列表点「重生语音」后重试。');
}
}
// 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气
const merged = list.map((text, index) => (index > 0 && text.indexOf('下一步') === 0 ? `。${text}` : text)).join(',');
speakText(merged, pitch);
};
export const stopSpeech = async () => {
try {
await Speech.stop();
} catch {}
playToken += 1;
cloudChain = Promise.resolve();
await unloadActiveSound();
};
// ==================== 播报原子取词 ====================
/** 步骤播报的最小原子单元,必须与服务端 tts.js 的 buildCardTTSBatch 逐字一致(云端查表依赖) */
export const stepSpeechAtoms = (
loop: number,
step: MotionStep,
nextStep: MotionStep | null,
loopMode: string
): string[] => {
const atoms: string[] = [];
if (loopMode !== 'once') atoms.push(`第${loop}轮`);
const voiceText = String(step?.voiceText || '').trim();
atoms.push(voiceText || `${String(step?.name || '').trim() || '步骤'},${formatSpeechDuration(step?.durationSec)}`);
if (nextStep) atoms.push(`下一步,${String(nextStep.name || '').trim() || '下一步'}`);
return atoms;
};
/** 倒计时原子,必须与服务端 tts.js 的 '还有 5 秒' / '还有 3 秒' 逐字一致 */
export const countdownAtom = (seconds: number) => `还有 ${seconds} 秒`;
// ==================== 预合成与探测 ====================
let ttsPreparedCardKey = '';
/** 卡片内容指纹:步骤/循环任一变化即视为新内容,需重新预合成(与 card.id 无关) */
export const cardTTSFingerprint = (card: MotionCard): string => {
try {
return JSON.stringify({
steps: (card.steps || []).map((step) => [
String(step.name || ''),
Number(step.durationSec) || 0,
String(step.voiceText || ''),
String(step.type || ''),
]),
loopMode: card.loopMode,
loopCount: card.loopCount,
});
} catch {
return '';
}
};
/** 批量预合成整卡语音;force=true 用于卡片列表的「重生语音」手动补救 */
export const prepareCardTTS = async (card: MotionCard, force = false): Promise<TTSPrepareResult | null> => {
if (!card?.id) return null;
const key = cardTTSFingerprint(card);
if (!force && key && ttsPreparedCardKey === key) return null;
ttsPreparedCardKey = key;
try {
const result = await motionReminderApi.prepareTTS(card);
ttsUrlMap = { ...ttsUrlMap, ...(result.map || {}) };
console.log(`[order_box tts] 预合成完成 ${result.success}/${result.total}`);
if (speechState.mode === 'cloud' && Object.keys(ttsUrlMap).length) setSpeechWarning('');
return result;
} catch (error) {
ttsPreparedCardKey = '';
console.warn('[order_box tts] 预合成失败:', error);
return null;
}
};
let probeDone = false;
/**
* 系统 TTS 是否真的可用:欢迎语自身当探测信号,onStart/onError + 1200ms 超时判定引擎是否启动;
* 「喇叭有没有真响」由调用方弹窗让用户确认。force=true 忽略「已探测过」强制重测。
* 返回 true 表示引擎已启动(仍需用户听觉确认),false 表示引擎没起来(已自动切云端兜底)。
*/
export const probeSpeech = async (force = false): Promise<boolean> => {
if (probeDone && !force) return speechState.mode === 'system';
probeDone = true;
const started = await speakSystem(WELCOME_TEXT, 1);
if (started) return true;
setSpeechMode('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。');
return false;
};
/** 用户确认「听到了 / 没听到」后落定通道 */
export const confirmSpeechHeard = (heard: boolean) => {
if (heard) {
setSpeechMode('system', '');
return;
}
setSpeechMode('cloud', '系统语音未出声,已切换云端语音兜底(需先启动一次流程完成预合成)。');
};