import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { IndustrySkillWordCloud } from '../../components/IndustrySkillWordCloud'; import { useSkillCloud, useSkillLearningPath } from '../../src/hooks'; import { colors } from '../../src/theme'; import type { SkillLearningPath, SkillWord, TopSkill } from '../../src/types'; /** 技能分组 → 中文标签(用于选中详情) */ const GROUP_LABEL: Record = { tech: '技术技能', soft: '软技能', domain: '领域能力', tool: '工具', }; /* ===================== 子组件:顶部导航 ===================== */ function Header({ onBack, onToggleSearch, searching, }: { onBack: () => void; onToggleSearch: () => void; searching: boolean; }) { return ( 行业技能词云 ); } /* ===================== 子组件:搜索栏 ===================== */ function SearchBar({ value, onChange, onClear, }: { value: string; onChange: (text: string) => void; onClear: () => void; }) { return ( {value !== '' && ( )} ); } /* ===================== 子组件:行业标签 ===================== */ function IndustryTabs({ industries, active, onSelect, }: { industries: string[]; active: string; onSelect: (industry: string) => void; }) { return ( {industries.map((item) => { const isActive = item === active; return ( onSelect(item)} hitSlop={4} > {item} ); })} ); } /* ===================== 子组件:筛选条件 ===================== */ function FilterBar({ items, }: { items: { label: string; value: string }[]; }) { return ( {items.map((item) => ( {item.value} ))} ); } /* ===================== 子组件:技能分类 ===================== */ function CategoryTabs({ categories, activeId, onSelect, }: { categories: { id: string; label: string }[]; activeId: string; onSelect: (id: string) => void; }) { return ( {categories.map((c) => { const isActive = c.id === activeId; return ( onSelect(c.id)} hitSlop={4} > {c.label} ); })} ); } /* ===================== 子组件:热门技能 TOP10 ===================== */ function TopSkillList({ skills }: { skills: TopSkill[] }) { return ( 热门技能 TOP 10 {skills.map((s) => ( {s.rank} {s.name} {s.demand} = 0 ? styles.skillUp : styles.skillDown]} > {s.change >= 0 ? '+' : ''} {s.change}% ))} ); } /* ===================== 子组件:技能匹配度 ===================== */ function MatchCard({ percent, onGenerate, generating, }: { percent: number; onGenerate: () => void; generating: boolean; }) { return ( 你的技能匹配度 {percent}% {generating ? ( ) : ( )} {generating ? '正在生成…' : '生成技能学习路径'} ); } /* ===================== 子组件:学习路径弹窗 ===================== */ function LearningPathModal({ visible, path, loading, error, onRetry, onClose, }: { visible: boolean; path: SkillLearningPath | null; loading: boolean; error: Error | null; onRetry: () => void; onClose: () => void; }) { return ( 技能学习路径 {loading ? ( 正在按行业热度排优先级… ) : error ? ( {error.message} 点击重试 ) : path ? ( {path.industry} · {path.position} 当前匹配度 {path.matchPercent}% → 补齐后约 {path.targetPercent}% 按行业热度权重从高到低排,先补权重最高的 {path.items.length} 项 {path.items.map((item) => ( {item.priority} {item.name} {GROUP_LABEL[item.group]} {item.reason} {item.weight} ))} {path.generatedAt} ) : null} ); } /* ===================== 子组件:选中技能详情 ===================== */ function SelectedDetail({ word }: { word: SkillWord }) { return ( {word.text} {GROUP_LABEL[word.group]} 行业热度权重 {word.weight} / 100,越靠中心越核心 {word.weight} ); } /* ===================== 子组件:四态 ===================== */ function Loading() { return ( 正在生成词云… ); } function ErrorState({ onRetry }: { onRetry: () => void }) { return ( 词云加载失败 点击重试 ); } function Empty() { return ( 该行业暂无技能词数据 ); } /* ===================== 页面 ===================== */ export default function SkillCloudScreen() { const router = useRouter(); const { industry, setIndustry, cloud, keyword, setKeyword, words, isEmpty, loading, error, refresh, } = useSkillCloud(); const [selectedId, setSelectedId] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState('all'); const [searching, setSearching] = useState(false); const [pathVisible, setPathVisible] = useState(false); const { path, loading: generating, error: pathError, generate, reset } = useSkillLearningPath(); const selectedWord = words.find((w) => w.id === selectedId) ?? null; const handleGenerate = async () => { // 先开弹窗再请求,弹窗自己有 loading / 失败重试三态,免得点完按钮没反应 setPathVisible(true); await generate(industry); }; const closePathModal = () => { setPathVisible(false); reset(); }; const toggleSearch = () => { if (searching) setKeyword(''); setSearching((prev) => !prev); }; return (
router.back()} onToggleSearch={toggleSearch} searching={searching} /> {loading ? ( ) : error ? ( ) : !cloud || cloud.words.length === 0 ? ( ) : ( <> {searching && ( setKeyword('')} /> )} { setIndustry(i); setSelectedId(null); }} /> {cloud.industry} · {keyword.trim() === '' ? '高频技能' : `含「${keyword.trim()}」的技能`} {isEmpty ? ( 没有匹配「{keyword.trim()}」的技能 setKeyword('')} hitSlop={6}> 清空关键词 ) : ( setSelectedId(w.id)} /> )} {selectedWord ? ( ) : ( 点击词云中的技能,查看热度详情 )} {cloud.updatedAt} )} void generate(industry)} onClose={closePathModal} /> ); } 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 }, matchBtnDisabled: { backgroundColor: colors.textDisabled }, searchBar: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: colors.white, paddingHorizontal: 16, paddingBottom: 10, }, searchInput: { flex: 1, backgroundColor: colors.pageBg, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 8, fontSize: 14, color: colors.textPrimary, }, searchEmpty: { alignItems: 'center', gap: 10, paddingVertical: 32, backgroundColor: colors.white, borderRadius: 14, borderWidth: 1, borderColor: colors.borderLight, }, modalMask: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' }, modalSheet: { backgroundColor: colors.white, borderTopLeftRadius: 18, borderTopRightRadius: 18, maxHeight: '80%', paddingBottom: 20, }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, modalCenter: { alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 48 }, modalBody: { paddingHorizontal: 16, paddingBottom: 12 }, pathSummary: { backgroundColor: colors.primaryLight, borderRadius: 12, padding: 14, marginTop: 14, marginBottom: 4, }, pathSummaryText: { fontSize: 14, fontWeight: '700', color: colors.primaryText }, pathSummaryGap: { marginTop: 6, fontSize: 13, color: colors.primaryText }, pathSummaryHint: { marginTop: 6, fontSize: 12, color: colors.textSecondary }, pathItem: { flexDirection: 'row', paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, pathPriority: { width: 24, height: 24, borderRadius: 12, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center', }, pathPriorityText: { fontSize: 12, fontWeight: '800', color: colors.white }, pathItemBody: { flex: 1, marginLeft: 12 }, pathItemHead: { flexDirection: 'row', alignItems: 'center', gap: 8 }, pathItemName: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, pathItemReason: { marginTop: 6, fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, 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 }, });