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

603 lines
16 KiB

import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import type { ComponentProps } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon } from 'react-native-svg';
import { useGrowthData } from '../../src/hooks';
import { articleThumbnailColors, colors, headerGradient, jobLevelTextColors } from '../../src/theme';
import { formatThousands, formatViews } from '../../src/utils';
import { JOB_LEVEL_LABEL } from '../../src/constants';
import type { AbilityDimension } from '../../src/types';
type IconName = ComponentProps<typeof Ionicons>['name'];
/* ===================== 雷达图 ===================== */
const RADAR_SIZE = 132;
const RADAR_CENTER = RADAR_SIZE / 2;
/** 最大绘制半径(留边距,避免顶点被裁切) */
const RADAR_MAX_RADIUS = RADAR_SIZE / 2 - 4;
/** 计算雷达图第 index 个顶点的坐标(从正上方开始,顺时针) */
function radarPoint(index: number, total: number, radius: number) {
const angle = (Math.PI * 2 * index) / total - Math.PI / 2;
return {
x: RADAR_CENTER + radius * Math.cos(angle),
y: RADAR_CENTER + radius * Math.sin(angle),
};
}
/**
* 四维能力雷达图:3 层网格 + 轴线 + 数据填充多边形 + 顶点圆点
*/
function RadarChart({ dimensions }: { dimensions: AbilityDimension[] }) {
const total = dimensions.length;
// 3 层网格(1/3、2/3、3/3 半径)
const gridPolygons = [1, 2, 3].map((level) =>
dimensions
.map((_, i) => {
const p = radarPoint(i, total, (RADAR_MAX_RADIUS * level) / 3);
return `${p.x},${p.y}`;
})
.join(' '),
);
// 轴线(中心 → 顶点)
const axes = dimensions.map((_, i) => radarPoint(i, total, RADAR_MAX_RADIUS));
// 数据多边形 + 顶点
const dataVertices = dimensions.map((d, i) =>
radarPoint(i, total, (RADAR_MAX_RADIUS * d.value) / 100),
);
const dataPoints = dataVertices.map((p) => `${p.x},${p.y}`).join(' ');
return (
<Svg width={RADAR_SIZE} height={RADAR_SIZE}>
{gridPolygons.map((points, i) => (
<Polygon
key={`grid-${i}`}
points={points}
fill="none"
stroke={colors.borderStrong}
strokeWidth={1}
/>
))}
{axes.map((p, i) => (
<Line
key={`axis-${i}`}
x1={RADAR_CENTER}
y1={RADAR_CENTER}
x2={p.x}
y2={p.y}
stroke={colors.borderStrong}
strokeWidth={1}
/>
))}
<Polygon
points={dataPoints}
fill={colors.primary}
fillOpacity={0.22}
stroke={colors.primary}
strokeWidth={2}
/>
{dataVertices.map((p, i) => (
<Circle key={`dot-${i}`} cx={p.x} cy={p.y} r={3} fill={colors.primary} />
))}
</Svg>
);
}
/* ===================== 配色映射 ===================== */
/** AI 工具卡片色调映射 */
const TOOL_STYLE = {
primary: {
bg: colors.primaryLight,
border: colors.primaryBorder,
icon: colors.primary,
action: colors.primaryText,
},
success: {
bg: colors.successLight,
border: colors.successBorder,
icon: colors.success,
action: colors.successText,
},
} as const;
/* ===================== 页面 ===================== */
/**
* 成长中心页
*/
export default function GrowthScreen() {
const router = useRouter();
const { holland, ability, aiTools, articles, loading, error, refresh } = useGrowthData();
// 错误态
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
// 加载态
if (loading || !holland || !ability) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 1. 顶部导航 */}
<LinearGradient
colors={headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<Text style={styles.headerTitle}>成长中心</Text>
<Pressable style={styles.headerAction} onPress={() => router.push('/settings')}>
<Ionicons name="settings-outline" size={22} color={colors.textInverse} />
</Pressable>
</LinearGradient>
{/* 2. 霍兰德职业兴趣测评 Hero Card */}
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.heroCard}
>
<View style={styles.heroIcon}>
<Ionicons name="compass" size={30} color={colors.primary} />
</View>
<View style={styles.heroContent}>
<Text style={styles.heroTitle}>霍兰德职业兴趣测评</Text>
<Text style={styles.heroSubtitle}>发现你的职业性格类型</Text>
<View style={styles.heroBottom}>
<Pressable style={styles.heroButton} onPress={() => router.push('/holland')}>
<Text style={styles.heroButtonText}>立即测评</Text>
</Pressable>
<Text style={styles.heroParticipants}>
已有 {formatThousands(holland.totalParticipants)} 人完成
</Text>
</View>
</View>
</LinearGradient>
{/* 3. 四维能力测评 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>四维能力测评</Text>
<Pressable onPress={() => router.push('/ability')}>
<Text style={styles.sectionMore}>查看报告 &gt;</Text>
</Pressable>
</View>
<View style={styles.abilityCard}>
<View style={styles.abilityTop}>
<RadarChart dimensions={ability.dimensions} />
<View style={styles.scoreBlock}>
<View style={styles.scoreRow}>
<Text style={styles.scoreNumber}>{ability.overallScore}</Text>
<Text style={styles.scoreUnit}>分</Text>
<View style={styles.levelBadge}>
<Text style={[styles.levelBadgeText, { color: jobLevelTextColors[ability.level] }]}>
{JOB_LEVEL_LABEL[ability.level]}
</Text>
</View>
</View>
<View style={styles.bars}>
{ability.dimensions.map((dim) => (
<View key={dim.label} style={styles.barRow}>
<Text style={styles.barLabel}>{dim.label}</Text>
<View style={styles.barTrack}>
<View style={[styles.barFill, { width: `${dim.value}%` }]} />
</View>
<Text style={styles.barValue}>{dim.value}</Text>
</View>
))}
</View>
</View>
</View>
<Pressable style={styles.retestBtn} onPress={() => router.push('/ability')}>
<Text style={styles.retestText}>重新测评</Text>
</Pressable>
</View>
{/* 4. AI 求职工具 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>AI求职工具</Text>
</View>
{aiTools.length === 0 ? (
<View style={styles.emptyBox}>
<Ionicons name="construct-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyText}>暂无可用工具</Text>
</View>
) : (
<View style={styles.toolsRow}>
{aiTools.map((tool) => {
const style = TOOL_STYLE[tool.variant];
return (
<Pressable
key={tool.id}
style={[styles.toolCard, { backgroundColor: style.bg, borderColor: style.border }]}
// 目标页由接口层下发,页面不再自己写 id→路由的映射
onPress={() => router.push(tool.route as never)}
>
<Ionicons name={tool.icon as IconName} size={28} color={style.icon} />
<Text style={styles.toolTitle}>{tool.title}</Text>
<Text style={styles.toolDesc}>{tool.description}</Text>
<Text style={[styles.toolAction, { color: style.action }]}>{tool.action} →</Text>
</Pressable>
);
})}
</View>
)}
{/* 5. 职场干货 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>职场干货</Text>
{/* 没有文章时不渲染「更多」—— 否则点了没反应 */}
{articles.length > 0 && (
<Pressable onPress={() => router.push(`/article/${articles[0].id}`)}>
<Text style={styles.sectionMore}>更多 &gt;</Text>
</Pressable>
)}
</View>
{/* 文章为空时标题与「更多」仍在,下方却空无一物 —— 给出空态 */}
{articles.length === 0 && (
<View style={styles.emptyBox}>
<Ionicons name="newspaper-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyText}>暂无干货文章</Text>
</View>
)}
{articles.map((article) => {
const thumb = articleThumbnailColors[article.thumbnailColor];
return (
<Pressable
key={article.id}
style={styles.articleCard}
onPress={() => router.push(`/article/${article.id}`)}
>
<View style={[styles.articleThumb, { backgroundColor: thumb.bg }]}>
<Ionicons name={article.icon as IconName} size={26} color={thumb.color} />
</View>
<View style={styles.articleBody}>
<Text style={styles.articleTitle} numberOfLines={2}>
{article.title}
</Text>
<View style={styles.articleMeta}>
<Text style={styles.articleTag}>{article.tag}</Text>
<Text style={styles.articleViews}>{formatViews(article.views)}阅读</Text>
</View>
</View>
</Pressable>
);
})}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
stateWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 8,
paddingBottom: 24,
},
/* ---- 顶部导航 ---- */
header: {
height: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textInverse,
},
headerAction: {
position: 'absolute',
right: 0,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
/* ---- 霍兰德 Hero Card ---- */
heroCard: {
flexDirection: 'row',
marginTop: 8,
borderRadius: 8,
padding: 20,
},
heroIcon: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: colors.white,
alignItems: 'center',
justifyContent: 'center',
},
heroContent: {
flex: 1,
marginLeft: 14,
},
heroTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.white,
},
heroSubtitle: {
marginTop: 6,
fontSize: 13,
color: colors.white,
opacity: 0.9,
},
heroBottom: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
},
heroButton: {
borderRadius: 18,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: colors.white,
},
heroButtonText: {
fontSize: 14,
fontWeight: '600',
color: colors.primaryText,
},
heroParticipants: {
marginLeft: 10,
fontSize: 12,
color: colors.white,
opacity: 0.8,
},
/* ---- 通用分区标题 ---- */
emptyBox: {
backgroundColor: colors.surface,
borderRadius: 12,
paddingVertical: 32,
paddingHorizontal: 16,
alignItems: 'center',
gap: 8,
},
emptyText: { fontSize: 14, fontWeight: '600', color: colors.textSecondary },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
marginBottom: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
sectionMore: {
fontSize: 13,
color: colors.primary,
},
/* ---- 四维能力测评卡片 ---- */
abilityCard: {
backgroundColor: colors.white,
borderRadius: 8,
padding: 16,
},
abilityTop: {
flexDirection: 'row',
alignItems: 'center',
},
scoreBlock: {
flex: 1,
marginLeft: 16,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
},
scoreNumber: {
fontSize: 40,
fontWeight: '700',
color: colors.textPrimary,
},
scoreUnit: {
fontSize: 13,
color: colors.textSecondary,
marginLeft: 4,
marginTop: 16,
},
levelBadge: {
marginLeft: 10,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 8,
backgroundColor: colors.primaryLight,
},
levelBadgeText: {
fontSize: 12,
fontWeight: '600',
},
bars: {
marginTop: 12,
},
barRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
barLabel: {
width: 52,
fontSize: 12,
color: colors.textSecondary,
},
barTrack: {
flex: 1,
height: 6,
borderRadius: 3,
backgroundColor: colors.borderLight,
overflow: 'hidden',
},
barFill: {
height: 6,
borderRadius: 3,
backgroundColor: colors.primary,
},
barValue: {
width: 26,
marginLeft: 6,
fontSize: 12,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'right',
},
retestBtn: {
marginTop: 16,
alignSelf: 'center',
paddingHorizontal: 28,
paddingVertical: 9,
borderRadius: 20,
backgroundColor: colors.primaryLight,
},
retestText: {
fontSize: 14,
fontWeight: '600',
color: colors.primaryText,
},
/* ---- AI 求职工具 ---- */
toolsRow: {
flexDirection: 'row',
gap: 12,
},
toolCard: {
flex: 1,
borderRadius: 8,
padding: 16,
borderWidth: 1,
},
toolTitle: {
marginTop: 12,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
toolDesc: {
marginTop: 6,
fontSize: 12,
color: colors.textSecondary,
},
toolAction: {
marginTop: 12,
fontSize: 13,
fontWeight: '600',
},
/* ---- 职场干货 ---- */
articleCard: {
flexDirection: 'row',
backgroundColor: colors.white,
borderRadius: 8,
padding: 12,
marginBottom: 8,
},
articleThumb: {
width: 96,
height: 72,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
articleBody: {
flex: 1,
marginLeft: 12,
justifyContent: 'space-between',
},
articleTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
lineHeight: 20,
},
articleMeta: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 8,
},
articleTag: {
fontSize: 11,
color: colors.primaryText,
backgroundColor: colors.primaryLight,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
overflow: 'hidden',
},
articleViews: {
fontSize: 11,
color: colors.textHint,
},
});