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
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 },
|
|
});
|
|
|