Browse Source

feat: 法律服务付费闭环、会员权益兑现与岗位评价发布(2026-09-10)

按提交纪律精确分阶段 git add(未用 -A / 未用 git rm),tsc --noEmit 0 错误、
eslint .(全量)0 警告。

| 模块 | 主要文件 | 变更要点 | 状态 |
|---|---|---|---|
| 付费支付闭环 | legal/checkout.tsx(新)、useLegalMembership.ts(新)、legal.tsx、upgrade.tsx | 选套餐→结算→选支付方式→模拟支付→会员激活并 AsyncStorage 持久化;套餐卡可点击、返回时 focus 刷新 | 完成·真实SDK仅换 api 函数体 |
| 会员权益兑现 | legal/service.tsx(新)、useLegalServiceRequests.ts(新)、legal.tsx | 修复「付了钱却拿不到服务」:未开通引导开通,已开通进入使用页提交申请并留痕 | 完成 |
| 岗位评价发布 | jobs/[id].tsx、useJobDetail.ts、api.ts | 修复「评价生态却发不出评价」:新增 createJobReview,发布后置顶并持久化 | 完成 |
| 类型与导出 | types/legal.ts、types/index.ts、api/index.ts、hooks/index.ts | 新增订单/会员态/服务申请类型与对应函数、hook 导出 | 完成 |
| 文档 | 项目开发状态.md | 同步三批功能状态与审计结论 | 完成 |

注:mock 阶段持久化统一走 src/lib/storage.ts(AsyncStorage,youzhi: 前缀),
storage 为唯一真相源,真实后端就绪后仅替换 api.ts 对应函数体。
master
doublex001 2 months ago
parent
commit
cbf38e7745
  1. 35
      app/jobs/[id].tsx
  2. 97
      app/legal.tsx
  3. 395
      app/legal/checkout.tsx
  4. 430
      app/legal/service.tsx
  5. 78
      app/legal/upgrade.tsx
  6. 246
      src/api/api.ts
  7. 7
      src/api/index.ts
  8. 2
      src/hooks/index.ts
  9. 12
      src/hooks/useJobDetail.ts
  10. 92
      src/hooks/useLegalMembership.ts
  11. 58
      src/hooks/useLegalServiceRequests.ts
  12. 6
      src/types/index.ts
  13. 61
      src/types/legal.ts
  14. 5
      项目开发状态.md

35
app/jobs/[id].tsx

