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

478 lines
15 KiB

import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { IndustrySkillWordCloud } from '../../components/IndustrySkillWordCloud';
import { useSkillCloud } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { SkillWord, TopSkill } from '../../src/types';
/** 技能分组 → 中文标签(用于选中详情) */
const GROUP_LABEL: Record<SkillWord['group'], string> = {
tech: '技术技能',
soft: '软技能',
domain: '领域能力',
tool: '工具',
};
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onSearch }: { onBack: () => void; onSearch: () => 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>
<Pressable style={styles.headerBtn} onPress={onSearch} hitSlop={8}>
<Ionicons name="search-outline" size={22} color={colors.textPrimary} />
</Pressable>
</View>
);
}
/* ===================== 子组件:行业标签 ===================== */
function IndustryTabs({
industries,
active,
onSelect,
}: {
industries: string[];
active: string;
onSelect: (industry: string) => void;
}) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabsWrap}
contentContainerStyle={styles.tabs}
>
{industries.map((item) => {
const isActive = item === active;
return (
<Pressable
key={item}
style={[styles.tab, isActive && styles.tabActive]}
onPress={() => onSelect(item)}
hitSlop={4}
>
<Text style={[styles.tabText, isActive && styles.tabTextActive]}>{item}</Text>
</Pressable>
);
})}
</ScrollView>
);
}
/* ===================== 子组件:筛选条件 ===================== */
function FilterBar({
items,
}: {
items: { label: string; value: string }[];
}) {
return (
<View style={styles.filterBar}>
{items.map((item) => (
<View key={item.label} style={styles.filterItem}>
<Text style={styles.filterValue}>{item.value}</Text>
<Ionicons name="chevron-down" size={14} color={colors.textHint} />
</View>
))}
</View>
);
}
/* ===================== 子组件:技能分类 ===================== */
function CategoryTabs({
categories,
activeId,
onSelect,
}: {
categories: { id: string; label: string }[];
activeId: string;
onSelect: (id: string) => void;
}) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryTabs}
>
{categories.map((c) => {
const isActive = c.id === activeId;
return (
<Pressable
key={c.id}
style={[styles.categoryTab, isActive && styles.categoryTabActive]}
onPress={() => onSelect(c.id)}
hitSlop={4}
>
<Text style={[styles.categoryText, isActive && styles.categoryTextActive]}>
{c.label}
</Text>
</Pressable>
);
})}
</ScrollView>
);
}
/* ===================== 子组件:热门技能 TOP10 ===================== */
function TopSkillList({ skills }: { skills: TopSkill[] }) {
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>热门技能 TOP 10</Text>
{skills.map((s) => (
<View key={s.id} style={styles.skillRow}>
<View style={styles.skillRank}>
<Text style={styles.skillRankText}>{s.rank}</Text>
</View>
<Text style={styles.skillName}>{s.name}</Text>
<View style={styles.skillTrack}>
<View style={[styles.skillFill, { width: `${s.demand}%` }]} />
</View>
<Text style={styles.skillDemand}>{s.demand}</Text>
<Text
style={[styles.skillChange, s.change >= 0 ? styles.skillUp : styles.skillDown]}
>
{s.change >= 0 ? '+' : ''}
{s.change}%
</Text>
</View>
))}
</View>
);
}
/* ===================== 子组件:技能匹配度 ===================== */
function MatchCard({ percent }: { percent: number }) {
return (
<View style={styles.matchCard}>
<View style={styles.matchHeader}>
<Text style={styles.matchTitle}>你的技能匹配度</Text>
<Text style={styles.matchPercent}>{percent}%</Text>
</View>
<View style={styles.matchTrack}>
<View style={[styles.matchFill, { width: `${percent}%` }]} />
</View>
<Pressable style={styles.matchBtn}>
<Ionicons name="sparkles" size={16} color={colors.white} />
<Text style={styles.matchBtnText}>生成技能学习路径</Text>
</Pressable>
</View>
);
}
/* ===================== 子组件:选中技能详情 ===================== */
function SelectedDetail({ word }: { word: SkillWord }) {
return (
<View style={styles.detailCard}>
<View style={styles.detailHead}>
<Text style={styles.detailWord}>{word.text}</Text>
<View style={styles.groupBadge}>
<Text style={styles.groupText}>{GROUP_LABEL[word.group]}</Text>
</View>
</View>
<Text style={styles.detailDesc}>行业热度权重 {word.weight} / 100,越靠中心越核心</Text>
<View style={styles.weightRow}>
<View style={styles.weightTrack}>
<View style={[styles.weightFill, { width: `${word.weight}%` }]} />
</View>
<Text style={styles.weightNum}>{word.weight}</Text>
</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="cloud-offline-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>
);
}
function Empty() {
return (
<View style={styles.center}>
<Ionicons name="search-outline" size={40} color={colors.textHint} />
<Text style={styles.tip}>该行业暂无技能词数据</Text>
</View>
);
}
/* ===================== 页面 ===================== */
export default function SkillCloudScreen() {
const router = useRouter();
const { industry, setIndustry, cloud, loading, error, refresh } = useSkillCloud();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeCategoryId, setActiveCategoryId] = useState<string>('all');
const selectedWord = cloud?.words.find((w) => w.id === selectedId) ?? null;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSearch={() => {}} />
{loading ? (
<Loading />
) : error ? (
<ErrorState onRetry={refresh} />
) : !cloud || cloud.words.length === 0 ? (
<Empty />
) : (
<>
<IndustryTabs
industries={cloud.filters.industries}
active={industry}
onSelect={(i) => {
setIndustry(i);
setSelectedId(null);
}}
/>
<FilterBar
items={[
{ label: '岗位', value: cloud.position },
{ label: '城市', value: cloud.city },
{ label: '经验', value: cloud.experience },
]}
/>
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>{cloud.industry} · 高频技能</Text>
<View style={styles.cloudCard}>
<IndustrySkillWordCloud
words={cloud.words}
selectedId={selectedId}
onSelect={(w) => setSelectedId(w.id)}
/>
</View>
<CategoryTabs
categories={cloud.categories}
activeId={activeCategoryId}
onSelect={setActiveCategoryId}
/>
{selectedWord ? (
<SelectedDetail word={selectedWord} />
) : (
<Text style={styles.hint}>点击词云中的技能,查看热度详情</Text>
)}
<TopSkillList skills={cloud.topSkills} />
<MatchCard percent={cloud.matchPercent} />
<Text style={styles.updated}>{cloud.updatedAt}</Text>
</ScrollView>
</>
)}
</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 },
tabsWrap: { backgroundColor: colors.white, maxHeight: 52 },
tabs: { flexDirection: 'row', paddingHorizontal: 16, gap: 8, paddingVertical: 10 },
tab: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: colors.pageBg,
},
tabActive: { backgroundColor: colors.primary },
tabText: { fontSize: 13, color: colors.textSecondary, fontWeight: '500' },
tabTextActive: { color: colors.textInverse, fontWeight: '600' },
filterBar: {
flexDirection: 'row',
backgroundColor: colors.white,
paddingHorizontal: 16,
paddingBottom: 12,
gap: 10,
},
filterItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.pageBg,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 5,
},
filterValue: { fontSize: 12, color: colors.textPrimary, fontWeight: '600' },
categoryTabs: { flexDirection: 'row', paddingVertical: 12, gap: 8 },
categoryTab: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
categoryTabActive: { backgroundColor: colors.primaryLight, borderColor: colors.primary },
categoryText: { fontSize: 12, color: colors.textSecondary },
categoryTextActive: { color: colors.primaryText, fontWeight: '600' },
body: { paddingHorizontal: 16, paddingBottom: 32 },
sectionTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 10 },
cloudCard: {
backgroundColor: colors.white,
borderRadius: 14,
paddingVertical: 8,
paddingHorizontal: 4,
borderWidth: 1,
borderColor: colors.borderLight,
},
hint: { marginTop: 14, textAlign: 'center', fontSize: 13, color: colors.textHint },
updated: { marginTop: 16, textAlign: 'center', fontSize: 12, color: colors.textHint },
card: {
marginTop: 16,
backgroundColor: colors.white,
borderRadius: 14,
padding: 16,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
skillRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
skillRank: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
},
skillRankText: { fontSize: 11, fontWeight: '700', color: colors.primaryText },
skillName: { width: 90, fontSize: 13, color: colors.textPrimary, marginLeft: 10 },
skillTrack: {
flex: 1,
height: 6,
borderRadius: 3,
backgroundColor: colors.borderLight,
overflow: 'hidden',
},
skillFill: { height: 6, borderRadius: 3, backgroundColor: colors.primary },
skillDemand: {
width: 30,
fontSize: 12,
fontWeight: '700',
color: colors.textPrimary,
textAlign: 'right',
},
skillChange: { width: 44, fontSize: 11, textAlign: 'right' },
skillUp: { color: colors.successText },
skillDown: { color: colors.danger },
matchCard: {
marginTop: 16,
backgroundColor: colors.white,
borderRadius: 14,
padding: 16,
borderWidth: 1,
borderColor: colors.borderLight,
},
matchHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
matchTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
matchPercent: { fontSize: 20, fontWeight: '800', color: colors.primary },
matchTrack: {
marginTop: 12,
height: 10,
borderRadius: 5,
backgroundColor: colors.borderLight,
overflow: 'hidden',
},
matchFill: { height: 10, borderRadius: 5, backgroundColor: colors.primary },
matchBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
backgroundColor: colors.primaryBtn,
borderRadius: 12,
paddingVertical: 12,
},
matchBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 },
detailCard: {
marginTop: 12,
backgroundColor: colors.white,
borderRadius: 14,
padding: 16,
borderWidth: 1,
borderColor: colors.successBorder,
},
detailHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
detailWord: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
groupBadge: {
backgroundColor: colors.successLight,
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 4,
},
groupText: { fontSize: 12, color: colors.successText, fontWeight: '600' },
detailDesc: { marginTop: 8, fontSize: 13, color: colors.textSecondary },
weightRow: { marginTop: 12, flexDirection: 'row', alignItems: 'center', gap: 10 },
weightTrack: { flex: 1, height: 8, borderRadius: 4, backgroundColor: colors.borderLight },
weightFill: { height: 8, borderRadius: 4, backgroundColor: colors.success },
weightNum: {
fontSize: 13,
fontWeight: '700',
color: colors.successText,
width: 28,
textAlign: 'right',
},
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 },
});