Browse Source
P1 收藏/历史占位页补真实数据: - 新增 fetchFavorites/fetchHistory 接口(api.ts + index.ts 登记) - 新增 useFavorites/useHistory hooks 并注册导出 - 新增复用组件 JobCard,favorites/history 接入真实列表+三态+点击进 /jobs/[id] P2 空 onPress 接线: - growth 设置→/settings、文章卡与"更多"→/article/[id] - legal 四分支:chat/templates/faq/upgrade 分别跳对应子页 - 新增 articles 详情页 /article/[id] 与 legal 三子页(faq/upgrade/templates) - appeal 提交成功弹 Alert 并 router.back()master
15 changed files with 985 additions and 21 deletions
@ -0,0 +1,162 @@ |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { useLocalSearchParams, useRouter } from 'expo-router'; |
|||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import type { ComponentProps } from 'react'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { useGrowthData } from '../../src/hooks'; |
|||
import { LoadingState, EmptyState } from '../../src/components/StateView'; |
|||
import { colors } from '../../src/theme'; |
|||
import { formatViews } from '../../src/utils'; |
|||
import type { Article } from '../../src/types'; |
|||
|
|||
type IconName = ComponentProps<typeof Ionicons>['name']; |
|||
|
|||
const THUMBNAIL_BG: Record<Article['thumbnailColor'], string> = { |
|||
violet: colors.infoLight, |
|||
orange: colors.actionLight, |
|||
green: colors.successLight, |
|||
}; |
|||
const THUMBNAIL_COLOR: Record<Article['thumbnailColor'], string> = { |
|||
violet: colors.info, |
|||
orange: colors.action, |
|||
green: colors.success, |
|||
}; |
|||
|
|||
/* ===================== 顶部导航 ===================== */ |
|||
|
|||
function Header({ onBack }: { onBack: () => 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> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 页面 ===================== */ |
|||
|
|||
export default function ArticleScreen() { |
|||
const router = useRouter(); |
|||
const { id } = useLocalSearchParams<{ id: string }>(); |
|||
const { articles, loading, error, refresh } = useGrowthData(); |
|||
|
|||
const article = articles.find((a) => a.id === id); |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.safeArea} edges={['top']}> |
|||
<Header onBack={() => router.back()} /> |
|||
{loading ? ( |
|||
<LoadingState text="加载文章中…" /> |
|||
) : error ? ( |
|||
<EmptyState |
|||
icon="alert-circle-outline" |
|||
title="加载失败" |
|||
subtitle="请稍后重试" |
|||
actionLabel="重试" |
|||
onAction={refresh} |
|||
/> |
|||
) : !article ? ( |
|||
<EmptyState icon="document-text-outline" title="文章不存在" subtitle="该内容可能已下架" /> |
|||
) : ( |
|||
<ScrollView |
|||
style={styles.scroll} |
|||
contentContainerStyle={styles.content} |
|||
showsVerticalScrollIndicator={false} |
|||
> |
|||
<View style={[styles.thumb, { backgroundColor: THUMBNAIL_BG[article.thumbnailColor] }]}> |
|||
<Ionicons |
|||
name={article.icon as IconName} |
|||
size={40} |
|||
color={THUMBNAIL_COLOR[article.thumbnailColor]} |
|||
/> |
|||
</View> |
|||
<Text style={styles.title}>{article.title}</Text> |
|||
<View style={styles.meta}> |
|||
<Text style={styles.tag}>{article.tag}</Text> |
|||
<Text style={styles.views}>{formatViews(article.views)}阅读</Text> |
|||
</View> |
|||
<Text style={styles.body}> |
|||
{`在求职与职业发展的道路上,清晰的自我认知与扎实的准备同样重要。本文围绕「${article.title}」展开,帮助你建立系统的应对思路——从自我定位、能力补齐到面试表达,形成可落地的行动清单。\n\n(以下为正文占位内容,后端就绪后由接口返回真实文章正文。)`} |
|||
</Text> |
|||
</ScrollView> |
|||
)} |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
safeArea: { |
|||
flex: 1, |
|||
backgroundColor: colors.pageBg, |
|||
}, |
|||
header: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
paddingHorizontal: 8, |
|||
height: 48, |
|||
backgroundColor: colors.white, |
|||
borderBottomWidth: 1, |
|||
borderBottomColor: colors.borderLight, |
|||
}, |
|||
headerBtn: { |
|||
width: 40, |
|||
height: 40, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
headerTitle: { |
|||
fontSize: 17, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
scroll: { |
|||
flex: 1, |
|||
}, |
|||
content: { |
|||
padding: 16, |
|||
paddingBottom: 24, |
|||
}, |
|||
thumb: { |
|||
width: 72, |
|||
height: 72, |
|||
borderRadius: 16, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
marginBottom: 14, |
|||
}, |
|||
title: { |
|||
fontSize: 20, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
lineHeight: 28, |
|||
}, |
|||
meta: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
marginTop: 10, |
|||
marginBottom: 16, |
|||
}, |
|||
tag: { |
|||
fontSize: 11, |
|||
color: colors.primaryText, |
|||
backgroundColor: colors.primaryLight, |
|||
paddingHorizontal: 8, |
|||
paddingVertical: 2, |
|||
borderRadius: 8, |
|||
overflow: 'hidden', |
|||
marginRight: 10, |
|||
}, |
|||
views: { |
|||
fontSize: 12, |
|||
color: colors.textHint, |
|||
}, |
|||
body: { |
|||
fontSize: 14, |
|||
lineHeight: 24, |
|||
color: colors.textSecondary, |
|||
}, |
|||
}); |
|||
@ -1,14 +1,86 @@ |
|||
import { EmptyState } from '../src/components/StateView'; |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { useRouter } from 'expo-router'; |
|||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { useFavorites } from '../src/hooks'; |
|||
import { JobCard } from '../src/components/JobCard'; |
|||
import { LoadingState, ErrorState, EmptyState } from '../src/components/StateView'; |
|||
import { colors } from '../src/theme'; |
|||
|
|||
/** |
|||
* 我的收藏页(占位) |
|||
* 我的收藏页(职位) |
|||
*/ |
|||
export default function FavoritesScreen() { |
|||
const router = useRouter(); |
|||
const { items, loading, error, refresh, isEmpty } = useFavorites(); |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.safeArea} edges={['top']}> |
|||
<View style={styles.header}> |
|||
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={8}> |
|||
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> |
|||
</Pressable> |
|||
<Text style={styles.headerTitle}>我的收藏</Text> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
|
|||
{loading ? ( |
|||
<LoadingState text="加载收藏中…" /> |
|||
) : error ? ( |
|||
<ErrorState message="收藏加载失败,请重试" onRetry={refresh} /> |
|||
) : isEmpty ? ( |
|||
<EmptyState |
|||
icon="heart-outline" |
|||
title="我的收藏" |
|||
subtitle="收藏页面开发中,敬请期待" |
|||
title="还没有收藏" |
|||
subtitle="浏览职位时点击收藏,会在这里出现" |
|||
/> |
|||
) : ( |
|||
<ScrollView |
|||
style={styles.scroll} |
|||
contentContainerStyle={styles.content} |
|||
showsVerticalScrollIndicator={false} |
|||
> |
|||
{items.map((job) => ( |
|||
<JobCard key={job.id} job={job} onPress={() => router.push(`/jobs/${job.id}`)} /> |
|||
))} |
|||
</ScrollView> |
|||
)} |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
safeArea: { |
|||
flex: 1, |
|||
backgroundColor: colors.pageBg, |
|||
}, |
|||
header: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
paddingHorizontal: 8, |
|||
height: 48, |
|||
backgroundColor: colors.white, |
|||
borderBottomWidth: 1, |
|||
borderBottomColor: colors.borderLight, |
|||
}, |
|||
headerBtn: { |
|||
width: 40, |
|||
height: 40, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
headerTitle: { |
|||
fontSize: 17, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
scroll: { |
|||
flex: 1, |
|||
}, |
|||
content: { |
|||
paddingHorizontal: 12, |
|||
paddingTop: 12, |
|||
paddingBottom: 24, |
|||
}, |
|||
}); |
|||
|
|||
@ -1,14 +1,86 @@ |
|||
import { EmptyState } from '../src/components/StateView'; |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { useRouter } from 'expo-router'; |
|||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { useHistory } from '../src/hooks'; |
|||
import { JobCard } from '../src/components/JobCard'; |
|||
import { LoadingState, ErrorState, EmptyState } from '../src/components/StateView'; |
|||
import { colors } from '../src/theme'; |
|||
|
|||
/** |
|||
* 浏览历史页(占位) |
|||
* 浏览历史页(职位) |
|||
*/ |
|||
export default function HistoryScreen() { |
|||
const router = useRouter(); |
|||
const { items, loading, error, refresh, isEmpty } = useHistory(); |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.safeArea} edges={['top']}> |
|||
<View style={styles.header}> |
|||
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={8}> |
|||
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> |
|||
</Pressable> |
|||
<Text style={styles.headerTitle}>浏览历史</Text> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
|
|||
{loading ? ( |
|||
<LoadingState text="加载历史中…" /> |
|||
) : error ? ( |
|||
<ErrorState message="历史加载失败,请重试" onRetry={refresh} /> |
|||
) : isEmpty ? ( |
|||
<EmptyState |
|||
icon="time-outline" |
|||
title="浏览历史" |
|||
subtitle="浏览历史页面开发中,敬请期待" |
|||
title="还没有浏览记录" |
|||
subtitle="看过职位后,会在这里留下足迹" |
|||
/> |
|||
) : ( |
|||
<ScrollView |
|||
style={styles.scroll} |
|||
contentContainerStyle={styles.content} |
|||
showsVerticalScrollIndicator={false} |
|||
> |
|||
{items.map((job) => ( |
|||
<JobCard key={job.id} job={job} onPress={() => router.push(`/jobs/${job.id}`)} /> |
|||
))} |
|||
</ScrollView> |
|||
)} |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
safeArea: { |
|||
flex: 1, |
|||
backgroundColor: colors.pageBg, |
|||
}, |
|||
header: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
paddingHorizontal: 8, |
|||
height: 48, |
|||
backgroundColor: colors.white, |
|||
borderBottomWidth: 1, |
|||
borderBottomColor: colors.borderLight, |
|||
}, |
|||
headerBtn: { |
|||
width: 40, |
|||
height: 40, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
headerTitle: { |
|||
fontSize: 17, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
scroll: { |
|||
flex: 1, |
|||
}, |
|||
content: { |
|||
paddingHorizontal: 12, |
|||
paddingTop: 12, |
|||
paddingBottom: 24, |
|||
}, |
|||
}); |
|||
|
|||
@ -0,0 +1,137 @@ |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { useRouter } from 'expo-router'; |
|||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { useState } from 'react'; |
|||
import { useLegalData } from '../../src/hooks'; |
|||
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; |
|||
import { colors } from '../../src/theme'; |
|||
import type { LegalFAQ } from '../../src/types'; |
|||
|
|||
/* ===================== 顶部导航 ===================== */ |
|||
|
|||
function Header({ onBack }: { onBack: () => 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> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/* ===================== FAQ 项 ===================== */ |
|||
|
|||
function FAQItem({ faq }: { faq: LegalFAQ }) { |
|||
const [expanded, setExpanded] = useState(false); |
|||
return ( |
|||
<Pressable style={styles.faqItem} onPress={() => setExpanded((v) => !v)}> |
|||
<View style={styles.faqRow}> |
|||
<Text style={styles.faqQ}>{faq.question}</Text> |
|||
<Ionicons |
|||
name={expanded ? 'chevron-up' : 'chevron-down'} |
|||
size={18} |
|||
color={colors.textHint} |
|||
/> |
|||
</View> |
|||
{expanded && <Text style={styles.faqA}>{faq.answer}</Text>} |
|||
</Pressable> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 页面 ===================== */ |
|||
|
|||
export default function LegalFaqScreen() { |
|||
const router = useRouter(); |
|||
const { faqs, loading, error, refresh } = useLegalData(); |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.safeArea} edges={['top']}> |
|||
<Header onBack={() => router.back()} /> |
|||
{loading ? ( |
|||
<LoadingState text="加载问题中…" /> |
|||
) : error ? ( |
|||
<ErrorState message="加载失败,请重试" onRetry={refresh} /> |
|||
) : faqs.length === 0 ? ( |
|||
<EmptyState icon="help-circle-outline" title="暂无常见问题" subtitle="如有疑问,可前往法律咨询" /> |
|||
) : ( |
|||
<ScrollView |
|||
style={styles.scroll} |
|||
contentContainerStyle={styles.content} |
|||
showsVerticalScrollIndicator={false} |
|||
> |
|||
<View style={styles.faqCard}> |
|||
{faqs.map((faq) => ( |
|||
<FAQItem key={faq.id} faq={faq} /> |
|||
))} |
|||
</View> |
|||
</ScrollView> |
|||
)} |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
safeArea: { |
|||
flex: 1, |
|||
backgroundColor: colors.pageBg, |
|||
}, |
|||
header: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
paddingHorizontal: 8, |
|||
height: 48, |
|||
backgroundColor: colors.white, |
|||
borderBottomWidth: 1, |
|||
borderBottomColor: colors.borderLight, |
|||
}, |
|||
headerBtn: { |
|||
width: 40, |
|||
height: 40, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
headerTitle: { |
|||
fontSize: 17, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
scroll: { |
|||
flex: 1, |
|||
}, |
|||
content: { |
|||
padding: 12, |
|||
paddingBottom: 24, |
|||
}, |
|||
faqCard: { |
|||
backgroundColor: colors.white, |
|||
borderRadius: 8, |
|||
paddingHorizontal: 12, |
|||
}, |
|||
faqItem: { |
|||
paddingVertical: 12, |
|||
borderBottomWidth: 1, |
|||
borderBottomColor: colors.borderLight, |
|||
}, |
|||
faqRow: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
}, |
|||
faqQ: { |
|||
flex: 1, |
|||
fontSize: 14, |
|||
fontWeight: '600', |
|||
color: colors.textPrimary, |
|||
marginRight: 8, |
|||
}, |
|||
faqA: { |
|||
marginTop: 8, |
|||
fontSize: 13, |
|||
lineHeight: 19, |
|||
color: colors.textSecondary, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,135 @@ |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { useRouter } from 'expo-router'; |
|||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { useLegalData } from '../../src/hooks'; |
|||
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; |
|||
import { colors } from '../../src/theme'; |
|||
import type { LegalTemplate } from '../../src/types'; |
|||
|
|||
/* ===================== 顶部导航 ===================== */ |
|||
|
|||
function Header({ onBack }: { onBack: () => 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> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 模板卡片 ===================== */ |
|||
|
|||
function TemplateCard({ template, onPress }: { template: LegalTemplate; onPress: () => void }) { |
|||
return ( |
|||
<Pressable style={styles.templateCard} onPress={onPress}> |
|||
<Ionicons name={template.icon as never} size={22} color={colors.primary} /> |
|||
<View style={styles.templateBody}> |
|||
<Text style={styles.templateName}>{template.name}</Text> |
|||
<Text style={styles.templateDesc}>{template.description}</Text> |
|||
</View> |
|||
<Ionicons name="chevron-forward" size={18} color={colors.textHint} /> |
|||
</Pressable> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 页面 ===================== */ |
|||
|
|||
export default function LegalTemplatesScreen() { |
|||
const router = useRouter(); |
|||
const { templates, loading, error, refresh } = useLegalData(); |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.safeArea} edges={['top']}> |
|||
<Header onBack={() => router.back()} /> |
|||
{loading ? ( |
|||
<LoadingState text="加载模板中…" /> |
|||
) : error ? ( |
|||
<ErrorState message="加载失败,请重试" onRetry={refresh} /> |
|||
) : templates.length === 0 ? ( |
|||
<EmptyState icon="document-text-outline" title="暂无文书模板" subtitle="如有疑问,可前往法律咨询" /> |
|||
) : ( |
|||
<ScrollView |
|||
style={styles.scroll} |
|||
contentContainerStyle={styles.content} |
|||
showsVerticalScrollIndicator={false} |
|||
> |
|||
<View style={styles.templateGrid}> |
|||
{templates.map((template) => ( |
|||
<TemplateCard |
|||
key={template.id} |
|||
template={template} |
|||
onPress={() => |
|||
Alert.alert('文书预览', `「${template.name}」预览与下载功能即将上线`) |
|||
} |
|||
/> |
|||
))} |
|||
</View> |
|||
</ScrollView> |
|||
)} |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
safeArea: { |
|||
flex: 1, |
|||
backgroundColor: colors.pageBg, |
|||
}, |
|||
header: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
paddingHorizontal: 8, |
|||
height: 48, |
|||
backgroundColor: colors.white, |
|||
borderBottomWidth: 1, |
|||
borderBottomColor: colors.borderLight, |
|||
}, |
|||
headerBtn: { |
|||
width: 40, |
|||
height: 40, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
headerTitle: { |
|||
fontSize: 17, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
scroll: { |
|||
flex: 1, |
|||
}, |
|||
content: { |
|||
padding: 12, |
|||
paddingBottom: 24, |
|||
}, |
|||
templateGrid: { |
|||
gap: 8, |
|||
}, |
|||
templateCard: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
padding: 12, |
|||
borderRadius: 8, |
|||
backgroundColor: colors.white, |
|||
}, |
|||
templateBody: { |
|||
flex: 1, |
|||
marginLeft: 12, |
|||
marginRight: 8, |
|||
}, |
|||
templateName: { |
|||
fontSize: 14, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
templateDesc: { |
|||
marginTop: 2, |
|||
fontSize: 12, |
|||
color: colors.textSecondary, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,187 @@ |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { useRouter } from 'expo-router'; |
|||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { useLegalData } from '../../src/hooks'; |
|||
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; |
|||
import { colors } from '../../src/theme'; |
|||
import type { LegalPlan } from '../../src/types'; |
|||
|
|||
/* ===================== 顶部导航 ===================== */ |
|||
|
|||
function Header({ onBack }: { onBack: () => 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> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 套餐卡片 ===================== */ |
|||
|
|||
function PlanCard({ plan }: { plan: LegalPlan }) { |
|||
return ( |
|||
<View style={[styles.planCard, plan.recommended && styles.planCardRecommended]}> |
|||
{plan.recommended && ( |
|||
<View style={styles.recommendedTag}> |
|||
<Text style={styles.recommendedText}>推荐</Text> |
|||
</View> |
|||
)} |
|||
<Text style={styles.planName}>{plan.name}</Text> |
|||
<View style={styles.planPriceRow}> |
|||
<Text style={styles.planCurrency}>¥</Text> |
|||
<Text style={styles.planPrice}>{plan.price}</Text> |
|||
<Text style={styles.planPeriod}>{plan.period}</Text> |
|||
</View> |
|||
<View style={styles.benefits}> |
|||
{plan.benefits.map((benefit, idx) => ( |
|||
<View key={idx} style={styles.benefitRow}> |
|||
<Ionicons name="checkmark-circle" size={14} color={colors.success} /> |
|||
<Text style={styles.benefitText}>{benefit}</Text> |
|||
</View> |
|||
))} |
|||
</View> |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 页面 ===================== */ |
|||
|
|||
export default function LegalUpgradeScreen() { |
|||
const router = useRouter(); |
|||
const { plans, loading, error, refresh } = useLegalData(); |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.safeArea} edges={['top']}> |
|||
<Header onBack={() => router.back()} /> |
|||
{loading ? ( |
|||
<LoadingState text="加载套餐中…" /> |
|||
) : error ? ( |
|||
<ErrorState message="加载失败,请重试" onRetry={refresh} /> |
|||
) : plans.length === 0 ? ( |
|||
<EmptyState icon="card-outline" title="暂无会员套餐" subtitle="如有疑问,可前往法律咨询" /> |
|||
) : ( |
|||
<ScrollView |
|||
style={styles.scroll} |
|||
contentContainerStyle={styles.content} |
|||
showsVerticalScrollIndicator={false} |
|||
> |
|||
<Text style={styles.tip}>开通会员后可享受律师咨询、合同审核等专属法律服务</Text> |
|||
<View style={styles.planGrid}> |
|||
{plans.map((plan) => ( |
|||
<PlanCard key={plan.id} plan={plan} /> |
|||
))} |
|||
</View> |
|||
</ScrollView> |
|||
)} |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
safeArea: { |
|||
flex: 1, |
|||
backgroundColor: colors.pageBg, |
|||
}, |
|||
header: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
paddingHorizontal: 8, |
|||
height: 48, |
|||
backgroundColor: colors.white, |
|||
borderBottomWidth: 1, |
|||
borderBottomColor: colors.borderLight, |
|||
}, |
|||
headerBtn: { |
|||
width: 40, |
|||
height: 40, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
headerTitle: { |
|||
fontSize: 17, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
scroll: { |
|||
flex: 1, |
|||
}, |
|||
content: { |
|||
padding: 12, |
|||
paddingBottom: 24, |
|||
}, |
|||
tip: { |
|||
fontSize: 13, |
|||
color: colors.textSecondary, |
|||
marginBottom: 12, |
|||
}, |
|||
planGrid: { |
|||
gap: 12, |
|||
}, |
|||
planCard: { |
|||
padding: 14, |
|||
borderRadius: 8, |
|||
backgroundColor: colors.white, |
|||
borderWidth: 1, |
|||
borderColor: colors.border, |
|||
}, |
|||
planCardRecommended: { |
|||
borderColor: colors.action, |
|||
backgroundColor: colors.actionLight, |
|||
}, |
|||
recommendedTag: { |
|||
alignSelf: 'flex-start', |
|||
paddingHorizontal: 6, |
|||
paddingVertical: 2, |
|||
borderRadius: 4, |
|||
backgroundColor: colors.action, |
|||
marginBottom: 8, |
|||
}, |
|||
recommendedText: { |
|||
fontSize: 10, |
|||
fontWeight: '700', |
|||
color: colors.white, |
|||
}, |
|||
planName: { |
|||
fontSize: 15, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
}, |
|||
planPriceRow: { |
|||
flexDirection: 'row', |
|||
alignItems: 'baseline', |
|||
marginTop: 8, |
|||
marginBottom: 10, |
|||
}, |
|||
planCurrency: { |
|||
fontSize: 14, |
|||
fontWeight: '700', |
|||
color: colors.actionText, |
|||
}, |
|||
planPrice: { |
|||
fontSize: 28, |
|||
fontWeight: '700', |
|||
color: colors.actionText, |
|||
}, |
|||
planPeriod: { |
|||
fontSize: 13, |
|||
color: colors.textSecondary, |
|||
}, |
|||
benefits: { |
|||
gap: 6, |
|||
}, |
|||
benefitRow: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: 6, |
|||
}, |
|||
benefitText: { |
|||
fontSize: 12, |
|||
color: colors.textSecondary, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,122 @@ |
|||
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
|||
import { colors } from '../theme'; |
|||
import type { Job } from '../types'; |
|||
|
|||
/** 收藏 / 历史等列表复用的职位摘要卡片(不含 level 字段) */ |
|||
export type JobSummary = Omit<Job, 'level'>; |
|||
|
|||
/* ===================== 职位卡片 ===================== |
|||
* 仅展示列表态所需的字段(公司、职位、薪资、城市、评分、福利标签), |
|||
* 点击进入岗位详情 /jobs/[id]。颜色全部走主题 token,遵守 AGENTS.md 配色约束。 |
|||
*/ |
|||
export function JobCard({ job, onPress }: { job: JobSummary; onPress: () => void }) { |
|||
return ( |
|||
<Pressable style={styles.card} onPress={onPress}> |
|||
<View style={styles.top}> |
|||
<Text style={styles.company} numberOfLines={1}> |
|||
{job.companyName} |
|||
</Text> |
|||
<View style={styles.scoreBadge}> |
|||
<Text style={styles.scoreText}>{job.score}</Text> |
|||
</View> |
|||
</View> |
|||
|
|||
<Text style={styles.position} numberOfLines={1}> |
|||
{job.positionName} |
|||
</Text> |
|||
|
|||
<View style={styles.meta}> |
|||
<Text style={styles.salary}> |
|||
{job.salaryMin}-{job.salaryMax}K |
|||
</Text> |
|||
<Text style={styles.sep}>·</Text> |
|||
<Text style={styles.metaText}>{job.city}</Text> |
|||
<Text style={styles.sep}>·</Text> |
|||
<Text style={styles.metaText}>{job.experience}</Text> |
|||
</View> |
|||
|
|||
<View style={styles.tags}> |
|||
{job.benefits.slice(0, 3).map((benefit) => ( |
|||
<View key={benefit} style={styles.tag}> |
|||
<Text style={styles.tagText}>{benefit}</Text> |
|||
</View> |
|||
))} |
|||
</View> |
|||
</Pressable> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
card: { |
|||
backgroundColor: colors.white, |
|||
borderRadius: 12, |
|||
padding: 14, |
|||
marginBottom: 10, |
|||
borderWidth: 1, |
|||
borderColor: colors.borderLight, |
|||
}, |
|||
top: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
}, |
|||
company: { |
|||
flex: 1, |
|||
fontSize: 14, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
marginRight: 8, |
|||
}, |
|||
scoreBadge: { |
|||
paddingHorizontal: 8, |
|||
paddingVertical: 2, |
|||
borderRadius: 8, |
|||
backgroundColor: colors.primaryLight, |
|||
}, |
|||
scoreText: { |
|||
fontSize: 13, |
|||
fontWeight: '700', |
|||
color: colors.primaryText, |
|||
}, |
|||
position: { |
|||
marginTop: 6, |
|||
fontSize: 16, |
|||
fontWeight: '600', |
|||
color: colors.textPrimary, |
|||
}, |
|||
meta: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
marginTop: 8, |
|||
}, |
|||
salary: { |
|||
fontSize: 14, |
|||
fontWeight: '700', |
|||
color: colors.primaryText, |
|||
}, |
|||
sep: { |
|||
marginHorizontal: 6, |
|||
fontSize: 12, |
|||
color: colors.textHint, |
|||
}, |
|||
metaText: { |
|||
fontSize: 12, |
|||
color: colors.textSecondary, |
|||
}, |
|||
tags: { |
|||
flexDirection: 'row', |
|||
flexWrap: 'wrap', |
|||
gap: 6, |
|||
marginTop: 10, |
|||
}, |
|||
tag: { |
|||
paddingHorizontal: 8, |
|||
paddingVertical: 3, |
|||
borderRadius: 6, |
|||
backgroundColor: colors.borderLight, |
|||
}, |
|||
tagText: { |
|||
fontSize: 11, |
|||
color: colors.textSecondary, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,17 @@ |
|||
/** |
|||
* 我的收藏(职位)数据 hook:复用通用异步取数 hook,统一管理三态。 |
|||
*/ |
|||
import type { Job } from '../types'; |
|||
import { fetchFavorites } from '../api'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
export type FavoriteJob = Omit<Job, 'level'>; |
|||
|
|||
export function useFavorites() { |
|||
const { data, loading, error, refresh } = useAsyncData(fetchFavorites); |
|||
const items = data ?? []; |
|||
// 空状态:加载完成且无错误、但列表为空
|
|||
const isEmpty = !loading && !error && items.length === 0; |
|||
|
|||
return { items, loading, error, refresh, isEmpty }; |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
/** |
|||
* 浏览历史(职位)数据 hook:复用通用异步取数 hook,统一管理三态。 |
|||
*/ |
|||
import type { Job } from '../types'; |
|||
import { fetchHistory } from '../api'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
export type HistoryJob = Omit<Job, 'level'>; |
|||
|
|||
export function useHistory() { |
|||
const { data, loading, error, refresh } = useAsyncData(fetchHistory); |
|||
const items = data ?? []; |
|||
// 空状态:加载完成且无错误、但列表为空
|
|||
const isEmpty = !loading && !error && items.length === 0; |
|||
|
|||
return { items, loading, error, refresh, isEmpty }; |
|||
} |
|||
Loading…
Reference in new issue