Files
digital-pilates/app/checkin/index.tsx
richarjiang 5d09cc05dc feat: 更新文章功能和相关依赖
- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容
- 添加文章卡片组件,展示推荐文章的标题、封面和阅读量
- 更新文章服务,支持获取文章列表和根据 ID 获取文章详情
- 集成腾讯云 COS SDK,支持文件上传功能
- 优化打卡功能,支持按日期加载和展示打卡记录
- 更新相关依赖,确保项目兼容性和功能完整性
- 调整样式以适应新功能的展示和交互
2025-08-14 16:03:19 +08:00

326 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import type { CheckinExercise } from '@/store/checkinSlice';
import { getDailyCheckins, removeExercise, replaceExercises, setCurrentDate, syncCheckin, toggleExerciseCompleted } from '@/store/checkinSlice';
import { buildClassicalSession } from '@/utils/classicalSession';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
function formatDate(d: Date) {
const y = d.getFullYear();
const m = `${d.getMonth() + 1}`.padStart(2, '0');
const day = `${d.getDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
}
export default function CheckinHome() {
const dispatch = useAppDispatch();
const router = useRouter();
const params = useLocalSearchParams<{ date?: string }>();
const today = useMemo(() => formatDate(new Date()), []);
const checkin = useAppSelector((s) => (s as any).checkin);
const routeDateParam = typeof params?.date === 'string' && params.date ? params.date : undefined;
const currentDate: string = routeDateParam || (checkin?.currentDate as string) || today;
const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] });
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
console.log('CheckinHome render', {
currentDate,
routeDateParam,
itemsCount: record?.items?.length || 0,
rawCount: (record as any)?.raw?.length || 0,
});
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => {
// 初始化当前日期:路由参数优先,其次 store最后今天
if (currentDate && checkin?.currentDate !== currentDate) {
dispatch(setCurrentDate(currentDate));
}
// 仅当切换日期时获取一次,避免重复请求
if (currentDate && lastFetchedRef.current !== currentDate) {
lastFetchedRef.current = currentDate;
dispatch(getDailyCheckins(currentDate)).unwrap().catch((err: any) => {
Alert.alert('获取打卡失败', err?.message || '请稍后重试');
});
}
}, [dispatch, currentDate]);
const lastSyncSigRef = useRef<string>('');
useFocusEffect(
React.useCallback(() => {
// 仅当本地条目发生变更时才上报,避免反复刷写
const sig = JSON.stringify(record?.items || []);
if (record?.items && Array.isArray(record.items) && sig !== lastSyncSigRef.current) {
lastSyncSigRef.current = sig;
dispatch(syncCheckin({ date: currentDate, items: record.items as CheckinExercise[], note: record?.note }));
}
return () => { };
}, [dispatch, currentDate, record?.items, record?.note])
);
const [genVisible, setGenVisible] = useState(false);
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
const [genWithRests, setGenWithRests] = useState(true);
const [genWithNotes, setGenWithNotes] = useState(true);
const [genRest, setGenRest] = useState('30');
const onGenerate = () => {
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel });
dispatch(replaceExercises({ date: currentDate, items, note }));
dispatch(syncCheckin({ date: currentDate, items, note }));
setGenVisible(false);
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
};
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<View pointerEvents="none" style={styles.bgOrnaments}>
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentPrimary, top: -60, right: -60 }]} />
<View style={[styles.blob, { backgroundColor: colorTokens.ornamentAccent, bottom: -70, left: -70 }]} />
</View>
<HeaderBar title="每日打卡" onBack={() => router.back()} withSafeTop={false} transparent />
<View style={[styles.hero, { backgroundColor: colorTokens.heroSurfaceTint }]}>
<Text style={[styles.title, { color: colorTokens.text }]}>{currentDate}</Text>
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}></Text>
</View>
<View style={styles.actionRow}>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
onPress={() => router.push({ pathname: '/checkin/select', params: { date: currentDate } })}
>
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity>
<View style={{ height: 10 }} />
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colorTokens.primary }]}
onPress={() => setGenVisible(true)}
>
<Text style={[styles.secondaryBtnText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View>
<FlatList
data={(record?.items && record.items.length > 0)
? record.items
: ((record as any)?.raw || [])}
keyExtractor={(item, index) => (item?.key || item?.id || `${currentDate}_${index}`)}
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 20 }}
ListEmptyComponent={
<View style={[styles.emptyBox, { backgroundColor: colorTokens.card }]}>
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}></Text>
</View>
}
renderItem={({ item }) => {
// 若为后端原始项(无 key以标题/时间为卡片,禁用交互
const isRaw = !item?.key;
if (isRaw) {
const title = item?.title || '每日训练打卡';
const status = item?.status || '';
const startedAt = item?.startedAt ? new Date(item.startedAt).toLocaleString() : '';
return (
<View style={[styles.card, { backgroundColor: colorTokens.card }]}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{title}</Text>
{!!status && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{status}</Text>}
{!!startedAt && <Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{startedAt}</Text>}
</View>
</View>
);
}
const exercise = item as CheckinExercise;
const type = exercise.itemType ?? 'exercise';
const isRest = type === 'rest';
const isNote = type === 'note';
const cardStyle = [styles.card, { backgroundColor: colorTokens.card }];
if (isRest || isNote) {
return (
<View style={styles.inlineRow}>
<Ionicons name={isRest ? 'time-outline' : 'information-circle-outline'} size={14} color={colorTokens.textMuted} />
<View style={[styles.inlineBadge, isRest ? styles.inlineBadgeRest : styles.inlineBadgeNote, { borderColor: colorTokens.border }]}>
<Text style={[isNote ? styles.inlineTextItalic : styles.inlineText, { color: colorTokens.textMuted }]}>
{isRest ? `间隔休息 ${exercise.restSec ?? 30}s` : (exercise.note || '提示')}
</Text>
</View>
<TouchableOpacity
style={styles.inlineRemoveBtn}
onPress={() =>
Alert.alert('确认移除', '确定要移除该条目吗?', [
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => {
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
},
},
])
}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons name="close-outline" size={16} color={colorTokens.textMuted} />
</TouchableOpacity>
</View>
);
}
return (
<View style={cardStyle as any}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colorTokens.text }]}>{exercise.name}</Text>
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>{exercise.category}</Text>
{isNote && (
<Text style={[styles.cardMetaItalic, { color: colorTokens.textMuted }]}>{exercise.note || '提示'}</Text>
)}
{!isNote && (
<Text style={[styles.cardMeta, { color: colorTokens.textMuted }]}>
{isRest
? `建议休息 ${exercise.restSec ?? 30}s`
: `组数 ${exercise.sets}${exercise.reps ? ` · 每组 ${exercise.reps}` : ''}${exercise.durationSec ? ` · 每组 ${exercise.durationSec}s` : ''}`}
</Text>
)}
</View>
{type === 'exercise' && (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={exercise.completed ? '已完成,点击取消完成' : '未完成,点击标记完成'}
style={styles.doneIconBtn}
onPress={() => {
dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).map((it: CheckinExercise) =>
it.key === exercise.key ? { ...it, completed: !it.completed } : it
);
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
}}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Ionicons
name={exercise.completed ? 'checkmark-circle' : 'checkmark-circle-outline'}
size={24}
color={exercise.completed ? colorTokens.primary : colorTokens.textMuted}
/>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.removeBtn, { backgroundColor: colorTokens.border }]}
onPress={() =>
Alert.alert('确认移除', '确定要移除该动作吗?', [
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => {
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
},
},
])
}
>
<Text style={[styles.removeBtnText, { color: colorTokens.text }]}></Text>
</TouchableOpacity>
</View>
);
}}
/>
{/* 生成配置弹窗 */}
<Modal visible={genVisible} transparent animationType="fade" onRequestClose={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalOverlay} onPress={() => setGenVisible(false)}>
<TouchableOpacity activeOpacity={1} style={[styles.modalSheet, { backgroundColor: colorTokens.card }]} onPress={(e) => e.stopPropagation() as any}>
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalLabel, { color: colorTokens.textMuted }]}></Text>
<View style={styles.segmentedRow}>
{(['beginner', 'intermediate', 'advanced'] as const).map((lv) => (
<TouchableOpacity key={lv} style={[styles.segment, genLevel === lv && { backgroundColor: colorTokens.primary }]} onPress={() => setGenLevel(lv)}>
<Text style={[styles.segmentText, genLevel === lv && { color: colorTokens.onPrimary }]}>
{lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.switchRow}>
<Text style={[styles.switchLabel, { color: colorTokens.text }]}></Text>
<Switch value={genWithRests} onValueChange={setGenWithRests} />
</View>
<View style={styles.switchRow}>
<Text style={[styles.switchLabel, { color: colorTokens.text }]}></Text>
<Switch value={genWithNotes} onValueChange={setGenWithNotes} />
</View>
<View style={styles.inputRow}>
<Text style={[styles.switchLabel, { color: colorTokens.textMuted }]}></Text>
<TextInput value={genRest} onChangeText={setGenRest} keyboardType="number-pad" style={[styles.input, { borderColor: colorTokens.border, color: colorTokens.text }]} />
</View>
<View style={{ height: 8 }} />
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]} onPress={onGenerate}>
<Text style={[styles.primaryBtnText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
container: { flex: 1, backgroundColor: '#F7F8FA' },
header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 },
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
hero: { backgroundColor: 'rgba(187,242,70,0.18)', borderRadius: 16, padding: 14 },
title: { fontSize: 24, fontWeight: '800', color: '#111827' },
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
bgOrnaments: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
blob: { position: 'absolute', width: 260, height: 260, borderRadius: 999 },
blobPrimary: { backgroundColor: '#00000000' },
blobPurple: { backgroundColor: '#00000000' },
actionRow: { paddingHorizontal: 20, marginTop: 8 },
primaryBtn: { backgroundColor: '#111827', paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
secondaryBtnText: { fontWeight: '800' },
emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 },
emptyText: { color: '#6B7280' },
card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
cardTitle: { fontSize: 16, fontWeight: '800', color: '#111827' },
cardMeta: { marginTop: 4, fontSize: 12, color: '#6B7280' },
cardMetaItalic: { marginTop: 4, fontSize: 12, color: '#6B7280', fontStyle: 'italic' },
removeBtn: { backgroundColor: '#F3F4F6', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 },
removeBtnText: { color: '#111827', fontWeight: '700' },
doneIconBtn: { paddingHorizontal: 4, paddingVertical: 4, borderRadius: 16, marginRight: 8 },
inlineRow: { marginTop: 10, marginHorizontal: 20, flexDirection: 'row', alignItems: 'center' },
inlineBadge: { marginLeft: 6, borderWidth: 1, borderRadius: 999, paddingVertical: 6, paddingHorizontal: 10 },
inlineBadgeRest: { backgroundColor: '#F8FAFC' },
inlineBadgeNote: { backgroundColor: '#F9FAFB' },
inlineText: { fontSize: 12, fontWeight: '700' },
inlineTextItalic: { fontSize: 12, fontStyle: 'italic' },
inlineRemoveBtn: { marginLeft: 6, padding: 4, borderRadius: 999 },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'flex-end' },
modalSheet: { width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 24 },
modalTitle: { fontSize: 16, fontWeight: '800', marginBottom: 8 },
modalLabel: { fontSize: 12, marginBottom: 6 },
segmentedRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
segment: { flex: 1, borderRadius: 999, borderWidth: 1, borderColor: '#E5E7EB', paddingVertical: 8, alignItems: 'center' },
segmentText: { fontWeight: '700' },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 },
switchLabel: { fontWeight: '700' },
inputRow: { marginTop: 8 },
input: { height: 40, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 },
});