轻松聘
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.
 
 
 

508 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, semanticColorTokens } from '../src/theme';
import type {
InterviewAbilityImprovement,
InterviewMode,
InterviewModeOption,
InterviewQuestionTypeCode,
InterviewQuestionTypeItem,
InterviewRecent,
InterviewSpecial,
} from '../src/types';
/* ===================== 子组件:顶部导航 ===================== */
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 = semanticColorTokens[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 = semanticColorTokens[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 = semanticColorTokens[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: 8,
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: 8,
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 },
});