Browse Source
按提交纪律精确分阶段 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
14 changed files with 1510 additions and 24 deletions
@ -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, |
||||
|
}, |
||||
|
}); |
||||
@ -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, |
||||
|
}, |
||||
|
}); |
||||
@ -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, |
||||
|
}; |
||||
|
} |
||||
@ -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 }; |
||||
|
} |
||||
Loading…
Reference in new issue