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

763 lines
23 KiB

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<SkillWord['group'], string> = {
tech: '技术技能',
soft: '软技能',
domain: '领域能力',
tool: '工具',
};
/* ===================== 子组件:顶部导航 ===================== */
function Header({
onBack,
onToggleSearch,
searching,
}: {
onBack: () => void;
onToggleSearch: () => void;
searching: boolean;
}) {
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={onToggleSearch} hitSlop={8}>
<Ionicons
name={searching ? 'close' : 'search-outline'}
size={22}
color={colors.textPrimary}
/>
</Pressable>
</View>
);
}
/* ===================== 子组件:搜索栏 ===================== */
function SearchBar({
value,
onChange,
onClear,
}: {
value: string;
onChange: (text: string) => void;
onClear: () => void;
}) {
return (
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={16} color={colors.textHint} />
<TextInput
style={styles.searchInput}
value={value}
onChangeText={onChange}
placeholder="搜索技能,如 React"
placeholderTextColor={colors.textHint}
autoFocus
returnKeyType="search"
/>
{value !== '' && (
<Pressable onPress={onClear} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</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,
onGenerate,
generating,
}: {
percent: number;
onGenerate: () => void;
generating: boolean;
}) {
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, generating && styles.matchBtnDisabled]}
onPress={onGenerate}
disabled={generating}
>
{generating ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Ionicons name="sparkles" size={16} color={colors.white} />
)}
<Text style={styles.matchBtnText}>
{generating ? '正在生成…' : '生成技能学习路径'}
</Text>
</Pressable>
</View>
);
}
/* ===================== 子组件:学习路径弹窗 ===================== */
function LearningPathModal({
visible,
path,
loading,
error,
onRetry,
onClose,
}: {
visible: boolean;
path: SkillLearningPath | null;
loading: boolean;
error: Error | null;
onRetry: () => void;
onClose: () => void;
}) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={styles.modalMask}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>技能学习路径</Text>
<Pressable onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
{loading ? (
<View style={styles.modalCenter}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.tip}>正在按行业热度排优先级…</Text>
</View>
) : error ? (
<View style={styles.modalCenter}>
<Text style={styles.tip}>{error.message}</Text>
<Pressable style={styles.retryBtn} onPress={onRetry} hitSlop={6}>
<Text style={styles.retryText}>点击重试</Text>
</Pressable>
</View>
) : path ? (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.modalBody}>
<View style={styles.pathSummary}>
<Text style={styles.pathSummaryText}>
{path.industry} · {path.position}
</Text>
<Text style={styles.pathSummaryGap}>
当前匹配度 {path.matchPercent}% → 补齐后约 {path.targetPercent}%
</Text>
<Text style={styles.pathSummaryHint}>
按行业热度权重从高到低排,先补权重最高的 {path.items.length} 项
</Text>
</View>
{path.items.map((item) => (
<View key={item.id} style={styles.pathItem}>
<View style={styles.pathPriority}>
<Text style={styles.pathPriorityText}>{item.priority}</Text>
</View>
<View style={styles.pathItemBody}>
<View style={styles.pathItemHead}>
<Text style={styles.pathItemName}>{item.name}</Text>
<View style={styles.groupBadge}>
<Text style={styles.groupText}>{GROUP_LABEL[item.group]}</Text>
</View>
</View>
<Text style={styles.pathItemReason}>{item.reason}</Text>
<View style={styles.weightRow}>
<View style={styles.weightTrack}>
<View style={[styles.weightFill, { width: `${item.weight}%` }]} />
</View>
<Text style={styles.weightNum}>{item.weight}</Text>
</View>
</View>
</View>
))}
<Text style={styles.updated}>{path.generatedAt}</Text>
</ScrollView>
) : null}
</View>
</View>
</Modal>
);
}
/* ===================== 子组件:选中技能详情 ===================== */
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,
keyword,
setKeyword,
words,
isEmpty,
loading,
error,
refresh,
} = useSkillCloud();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeCategoryId, setActiveCategoryId] = useState<string>('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 (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onToggleSearch={toggleSearch} searching={searching} />
{loading ? (
<Loading />
) : error ? (
<ErrorState onRetry={refresh} />
) : !cloud || cloud.words.length === 0 ? (
<Empty />
) : (
<>
{searching && (
<SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
)}
<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} · {keyword.trim() === '' ? '高频技能' : `含「${keyword.trim()}」的技能`}
</Text>
{isEmpty ? (
<View style={styles.searchEmpty}>
<Ionicons name="search-outline" size={32} color={colors.textHint} />
<Text style={styles.tip}>没有匹配「{keyword.trim()}」的技能</Text>
<Pressable style={styles.retryBtn} onPress={() => setKeyword('')} hitSlop={6}>
<Text style={styles.retryText}>清空关键词</Text>
</Pressable>
</View>
) : (
<View style={styles.cloudCard}>
<IndustrySkillWordCloud
words={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}
onGenerate={handleGenerate}
generating={generating}
/>
<Text style={styles.updated}>{cloud.updatedAt}</Text>
</ScrollView>
</>
)}
<LearningPathModal
visible={pathVisible}
path={path}
loading={generating}
error={pathError}
onRetry={() => void generate(industry)}
onClose={closePathModal}
/>
</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 },
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 },
});