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.
323 lines
9.4 KiB
323 lines
9.4 KiB
import { Ionicons } from '@expo/vector-icons';
|
|
import { useRouter } from 'expo-router';
|
|
import { useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useHollandAssessment } from '../../src/hooks';
|
|
import { colors } from '../../src/theme';
|
|
import type { HollandDimension, HollandOption } from '../../src/types';
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
R: colors.primary,
|
|
I: colors.info,
|
|
A: colors.action,
|
|
S: colors.success,
|
|
E: '#FFB347',
|
|
C: colors.textHint,
|
|
};
|
|
|
|
/* ===================== 子组件:顶部导航 ===================== */
|
|
|
|
function Header({ progress, onBack }: { progress: 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>
|
|
<View style={styles.headerCenter}>
|
|
<Text style={styles.headerTitle}>霍兰德测评</Text>
|
|
<Text style={styles.headerSub}>{progress}</Text>
|
|
</View>
|
|
<View style={styles.headerBtn} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:六维分布条 ===================== */
|
|
|
|
function DimensionBars({ dimensions }: { dimensions: HollandDimension[] }) {
|
|
return (
|
|
<View style={styles.dimCard}>
|
|
<Text style={styles.dimTitle}>当前六维分布</Text>
|
|
<View style={styles.dimGrid}>
|
|
{dimensions.map((d) => (
|
|
<View key={d.type} style={styles.dimRow}>
|
|
<Text style={styles.dimType}>{d.type}</Text>
|
|
<Text style={styles.dimName}>{d.name}</Text>
|
|
<View style={styles.dimTrack}>
|
|
<View
|
|
style={[
|
|
styles.dimFill,
|
|
{ width: `${d.percent}%`, backgroundColor: TYPE_COLORS[d.type] },
|
|
]}
|
|
/>
|
|
</View>
|
|
<Text style={styles.dimScore}>{d.score}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:选项 ===================== */
|
|
|
|
function OptionButton({
|
|
option,
|
|
selected,
|
|
onPress,
|
|
}: {
|
|
option: HollandOption;
|
|
selected: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable style={[styles.option, selected && styles.optionActive]} onPress={onPress}>
|
|
<Text style={[styles.optionText, selected && styles.optionTextActive]}>{option.label}</Text>
|
|
{selected && <Ionicons name="checkmark-circle" size={20} color={colors.primary} />}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:四态 ===================== */
|
|
|
|
function Loading() {
|
|
return (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
<Text style={styles.tip}>正在加载测评…</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<Ionicons name="help-circle-outline" size={40} color={colors.textHint} />
|
|
<Text style={styles.tip}>测评加载失败</Text>
|
|
<Pressable style={styles.retryBtn} onPress={onRetry} hitSlop={6}>
|
|
<Text style={styles.retryText}>点击重试</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 页面 ===================== */
|
|
|
|
export default function HollandQuestionScreen() {
|
|
const router = useRouter();
|
|
const { assessment, loading, error, refresh, submit } = useHollandAssessment();
|
|
const [selectedValue, setSelectedValue] = useState<number | null>(null);
|
|
const [localIndex, setLocalIndex] = useState(0);
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header progress="加载中…" onBack={() => router.back()} />
|
|
<Loading />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error || !assessment) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header progress="" onBack={() => router.back()} />
|
|
<ErrorState onRetry={refresh} />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const currentQuestion = Math.min(
|
|
assessment.currentQuestionIndex + 1 + localIndex,
|
|
assessment.totalQuestions,
|
|
);
|
|
const progressText = `第 ${currentQuestion}/${assessment.totalQuestions} 题`;
|
|
|
|
const handleNext = async () => {
|
|
if (selectedValue == null) return;
|
|
await submit(assessment.question.id, selectedValue);
|
|
setSelectedValue(null);
|
|
const nextIndex = localIndex + 1;
|
|
setLocalIndex(nextIndex);
|
|
if (nextIndex >= 3 || assessment.currentQuestionIndex + 1 >= assessment.totalQuestions) {
|
|
router.push('/holland-result');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header progress={progressText} onBack={() => router.back()} />
|
|
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
|
|
<DimensionBars dimensions={assessment.dimensions} />
|
|
|
|
<View style={styles.questionCard}>
|
|
<Text style={styles.questionTitle}>请根据你的兴趣程度选择</Text>
|
|
<Text style={styles.questionText}>{assessment.question.text}</Text>
|
|
</View>
|
|
|
|
<View style={styles.options}>
|
|
{assessment.options.map((option) => (
|
|
<OptionButton
|
|
key={option.value}
|
|
option={option}
|
|
selected={selectedValue === option.value}
|
|
onPress={() => setSelectedValue(option.value)}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
<View style={styles.footer}>
|
|
<Pressable style={[styles.navBtn, styles.navBtnPrev]} onPress={() => router.back()}>
|
|
<Text style={styles.navBtnPrevText}>上一题</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={[
|
|
styles.navBtn,
|
|
styles.navBtnNext,
|
|
selectedValue == null && styles.navBtnNextDisabled,
|
|
]}
|
|
onPress={handleNext}
|
|
disabled={selectedValue == null}
|
|
>
|
|
<Text style={styles.navBtnNextText}>下一题</Text>
|
|
</Pressable>
|
|
</View>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.pageBg },
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 12,
|
|
backgroundColor: colors.white,
|
|
},
|
|
headerBtn: { width: 40, alignItems: 'center' },
|
|
headerCenter: { alignItems: 'center' },
|
|
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
|
|
headerSub: { fontSize: 12, color: colors.textHint, marginTop: 2 },
|
|
body: { padding: 16, paddingBottom: 32 },
|
|
dimCard: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: 14,
|
|
padding: 16,
|
|
marginBottom: 12,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
},
|
|
dimTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 14 },
|
|
dimGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
|
dimRow: {
|
|
width: '47%',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
dimType: {
|
|
width: 18,
|
|
fontSize: 13,
|
|
fontWeight: '800',
|
|
color: colors.textPrimary,
|
|
},
|
|
dimName: {
|
|
width: 36,
|
|
fontSize: 12,
|
|
color: colors.textSecondary,
|
|
},
|
|
dimTrack: {
|
|
flex: 1,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: colors.borderLight,
|
|
overflow: 'hidden',
|
|
},
|
|
dimFill: { height: 6, borderRadius: 3 },
|
|
dimScore: {
|
|
width: 28,
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
textAlign: 'right',
|
|
},
|
|
questionCard: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: 14,
|
|
padding: 24,
|
|
marginBottom: 12,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
alignItems: 'center',
|
|
},
|
|
questionTitle: { fontSize: 13, color: colors.textHint, marginBottom: 16 },
|
|
questionText: {
|
|
fontSize: 20,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
textAlign: 'center',
|
|
lineHeight: 28,
|
|
},
|
|
options: { gap: 10, marginBottom: 20 },
|
|
option: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: colors.white,
|
|
borderRadius: 12,
|
|
paddingVertical: 16,
|
|
paddingHorizontal: 18,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
},
|
|
optionActive: {
|
|
backgroundColor: colors.primaryLight,
|
|
borderColor: colors.primary,
|
|
},
|
|
optionText: { fontSize: 15, color: colors.textPrimary },
|
|
optionTextActive: { fontWeight: '700', color: colors.primaryText },
|
|
footer: {
|
|
flexDirection: 'row',
|
|
gap: 12,
|
|
},
|
|
navBtn: {
|
|
flex: 1,
|
|
borderRadius: 12,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
},
|
|
navBtnPrev: {
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
navBtnPrevText: { fontSize: 15, fontWeight: '600', color: colors.textPrimary },
|
|
navBtnNext: { backgroundColor: colors.primaryBtn },
|
|
navBtnNextDisabled: { backgroundColor: colors.textDisabled },
|
|
navBtnNextText: { fontSize: 15, fontWeight: '700', color: colors.white },
|
|
center: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 40,
|
|
gap: 12,
|
|
},
|
|
tip: { fontSize: 14, color: colors.textSecondary },
|
|
retryBtn: {
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 8,
|
|
borderRadius: 16,
|
|
backgroundColor: colors.primaryLight,
|
|
},
|
|
retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
|
|
});
|
|
|