feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
@@ -0,0 +1,96 @@
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const RUNNER_CACHE_KEY = '@order_box_motion_runner_state';
export interface CachedRunnerState {
sessionId: string;
currentLoop: number;
completedLoops: number;
currentStepIndex: number;
remainingSec: number;
status: 'running' | 'paused';
pausedAt: string;
}
export interface UseMotionAppStateReturn {
pauseRunner: () => void;
resumeRunner: () => Promise<CachedRunnerState | null>;
clearCache: () => Promise<void>;
}
/**
* 监听 App 前后台切换,控制 Runner 暂停/恢复
* 进入后台时:暂停 Runner 并保存状态到 AsyncStorage
* 回到前台时:从 AsyncStorage 恢复状态
*/
export const useMotionAppState = (
onBackground: () => void,
onForeground: () => void
): UseMotionAppStateReturn => {
const appStateRef = useRef(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
const wasBackground = appStateRef.current.match(/inactive|background/);
const isForeground = nextAppState === 'active';
if (!wasBackground && nextAppState.match(/inactive|background/)) {
// 进入后台
onBackground();
}
if (wasBackground && isForeground) {
// 回到前台
onForeground();
}
appStateRef.current = nextAppState;
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription.remove();
}, [onBackground, onForeground]);
const pauseRunner = async () => {
// 由外部调用,负责把当前 runner 状态存入缓存
// 具体实现在 RunnerModal 中
};
const resumeRunner = async (): Promise<CachedRunnerState | null> => {
try {
const cached = await AsyncStorage.getItem(RUNNER_CACHE_KEY);
if (cached) {
return JSON.parse(cached);
}
} catch {}
return null;
};
const clearCache = async () => {
try {
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
} catch {}
};
return { pauseRunner, resumeRunner, clearCache };
};
/**
* 保存 Runner 状态到 AsyncStorage
*/
export const saveRunnerCache = async (state: CachedRunnerState): Promise<void> => {
try {
await AsyncStorage.setItem(RUNNER_CACHE_KEY, JSON.stringify(state));
} catch {}
};
/**
* 清除 Runner 缓存
*/
export const clearRunnerCache = async (): Promise<void> => {
try {
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
} catch {}
};
@@ -0,0 +1,241 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Speech from 'expo-speech';
import { motionReminderApi } from '../api';
import { MotionCard, SessionStatus } from '../types';
export interface RunnerState {
sessionId: string;
card: MotionCard;
status: SessionStatus;
currentLoop: number;
completedLoops: number;
currentStepIndex: number;
remainingSec: number;
manualStopped: boolean;
}
export interface UseRunnerReturn {
runner: RunnerState | null;
startSession: (card: MotionCard) => Promise<void>;
pauseSession: () => Promise<void>;
resumeSession: () => Promise<void>;
skipStep: () => void;
finishSession: (status: 'completed' | 'stopped', manualStopped: boolean) => Promise<void>;
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('');
const finishRunner = useCallback(async (status: 'completed' | 'stopped', manualStopped: boolean) => {
const currentRunner = runner;
if (!currentRunner) return;
try {
await motionReminderApi.finishSession({
id: currentRunner.sessionId,
status,
currentLoop: currentRunner.currentLoop,
completedLoops: currentRunner.completedLoops,
currentStepIndex: currentRunner.currentStepIndex,
manualStopped,
});
} catch (error) {
const message = error instanceof Error ? error.message : '结束失败';
console.error('[Runner] 同步失败:', message);
} finally {
Speech.stop();
setRunner(null);
announcementRef.current = '';
onSessionEnd?.();
}
}, [runner, onSessionEnd]);
const advanceRunner = useCallback(() => {
setRunner((prev) => {
if (!prev) return prev;
const steps = prev.card.steps;
const isLastStep = prev.currentStepIndex >= steps.length - 1;
if (!isLastStep) {
return {
...prev,
currentStepIndex: prev.currentStepIndex + 1,
remainingSec: Math.max(1, steps[prev.currentStepIndex + 1].durationSec || 0),
};
}
const nextCompletedLoops = prev.completedLoops + 1;
const totalLoops = prev.card.loopMode === 'repeat_count'
? Math.max(1, Number(prev.card.loopCount || 1))
: null;
if (prev.card.loopMode === 'once' || (totalLoops !== null && nextCompletedLoops >= totalLoops)) {
void finishRunner('completed', false);
return prev;
}
return {
...prev,
currentLoop: prev.currentLoop + 1,
completedLoops: nextCompletedLoops,
currentStepIndex: 0,
remainingSec: Math.max(1, steps[0].durationSec || 0),
};
});
}, [finishRunner]);
// 倒计时逻辑
useEffect(() => {
if (!runner || runner.status !== 'running') return;
const timer = setTimeout(() => {
const step = runner.card.steps[runner.currentStepIndex];
const isAction = step?.type === 'action';
const countdownPitch = isAction ? 1.1 : 0.9;
// 5/3 秒倒计时播报
if (runner.remainingSec === 5 || runner.remainingSec === 3) {
Speech.stop();
Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 });
}
if (runner.remainingSec <= 1) {
advanceRunner();
return;
}
setRunner((prev) => (prev ? { ...prev, remainingSec: prev.remainingSec - 1 } : prev));
}, 1000);
return () => clearTimeout(timer);
}, [runner, advanceRunner]);
// 语音播报逻辑
useEffect(() => {
if (!runner) {
announcementRef.current = '';
return;
}
const key = `${runner.sessionId}:${runner.currentLoop}:${runner.currentStepIndex}`;
if (announcementRef.current === key) return;
announcementRef.current = key;
const step = runner.card.steps[runner.currentStepIndex];
const nextStep = runner.card.steps[runner.currentStepIndex + 1] || null;
if (!step) return;
// 动作步骤用较高音调,休息步骤用较低音调
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,
});
// 同步到服务端
void motionReminderApi.syncSession({
id: runner.sessionId,
currentLoop: runner.currentLoop,
completedLoops: runner.completedLoops,
currentStepIndex: runner.currentStepIndex,
status: runner.status,
manualStopped: runner.manualStopped,
}).catch(() => {});
}, [runner]);
// 清理
useEffect(() => {
return () => {
Speech.stop();
};
}, []);
const startSession = async (card: MotionCard) => {
if (!card.steps.length) {
throw new Error('当前卡片没有可执行步骤');
}
const session = await motionReminderApi.startSession(card);
setRunner({
sessionId: session.id,
card: session.cardSnapshot,
status: 'running',
currentLoop: 1,
completedLoops: 0,
currentStepIndex: 0,
remainingSec: Math.max(1, session.cardSnapshot.steps[0]?.durationSec || 1),
manualStopped: false,
});
};
const pauseSession = async () => {
if (!runner) return;
setRunner({ ...runner, status: 'paused' });
try {
await motionReminderApi.syncSession({
id: runner.sessionId,
status: 'paused',
currentLoop: runner.currentLoop,
completedLoops: runner.completedLoops,
currentStepIndex: runner.currentStepIndex,
manualStopped: runner.manualStopped,
});
} catch {}
};
const resumeSession = async () => {
if (!runner) return;
setRunner({ ...runner, status: 'running' });
try {
await motionReminderApi.syncSession({
id: runner.sessionId,
status: 'running',
currentLoop: runner.currentLoop,
completedLoops: runner.completedLoops,
currentStepIndex: runner.currentStepIndex,
manualStopped: runner.manualStopped,
});
} catch {}
};
const skipStep = () => {
advanceRunner();
};
const syncToServer = async () => {
if (!runner) return;
await motionReminderApi.syncSession({
id: runner.sessionId,
currentLoop: runner.currentLoop,
completedLoops: runner.completedLoops,
currentStepIndex: runner.currentStepIndex,
status: runner.status,
manualStopped: runner.manualStopped,
});
};
return {
runner,
startSession,
pauseSession,
resumeSession,
skipStep,
finishSession: finishRunner,
syncToServer,
};
};