feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user