@ -148,8 +148,28 @@ function SectionTitle({
export default function JobDetailScreen() { export default function JobDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter(); const router = useRouter();
const { detail, overallScore, salaryData, reviews, loading, error, refresh } = useJobDetail(id); const { detail, overallScore, salaryData, reviews, loading, error, refresh, publishReview } =
useJobDetail(id);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [publishing, setPublishing] = useState(false);
// 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新)
const handlePublish = async () => {
const text = comment.trim();
if (!text) {
Alert.alert('提示', '请先输入你的评价内容');
return;
}
setPublishing(true);
try {
await publishReview(text);
setComment('');
} catch (e) {
Alert.alert('发布失败', (e as Error).message || '请稍后重试');
} finally {
setPublishing(false);
}
};
// 错误态 // 错误态
if (error) { if (error) {
@ -285,8 +305,16 @@ export default function JobDetailScreen() {
value={comment} value={comment}
onChangeText={setComment} onChangeText={setComment}
/> />
<Pressable style={styles.publishBtn} onPress={() => Alert.alert('发布评论', '评论发布功能开发中,敬请期待')}> <Pressable
style={[styles.publishBtn, publishing && styles.publishBtnDisabled]}
onPress={handlePublish}
disabled={publishing}
>
{publishing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.publishText}>发布</Text> <Text style={styles.publishText}>发布</Text>
)}
</Pressable> </Pressable>
</View> </View>
</SafeAreaView> </SafeAreaView>
@ -604,6 +632,9 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
backgroundColor: colors.primaryBtn, backgroundColor: colors.primaryBtn,
}, },
publishBtnDisabled: {
opacity: 0.6,
},
publishText: { publishText: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',

97
app/legal.tsx

@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { useState } from 'react'; import { useCallback, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
@ -10,7 +10,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData } from '../src/hooks'; import { useLegalData, useLegalMembership } from '../src/hooks';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import type { LegalFAQ, LegalPlan, LegalService, LegalTemplate } from '../src/types'; import type { LegalFAQ, LegalPlan, LegalService, LegalTemplate } from '../src/types';
@ -47,7 +47,16 @@ function BrandCard({ name, company, slogan, logo }: { name: string; company: str
/* ===================== 子组件:服务入口 ===================== */ /* ===================== 子组件:服务入口 ===================== */
function ServiceCard({ service, onPress }: { service: LegalService; onPress: () => void }) { function ServiceCard({
service,
isUnlocked,
onPress,
}: {
service: LegalService;
/** 会员服务是否已解锁(已开通会员) */
isUnlocked: boolean;
onPress: () => void;
}) {
const isMember = service.type === 'member'; const isMember = service.type === 'member';
return ( return (
<Pressable <Pressable
@ -65,7 +74,7 @@ function ServiceCard({ service, onPress }: { service: LegalService; onPress: ()
<Text style={styles.serviceTitle}>{service.title}</Text> <Text style={styles.serviceTitle}>{service.title}</Text>
{isMember && ( {isMember && (
<View style={styles.memberTag}> <View style={styles.memberTag}>
<Text style={styles.memberTagText}>会员</Text> <Text style={styles.memberTagText}>{isUnlocked ? '已开通' : '会员'}</Text>
</View> </View>
)} )}
</View> </View>
@ -107,9 +116,17 @@ function TemplateCard({ template }: { template: LegalTemplate }) {
/* ===================== 子组件:会员套餐 ===================== */ /* ===================== 子组件:会员套餐 ===================== */
function PlanCard({ plan }: { plan: LegalPlan }) { function PlanCard({ plan, onPress }: { plan: LegalPlan; onPress?: () => void }) {
return ( return (
<View style={[styles.planCard, plan.recommended && styles.planCardRecommended]}> <Pressable
style={({ pressed }) => [
styles.planCard,
plan.recommended && styles.planCardRecommended,
pressed && styles.planPressed,
]}
onPress={onPress}
disabled={!onPress}
>
{plan.recommended && ( {plan.recommended && (
<View style={styles.recommendedTag}> <View style={styles.recommendedTag}>
<Text style={styles.recommendedText}>推荐</Text> <Text style={styles.recommendedText}>推荐</Text>
@ -129,7 +146,11 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
</View> </View>
))} ))}
</View> </View>
<View style={styles.planCtaRow}>
<Text style={styles.planCtaText}>立即开通</Text>
<Ionicons name="chevron-forward" size={16} color={colors.action} />
</View> </View>
</Pressable>
); );
} }
@ -138,6 +159,14 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
export default function LegalScreen() { export default function LegalScreen() {
const router = useRouter(); const router = useRouter();
const { brand, services, faqs, templates, plans, loading, error, refresh } = useLegalData(); const { brand, services, faqs, templates, plans, loading, error, refresh } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership();
// 返回本页时刷新会员态(支付成功后需即时反映)
useFocusEffect(
useCallback(() => {
refreshMembership();
}, [refreshMembership]),
);
const freeServices = services.filter((s) => s.type === 'free'); const freeServices = services.filter((s) => s.type === 'free');
const memberServices = services.filter((s) => s.type === 'member'); const memberServices = services.filter((s) => s.type === 'member');
@ -150,8 +179,13 @@ export default function LegalScreen() {
} else if (service.action === 'faq') { } else if (service.action === 'faq') {
router.push('/legal/faq'); router.push('/legal/faq');
} else if (service.action === 'upgrade') { } else if (service.action === 'upgrade') {
// 已开通会员 → 进入服务使用页(权益兑现);未开通 → 引导开通
if (membership) {
router.push(`/legal/service?id=${service.id}`);
} else {
router.push('/legal/upgrade'); router.push('/legal/upgrade');
} }
}
}; };
return ( return (
@ -190,6 +224,7 @@ export default function LegalScreen() {
<ServiceCard <ServiceCard
key={service.id} key={service.id}
service={service} service={service}
isUnlocked={false}
onPress={() => handleServicePress(service)} onPress={() => handleServicePress(service)}
/> />
))} ))}
@ -201,6 +236,7 @@ export default function LegalScreen() {
<ServiceCard <ServiceCard
key={service.id} key={service.id}
service={service} service={service}
isUnlocked={Boolean(membership)}
onPress={() => handleServicePress(service)} onPress={() => handleServicePress(service)}
/> />
))} ))}
@ -231,9 +267,21 @@ export default function LegalScreen() {
{plans.length > 0 && ( {plans.length > 0 && (
<> <>
<SectionTitle title="会员套餐" /> <SectionTitle title="会员套餐" />
{membership && (
<View style={styles.memberBanner}>
<Ionicons name="shield-checkmark" size={16} color={colors.action} />
<Text style={styles.memberBannerText}>
您已开通{membership.planName},可畅享会员专属服务
</Text>
</View>
)}
<View style={styles.planGrid}> <View style={styles.planGrid}>
{plans.map((plan) => ( {plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} /> <PlanCard
key={plan.id}
plan={plan}
onPress={() => router.push(`/legal/checkout?planId=${plan.id}`)}
/>
))} ))}
</View> </View>
</> </>
@ -474,6 +522,39 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
}, },
planPressed: {
opacity: 0.85,
},
planCtaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 12,
gap: 2,
},
planCtaText: {
fontSize: 13,
fontWeight: '700',
color: colors.action,
},
memberBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: colors.actionLight,
borderWidth: 1,
borderColor: colors.action,
marginBottom: 12,
},
memberBannerText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
planCardRecommended: { planCardRecommended: {
borderColor: colors.action, borderColor: colors.action,
backgroundColor: colors.actionLight, backgroundColor: colors.actionLight,

395
app/legal/checkout.tsx

@ -0,0 +1,395 @@
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership } from '../../src/hooks';
import { LoadingState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme';
import type { LegalPaymentChannel } from '../../src/types';
/* ===================== 付款方式 ===================== */
const CHANNELS: { id: LegalPaymentChannel; label: string; icon: keyof typeof Ionicons.glyphMap; color: string }[] = [
{ id: 'wechat', label: '微信支付', icon: 'logo-wechat', color: colors.success },
{ id: 'alipay', label: '支付宝', icon: 'wallet-outline', color: colors.primary },
];
/* ===================== 顶部导航 ===================== */
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 LegalCheckoutScreen() {
const router = useRouter();
const { planId } = useLocalSearchParams();
const id = Array.isArray(planId) ? planId[0] : planId;
const { plans, loading } = useLegalData();
const { createOrder, payOrder, processing } = useLegalMembership();
const [channel, setChannel] = useState<LegalPaymentChannel>('wechat');
const [paid, setPaid] = useState(false);
const plan = plans.find((p) => p.id === id);
const handlePay = useCallback(async () => {
if (!plan) return;
try {
const order = await createOrder(plan.id, channel);
const result = await payOrder(order.id);
if (result.status === 'paid') {
setPaid(true);
} else {
Alert.alert('支付失败', '请稍后重试');
}
} catch (e) {
Alert.alert('支付失败', (e as Error).message || '请稍后重试');
}
}, [plan, channel, createOrder, payOrder]);
// 加载套餐中
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<LoadingState text="加载套餐中…" />
</SafeAreaView>
);
}
// 套餐不存在(异常入口)
if (!plan) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<EmptyState icon="alert-circle-outline" title="套餐不存在" subtitle="请从会员套餐页重新进入" />
</SafeAreaView>
);
}
// 支付成功
if (paid) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.replace('/legal')} />
<View style={styles.successBox}>
<View style={styles.successIconWrap}>
<Ionicons name="checkmark-circle" size={56} color={colors.success} />
</View>
<Text style={styles.successTitle}>支付成功</Text>
<Text style={styles.successDesc}>
已开通{plan.name},可畅享会员专属法律服务
</Text>
<Pressable style={styles.successBtn} onPress={() => router.replace('/legal')}>
<Text style={styles.successBtnText}>返回法律服务</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 套餐摘要 */}
<View style={styles.summaryCard}>
<Text style={styles.summaryName}>{plan.name}</Text>
<View style={styles.summaryPriceRow}>
<Text style={styles.summaryCurrency}>¥</Text>
<Text style={styles.summaryPrice}>{plan.price}</Text>
<Text style={styles.summaryPeriod}>{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>
{/* 支付方式 */}
<Text style={styles.sectionTitle}>选择支付方式</Text>
<View style={styles.channelGroup}>
{CHANNELS.map((c) => {
const active = c.id === channel;
return (
<Pressable
key={c.id}
style={({ pressed }) => [
styles.channelRow,
active && styles.channelRowActive,
pressed && styles.channelPressed,
]}
onPress={() => setChannel(c.id)}
>
<Ionicons name={c.icon} size={24} color={c.color} />
<Text style={styles.channelLabel}>{c.label}</Text>
<View style={[styles.radio, active && styles.radioActive]}>
{active && <View style={styles.radioDot} />}
</View>
</Pressable>
);
})}
</View>
{/* 实付金额 */}
<View style={styles.amountRow}>
<Text style={styles.amountLabel}>实付金额</Text>
<View style={styles.amountValue}>
<Text style={styles.amountCurrency}>¥</Text>
<Text style={styles.amountPrice}>{plan.price}</Text>
</View>
</View>
</ScrollView>
{/* 确认支付(底部固定) */}
<View style={styles.footer}>
<Pressable
style={({ pressed }) => [styles.payBtn, pressed && styles.payBtnPressed, processing && styles.payBtnDisabled]}
onPress={handlePay}
disabled={processing}
>
{processing ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.payBtnText}>确认支付 ¥{plan.price}</Text>
)}
</Pressable>
</View>
</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,
},
summaryCard: {
padding: 16,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
summaryName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
summaryPriceRow: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 8,
marginBottom: 12,
},
summaryCurrency: {
fontSize: 15,
fontWeight: '700',
color: colors.actionText,
},
summaryPrice: {
fontSize: 30,
fontWeight: '700',
color: colors.actionText,
},
summaryPeriod: {
fontSize: 13,
color: colors.textSecondary,
marginLeft: 2,
},
benefits: {
gap: 6,
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 12,
color: colors.textSecondary,
},
sectionTitle: {
marginTop: 20,
marginBottom: 10,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
channelGroup: {
gap: 8,
},
channelRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
gap: 12,
},
channelRowActive: {
borderColor: colors.action,
backgroundColor: colors.actionLight,
},
channelPressed: {
opacity: 0.85,
},
channelLabel: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
radioActive: {
borderColor: colors.action,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.action,
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 20,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
amountLabel: {
fontSize: 14,
color: colors.textSecondary,
},
amountValue: {
flexDirection: 'row',
alignItems: 'baseline',
},
amountCurrency: {
fontSize: 15,
fontWeight: '700',
color: colors.actionText,
},
amountPrice: {
fontSize: 22,
fontWeight: '700',
color: colors.actionText,
},
footer: {
padding: 12,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
payBtn: {
height: 48,
borderRadius: 8,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
payBtnPressed: {
opacity: 0.9,
},
payBtnDisabled: {
opacity: 0.6,
},
payBtnText: {
fontSize: 16,
fontWeight: '700',
color: colors.white,
},
successBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
successIconWrap: {
marginBottom: 16,
},
successTitle: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
successDesc: {
marginTop: 8,
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
},
successBtn: {
marginTop: 28,
width: 200,
height: 46,
borderRadius: 23,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
successBtnText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
});

430
app/legal/service.tsx

@ -0,0 +1,430 @@
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership, useLegalServiceRequests } from '../../src/hooks';
import { LoadingState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme';
/* ===================== 顶部导航 ===================== */
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 LegalServiceScreen() {
const router = useRouter();
const { id } = useLocalSearchParams();
const serviceId = Array.isArray(id) ? id[0] : id;
const { services, loading } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership();
const {
requests,
loading: requestsLoading,
refresh: refreshRequests,
submit,
submitting,
} = useLegalServiceRequests();
const [content, setContent] = useState('');
// 返回本页时刷新会员态与申请记录
useFocusEffect(
useCallback(() => {
refreshMembership();
refreshRequests();
}, [refreshMembership, refreshRequests]),
);
const service = services.find((s) => s.id === serviceId);
const handleSubmit = useCallback(async () => {
if (!service) return;
const text = content.trim();
if (!text) {
Alert.alert('提示', '请先填写你的需求描述');
return;
}
try {
await submit(service.id, text);
setContent('');
Alert.alert('提交成功', '我们已收到你的申请,将尽快安排专人跟进。');
} catch (e) {
Alert.alert('提交失败', (e as Error).message || '请稍后重试');
}
}, [service, content, submit]);
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<LoadingState text="加载服务中…" />
</SafeAreaView>
);
}
if (!service) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<EmptyState icon="alert-circle-outline" title="服务不存在" subtitle="请从法律服务页重新进入" />
</SafeAreaView>
);
}
// 未开通会员:拦截并引导开通(付费前不给权益)
if (!membership) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.lockedBox}>
<View style={styles.lockedIconWrap}>
<Ionicons name="lock-closed-outline" size={44} color={colors.action} />
</View>
<Text style={styles.lockedTitle}>{service.title}</Text>
<Text style={styles.lockedDesc}>{service.description}</Text>
<Text style={styles.lockedTip}>该服务为会员专享,开通后即可使用</Text>
<Pressable style={styles.lockedBtn} onPress={() => router.push('/legal/upgrade')}>
<Text style={styles.lockedBtnText}>开通会员</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const myRequests = requests.filter((r) => r.serviceId === service.id);
const canSubmit = content.trim().length > 0 && !submitting;
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 服务说明 */}
<View style={styles.card}>
<View style={styles.serviceHead}>
<View style={styles.serviceIconWrap}>
<Ionicons
name={service.icon as keyof typeof Ionicons.glyphMap}
size={22}
color={colors.action}
/>
</View>
<View style={styles.serviceHeadBody}>
<Text style={styles.serviceName}>{service.title}</Text>
<Text style={styles.serviceDesc}>{service.description}</Text>
</View>
</View>
{service.requestHint && <Text style={styles.hint}>{service.requestHint}</Text>}
</View>
{/* 需求填写 */}
<Text style={styles.sectionTitle}>填写你的需求</Text>
<TextInput
style={styles.input}
value={content}
onChangeText={setContent}
placeholder="请描述你的具体情况与希望解决的问题…"
placeholderTextColor={colors.textHint}
multiline
maxLength={500}
textAlignVertical="top"
/>
<Text style={styles.counter}>{content.length}/500</Text>
<Pressable
style={({ pressed }) => [
styles.submitBtn,
!canSubmit && styles.submitBtnDisabled,
pressed && canSubmit && styles.submitBtnPressed,
]}
onPress={handleSubmit}
disabled={!canSubmit}
>
{submitting ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.submitBtnText}>提交申请</Text>
)}
</Pressable>
{/* 我的申请记录 */}
<Text style={styles.sectionTitle}>我的申请记录</Text>
{requestsLoading ? (
<ActivityIndicator color={colors.primary} style={styles.requestsLoading} />
) : myRequests.length === 0 ? (
<View style={styles.emptyCard}>
<Text style={styles.emptyText}>暂无申请记录</Text>
</View>
) : (
<View style={styles.requestList}>
{myRequests.map((r) => (
<View key={r.id} style={styles.requestCard}>
<View style={styles.requestHead}>
<Text style={styles.requestService}>{r.serviceName}</Text>
<View style={styles.statusTag}>
<Text style={styles.statusText}>
{r.status === 'submitted' ? '已提交' : r.status === 'processing' ? '处理中' : '已完成'}
</Text>
</View>
</View>
<Text style={styles.requestContent}>{r.content}</Text>
<Text style={styles.requestTime}>
{new Date(r.createdAt).toLocaleString('zh-CN')}
</Text>
</View>
))}
</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,
},
card: {
padding: 14,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
serviceHead: {
flexDirection: 'row',
alignItems: 'center',
},
serviceIconWrap: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.actionLight,
alignItems: 'center',
justifyContent: 'center',
},
serviceHeadBody: {
flex: 1,
marginLeft: 12,
},
serviceName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
serviceDesc: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
lineHeight: 17,
},
hint: {
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
sectionTitle: {
marginTop: 20,
marginBottom: 10,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
input: {
minHeight: 120,
padding: 12,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
fontSize: 14,
lineHeight: 20,
color: colors.textPrimary,
},
counter: {
marginTop: 6,
textAlign: 'right',
fontSize: 12,
color: colors.textHint,
},
submitBtn: {
marginTop: 16,
height: 48,
borderRadius: 8,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
submitBtnPressed: {
opacity: 0.9,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitBtnText: {
fontSize: 16,
fontWeight: '700',
color: colors.white,
},
requestsLoading: {
marginTop: 16,
},
emptyCard: {
padding: 20,
borderRadius: 8,
backgroundColor: colors.white,
alignItems: 'center',
},
emptyText: {
fontSize: 13,
color: colors.textSecondary,
},
requestList: {
gap: 8,
},
requestCard: {
padding: 12,
borderRadius: 8,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
requestHead: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
requestService: {
flex: 1,
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
marginRight: 8,
},
statusTag: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: colors.primaryLight,
},
statusText: {
fontSize: 11,
fontWeight: '700',
color: colors.primaryText,
},
requestContent: {
marginTop: 8,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
requestTime: {
marginTop: 8,
fontSize: 11,
color: colors.textHint,
},
lockedBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
lockedIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.actionLight,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
lockedTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
lockedDesc: {
marginTop: 8,
fontSize: 14,
lineHeight: 20,
color: colors.textSecondary,
textAlign: 'center',
},
lockedTip: {
marginTop: 12,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
lockedBtn: {
marginTop: 28,
width: 200,
height: 46,
borderRadius: 23,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
lockedBtnText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
});

78
app/legal/upgrade.tsx

@ -1,8 +1,9 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData } from '../../src/hooks'; import { useLegalData, useLegalMembership } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import type { LegalPlan } from '../../src/types'; import type { LegalPlan } from '../../src/types';
@ -23,9 +24,17 @@ function Header({ onBack }: { onBack: () => void }) {
/* ===================== 套餐卡片 ===================== */ /* ===================== 套餐卡片 ===================== */
function PlanCard({ plan }: { plan: LegalPlan }) { function PlanCard({ plan, onPress }: { plan: LegalPlan; onPress?: () => void }) {
return ( return (
<View style={[styles.planCard, plan.recommended && styles.planCardRecommended]}> <Pressable
style={({ pressed }) => [
styles.planCard,
plan.recommended && styles.planCardRecommended,
pressed && styles.planPressed,
]}
onPress={onPress}
disabled={!onPress}
>
{plan.recommended && ( {plan.recommended && (
<View style={styles.recommendedTag}> <View style={styles.recommendedTag}>
<Text style={styles.recommendedText}>推荐</Text> <Text style={styles.recommendedText}>推荐</Text>
@ -45,7 +54,11 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
</View> </View>
))} ))}
</View> </View>
<View style={styles.planCtaRow}>
<Text style={styles.planCtaText}>立即开通</Text>
<Ionicons name="chevron-forward" size={16} color={colors.action} />
</View> </View>
</Pressable>
); );
} }
@ -54,6 +67,15 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
export default function LegalUpgradeScreen() { export default function LegalUpgradeScreen() {
const router = useRouter(); const router = useRouter();
const { plans, loading, error, refresh } = useLegalData(); const { plans, loading, error, refresh } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership();
useFocusEffect(
useCallback(() => {
refreshMembership();
}, [refreshMembership]),
);
const formatDate = (iso: string) => new Date(iso).toLocaleDateString('zh-CN');
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top']}> <SafeAreaView style={styles.safeArea} edges={['top']}>
@ -70,10 +92,23 @@ export default function LegalUpgradeScreen() {
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{membership ? (
<View style={styles.memberBanner}>
<Ionicons name="shield-checkmark" size={16} color={colors.action} />
<Text style={styles.memberBannerText}>
您已开通{membership.planName},有效期至 {formatDate(membership.expiresAt)}
</Text>
</View>
) : (
<Text style={styles.tip}>开通会员后可享受律师咨询、合同审核等专属法律服务</Text> <Text style={styles.tip}>开通会员后可享受律师咨询、合同审核等专属法律服务</Text>
)}
<View style={styles.planGrid}> <View style={styles.planGrid}>
{plans.map((plan) => ( {plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} /> <PlanCard
key={plan.id}
plan={plan}
onPress={() => router.push(`/legal/checkout?planId=${plan.id}`)}
/>
))} ))}
</View> </View>
</ScrollView> </ScrollView>
@ -130,6 +165,39 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
}, },
planPressed: {
opacity: 0.85,
},
planCtaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 12,
gap: 2,
},
planCtaText: {
fontSize: 13,
fontWeight: '700',
color: colors.action,
},
memberBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: colors.actionLight,
borderWidth: 1,
borderColor: colors.action,
marginBottom: 12,
},
memberBannerText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
planCardRecommended: { planCardRecommended: {
borderColor: colors.action, borderColor: colors.action,
backgroundColor: colors.actionLight, backgroundColor: colors.actionLight,

246
src/api/api.ts

@ -74,8 +74,12 @@ import type {
LegalConversation, LegalConversation,
LegalFAQ, LegalFAQ,
LegalMessage, LegalMessage,
LegalMembership,
LegalOrder,
LegalPaymentChannel,
LegalPlan, LegalPlan,
LegalService, LegalService,
LegalServiceRequest,
LegalTemplate, LegalTemplate,
ToggleSetting, ToggleSetting,
ActionSetting, ActionSetting,
@ -122,6 +126,7 @@ import type {
} from '../types'; } from '../types';
import { MOCK_DELAY, SALARY_RANGES, TOKEN_TTL_MS } from '../constants'; import { MOCK_DELAY, SALARY_RANGES, TOKEN_TTL_MS } from '../constants';
import { colors } from '../theme'; import { colors } from '../theme';
import { storageGet, storageSet, storageRemove } from '../lib/storage';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
export const API_BASE_URL: string = export const API_BASE_URL: string =
@ -1240,8 +1245,50 @@ export async function fetchSalaryData(id: string): Promise<SalaryData> {
*/ */
export async function fetchEmployeeReviews(id: string): Promise<EmployeeReview[]> { export async function fetchEmployeeReviews(id: string): Promise<EmployeeReview[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'GET' }); return res.json(); // TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'GET' }); return res.json();
void id; // mock 阶段:内置评价 + 该岗位下本人已发布的评价(本人发布的最新在前)
return mockRequest(MOCK_EMPLOYEE_REVIEWS); const mine = (await readJobReviews()).filter((r) => r.jobId === id);
return mockRequest([...mine, ...MOCK_EMPLOYEE_REVIEWS]);
}
/* ===================== 岗位评价发布(mock 阶段持久化到 AsyncStorage) ===================== */
const JOB_REVIEWS_KEY = 'job:reviews';
/** 本人发布评价的存储结构:在 EmployeeReview 上补充归属岗位 */
interface StoredJobReview extends EmployeeReview {
jobId: string;
}
/** 读取本人发布的全部评价 */
async function readJobReviews(): Promise<StoredJobReview[]> {
const raw = await storageGet(JOB_REVIEWS_KEY);
return raw ? (JSON.parse(raw) as StoredJobReview[]) : [];
}
/**
* 发布岗位评价
* @method POST
* @url /api/jobs/:id/reviews
* @param id 岗位 id
* @param content 评价内容
* @returns Promise<EmployeeReview> 新建的评价
* @remark 真实后端下需做敏感词校验与重复提交限制,此处不做
*/
export async function createJobReview(id: string, content: string): Promise<EmployeeReview> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json();
const review: StoredJobReview = {
id: `my-review-${Date.now()}`,
jobId: id,
userName: '我',
userRole: '我的评价',
time: '刚刚',
content,
tags: [],
};
const all = await readJobReviews();
all.unshift(review);
await storageSet(JOB_REVIEWS_KEY, JSON.stringify(all));
return mockRequest(review);
} }
/** /**
@ -3813,6 +3860,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'people-outline', icon: 'people-outline',
type: 'member', type: 'member',
action: 'upgrade', action: 'upgrade',
requestHint: '请简要描述你遇到的劳动法律问题,例如试用期被辞退、拖欠工资、未签劳动合同等,律师将在 24 小时内响应。',
}, },
{ {
id: 'member-review', id: 'member-review',
@ -3821,6 +3869,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'reader-outline', icon: 'reader-outline',
type: 'member', type: 'member',
action: 'upgrade', action: 'upgrade',
requestHint: '请说明需要审核的文书类型与你重点关注的风险点,例如劳动合同中的违约金条款、竞业限制范围等。',
}, },
{ {
id: 'member-advisor', id: 'member-advisor',
@ -3829,6 +3878,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'shield-checkmark-outline', icon: 'shield-checkmark-outline',
type: 'member', type: 'member',
action: 'upgrade', action: 'upgrade',
requestHint: '请描述你希望顾问长期跟进的事项,例如在职期间的权益保障、跳槽风险把控等。',
}, },
]; ];
@ -3973,6 +4023,198 @@ export async function fetchLegalPlans(): Promise<LegalPlan[]> {
return mockRequest(MOCK_LEGAL_PLANS); return mockRequest(MOCK_LEGAL_PLANS);
} }
/* ===================== 付费 / 订单 / 会员态(mock 阶段持久化到 AsyncStorage) ===================== */
const LEGAL_MEMBERSHIP_KEY = 'legal:membership';
const LEGAL_ORDERS_KEY = 'legal:orders';
/** 读取会员态(storage 为唯一真相源,避免内存态过期) */
async function readLegalMembership(): Promise<LegalMembership | null> {
const raw = await storageGet(LEGAL_MEMBERSHIP_KEY);
return raw ? (JSON.parse(raw) as LegalMembership) : null;
}
/** 写入会员态(null 表示清除) */
async function writeLegalMembership(m: LegalMembership | null): Promise<void> {
if (m) {
await storageSet(LEGAL_MEMBERSHIP_KEY, JSON.stringify(m));
} else {
await storageRemove(LEGAL_MEMBERSHIP_KEY);
}
}
/** 读取订单列表 */
async function readLegalOrders(): Promise<LegalOrder[]> {
const raw = await storageGet(LEGAL_ORDERS_KEY);
return raw ? (JSON.parse(raw) as LegalOrder[]) : [];
}
/** 写入订单列表 */
async function writeLegalOrders(orders: LegalOrder[]): Promise<void> {
await storageSet(LEGAL_ORDERS_KEY, JSON.stringify(orders));
}
/**
* 获取当前会员态
* @method GET
* @url /api/legal/membership
* @returns Promise<LegalMembership | null> 未开通则返回 null
*/
export async function fetchLegalMembership(): Promise<LegalMembership | null> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/membership`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalMembership());
}
/**
* 获取付费订单列表(按创建时间倒序)
* @method GET
* @url /api/legal/orders
* @returns Promise<LegalOrder[]> 订单列表
*/
export async function fetchLegalOrders(): Promise<LegalOrder[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalOrders());
}
/**
* 创建会员订单(mock 阶段仅本地落库,不接真实支付)
* @method POST
* @url /api/legal/orders
* @param planId 套餐 id
* @param channel 支付方式
* @returns Promise<LegalOrder> 新建的待支付订单
*/
export async function createLegalOrder(
planId: string,
channel: LegalPaymentChannel,
): Promise<LegalOrder> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'POST', body: JSON.stringify({ planId, channel }) }); return res.json();
const plans = await fetchLegalPlans();
const plan = plans.find((p) => p.id === planId);
if (!plan) {
throw new Error('套餐不存在');
}
const order: LegalOrder = {
id: `order-${Date.now()}`,
planId: plan.id,
planName: plan.name,
price: plan.price,
channel,
status: 'pending',
createdAt: new Date().toISOString(),
};
const orders = await readLegalOrders();
orders.unshift(order);
await writeLegalOrders(orders);
return mockRequest(order);
}
/**
* 支付订单(mock 阶段模拟支付处理并激活会员态)
* @method POST
* @url /api/legal/orders/:id/pay
* @param orderId 订单 id
* @returns Promise<LegalOrder> 支付后的订单(status=paid)
* @remark 真实接入时,应由后端支付回调确认支付结果后再激活会员态,前端不自行判定成功
*/
export async function payLegalOrder(orderId: string): Promise<LegalOrder> {
// TODO: 调用真实支付 SDK → 后端回调确认 → 返回订单
const orders = await readLegalOrders();
const idx = orders.findIndex((o) => o.id === orderId);
if (idx === -1) {
throw new Error('订单不存在');
}
const paid: LegalOrder = {
...orders[idx],
status: 'paid',
paidAt: new Date().toISOString(),
};
orders[idx] = paid;
await writeLegalOrders(orders);
// 根据套餐周期计算会员有效期并激活会员态
const plans = await fetchLegalPlans();
const plan = plans.find((p) => p.id === paid.planId);
if (plan) {
const now = new Date();
const expires = new Date(now.getTime());
// period: '/年' 或 '/月'
if (plan.period.includes('年')) {
expires.setFullYear(now.getFullYear() + 1);
} else {
expires.setMonth(now.getMonth() + 1);
}
await writeLegalMembership({
planId: plan.id,
planName: plan.name,
startedAt: now.toISOString(),
expiresAt: expires.toISOString(),
});
}
// 模拟支付处理延时
await new Promise((resolve) => setTimeout(resolve, 800));
return mockRequest(paid);
}
/* ===================== 会员服务使用申请(付费后权益兑现) ===================== */
const LEGAL_REQUESTS_KEY = 'legal:requests';
/** 读取服务申请列表 */
async function readLegalRequests(): Promise<LegalServiceRequest[]> {
const raw = await storageGet(LEGAL_REQUESTS_KEY);
return raw ? (JSON.parse(raw) as LegalServiceRequest[]) : [];
}
/** 写入服务申请列表 */
async function writeLegalRequests(requests: LegalServiceRequest[]): Promise<void> {
await storageSet(LEGAL_REQUESTS_KEY, JSON.stringify(requests));
}
/**
* 获取会员服务使用申请列表(按提交时间倒序)
* @method GET
* @url /api/legal/service-requests
* @returns Promise<LegalServiceRequest[]> 申请列表
*/
export async function fetchLegalServiceRequests(): Promise<LegalServiceRequest[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalRequests());
}
/**
* 提交会员服务使用申请(仅已开通会员可提交,权限由后端校验)
* @method POST
* @url /api/legal/service-requests
* @param serviceId 服务 id
* @param content 用户填写的需求描述
* @returns Promise<LegalServiceRequest> 新建的申请
*/
export async function createLegalServiceRequest(
serviceId: string,
content: string,
): Promise<LegalServiceRequest> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'POST', body: JSON.stringify({ serviceId, content }) }); return res.json();
const services = await fetchLegalServices();
const service = services.find((s) => s.id === serviceId);
if (!service) {
throw new Error('服务不存在');
}
const request: LegalServiceRequest = {
id: `request-${Date.now()}`,
serviceId: service.id,
serviceName: service.title,
content,
status: 'submitted',
createdAt: new Date().toISOString(),
};
const requests = await readLegalRequests();
requests.unshift(request);
await writeLegalRequests(requests);
return mockRequest(request);
}
/** /**
* 获取默认法律咨询服务会话 * 获取默认法律咨询服务会话
* @method GET * @method GET

7
src/api/index.ts

@ -38,6 +38,7 @@ export {
fetchLegalServices, fetchLegalServices,
fetchLegalTemplates, fetchLegalTemplates,
fetchEmployeeReviews, fetchEmployeeReviews,
createJobReview,
fetchEnterpriseEntry, fetchEnterpriseEntry,
fetchHollandAssessment, fetchHollandAssessment,
fetchHollandResult, fetchHollandResult,
@ -75,6 +76,12 @@ export {
fetchDeleteAllData, fetchDeleteAllData,
submitDeleteData, submitDeleteData,
fetchLegalDocument, fetchLegalDocument,
fetchLegalMembership,
fetchLegalOrders,
createLegalOrder,
payLegalOrder,
fetchLegalServiceRequests,
createLegalServiceRequest,
loginByPhone, loginByPhone,
fetchWechatLoginResult, fetchWechatLoginResult,
bindWechatAndRegister, bindWechatAndRegister,

2
src/hooks/index.ts

@ -37,6 +37,8 @@ export { useJobPublish } from './useJobPublish';
export { useKeywordBlock } from './useKeywordBlock'; export { useKeywordBlock } from './useKeywordBlock';
export { useLegalData } from './useLegalData'; export { useLegalData } from './useLegalData';
export { useLegalDocument } from './useLegalDocument'; export { useLegalDocument } from './useLegalDocument';
export { useLegalMembership } from './useLegalMembership';
export { useLegalServiceRequests } from './useLegalServiceRequests';
export { useMessageData } from './useMessageData'; export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings'; export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData'; export { useOfferCompareData } from './useOfferCompareData';

12
src/hooks/useJobDetail.ts

@ -4,6 +4,7 @@
*/ */
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
createJobReview,
fetchEmployeeReviews, fetchEmployeeReviews,
fetchJobDetail, fetchJobDetail,
fetchJobOverallScore, fetchJobOverallScore,
@ -38,6 +39,16 @@ export function useJobDetail(id: string) {
const { data, loading, error, refresh } = useAsyncData(fetcher); const { data, loading, error, refresh } = useAsyncData(fetcher);
/** 发布岗位评价,成功后自动刷新评价列表 */
const publishReview = useCallback(
async (content: string) => {
const created = await createJobReview(id, content);
refresh();
return created;
},
[id, refresh],
);
return { return {
detail: data?.detail ?? null, detail: data?.detail ?? null,
overallScore: data?.overallScore ?? null, overallScore: data?.overallScore ?? null,
@ -46,5 +57,6 @@ export function useJobDetail(id: string) {
loading, loading,
error, error,
refresh, refresh,
publishReview,
}; };
} }

92
src/hooks/useLegalMembership.ts

@ -0,0 +1,92 @@
/**
* 法律服务会员态 hook:拉取当前会员态与付费订单,并提供
* 创建订单 / 模拟支付 的能力。mock 阶段数据持久化在 AsyncStorage。
*/
import { useCallback, useEffect, useState } from 'react';
import {
createLegalOrder,
fetchLegalMembership,
fetchLegalOrders,
payLegalOrder,
} from '../api';
import type { LegalMembership, LegalOrder, LegalPaymentChannel } from '../types';
export interface LegalMembershipState {
/** 当前会员态,未开通为 null */
membership: LegalMembership | null;
/** 付费订单列表(倒序) */
orders: LegalOrder[];
/** 首次加载中 */
loading: boolean;
/** 错误对象 */
error: Error | null;
/** 支付处理中(创建订单 + 模拟支付) */
processing: boolean;
/** 刷新会员态与订单 */
refresh: () => Promise<void>;
/** 创建订单,返回新建的待支付订单 */
createOrder: (planId: string, channel: LegalPaymentChannel) => Promise<LegalOrder>;
/** 支付订单,成功后同步会员态 */
payOrder: (orderId: string) => Promise<LegalOrder>;
}
export function useLegalMembership(): LegalMembershipState {
const [membership, setMembership] = useState<LegalMembership | null>(null);
const [orders, setOrders] = useState<LegalOrder[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [processing, setProcessing] = useState(false);
const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [m, o] = await Promise.all([fetchLegalMembership(), fetchLegalOrders()]);
setMembership(m);
setOrders(o);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const createOrder = useCallback(
async (planId: string, channel: LegalPaymentChannel) => {
const order = await createLegalOrder(planId, channel);
setOrders((prev) => [order, ...prev]);
return order;
},
[],
);
const payOrder = useCallback(async (orderId: string) => {
setProcessing(true);
try {
const order = await payLegalOrder(orderId);
setOrders((prev) => prev.map((o) => (o.id === orderId ? order : o)));
if (order.status === 'paid') {
const m = await fetchLegalMembership();
setMembership(m);
}
return order;
} finally {
setProcessing(false);
}
}, []);
return {
membership,
orders,
loading,
error,
processing,
refresh,
createOrder,
payOrder,
};
}

58
src/hooks/useLegalServiceRequests.ts

@ -0,0 +1,58 @@
/**
* 会员服务使用申请 hook:拉取本人的服务申请记录,并提供提交申请的能力。
* 权益兑现载体——已开通会员提交后由律师/顾问跟进。
*/
import { useCallback, useEffect, useState } from 'react';
import { createLegalServiceRequest, fetchLegalServiceRequests } from '../api';
import type { LegalServiceRequest } from '../types';
export interface LegalServiceRequestsState {
/** 申请记录(倒序) */
requests: LegalServiceRequest[];
/** 首次加载中 */
loading: boolean;
/** 错误对象 */
error: Error | null;
/** 提交中 */
submitting: boolean;
/** 刷新申请记录 */
refresh: () => Promise<void>;
/** 提交申请 */
submit: (serviceId: string, content: string) => Promise<LegalServiceRequest>;
}
export function useLegalServiceRequests(): LegalServiceRequestsState {
const [requests, setRequests] = useState<LegalServiceRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [submitting, setSubmitting] = useState(false);
const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
setRequests(await fetchLegalServiceRequests());
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const submit = useCallback(async (serviceId: string, content: string) => {
setSubmitting(true);
try {
const created = await createLegalServiceRequest(serviceId, content);
setRequests((prev) => [created, ...prev]);
return created;
} finally {
setSubmitting(false);
}
}, []);
return { requests, loading, error, submitting, refresh, submit };
}

6
src/types/index.ts

@ -192,6 +192,12 @@ export type {
LegalService, LegalService,
LegalServiceType, LegalServiceType,
LegalTemplate, LegalTemplate,
LegalPaymentChannel,
LegalOrderStatus,
LegalOrder,
LegalMembership,
LegalServiceRequest,
LegalServiceRequestStatus,
} from './legal'; } from './legal';
export type { export type {
AboutInfo, AboutInfo,

61
src/types/legal.ts

@ -20,6 +20,8 @@ export interface LegalService {
type: LegalServiceType; type: LegalServiceType;
/** 跳转动作:chat=法律咨询对话 / templates=文书模板 / faq=常见问题 / upgrade=开通会员 */ /** 跳转动作:chat=法律咨询对话 / templates=文书模板 / faq=常见问题 / upgrade=开通会员 */
action: 'chat' | 'templates' | 'faq' | 'upgrade'; action: 'chat' | 'templates' | 'faq' | 'upgrade';
/** 会员服务使用页的引导提示语(仅 type=member 时配置) */
requestHint?: string;
} }
/** 法律常见问题 */ /** 法律常见问题 */
@ -101,3 +103,62 @@ export interface LegalBrand {
/** 品牌 Logo(mock 阶段用 emoji/图标占位) */ /** 品牌 Logo(mock 阶段用 emoji/图标占位) */
logo: string; logo: string;
} }
/* ===================== 付费 / 订单 / 会员态 ===================== */
/** 支付方式(mock 阶段仅作选择,真实支付由后端凭证决定) */
export type LegalPaymentChannel = 'wechat' | 'alipay';
/** 订单状态:待支付 / 已支付 / 失败 */
export type LegalOrderStatus = 'pending' | 'paid' | 'failed';
/** 会员订单 */
export interface LegalOrder {
/** 订单 id */
id: string;
/** 套餐 id */
planId: string;
/** 套餐名称(下单时快照) */
planName: string;
/** 实付金额(元) */
price: number;
/** 支付方式 */
channel: LegalPaymentChannel;
/** 订单状态 */
status: LegalOrderStatus;
/** 创建时间 ISO */
createdAt: string;
/** 支付时间 ISO(status=paid 时存在) */
paidAt?: string;
}
/** 会员态 */
export interface LegalMembership {
/** 套餐 id */
planId: string;
/** 套餐名称 */
planName: string;
/** 开通时间 ISO */
startedAt: string;
/** 到期时间 ISO */
expiresAt: string;
}
/** 会员服务申请状态:已提交 / 处理中 / 已完成 */
export type LegalServiceRequestStatus = 'submitted' | 'processing' | 'done';
/** 会员服务使用申请(付费后权益兑现的载体) */
export interface LegalServiceRequest {
/** 申请 id */
id: string;
/** 服务 id */
serviceId: string;
/** 服务名称(提交时快照) */
serviceName: string;
/** 用户填写的需求描述 */
content: string;
/** 申请状态 */
status: LegalServiceRequestStatus;
/** 提交时间 ISO */
createdAt: string;
}

5
项目开发状态.md

@ -75,7 +75,8 @@
### 4.2 功能完整性缺口(标注【待确认】为推断项,需与产品对齐) ### 4.2 功能完整性缺口(标注【待确认】为推断项,需与产品对齐)
- **通关小游戏(7 关)**:框架页 `game.tsx` + `game/level.tsx` 已建,7 关(认识自己 → 能力 → 岗位 → 企业 → 简历 → 面试 → 签约)**内容与数据已完整**:`MOCK_GAME_LEVELS` 共 22 题(self 4 / 其余各 3),题型含单选/多选/输入,配套解析与 `tags` 用于岗位推荐。评分(≥60 通关)、解锁、进度聚合逻辑均在 `submitGameLevel` 实现。✅ 2026-09-10 完成内容增厚(每关由 1–2 题补至 3 题左右)。 - **通关小游戏(7 关)**:框架页 `game.tsx` + `game/level.tsx` 已建,7 关(认识自己 → 能力 → 岗位 → 企业 → 简历 → 面试 → 签约)**内容与数据已完整**:`MOCK_GAME_LEVELS` 共 22 题(self 4 / 其余各 3),题型含单选/多选/输入,配套解析与 `tags` 用于岗位推荐。评分(≥60 通关)、解锁、进度聚合逻辑均在 `submitGameLevel` 实现。✅ 2026-09-10 完成内容增厚(每关由 1–2 题补至 3 题左右)。
- **阿布阿诺 AI 法律服务**:免费层(咨询 / 模板 / FAQ)页已建;付费层 `legal/upgrade.tsx` 已建,但付费 / 支付链路 **【待确认】是否接通**。 - **阿布阿诺 AI 法律服务**:免费层(咨询 / 模板 / FAQ)页已建;付费层 `legal/upgrade.tsx` 已建,**付费 / 支付链路已接通(mock 支付闭环)**——`legal/checkout.tsx` 选套餐→选支付方式→模拟支付→会员态激活并 AsyncStorage 持久化;`legal.tsx` / `upgrade.tsx` 套餐卡可点击进入结算,会员态在返回时即时刷新。真实支付 SDK 后续仅换 `api.ts` 的 `createLegalOrder` / `payLegalOrder` 函数体。**付费后权益已兑现**:新增 `legal/service.tsx` 会员服务使用页——未开通点会员服务仍引导开通,已开通则进入使用页(服务说明 + 引导语 + 需求输入框 + 提交申请 + 申请记录持久化),`legal.tsx` 会员卡标签按态显示「会员 / 已开通」。
- **岗位评价发布(双向透明评价生态核心)**:✅ 2026-09-10 接通。此前 `jobs/[id].tsx` 底部发布按钮为「功能开发中」占位——产品核心定位即评价体系却无法发布。现已新增 `createJobReview`(AsyncStorage 键 `job:reviews`,按 jobId 归属),`fetchEmployeeReviews` 合并「本人发布(置顶)+ 内置评价」,`useJobDetail` 暴露 `publishReview`,页面接线含空输入拦截、发布中 loading、成功后清空输入框并自动刷新列表。
- **隐私政策 / 用户协议内容**:`src/api/api.ts` 中 `MOCK_PRIVACY_POLICY` / `MOCK_USER_AGREEMENT` 已含完整合规草案(各 9–10 章),**18 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。 - **隐私政策 / 用户协议内容**:`src/api/api.ts` 中 `MOCK_PRIVACY_POLICY` / `MOCK_USER_AGREEMENT` 已含完整合规草案(各 9–10 章),**18 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。
### 4.3 工程与上线 ### 4.3 工程与上线
@ -90,7 +91,7 @@
## 五、后续增量开发建议(优先级) ## 五、后续增量开发建议(优先级)
- **P0(上架前必做)**:图标替换【待你提供 1024×1024 logo】 · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,待账号注入占位】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】**。 - **P0(上架前必做)**:图标替换【待你提供 1024×1024 logo】 · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,待账号注入占位】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】**。
- **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · 付费法律服务支付 · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。 - **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,tsc/eslint 0 问题】** · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。
- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · 真实后端对接 · OTA 通道验证。 - **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · 真实后端对接 · OTA 通道验证。
--- ---

Loading…
Cancel
Save