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.
650 lines
18 KiB
650 lines
18 KiB
import { Ionicons } from '@expo/vector-icons';
|
|
import { useRouter } from 'expo-router';
|
|
import {
|
|
Alert,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg';
|
|
import { useInterviewScore } from '../src/hooks';
|
|
import { LoadingState, ErrorState } from '../src/components/StateView';
|
|
import { colors, semanticColorTokens } from '../src/theme';
|
|
import { formatDateTime } from '../src/utils';
|
|
import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types';
|
|
|
|
/* ===================== 子组件:顶部导航 ===================== */
|
|
|
|
function Header({ onBack }: { 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}>面试评分</Text>
|
|
{/* 原先这里是「历史评分」入口:没有历史评分页,先摘掉,加页面时再挂回来 */}
|
|
<View style={styles.headerBtn} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:面试信息卡 ===================== */
|
|
|
|
function InterviewInfoCard({ data }: { data: InterviewScoreData }) {
|
|
return (
|
|
<View style={styles.infoCard}>
|
|
<View style={styles.infoRow}>
|
|
<View style={styles.infoItem}>
|
|
<Text style={styles.infoLabel}>候选人</Text>
|
|
<Text style={styles.infoValue}>{data.candidateName}</Text>
|
|
</View>
|
|
<View style={styles.infoItem}>
|
|
<Text style={styles.infoLabel}>面试岗位</Text>
|
|
<Text style={styles.infoValue}>{data.positionName}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.infoRow}>
|
|
<View style={styles.infoItem}>
|
|
<Text style={styles.infoLabel}>面试时间</Text>
|
|
<Text style={styles.infoValue}>{data.interviewTime}</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:综合评分卡 ===================== */
|
|
|
|
function OverallScoreCard({ score }: { score: number }) {
|
|
return (
|
|
<View style={styles.scoreCard}>
|
|
<View>
|
|
<Text style={styles.scoreLabel}>综合评分</Text>
|
|
<Text style={styles.scoreValue}>{score}</Text>
|
|
</View>
|
|
<View style={styles.scoreIcon}>
|
|
<Ionicons name="ribbon-outline" size={40} color={colors.white} />
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:雷达图 ===================== */
|
|
|
|
function RadarChart({ dimensions }: { dimensions: InterviewScoreDimension[] }) {
|
|
const size = 220;
|
|
const center = size / 2;
|
|
const radius = 80;
|
|
const angleStep = (Math.PI * 2) / dimensions.length;
|
|
|
|
const axisPoints = dimensions.map((_, i) => {
|
|
const angle = i * angleStep - Math.PI / 2;
|
|
return {
|
|
x: center + radius * Math.cos(angle),
|
|
y: center + radius * Math.sin(angle),
|
|
label: dimensions[i].name,
|
|
};
|
|
});
|
|
|
|
const dataPoints = dimensions.map((d, i) => {
|
|
const angle = i * angleStep - Math.PI / 2;
|
|
const r = (d.score / d.maxScore) * radius;
|
|
return {
|
|
x: center + r * Math.cos(angle),
|
|
y: center + r * Math.sin(angle),
|
|
};
|
|
});
|
|
|
|
const polygonPoints = dataPoints.map((p) => `${p.x},${p.y}`).join(' ');
|
|
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>八维能力评分</Text>
|
|
<View style={styles.radarWrap}>
|
|
<Svg width={size} height={size}>
|
|
{[0.2, 0.4, 0.6, 0.8, 1].map((scale) => (
|
|
<Polygon
|
|
key={scale}
|
|
points={axisPoints.map((a) => {
|
|
const px = center + (a.x - center) * scale;
|
|
const py = center + (a.y - center) * scale;
|
|
return `${px},${py}`;
|
|
}).join(' ')}
|
|
fill="none"
|
|
stroke={colors.borderLight}
|
|
strokeWidth={1}
|
|
/>
|
|
))}
|
|
{axisPoints.map((a, i) => (
|
|
<Line key={i} x1={center} y1={center} x2={a.x} y2={a.y} stroke={colors.borderLight} strokeWidth={1} />
|
|
))}
|
|
<Polygon points={polygonPoints} fill={colors.primary + '25'} stroke={colors.primary} strokeWidth={2} />
|
|
{axisPoints.map((a, i) => {
|
|
const labelX = center + (a.x - center) * 1.18;
|
|
const labelY = center + (a.y - center) * 1.18;
|
|
return (
|
|
<SvgText key={i} x={labelX} y={labelY} fill={colors.textSecondary} fontSize={10} textAnchor="middle" alignmentBaseline="middle">
|
|
{a.label}
|
|
</SvgText>
|
|
);
|
|
})}
|
|
</Svg>
|
|
</View>
|
|
<View style={styles.dimensionList}>
|
|
{dimensions.map((d) => (
|
|
<View key={d.id} style={styles.dimensionItem}>
|
|
<Text style={styles.dimensionName}>{d.name}</Text>
|
|
<View style={styles.dimensionTrack}>
|
|
<View style={[styles.dimensionFill, { width: `${d.score}%` }]} />
|
|
</View>
|
|
<Text style={styles.dimensionScore}>{d.score}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:评价标签 ===================== */
|
|
|
|
function TagGroup({ tags, onToggle }: { tags: InterviewScoreTag[]; onToggle: (id: string) => void }) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>面试评价标签</Text>
|
|
<View style={styles.tagRow}>
|
|
{tags.map((tag) => {
|
|
const color = semanticColorTokens[tag.color] ?? colors.primary;
|
|
return (
|
|
<Pressable
|
|
key={tag.id}
|
|
style={[styles.tag, tag.selected && { backgroundColor: color, borderColor: color }]}
|
|
onPress={() => onToggle(tag.id)}
|
|
>
|
|
{tag.selected && <Ionicons name="checkmark" size={12} color={colors.white} />}
|
|
<Text style={[styles.tagText, tag.selected && styles.tagTextActive]}>{tag.label}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:文字评价 ===================== */
|
|
|
|
function CommentInput({ value, onChangeText }: { value: string; onChangeText: (text: string) => void }) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>文字评价</Text>
|
|
<TextInput
|
|
style={styles.textarea}
|
|
value={value}
|
|
onChangeText={onChangeText}
|
|
placeholder="请输入对候选人的综合评价..."
|
|
placeholderTextColor={colors.textHint}
|
|
multiline
|
|
textAlignVertical="top"
|
|
maxLength={500}
|
|
/>
|
|
<Text style={styles.charCount}>{value.length}/500</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:录用建议 ===================== */
|
|
|
|
function HireSuggestionGroup({
|
|
suggestions,
|
|
onSelect,
|
|
}: {
|
|
suggestions: HireSuggestion[];
|
|
onSelect: (type: HireSuggestion['type']) => void;
|
|
}) {
|
|
const typeColor: Record<string, string> = {
|
|
strong: colors.success,
|
|
consider: colors.compareOrange,
|
|
reject: colors.danger,
|
|
};
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>录用建议</Text>
|
|
{suggestions.map((s) => (
|
|
<Pressable
|
|
key={s.type}
|
|
style={[
|
|
styles.suggestionRow,
|
|
s.selected && { backgroundColor: typeColor[s.type] + '10', borderColor: typeColor[s.type] },
|
|
]}
|
|
onPress={() => onSelect(s.type)}
|
|
>
|
|
<View style={[styles.suggestionDot, { backgroundColor: typeColor[s.type] }]} />
|
|
<View style={styles.suggestionBody}>
|
|
<Text style={styles.suggestionTitle}>{s.title}</Text>
|
|
<Text style={styles.suggestionDesc}>{s.desc}</Text>
|
|
</View>
|
|
{s.selected && <Ionicons name="checkmark-circle" size={20} color={typeColor[s.type]} />}
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:薪资建议 ===================== */
|
|
|
|
function SalarySuggestionCard({ salary }: { salary: InterviewScoreData['salarySuggestion'] }) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>薪资建议(选填)</Text>
|
|
<View style={styles.salaryBox}>
|
|
<View>
|
|
<Text style={styles.salaryRange}>{salary.range}</Text>
|
|
<Text style={styles.salaryLevel}>{salary.level}</Text>
|
|
</View>
|
|
<Ionicons name="cash-outline" size={32} color={colors.info} />
|
|
</View>
|
|
<Text style={styles.salaryNote}>{salary.note}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 页面主组件 ===================== */
|
|
|
|
export default function InterviewScoreScreen() {
|
|
const router = useRouter();
|
|
const {
|
|
score,
|
|
loading,
|
|
error,
|
|
toggleTag,
|
|
updateComment,
|
|
selectHireSuggestion,
|
|
submit,
|
|
submitting,
|
|
saveDraft,
|
|
savingDraft,
|
|
restoreDraft,
|
|
discardDraft,
|
|
hasPendingDraft,
|
|
draftSavedAt,
|
|
refresh,
|
|
} = useInterviewScore('interview-1');
|
|
|
|
/** 保存草稿:成功后就地在按钮旁显示保存时间,失败给出提示 */
|
|
const handleSaveDraft = async () => {
|
|
try {
|
|
const savedAt = await saveDraft();
|
|
if (savedAt) {
|
|
Alert.alert('已保存草稿', `保存时间 ${formatDateTime(savedAt)},下次进入这场面试时可恢复。`);
|
|
}
|
|
} catch {
|
|
Alert.alert('保存失败', '草稿没能存到本机,请稍后重试。');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
|
|
<LoadingState text="加载中…" />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error || !score) {
|
|
return (
|
|
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
|
|
<ErrorState onRetry={refresh} />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<SafeAreaView edges={['top']}>
|
|
<Header onBack={() => router.back()} />
|
|
</SafeAreaView>
|
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
|
{hasPendingDraft && (
|
|
<View style={styles.draftBanner}>
|
|
<Ionicons name="time-outline" size={18} color={colors.infoText} />
|
|
<View style={styles.draftBannerBody}>
|
|
<Text style={styles.draftBannerTitle}>
|
|
发现未提交的草稿{draftSavedAt ? `(${formatDateTime(draftSavedAt)} 保存)` : ''}
|
|
</Text>
|
|
<Text style={styles.draftBannerDesc}>恢复后会覆盖当前页面上的评分内容</Text>
|
|
</View>
|
|
<Pressable style={styles.draftBannerBtn} onPress={restoreDraft} hitSlop={6}>
|
|
<Text style={styles.draftBannerBtnText}>恢复</Text>
|
|
</Pressable>
|
|
<Pressable onPress={() => void discardDraft()} hitSlop={6}>
|
|
<Text style={styles.draftBannerDiscard}>放弃</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
<InterviewInfoCard data={score} />
|
|
<OverallScoreCard score={score.overallScore} />
|
|
<RadarChart dimensions={score.dimensions} />
|
|
<TagGroup tags={score.tags} onToggle={toggleTag} />
|
|
<CommentInput value={score.comment} onChangeText={updateComment} />
|
|
<HireSuggestionGroup suggestions={score.hireSuggestions} onSelect={selectHireSuggestion} />
|
|
<SalarySuggestionCard salary={score.salarySuggestion} />
|
|
</ScrollView>
|
|
<View style={styles.footer}>
|
|
<Pressable
|
|
style={[styles.draftBtn, savingDraft && styles.draftBtnDisabled]}
|
|
onPress={handleSaveDraft}
|
|
disabled={savingDraft}
|
|
>
|
|
<Text style={styles.draftText}>{savingDraft ? '保存中…' : '保存草稿'}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
|
|
onPress={submit}
|
|
disabled={submitting}
|
|
>
|
|
<Text style={styles.submitText}>{submitting ? '提交中...' : '提交评分'}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 样式 ===================== */
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.pageBg,
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.pageBg,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
backgroundColor: colors.white,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderLight,
|
|
},
|
|
headerBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
headerTitle: {
|
|
fontSize: 17,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary,
|
|
},
|
|
scroll: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
padding: 8,
|
|
paddingBottom: 100,
|
|
},
|
|
infoCard: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: 8,
|
|
padding: 8,
|
|
marginBottom: 8,
|
|
},
|
|
infoRow: {
|
|
flexDirection: 'row',
|
|
marginBottom: 12,
|
|
},
|
|
infoItem: {
|
|
flex: 1,
|
|
},
|
|
infoLabel: {
|
|
fontSize: 12,
|
|
color: colors.textHint,
|
|
marginBottom: 4,
|
|
},
|
|
infoValue: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary,
|
|
},
|
|
scoreCard: {
|
|
backgroundColor: colors.primary,
|
|
borderRadius: 8,
|
|
padding: 8,
|
|
marginBottom: 8,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
scoreLabel: {
|
|
fontSize: 14,
|
|
color: colors.white,
|
|
opacity: 0.9,
|
|
},
|
|
scoreValue: {
|
|
fontSize: 42,
|
|
fontWeight: '700',
|
|
color: colors.white,
|
|
marginTop: 4,
|
|
},
|
|
scoreIcon: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
backgroundColor: colors.onBrandSurface,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
card: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: 8,
|
|
padding: 8,
|
|
marginBottom: 8,
|
|
},
|
|
cardTitle: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
marginBottom: 12,
|
|
},
|
|
radarWrap: {
|
|
alignItems: 'center',
|
|
},
|
|
dimensionList: {
|
|
marginTop: 16,
|
|
},
|
|
dimensionItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginBottom: 10,
|
|
},
|
|
dimensionName: {
|
|
width: 70,
|
|
fontSize: 13,
|
|
color: colors.textSecondary,
|
|
},
|
|
dimensionTrack: {
|
|
flex: 1,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: colors.borderLight,
|
|
marginHorizontal: 10,
|
|
},
|
|
dimensionFill: {
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: colors.primary,
|
|
},
|
|
dimensionScore: {
|
|
width: 28,
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary,
|
|
textAlign: 'right',
|
|
},
|
|
tagRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
marginHorizontal: -4,
|
|
},
|
|
tag: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 7,
|
|
borderRadius: 18,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
margin: 4,
|
|
backgroundColor: colors.white,
|
|
},
|
|
tagText: {
|
|
fontSize: 13,
|
|
color: colors.textPrimary,
|
|
marginLeft: 4,
|
|
},
|
|
tagTextActive: {
|
|
color: colors.white,
|
|
fontWeight: '500',
|
|
},
|
|
textarea: {
|
|
height: 120,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
fontSize: 14,
|
|
color: colors.textPrimary,
|
|
backgroundColor: colors.pageBg,
|
|
},
|
|
charCount: {
|
|
textAlign: 'right',
|
|
fontSize: 12,
|
|
color: colors.textHint,
|
|
marginTop: 8,
|
|
},
|
|
suggestionRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: 8,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
marginBottom: 8,
|
|
},
|
|
suggestionDot: {
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
marginRight: 10,
|
|
},
|
|
suggestionBody: {
|
|
flex: 1,
|
|
},
|
|
suggestionTitle: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary,
|
|
marginBottom: 2,
|
|
},
|
|
suggestionDesc: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary,
|
|
},
|
|
salaryBox: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.infoLight,
|
|
borderRadius: 8,
|
|
padding: 8,
|
|
},
|
|
salaryRange: {
|
|
fontSize: 24,
|
|
fontWeight: '700',
|
|
color: colors.info,
|
|
},
|
|
salaryLevel: {
|
|
fontSize: 14,
|
|
color: colors.infoText,
|
|
marginTop: 4,
|
|
},
|
|
salaryNote: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary,
|
|
marginTop: 10,
|
|
},
|
|
footer: {
|
|
flexDirection: 'row',
|
|
padding: 16,
|
|
paddingBottom: 24,
|
|
backgroundColor: colors.white,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.borderLight,
|
|
},
|
|
draftBtn: {
|
|
flex: 1,
|
|
height: 46,
|
|
borderRadius: 24,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginRight: 12,
|
|
},
|
|
draftText: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary,
|
|
},
|
|
draftBtnDisabled: { opacity: 0.6 },
|
|
draftBanner: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
backgroundColor: colors.infoLight,
|
|
borderWidth: 1,
|
|
borderColor: colors.infoBorder,
|
|
borderRadius: 8,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 8,
|
|
marginBottom: 8,
|
|
},
|
|
draftBannerBody: { flex: 1 },
|
|
draftBannerTitle: { fontSize: 13, fontWeight: '700', color: colors.infoText },
|
|
draftBannerDesc: { fontSize: 11, color: colors.textSecondary, marginTop: 2 },
|
|
draftBannerBtn: {
|
|
backgroundColor: colors.infoBtn,
|
|
borderRadius: 14,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
},
|
|
draftBannerBtnText: { fontSize: 12, fontWeight: '700', color: colors.white },
|
|
draftBannerDiscard: { fontSize: 12, color: colors.textSecondary },
|
|
submitBtn: {
|
|
flex: 2,
|
|
height: 46,
|
|
borderRadius: 24,
|
|
backgroundColor: colors.primaryBtn,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
submitBtnDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
submitText: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: colors.white,
|
|
},
|
|
});
|
|
|