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.
516 lines
15 KiB
516 lines
15 KiB
import { Ionicons } from '@expo/vector-icons';
|
|
import { useRouter } from 'expo-router';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useAIInterview } from '../src/hooks';
|
|
import { buildInterviewConfigText } from '../src/api';
|
|
import { shareText } from '../src/lib/shareFile';
|
|
import { colors } from '../src/theme';
|
|
import type {
|
|
InterviewAbilityImprovement,
|
|
InterviewMode,
|
|
InterviewModeOption,
|
|
InterviewQuestionTypeCode,
|
|
InterviewQuestionTypeItem,
|
|
InterviewRecent,
|
|
InterviewSpecial,
|
|
} from '../src/types';
|
|
|
|
const COLOR_TOKENS: Record<string, string> = {
|
|
primary: colors.primary,
|
|
success: colors.success,
|
|
action: colors.action,
|
|
info: colors.info,
|
|
danger: colors.danger,
|
|
};
|
|
|
|
/* ===================== 子组件:顶部导航 ===================== */
|
|
|
|
function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => 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}>AI模拟面试</Text>
|
|
{onShare ? (
|
|
<Pressable style={styles.headerBtn} onPress={onShare} hitSlop={8}>
|
|
<Ionicons name="share-outline" size={22} color={colors.textPrimary} />
|
|
</Pressable>
|
|
) : (
|
|
<View style={styles.headerBtn} />
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:面试模式选择 ===================== */
|
|
|
|
function ModeCards({
|
|
modes,
|
|
active,
|
|
onSelect,
|
|
}: {
|
|
modes: InterviewModeOption[];
|
|
active: InterviewMode;
|
|
onSelect: (mode: InterviewMode) => void;
|
|
}) {
|
|
return (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>面试模式</Text>
|
|
{modes.map((mode) => {
|
|
const selected = mode.id === active;
|
|
return (
|
|
<Pressable
|
|
key={mode.id}
|
|
style={[styles.modeCard, selected && styles.modeCardActive]}
|
|
onPress={() => onSelect(mode.id)}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.modeIcon,
|
|
{ backgroundColor: selected ? colors.primary : colors.pageBg },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={mode.icon as keyof typeof Ionicons.glyphMap}
|
|
size={22}
|
|
color={selected ? colors.white : colors.textHint}
|
|
/>
|
|
</View>
|
|
<View style={styles.modeBody}>
|
|
<Text style={[styles.modeLabel, selected && styles.modeLabelActive]}>
|
|
{mode.label}
|
|
</Text>
|
|
<Text style={styles.modeDesc}>{mode.desc}</Text>
|
|
</View>
|
|
{selected && <Ionicons name="checkmark-circle" size={22} color={colors.primary} />}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:岗位信息(两列网格) ===================== */
|
|
|
|
function JobInfoGrid({
|
|
items,
|
|
}: {
|
|
items: { label: string; value: string }[];
|
|
}) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<View style={styles.jobGrid}>
|
|
{items.map((item) => (
|
|
<View key={item.label} style={styles.jobItem}>
|
|
<Text style={styles.jobLabel}>{item.label}</Text>
|
|
<Text style={styles.jobValue}>{item.value}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:题型设置 ===================== */
|
|
|
|
function QuestionTypeTags({
|
|
types,
|
|
active,
|
|
onToggle,
|
|
}: {
|
|
types: InterviewQuestionTypeItem[];
|
|
active: InterviewQuestionTypeCode[];
|
|
onToggle: (id: InterviewQuestionTypeCode) => void;
|
|
}) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>题型设置</Text>
|
|
<View style={styles.tagRow}>
|
|
{types.map((t) => {
|
|
const selected = active.includes(t.id);
|
|
const color = COLOR_TOKENS[t.color] ?? colors.primary;
|
|
return (
|
|
<Pressable
|
|
key={t.id}
|
|
style={[
|
|
styles.typeTag,
|
|
selected && { backgroundColor: color, borderColor: color },
|
|
]}
|
|
onPress={() => onToggle(t.id)}
|
|
>
|
|
{selected && <Ionicons name="checkmark" size={14} color={colors.white} />}
|
|
<Text style={[styles.typeText, selected && styles.typeTextActive]}>{t.label}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:专项突破选项 ===================== */
|
|
|
|
function SpecialTags({
|
|
specials,
|
|
active,
|
|
onToggle,
|
|
}: {
|
|
specials: InterviewSpecial[];
|
|
active: string[];
|
|
onToggle: (id: string) => void;
|
|
}) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>专项突破</Text>
|
|
<View style={styles.tagRow}>
|
|
{specials.map((s) => {
|
|
const selected = active.includes(s.id);
|
|
return (
|
|
<Pressable
|
|
key={s.id}
|
|
style={[styles.specialTag, selected && styles.specialTagActive]}
|
|
onPress={() => onToggle(s.id)}
|
|
>
|
|
<Text style={[styles.specialText, selected && styles.specialTextActive]}>
|
|
{s.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:预估可提升能力 ===================== */
|
|
|
|
function ImprovementCard({
|
|
improvements,
|
|
}: {
|
|
improvements: InterviewAbilityImprovement[];
|
|
}) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>本次预估可提升能力</Text>
|
|
{improvements.map((imp) => {
|
|
const color = COLOR_TOKENS[imp.color] ?? colors.primary;
|
|
return (
|
|
<View key={imp.id} style={styles.improvementRow}>
|
|
<Text style={styles.improvementLabel}>{imp.label}</Text>
|
|
<View style={styles.improvementTrack}>
|
|
<View
|
|
style={[
|
|
styles.improvementFill,
|
|
{ width: `${imp.percent * 5}%`, backgroundColor: color },
|
|
]}
|
|
/>
|
|
</View>
|
|
<Text style={[styles.improvementValue, { color }]}>+{imp.percent}%</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:最近练习 ===================== */
|
|
|
|
function RecentCard({ recent }: { recent: InterviewRecent[] }) {
|
|
return (
|
|
<View style={styles.card}>
|
|
{/* 原先这里挂着「全部记录 >」:项目内没有面试练习记录页,先摘掉 */}
|
|
<View style={styles.cardHeaderRow}>
|
|
<Text style={styles.cardTitle}>最近练习</Text>
|
|
</View>
|
|
{recent.map((item) => {
|
|
const color = COLOR_TOKENS[item.resultColor] ?? colors.primary;
|
|
return (
|
|
<View key={item.id} style={styles.recentRow}>
|
|
<Text style={styles.recentDate}>{item.date}</Text>
|
|
<Text style={styles.recentTitle}>{item.title}</Text>
|
|
<View style={[styles.recentTag, { backgroundColor: `${color}20` }]}>
|
|
<Text style={[styles.recentResult, { color }]}>{item.result}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:四态 ===================== */
|
|
|
|
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="videocam-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 InterviewScreen() {
|
|
const router = useRouter();
|
|
const {
|
|
data,
|
|
loading,
|
|
error,
|
|
refresh,
|
|
mode,
|
|
setMode,
|
|
questionTypes,
|
|
toggleQuestionType,
|
|
specials,
|
|
toggleSpecial,
|
|
start,
|
|
starting,
|
|
} = useAIInterview();
|
|
|
|
const handleStart = async () => {
|
|
await start();
|
|
};
|
|
|
|
/**
|
|
* 分享面试配置:拼成文本经系统分享面板发出。
|
|
* 传入的是**页面上的当前选择**(模式 / 题型 / 专项),不是接口默认值 —— 用户改过之后两者不同。
|
|
* `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。
|
|
*/
|
|
const handleShare = async () => {
|
|
if (!data) return;
|
|
const outcome = await shareText(
|
|
buildInterviewConfigText(data, mode, questionTypes, specials),
|
|
'AI 模拟面试配置',
|
|
);
|
|
if (!outcome.ok) Alert.alert('无法分享', outcome.message);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header onBack={() => router.back()} />
|
|
<Loading />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header onBack={() => router.back()} />
|
|
<ErrorState onRetry={refresh} />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<Header onBack={() => router.back()} onShare={handleShare} />
|
|
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
|
|
<ModeCards modes={data.modes} active={mode} onSelect={setMode} />
|
|
|
|
<JobInfoGrid
|
|
items={[
|
|
{ label: '面试岗位', value: data.jobInfo.position },
|
|
{ label: '面试公司', value: data.jobInfo.company },
|
|
{ label: '面试难度', value: data.jobInfo.difficulty },
|
|
{ label: '面试时长', value: `${data.jobInfo.duration}分钟` },
|
|
]}
|
|
/>
|
|
|
|
<QuestionTypeTags
|
|
types={data.questionTypes}
|
|
active={questionTypes}
|
|
onToggle={toggleQuestionType}
|
|
/>
|
|
|
|
{mode === 'special' && (
|
|
<SpecialTags specials={data.specials} active={specials} onToggle={toggleSpecial} />
|
|
)}
|
|
|
|
<ImprovementCard improvements={data.improvements} />
|
|
|
|
<RecentCard recent={data.recent} />
|
|
</ScrollView>
|
|
|
|
<View style={styles.footer}>
|
|
<Pressable
|
|
style={[styles.startBtn, starting && styles.startBtnDisabled]}
|
|
onPress={handleStart}
|
|
disabled={starting}
|
|
>
|
|
{starting ? (
|
|
<ActivityIndicator size="small" color={colors.white} />
|
|
) : (
|
|
<Text style={styles.startBtnText}>开始面试</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</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' },
|
|
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
|
|
body: { paddingHorizontal: 16, paddingBottom: 100, paddingTop: 12 },
|
|
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 },
|
|
section: { marginBottom: 16 },
|
|
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
|
|
modeCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.white,
|
|
borderRadius: 12,
|
|
padding: 14,
|
|
marginBottom: 10,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
},
|
|
modeCardActive: { borderColor: colors.primary, backgroundColor: colors.primaryLight },
|
|
modeIcon: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
modeBody: { flex: 1, marginLeft: 12 },
|
|
modeLabel: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
|
|
modeLabelActive: { color: colors.primaryText },
|
|
modeDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2 },
|
|
card: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: 14,
|
|
padding: 16,
|
|
marginBottom: 12,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
},
|
|
cardHeaderRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 12,
|
|
},
|
|
cardTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
|
|
moreLink: { fontSize: 13, color: colors.primary },
|
|
jobGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
jobItem: { width: '50%', marginBottom: 14 },
|
|
jobLabel: { fontSize: 12, color: colors.textHint, marginBottom: 4 },
|
|
jobValue: { fontSize: 14, fontWeight: '700', color: colors.textPrimary },
|
|
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
|
typeTag: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
borderRadius: 18,
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
typeText: { fontSize: 13, color: colors.textSecondary, marginLeft: 4 },
|
|
typeTextActive: { color: colors.white, fontWeight: '600' },
|
|
specialTag: {
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
borderRadius: 18,
|
|
backgroundColor: colors.pageBg,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
},
|
|
specialTagActive: { backgroundColor: colors.primaryLight, borderColor: colors.primary },
|
|
specialText: { fontSize: 13, color: colors.textSecondary },
|
|
specialTextActive: { color: colors.primaryText, fontWeight: '600' },
|
|
improvementRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: 8,
|
|
},
|
|
improvementLabel: { width: 70, fontSize: 13, color: colors.textPrimary },
|
|
improvementTrack: {
|
|
flex: 1,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: colors.borderLight,
|
|
overflow: 'hidden',
|
|
},
|
|
improvementFill: { height: 8, borderRadius: 4 },
|
|
improvementValue: {
|
|
width: 50,
|
|
fontSize: 13,
|
|
fontWeight: '700',
|
|
textAlign: 'right',
|
|
},
|
|
recentRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: 10,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.borderLight,
|
|
},
|
|
recentDate: { width: 60, fontSize: 12, color: colors.textHint },
|
|
recentTitle: { flex: 1, fontSize: 14, color: colors.textPrimary },
|
|
recentTag: { borderRadius: 10, paddingHorizontal: 8, paddingVertical: 2 },
|
|
recentResult: { fontSize: 12, fontWeight: '600' },
|
|
footer: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
backgroundColor: colors.white,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.borderLight,
|
|
},
|
|
startBtn: {
|
|
backgroundColor: colors.primaryBtn,
|
|
borderRadius: 24,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
},
|
|
startBtnDisabled: { backgroundColor: colors.textDisabled },
|
|
startBtnText: { fontSize: 16, fontWeight: '700', color: colors.white },
|
|
});
|
|
|