Browse Source

feat: 补全 P1 占位页与 P2 空 onPress 接线

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
doublex001 2 months ago
parent
commit
7a239ffe5a
  1. 6
      app/(tabs)/growth.tsx
  2. 5
      app/appeal.tsx
  3. 162
      app/article/[id].tsx
  4. 86
      app/favorites.tsx
  5. 86
      app/history.tsx
  6. 6
      app/legal.tsx
  7. 137
      app/legal/faq.tsx
  8. 135
      app/legal/templates.tsx
  9. 187
      app/legal/upgrade.tsx
  10. 36
      src/api/api.ts
  11. 2
      src/api/index.ts
  12. 122
      src/components/JobCard.tsx
  13. 2
      src/hooks/index.ts
  14. 17
      src/hooks/useFavorites.ts
  15. 17
      src/hooks/useHistory.ts

6
app/(tabs)/growth.tsx

@ -180,7 +180,7 @@ export default function GrowthScreen() {
style={styles.header}
>
<Text style={styles.headerTitle}>成长中心</Text>
<Pressable style={styles.headerAction} onPress={() => { /* TODO: 设置/更多 */ }}>
<Pressable style={styles.headerAction} onPress={() => router.push('/settings')}>
<Ionicons name="settings-outline" size={22} color={colors.textInverse} />
</Pressable>
</LinearGradient>
@ -276,7 +276,7 @@ export default function GrowthScreen() {
{/* 5. 职场干货 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>职场干货</Text>
<Pressable onPress={() => { /* TODO: 更多 */ }}>
<Pressable onPress={() => { if (articles[0]) router.push(`/article/${articles[0].id}`); }}>
<Text style={styles.sectionMore}>更多 &gt;</Text>
</Pressable>
</View>
@ -286,7 +286,7 @@ export default function GrowthScreen() {
<Pressable
key={article.id}
style={styles.articleCard}
onPress={() => { /* TODO: 阅读文章 */ }}
onPress={() => router.push(`/article/${article.id}`)}
>
<View style={[styles.articleThumb, { backgroundColor: thumb.bg }]}>
<Ionicons name={article.icon as IconName} size={26} color={thumb.color} />

5
app/appeal.tsx

@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
@ -322,7 +323,9 @@ export default function AppealScreen() {
const handleSubmit = async () => {
const result = await submit();
if (result) {
// TODO: 提交成功后跳转结果页或关闭弹层
Alert.alert('提交成功', '我们会在 3 个工作日内完成审核,结果将通过站内消息通知你。', [
{ text: '确定', onPress: () => router.back() },
]);
}
};

162
app/article/[id].tsx

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

86
app/favorites.tsx

@ -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 (
<EmptyState
icon="heart-outline"
title="我的收藏"
subtitle="收藏页面开发中,敬请期待"
/>
<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="浏览职位时点击收藏,会在这里出现"
/>
) : (
<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,
},
});

86
app/history.tsx

@ -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 (
<EmptyState
icon="time-outline"
title="浏览历史"
subtitle="浏览历史页面开发中,敬请期待"
/>
<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="看过职位后,会在这里留下足迹"
/>
) : (
<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,
},
});

6
app/legal.tsx

@ -146,11 +146,11 @@ export default function LegalScreen() {
if (service.action === 'chat') {
router.push('/legal/chat');
} else if (service.action === 'templates') {
// 模板在首页已展示,滚动到模板区更自然;这里简单提示
router.push('/legal/templates');
} else if (service.action === 'faq') {
// TODO: 接入 FAQ 列表页
router.push('/legal/faq');
} else if (service.action === 'upgrade') {
// TODO: 接入付费升级入口
router.push('/legal/upgrade');
}
};

137
app/legal/faq.tsx

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

135
app/legal/templates.tsx

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

187
app/legal/upgrade.tsx

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

36
src/api/api.ts

@ -912,6 +912,42 @@ export async function fetchJobList(
});
}
/* ===================== 我的收藏 / 浏览历史(mock 数据) ===================== */
/** 收藏的职位 id 列表(mock 阶段取 MOCK_JOBS 子集) */
const MOCK_FAVORITE_IDS = ['1', '3', '6'];
/** 浏览历史的职位 id 列表(mock 阶段取 MOCK_JOBS 子集) */
const MOCK_HISTORY_IDS = ['2', '4', '5', '7'];
const MOCK_FAVORITES: Omit<Job, 'level'>[] = MOCK_JOBS.filter((job) =>
MOCK_FAVORITE_IDS.includes(job.id),
);
const MOCK_HISTORY: Omit<Job, 'level'>[] = MOCK_JOBS.filter((job) =>
MOCK_HISTORY_IDS.includes(job.id),
);
/**
* 获取我的收藏(职位)列表
* @method GET
* @url /api/user/favorites
* @returns Promise<Omit<Job, 'level'>[]> 收藏的职位数组
*/
export async function fetchFavorites(): Promise<Omit<Job, 'level'>[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/favorites`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_FAVORITES);
}
/**
* 获取浏览历史(职位)列表
* @method GET
* @url /api/user/history
* @returns Promise<Omit<Job, 'level'>[]> 浏览过的职位数组(按时间倒序)
*/
export async function fetchHistory(): Promise<Omit<Job, 'level'>[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/history`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HISTORY);
}
/**
* 获取会话列表
* @method GET

2
src/api/index.ts

@ -45,6 +45,8 @@ export {
fetchInterviews,
fetchJobDetail,
fetchJobList,
fetchFavorites,
fetchHistory,
fetchOfferCompareData,
fetchJobOverallScore,
fetchJobPreference,

122
src/components/JobCard.tsx

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

2
src/hooks/index.ts

@ -28,6 +28,8 @@ export { useJobDetail } from './useJobDetail';
export { useJobEvaluationEmptyState } from './useJobEvaluationEmptyState';
export { useJobEvaluations } from './useJobEvaluations';
export { useJobList } from './useJobList';
export { useFavorites } from './useFavorites';
export { useHistory } from './useHistory';
export { useJobManagement } from './useJobManagement';
export { useJobPreference } from './useJobPreference';
export { useJobProgress } from './useJobProgress';

17
src/hooks/useFavorites.ts

@ -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 };
}

17
src/hooks/useHistory.ts

@ -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…
Cancel
Save