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.
602 lines
16 KiB
602 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, spacing} 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}>查看报告 ></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}>更多 ></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: 8,
|
|
},
|
|
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: spacing.framework,
|
|
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: 8,
|
|
padding: 8,
|
|
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: 8,
|
|
},
|
|
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: 8,
|
|
},
|
|
toolCard: {
|
|
flex: 1,
|
|
borderRadius: 8,
|
|
padding: 8,
|
|
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: 8,
|
|
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,
|
|
},
|
|
});
|
|
|