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
@@ -1,7 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Speech from 'expo-speech';
import { motionReminderApi } from '../api';
import { MotionCard, SessionStatus } from '../types';
import {
countdownAtom,
prepareCardTTS,
speakAtoms,
stepSpeechAtoms,
stopSpeech,
} from '../speech';
export interface RunnerState {
sessionId: string;
@@ -24,17 +30,6 @@ export interface UseRunnerReturn {
syncToServer: () => Promise<void>;
}
const formatSpeechDuration = (seconds: number) => {
const safe = Math.max(0, Math.floor(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}秒`);
if (!parts.length) parts.push('0秒');
return parts.join('');
};
export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
const [runner, setRunner] = useState<RunnerState | null>(null);
const announcementRef = useRef('');
@@ -55,7 +50,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
const message = error instanceof Error ? error.message : '结束失败';
console.error('[Runner] 同步失败:', message);
} finally {
Speech.stop();
void stopSpeech();
setRunner(null);
announcementRef.current = '';
onSessionEnd?.();
@@ -105,10 +100,9 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
const isAction = step?.type === 'action';
const countdownPitch = isAction ? 1.1 : 0.9;
// 5/3 秒倒计时播报
// 5/3 秒倒计时播报:文本与系统 TTS / 云端 mp3 查表共用同一原子
if (runner.remainingSec === 5 || runner.remainingSec === 3) {
Speech.stop();
Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 });
speakAtoms([countdownAtom(runner.remainingSec)], countdownPitch);
}
if (runner.remainingSec <= 1) {
@@ -138,15 +132,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
// 动作步骤用较高音调,休息步骤用较低音调
const stepPitch = step.type === 'action' ? 1.1 : 0.9;
const title = step.voiceText || `${step.name},${formatSpeechDuration(step.durationSec || 0)}`;
const nextText = nextStep ? `。下一步,${nextStep.name}` : '';
Speech.stop();
Speech.speak(`第 ${runner.currentLoop} 轮,${title}${nextText}`, {
language: 'zh-CN',
pitch: stepPitch,
rate: 0.95,
});
speakAtoms(stepSpeechAtoms(runner.currentLoop, step, nextStep, runner.card.loopMode), stepPitch);
// 同步到服务端
void motionReminderApi.syncSession({
@@ -162,7 +148,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
// 清理
useEffect(() => {
return () => {
Speech.stop();
void stopSpeech();
};
}, []);
@@ -170,6 +156,8 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
if (!card.steps.length) {
throw new Error('当前卡片没有可执行步骤');
}
// 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS)
void prepareCardTTS(card);
const session = await motionReminderApi.startSession(card);
setRunner({
sessionId: session.id,
@@ -238,4 +226,4 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
finishSession: finishRunner,
syncToServer,
};
};
};