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