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,369 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { motionReminderApi } from '../api';
import { useRunner } from '../hooks/useRunner';
import { CardEditor } from '../components/CardEditor';
import { RunnerModal } from '../components/RunnerModal';
import { theme } from '../../../theme';
import { MotionCard, MotionSession, OrderBoxState } from '../../../types';
interface CardsScreenProps {
onExit: () => void;
serverState: OrderBoxState | null;
onRefresh: () => void;
}
const formatDuration = (seconds: number) => {
const safe = Math.max(0, Math.floor(seconds || 0));
const mm = Math.floor(safe / 60);
const ss = safe % 60;
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
};
const formatLoopText = (loopMode: string, loopCount: number | null) => {
if (loopMode === 'once') return '单次执行';
if (loopMode === 'repeat_forever') return '无限循环';
return `循环 ${Math.max(1, Number(loopCount || 1))} 次`;
};
export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, onRefresh }) => {
const [cards, setCards] = useState<MotionCard[]>([]);
const [loading, setLoading] = useState(true);
const [editorVisible, setEditorVisible] = useState(false);
const [editingCard, setEditingCard] = useState<MotionCard | null>(null);
const runner = useRunner(() => {
onRefresh();
});
const loadData = useCallback(async () => {
try {
const nextCards = await motionReminderApi.listCards();
setCards(nextCards);
} catch (error) {
const message = error instanceof Error ? error.message : '加载失败';
Alert.alert('加载失败', message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const handleRefresh = () => {
void loadData();
onRefresh();
};
const openCreateCard = () => {
setEditingCard(null);
setEditorVisible(true);
};
const openEditCard = (card: MotionCard) => {
setEditingCard(card);
setEditorVisible(true);
};
const deleteCard = (card: MotionCard) => {
Alert.alert('确认删除', `确定删除"${card.title}"吗?`, [
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
await motionReminderApi.deleteCard(card.id);
await loadData();
} catch (error) {
const message = error instanceof Error ? error.message : '删除失败';
Alert.alert('删除失败', message);
}
},
},
]);
};
const startCard = async (card: MotionCard) => {
if (!card.steps.length) {
Alert.alert('无法启动', '当前卡片没有可执行步骤');
return;
}
try {
await runner.startSession(card);
} catch (error) {
const message = error instanceof Error ? error.message : '启动失败';
Alert.alert('启动失败', message);
}
};
const clearServerRunningState = async () => {
try {
await import('../../../api').then(({ orderBoxApi }) => orderBoxApi.resetRunningState());
handleRefresh();
} catch (error) {
const message = error instanceof Error ? error.message : '清理失败';
Alert.alert('清理失败', message);
}
};
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={onExit}>
<Ionicons name="chevron-back" size={18} color={theme.colors.text} />
<Text style={styles.backButtonText}>返回框子</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.primaryButton} onPress={openCreateCard}>
<Ionicons name="add" size={18} color="#081018" />
<Text style={styles.primaryButtonText}>新建卡片</Text>
</TouchableOpacity>
</View>
{serverState?.runningSession && !runner.runner ? (
<View style={styles.warnCard}>
<Text style={styles.warnCardTitle}>检测到服务端仍保留一个运行中的 session</Text>
<Text style={styles.warnCardText}>如果这是上次异常退出遗留,可以先清理占用再重新启动。</Text>
<TouchableOpacity style={styles.secondaryButton} onPress={clearServerRunningState}>
<Text style={styles.secondaryButtonText}>清理占用</Text>
</TouchableOpacity>
</View>
) : null}
{loading ? (
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
) : (
<FlatList
data={cards}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => (
<View style={styles.cardItem}>
<View style={styles.cardItemHeader}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardMeta}>{formatLoopText(item.loopMode, item.loopCount)}</Text>
</View>
<Text style={styles.cardSubText}>{item.steps.length} 个步骤</Text>
<View style={styles.stepPreviewWrap}>
{item.steps.slice(0, 4).map((step, index) => (
<Text key={step.id} style={styles.stepPreviewText}>
{index + 1}. {step.name} · {formatDuration(step.durationSec || 0)}
</Text>
))}
{item.steps.length > 4 ? (
<Text style={styles.cardSubText}>还有 {item.steps.length - 4} 个步骤…</Text>
) : null}
</View>
<View style={styles.cardActions}>
<TouchableOpacity style={styles.primaryActionButton} onPress={() => startCard(item)}>
<Ionicons name="play" size={16} color="#081018" />
<Text style={styles.primaryActionButtonText}>启动</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ghostActionButton} onPress={() => openEditCard(item)}>
<Text style={styles.ghostActionButtonText}>编辑</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ghostActionButton} onPress={() => deleteCard(item)}>
<Text style={[styles.ghostActionButtonText, styles.dangerText]}>删除</Text>
</TouchableOpacity>
</View>
</View>
)}
ListEmptyComponent={
<View style={styles.emptyCard}>
<Text style={styles.emptyTitle}>还没有流程卡片</Text>
<Text style={styles.emptyText}>先建一个训练流程,再从卡片直接启动执行界面。</Text>
</View>
}
/>
)}
<CardEditor
visible={editorVisible}
initialCard={editingCard}
onSave={() => {
setEditorVisible(false);
handleRefresh();
}}
onClose={() => setEditorVisible(false)}
/>
<RunnerModal runner={runner} onClose={() => {}} />
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: theme.spacing.md,
paddingTop: theme.spacing.md,
gap: theme.spacing.sm,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing.xs,
},
backButtonText: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing.xs,
backgroundColor: theme.colors.primary,
paddingHorizontal: theme.spacing.md,
paddingVertical: 10,
borderRadius: theme.borderRadius.md,
},
primaryButtonText: {
...theme.typography.bodySmall,
color: '#081018',
fontWeight: '700',
},
warnCard: {
margin: theme.spacing.md,
backgroundColor: theme.colors.surfaceSoft,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.warning,
padding: theme.spacing.md,
gap: theme.spacing.sm,
},
warnCardTitle: {
...theme.typography.bodyLarge,
color: theme.colors.text,
},
warnCardText: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
lineHeight: 20,
},
secondaryButton: {
alignSelf: 'flex-start',
paddingHorizontal: theme.spacing.md,
paddingVertical: 10,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
},
secondaryButtonText: {
...theme.typography.bodySmall,
color: theme.colors.text,
fontWeight: '600',
},
loader: {
flex: 1,
justifyContent: 'center',
},
listContent: {
padding: theme.spacing.md,
gap: theme.spacing.md,
},
cardItem: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
padding: theme.spacing.md,
gap: theme.spacing.sm,
},
cardItemHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: theme.spacing.sm,
},
cardTitle: {
...theme.typography.h3,
color: theme.colors.text,
flex: 1,
},
cardMeta: {
...theme.typography.bodySmall,
color: theme.colors.primary,
},
cardSubText: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
},
stepPreviewWrap: {
gap: theme.spacing.xs,
},
stepPreviewText: {
...theme.typography.bodySmall,
color: theme.colors.text,
},
cardActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: theme.spacing.sm,
marginTop: theme.spacing.xs,
},
primaryActionButton: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing.xs,
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing.md,
paddingVertical: 10,
},
primaryActionButtonText: {
...theme.typography.bodySmall,
color: '#081018',
fontWeight: '700',
},
ghostActionButton: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing.md,
paddingVertical: 10,
},
ghostActionButtonText: {
...theme.typography.bodySmall,
color: theme.colors.text,
fontWeight: '600',
},
dangerText: {
color: theme.colors.error,
},
emptyCard: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
padding: theme.spacing.xl,
alignItems: 'center',
gap: theme.spacing.sm,
},
emptyTitle: {
...theme.typography.h3,
color: theme.colors.text,
},
emptyText: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
textAlign: 'center',
lineHeight: 20,
},
});
@@ -0,0 +1,130 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
StyleSheet,
Text,
View,
} from 'react-native';
import { motionReminderApi } from '../api';
import { theme } from '../../../theme';
import { MotionSession } from '../types';
interface HistoryScreenProps {
onRefresh: () => void;
}
const formatDateTime = (value?: string | null) => {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN');
};
export const HistoryScreen: React.FC<HistoryScreenProps> = ({ onRefresh }) => {
const [history, setHistory] = useState<MotionSession[]>([]);
const [loading, setLoading] = useState(true);
const loadData = useCallback(async () => {
try {
const nextHistory = await motionReminderApi.listHistory();
setHistory(nextHistory);
} catch (error) {
const message = error instanceof Error ? error.message : '加载失败';
Alert.alert('加载失败', message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const handleRefresh = () => {
void loadData();
onRefresh();
};
return (
<View style={styles.container}>
{loading ? (
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
) : (
<FlatList
data={history}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => (
<View style={styles.cardItem}>
<Text style={styles.cardTitle}>{item.cardSnapshot?.title || '未知流程'}</Text>
<Text style={styles.historyLine}>开始:{formatDateTime(item.startedAt)}</Text>
<Text style={styles.historyLine}>结束:{formatDateTime(item.endedAt)}</Text>
<Text style={styles.historyLine}>完成轮次:{Math.max(0, Number(item.completedLoops || 0))}</Text>
<Text style={styles.historyLine}>
结束方式:{item.manualStopped ? '手动提前结束' : item.status === 'completed' ? '正常完成' : '停止'}
</Text>
</View>
)}
ListEmptyComponent={
<View style={styles.emptyCard}>
<Text style={styles.emptyTitle}>还没有执行记录</Text>
<Text style={styles.emptyText}>从卡片页启动一次流程后,这里会出现历史记录。</Text>
</View>
}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
loader: {
flex: 1,
justifyContent: 'center',
},
listContent: {
padding: theme.spacing.md,
gap: theme.spacing.md,
},
cardItem: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
padding: theme.spacing.md,
gap: theme.spacing.sm,
},
cardTitle: {
...theme.typography.h3,
color: theme.colors.text,
},
historyLine: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
},
emptyCard: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
padding: theme.spacing.xl,
alignItems: 'center',
gap: theme.spacing.sm,
},
emptyTitle: {
...theme.typography.h3,
color: theme.colors.text,
},
emptyText: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
textAlign: 'center',
lineHeight: 20,
},
});