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