轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

457 lines
12 KiB

import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useMemo, useState } from 'react';
import { useGameData } from '../../src/hooks';
import { colors , spacing } from '../../src/theme';
import type { GameAnswer, GameQuestion } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ title, onBack }: { title: string; onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>{title}</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 子组件:题目卡片 ===================== */
function QuestionCard({
index,
question,
answer,
onSelect,
onInput,
submitted,
}: {
index: number;
question: GameQuestion;
answer: GameAnswer | undefined;
onSelect: (questionId: string, optionId: string, type: GameQuestion['type']) => void;
onInput: (questionId: string, value: string) => void;
submitted: boolean;
}) {
return (
<View style={styles.questionCard}>
<Text style={styles.questionIndex}>题目 {index + 1}</Text>
<Text style={styles.questionTitle}>{question.title}</Text>
{question.type === 'input' ? (
<TextInput
style={styles.input}
placeholder={question.placeholder ?? '请输入答案'}
placeholderTextColor={colors.textHint}
value={answer?.inputValue ?? ''}
onChangeText={(text) => onInput(question.id, text)}
editable={!submitted}
/>
) : (
<View style={styles.options}>
{question.options?.map((option) => {
const selected = answer?.selectedIds.includes(option.id) ?? false;
const showCorrect = submitted && option.isCorrect;
const showWrong = submitted && selected && !option.isCorrect;
return (
<Pressable
key={option.id}
style={[
styles.option,
selected && styles.optionSelected,
showCorrect && styles.optionCorrect,
showWrong && styles.optionWrong,
]}
onPress={() => onSelect(question.id, option.id, question.type)}
disabled={submitted}
>
<Text
style={[
styles.optionText,
selected && styles.optionTextSelected,
showCorrect && styles.optionTextCorrect,
showWrong && styles.optionTextWrong,
]}
>
{option.text}
</Text>
{showCorrect && <Ionicons name="checkmark" size={18} color={colors.success} />}
{showWrong && <Ionicons name="close" size={18} color={colors.danger} />}
</Pressable>
);
})}
</View>
)}
{submitted && (
<View style={styles.explanation}>
<Text style={styles.explanationLabel}>解析</Text>
<Text style={styles.explanationText}>{question.explanation}</Text>
</View>
)}
</View>
);
}
/* ===================== 页面 ===================== */
export default function GameLevelScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const { levels, progress, loading, error, refresh, submitLevel, submitting } = useGameData();
const level = useMemo(() => levels.find((l) => l.id === id), [levels, id]);
const result = useMemo(
() => progress?.results.find((r) => r.levelId === id),
[progress, id],
);
const [answers, setAnswers] = useState<GameAnswer[]>([]);
const [submitted, setSubmitted] = useState(false);
// 已有历史结果时进入结果态
const hasResult = result?.completed === true;
const showResult = submitted || hasResult;
const handleSelect = (
questionId: string,
optionId: string,
type: GameQuestion['type'],
) => {
setAnswers((prev) => {
const next = prev.filter((a) => a.questionId !== questionId);
const existing = prev.find((a) => a.questionId === questionId);
let selectedIds: string[];
if (type === 'multiple') {
const current = existing?.selectedIds ?? [];
selectedIds = current.includes(optionId)
? current.filter((sid) => sid !== optionId)
: [...current, optionId];
} else {
selectedIds = [optionId];
}
return [...next, { questionId, selectedIds, isCorrect: false }];
});
};
const handleInput = (questionId: string, value: string) => {
setAnswers((prev) => {
const next = prev.filter((a) => a.questionId !== questionId);
return [...next, { questionId, selectedIds: [], inputValue: value, isCorrect: false }];
});
};
const handleSubmit = async () => {
if (!level) return;
const payload = level.questions.map((q) => {
const answer = answers.find((a) => a.questionId === q.id);
return {
questionId: q.id,
selectedIds: answer?.selectedIds ?? [],
inputValue: answer?.inputValue,
isCorrect: false,
};
});
await submitLevel({ levelId: level.id, answers: payload });
setSubmitted(true);
};
const handleNext = () => {
if (!level?.nextLevel) {
router.replace('/game');
return;
}
router.replace(`/game/level?id=${level.nextLevel}`);
};
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header title="关卡" onBack={() => router.back()} />
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !level) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header title="关卡" onBack={() => router.back()} />
<View style={styles.stateBox}>
<Text style={styles.stateText}>{error ? '加载失败' : '关卡不存在'}</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header title={level.title} onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Text style={styles.levelDesc}>{level.description}</Text>
{level.questions.map((q, idx) => (
<QuestionCard
key={q.id}
index={idx}
question={q}
answer={answers.find((a) => a.questionId === q.id)}
onSelect={handleSelect}
onInput={handleInput}
submitted={showResult}
/>
))}
{showResult && result && (
<View style={styles.resultCard}>
<Text style={styles.resultTitle}>
本关得分:<Text style={styles.resultScore}>{result.score}</Text> 分
</Text>
<Text style={styles.resultHint}>
{result.completed ? '恭喜通关!' : '未达 60 分,可重新挑战'}
</Text>
</View>
)}
{!showResult ? (
<Pressable
style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.submitText}>{submitting ? '提交中…' : '提交答案'}</Text>
</Pressable>
) : (
<Pressable style={styles.nextBtn} onPress={handleNext}>
<Text style={styles.nextText}>{level.nextLevel ? '进入下一关' : '返回通关地图'}</Text>
</Pressable>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.card,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: spacing.card,
paddingBottom: 24,
},
levelDesc: {
fontSize: 14,
lineHeight: 20,
color: colors.textSecondary,
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
},
questionCard: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
},
questionIndex: {
fontSize: 12,
fontWeight: '600',
color: colors.primary,
marginBottom: 6,
},
questionTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
lineHeight: 22,
marginBottom: 12,
},
options: {
gap: 8,
},
option: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.card,
paddingVertical: spacing.card,
borderRadius: spacing.card,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.white,
},
optionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
optionCorrect: {
borderColor: colors.success,
backgroundColor: colors.successLight,
},
optionWrong: {
borderColor: colors.danger,
backgroundColor: colors.dangerLight,
},
optionText: {
flex: 1,
fontSize: 14,
color: colors.textPrimary,
},
optionTextSelected: {
color: colors.primaryText,
fontWeight: '600',
},
optionTextCorrect: {
color: colors.successText,
fontWeight: '600',
},
optionTextWrong: {
color: colors.dangerText,
fontWeight: '600',
},
input: {
height: 44,
borderRadius: 6,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 12,
fontSize: 14,
color: colors.textPrimary,
},
explanation: {
marginTop: 8,
padding: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.infoLight,
},
explanationLabel: {
fontSize: 12,
fontWeight: '700',
color: colors.infoText,
marginBottom: 4,
},
explanationText: {
fontSize: 13,
lineHeight: 18,
color: colors.textSecondary,
},
resultCard: {
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
},
resultTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
resultScore: {
color: colors.primary,
},
resultHint: {
marginTop: 6,
fontSize: 13,
color: colors.textSecondary,
},
submitBtn: {
marginTop: 8,
height: 48,
borderRadius: spacing.card,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
submitBtnDisabled: {
opacity: 0.6,
},
submitText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
nextBtn: {
marginTop: 8,
height: 48,
borderRadius: spacing.card,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.successBtn,
},
nextText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: spacing.card,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
});