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() { |
export default function FavoritesScreen() { |
||||
|
const router = useRouter(); |
||||
|
const { items, loading, error, refresh, isEmpty } = useFavorites(); |
||||
|
|
||||
return ( |
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 |
<EmptyState |
||||
icon="heart-outline" |
icon="heart-outline" |
||||
title="我的收藏" |
title="还没有收藏" |
||||
subtitle="收藏页面开发中,敬请期待" |
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() { |
export default function HistoryScreen() { |
||||
|
const router = useRouter(); |
||||
|
const { items, loading, error, refresh, isEmpty } = useHistory(); |
||||
|
|
||||
return ( |
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 |
<EmptyState |
||||
icon="time-outline" |
icon="time-outline" |
||||
title="浏览历史" |
title="还没有浏览记录" |
||||
subtitle="浏览历史页面开发中,敬请期待" |
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