Compare commits
4 Commits
731b4c9bac
...
f6d42023be
| Author | SHA1 | Date |
|---|---|---|
|
|
f6d42023be | 2 months ago |
|
|
cbf38e7745 | 2 months ago |
|
|
1db789397c | 2 months ago |
|
|
b8d19d29cc | 2 months ago |
60 changed files with 3809 additions and 7806 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,145 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { useRouter, useLocalSearchParams } from 'expo-router'; |
||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
||||
|
import { useLegalData } from '../../src/hooks'; |
||||
|
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; |
||||
|
import { colors } from '../../src/theme'; |
||||
|
|
||||
|
/* ===================== 顶部导航 ===================== */ |
||||
|
|
||||
|
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 LegalTemplateScreen() { |
||||
|
const router = useRouter(); |
||||
|
const { id } = useLocalSearchParams(); |
||||
|
const { templates, loading, error, refresh } = useLegalData(); |
||||
|
|
||||
|
const templateId = Array.isArray(id) ? id[0] : id; |
||||
|
const template = templateId ? templates.find((t) => t.id === templateId) : undefined; |
||||
|
|
||||
|
return ( |
||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}> |
||||
|
<Header onBack={() => router.back()} /> |
||||
|
{loading ? ( |
||||
|
<LoadingState text="加载模板中…" /> |
||||
|
) : error ? ( |
||||
|
<ErrorState message="加载失败,请重试" onRetry={refresh} /> |
||||
|
) : !template ? ( |
||||
|
<EmptyState icon="document-text-outline" title="未找到该模板" subtitle="请返回重新选择" /> |
||||
|
) : ( |
||||
|
<ScrollView |
||||
|
style={styles.scroll} |
||||
|
contentContainerStyle={styles.content} |
||||
|
showsVerticalScrollIndicator={false} |
||||
|
> |
||||
|
<View style={styles.headCard}> |
||||
|
<Ionicons name={template.icon as never} size={28} color={colors.primary} /> |
||||
|
<View style={styles.headBody}> |
||||
|
<Text style={styles.name}>{template.name}</Text> |
||||
|
<Text style={styles.desc}>{template.description}</Text> |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.contentCard}> |
||||
|
{/* selectable:长按正文即可复制,无需额外原生模块(mock 阶段) */} |
||||
|
<Text style={styles.contentText} selectable> |
||||
|
{template.content} |
||||
|
</Text> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.tip}> |
||||
|
长按正文即可复制;正式下载 / 导出为 Word·PDF 需后端文件支持,当前为内容预览。 |
||||
|
</Text> |
||||
|
</ScrollView> |
||||
|
)} |
||||
|
</SafeAreaView> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
safeArea: { |
||||
|
flex: 1, |
||||
|
backgroundColor: colors.pageBg, |
||||
|
}, |
||||
|
header: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'space-between', |
||||
|
paddingHorizontal: 8, |
||||
|
height: 48, |
||||
|
backgroundColor: colors.white, |
||||
|
borderBottomWidth: 1, |
||||
|
borderBottomColor: colors.borderLight, |
||||
|
}, |
||||
|
headerBtn: { |
||||
|
width: 40, |
||||
|
height: 40, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
headerTitle: { |
||||
|
fontSize: 17, |
||||
|
fontWeight: '700', |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
scroll: { |
||||
|
flex: 1, |
||||
|
}, |
||||
|
content: { |
||||
|
padding: 12, |
||||
|
paddingBottom: 24, |
||||
|
}, |
||||
|
headCard: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
padding: 16, |
||||
|
borderRadius: 12, |
||||
|
backgroundColor: colors.white, |
||||
|
marginBottom: 12, |
||||
|
}, |
||||
|
headBody: { |
||||
|
flex: 1, |
||||
|
marginLeft: 12, |
||||
|
}, |
||||
|
name: { |
||||
|
fontSize: 16, |
||||
|
fontWeight: '700', |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
desc: { |
||||
|
marginTop: 2, |
||||
|
fontSize: 12, |
||||
|
color: colors.textSecondary, |
||||
|
}, |
||||
|
contentCard: { |
||||
|
padding: 16, |
||||
|
borderRadius: 12, |
||||
|
backgroundColor: colors.white, |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.borderLight, |
||||
|
}, |
||||
|
contentText: { |
||||
|
fontSize: 14, |
||||
|
lineHeight: 22, |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
tip: { |
||||
|
marginTop: 12, |
||||
|
fontSize: 12, |
||||
|
color: colors.textHint, |
||||
|
lineHeight: 18, |
||||
|
}, |
||||
|
}); |
||||
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 287 KiB |
@ -0,0 +1,134 @@ |
|||||
|
import { useEffect, useRef, useState } from 'react'; |
||||
|
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { useOnboarding } from '../../../src/hooks'; |
||||
|
import { ScreenShell } from '../ui/ScreenShell'; |
||||
|
import { PrimaryButton } from '../ui/PrimaryButton'; |
||||
|
|
||||
|
const RESEND_SECONDS = 60; |
||||
|
|
||||
|
/** P2W 微信登录短信验证码页:×关闭 + 4格验证码 + 重新发送倒计时 + 语音/收不到短信入口 */ |
||||
|
export function P2WechatCode() { |
||||
|
const { data, navigate, back } = useOnboarding(); |
||||
|
const [code, setCode] = useState(''); |
||||
|
const [left, setLeft] = useState(RESEND_SECONDS); |
||||
|
const timer = useRef<ReturnType<typeof setInterval> | null>(null); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
timer.current = setInterval(() => { |
||||
|
setLeft((l) => { |
||||
|
if (l <= 1) { |
||||
|
if (timer.current) clearInterval(timer.current); |
||||
|
return 0; |
||||
|
} |
||||
|
return l - 1; |
||||
|
}); |
||||
|
}, 1000); |
||||
|
return () => { |
||||
|
if (timer.current) clearInterval(timer.current); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
const phone = data.phone; |
||||
|
/** 尾号:有真实手机号用尾4位,否则演示 9367 */ |
||||
|
const tail = phone.length === 11 ? phone.slice(7) : '9367'; |
||||
|
const canNext = code.length >= 4; |
||||
|
|
||||
|
const resend = () => { |
||||
|
if (left > 0) return; |
||||
|
setLeft(RESEND_SECONDS); |
||||
|
setCode(''); |
||||
|
timer.current = setInterval(() => { |
||||
|
setLeft((l) => { |
||||
|
if (l <= 1) { |
||||
|
if (timer.current) clearInterval(timer.current); |
||||
|
return 0; |
||||
|
} |
||||
|
return l - 1; |
||||
|
}); |
||||
|
}, 1000); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<ScreenShell showBack onBack={back} backIcon="close"> |
||||
|
<Text style={styles.h1}>输入短信验证码</Text> |
||||
|
<Text style={styles.subtitle}>已向您的手机 {tail} 发送验证码</Text> |
||||
|
|
||||
|
{/* 4 格分离输入:透明输入层覆盖,格子仅做展示 */} |
||||
|
<Pressable style={styles.cellsWrap}> |
||||
|
<View style={styles.cellsRow}> |
||||
|
{[0, 1, 2, 3].map((i) => ( |
||||
|
<View key={i} style={styles.cell}> |
||||
|
<Text style={styles.cellText}>{code[i] ?? ''}</Text> |
||||
|
<View style={[styles.cellLine, code.length === i && styles.cellLineActive]} /> |
||||
|
</View> |
||||
|
))} |
||||
|
</View> |
||||
|
<TextInput |
||||
|
style={styles.hiddenInput} |
||||
|
value={code} |
||||
|
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 4))} |
||||
|
keyboardType="number-pad" |
||||
|
maxLength={4} |
||||
|
autoFocus |
||||
|
/> |
||||
|
</Pressable> |
||||
|
|
||||
|
<Pressable onPress={resend} hitSlop={6} disabled={left > 0}> |
||||
|
<Text style={[styles.resend, left > 0 && styles.resendDisabled]}> |
||||
|
{left > 0 ? `重新发送(${left}s)` : '重新发送'} |
||||
|
</Text> |
||||
|
</Pressable> |
||||
|
|
||||
|
<View style={styles.btnArea}> |
||||
|
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('B1')} /> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.helpRow}> |
||||
|
<Pressable hitSlop={6}> |
||||
|
<Text style={styles.helpText}>语音接听验证码</Text> |
||||
|
</Pressable> |
||||
|
<Pressable hitSlop={6}> |
||||
|
<Text style={styles.helpText}>接收不到短信</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
|
||||
|
<View style={styles.toastBar}> |
||||
|
<Text style={styles.toastText}>短信验证码正在发送,请注意查收</Text> |
||||
|
</View> |
||||
|
</ScreenShell> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
h1: { fontSize: 24, fontWeight: '800', color: colors.textPrimary, marginTop: 14 }, |
||||
|
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 12 }, |
||||
|
cellsWrap: { marginTop: 34 }, |
||||
|
cellsRow: { flexDirection: 'row', gap: 18 }, |
||||
|
cell: { flex: 1, alignItems: 'center' }, |
||||
|
cellText: { fontSize: 26, fontWeight: '700', color: colors.textPrimary, minHeight: 36 }, |
||||
|
cellLine: { width: '100%', height: 2, backgroundColor: colors.border, marginTop: 8 }, |
||||
|
cellLineActive: { backgroundColor: colors.primary }, |
||||
|
hiddenInput: { |
||||
|
...StyleSheet.absoluteFill, |
||||
|
opacity: 0, |
||||
|
}, |
||||
|
resend: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 22 }, |
||||
|
resendDisabled: { color: colors.textHint }, |
||||
|
btnArea: { marginTop: 30 }, |
||||
|
helpRow: { |
||||
|
flexDirection: 'row', |
||||
|
justifyContent: 'space-between', |
||||
|
marginTop: 22, |
||||
|
}, |
||||
|
helpText: { fontSize: 13, color: colors.textHint }, |
||||
|
toastBar: { |
||||
|
alignSelf: 'center', |
||||
|
marginTop: 30, |
||||
|
paddingHorizontal: 18, |
||||
|
paddingVertical: 10, |
||||
|
borderRadius: 8, |
||||
|
backgroundColor: colors.borderLight, |
||||
|
}, |
||||
|
toastText: { fontSize: 13, color: colors.textDisabled }, |
||||
|
}); |
||||
@ -0,0 +1,49 @@ |
|||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { Modal } from './Modal'; |
||||
|
|
||||
|
interface Props { |
||||
|
visible: boolean; |
||||
|
/** 关闭弹窗,停留在登录页不做任何操作 */ |
||||
|
onClose: () => void; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 账号注销审核期提示弹窗(微信登录第三种场景)。 |
||||
|
* |
||||
|
* 账号处于注销审核期时不可登录、不可复活,因此只提供「知道了」, |
||||
|
* 不提供任何重新注册出口;关闭后停留在登录页。 |
||||
|
*/ |
||||
|
export function DeletedAccountModal({ visible, onClose }: Props) { |
||||
|
return ( |
||||
|
<Modal visible={visible} onClose={onClose}> |
||||
|
<Text style={styles.title}>无法登录</Text> |
||||
|
<Text style={styles.message}>该账号正在申请注销中,无法登录。</Text> |
||||
|
<View style={styles.btnRow}> |
||||
|
<Pressable style={[styles.btn, styles.confirmBtn]} onPress={onClose} hitSlop={4}> |
||||
|
<Text style={styles.confirmText}>知道了</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
</Modal> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
title: { |
||||
|
fontSize: 16, |
||||
|
fontWeight: '600', |
||||
|
color: colors.textPrimary, |
||||
|
textAlign: 'center', |
||||
|
marginBottom: 10, |
||||
|
}, |
||||
|
message: { fontSize: 14, lineHeight: 22, color: colors.textSecondary, textAlign: 'center' }, |
||||
|
btnRow: { marginTop: 20 }, |
||||
|
btn: { |
||||
|
height: 46, |
||||
|
borderRadius: 23, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
confirmBtn: { backgroundColor: colors.primary }, |
||||
|
confirmText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, |
||||
|
}); |
||||
@ -0,0 +1,306 @@ |
|||||
|
import { useCallback, useEffect, useRef, useState } from 'react'; |
||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { PanResponder, Pressable, StyleSheet, Text, View } from 'react-native'; |
||||
|
import type { ImageSourcePropType } from 'react-native'; |
||||
|
import { |
||||
|
Circle, |
||||
|
ClipPath, |
||||
|
Defs, |
||||
|
G, |
||||
|
Image as SvgImage, |
||||
|
LinearGradient, |
||||
|
Path, |
||||
|
Rect, |
||||
|
Stop, |
||||
|
Svg, |
||||
|
} from 'react-native-svg'; |
||||
|
import { colors } from '../../../src/theme'; |
||||
|
import { Modal } from './Modal'; |
||||
|
import { CAPTCHA_BACKGROUNDS } from '../assets'; |
||||
|
|
||||
|
interface Props { |
||||
|
visible: boolean; |
||||
|
/** 拼图块对齐缺口时判定通过 */ |
||||
|
onSuccess: () => void; |
||||
|
/** 点遮罩 / 关闭 */ |
||||
|
onClose: () => void; |
||||
|
} |
||||
|
|
||||
|
const KNOB = 44; |
||||
|
const AREA_H = 170; |
||||
|
|
||||
|
/** 生成经典拼图形状(四边各一个半圆凸起)的 SVG path */ |
||||
|
function jigsaw(x: number, y: number, w: number, h: number): string { |
||||
|
const r = w * 0.24; // 凸起半宽
|
||||
|
const d = w * 0.2; // 凸起深度
|
||||
|
const mx = x + w / 2; |
||||
|
const my = y + h / 2; |
||||
|
return [ |
||||
|
`M ${x} ${y}`, |
||||
|
`L ${mx - r} ${y}`, |
||||
|
`C ${mx - r} ${y - d}, ${mx + r} ${y - d}, ${mx + r} ${y}`, |
||||
|
`L ${x + w} ${y}`, |
||||
|
`L ${x + w} ${my - r}`, |
||||
|
`C ${x + w + d} ${my - r}, ${x + w + d} ${my + r}, ${x + w} ${my + r}`, |
||||
|
`L ${x + w} ${y + h}`, |
||||
|
`L ${mx + r} ${y + h}`, |
||||
|
`C ${mx + r} ${y + h + d}, ${mx - r} ${y + h + d}, ${mx - r} ${y + h}`, |
||||
|
`L ${x} ${y + h}`, |
||||
|
`L ${x} ${my + r}`, |
||||
|
`C ${x - d} ${my + r}, ${x - d} ${my - r}, ${x} ${my - r}`, |
||||
|
'Z', |
||||
|
].join(' '); |
||||
|
} |
||||
|
|
||||
|
interface Puzzle { |
||||
|
PW: number; |
||||
|
PH: number; |
||||
|
TY: number; |
||||
|
targetPieceX: number; |
||||
|
targetDx: number; |
||||
|
leftMargin: number; |
||||
|
pieceTravelRange: number; |
||||
|
} |
||||
|
|
||||
|
/** 滑块拼图验证弹窗:拖动滑块使拼图块对齐随机缺口,位置匹配即判定通过 */ |
||||
|
export function SliderCaptcha({ visible, onSuccess, onClose }: Props) { |
||||
|
const [areaW, setAreaW] = useState(0); |
||||
|
const [trackW, setTrackW] = useState(0); |
||||
|
const [dx, setDx] = useState(0); |
||||
|
const [passed, setPassed] = useState(false); |
||||
|
const [puzzle, setPuzzle] = useState<Puzzle | null>(null); |
||||
|
const [refreshKey, setRefreshKey] = useState(0); |
||||
|
/** 当前使用的背景图(每次 generate 随机从池中选取) */ |
||||
|
const [bg, setBg] = useState<ImageSourcePropType | null>(null); |
||||
|
const startX = useRef(0); |
||||
|
|
||||
|
const knobMaxDx = Math.max(0, trackW - KNOB - 4); |
||||
|
// 用 ref 持有最新状态,避免 PanResponder 闭包捕获首帧旧值(puzzle 为 null)导致滑块无法抢占手势、划不动
|
||||
|
const latest = useRef({ dx: 0, knobMaxDx: 0, passed: false, puzzle: null as Puzzle | null, onSuccess }); |
||||
|
latest.current = { dx, knobMaxDx, passed, puzzle, onSuccess }; |
||||
|
|
||||
|
// 测量完成后随机生成缺口位置、拼图块目标位移,并随机选取背景图
|
||||
|
const generate = useCallback(() => { |
||||
|
if (areaW <= 0 || trackW <= 0) return; |
||||
|
const maxDx = Math.max(10, trackW - KNOB - 4); |
||||
|
const PW = Math.min(Math.max(areaW * 0.28, 54), 96); |
||||
|
const PH = PW; |
||||
|
const leftMargin = 6; |
||||
|
const pieceTravelRange = Math.max(10, areaW - PW - leftMargin * 2); |
||||
|
const targetPieceX = leftMargin + 24 + Math.random() * Math.max(1, pieceTravelRange - 48); |
||||
|
const TY = 16 + Math.random() * Math.max(1, AREA_H - PH - 24); |
||||
|
const targetDx = ((targetPieceX - leftMargin) / pieceTravelRange) * maxDx; |
||||
|
setPuzzle({ PW, PH, TY, targetPieceX, targetDx, leftMargin, pieceTravelRange }); |
||||
|
setDx(0); |
||||
|
setPassed(false); |
||||
|
// 从背景图池中随机选一张
|
||||
|
if (CAPTCHA_BACKGROUNDS.length > 0) { |
||||
|
setBg(CAPTCHA_BACKGROUNDS[Math.floor(Math.random() * CAPTCHA_BACKGROUNDS.length)]); |
||||
|
} |
||||
|
}, [areaW, trackW]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (visible && areaW > 0 && trackW > 0) generate(); |
||||
|
}, [visible, areaW, trackW, refreshKey, generate]); |
||||
|
|
||||
|
const pieceX = |
||||
|
puzzle && knobMaxDx > 0 |
||||
|
? puzzle.leftMargin + (dx / knobMaxDx) * puzzle.pieceTravelRange |
||||
|
: puzzle?.leftMargin ?? 0; |
||||
|
|
||||
|
const pan = useRef( |
||||
|
PanResponder.create({ |
||||
|
onStartShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, |
||||
|
onMoveShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, |
||||
|
onPanResponderGrant: (e) => { |
||||
|
startX.current = e.nativeEvent.pageX; |
||||
|
}, |
||||
|
onPanResponderMove: (e) => { |
||||
|
const { puzzle: pz, knobMaxDx: max } = latest.current; |
||||
|
if (!pz) return; |
||||
|
const next = Math.min(Math.max(e.nativeEvent.pageX - startX.current, 0), max); |
||||
|
latest.current.dx = next; // 同步到 ref,保证松手判定用的是最终位移
|
||||
|
setDx(next); |
||||
|
}, |
||||
|
onPanResponderRelease: () => { |
||||
|
const { puzzle: pz, knobMaxDx: max, dx: cur, onSuccess: ok } = latest.current; |
||||
|
if (!pz) return; |
||||
|
const tol = Math.max(8, max * 0.045); |
||||
|
if (Math.abs(cur - pz.targetDx) <= tol) { |
||||
|
setDx(pz.targetDx); |
||||
|
setPassed(true); |
||||
|
setTimeout(ok, 400); |
||||
|
} else { |
||||
|
setDx(0); |
||||
|
} |
||||
|
}, |
||||
|
}), |
||||
|
).current; |
||||
|
|
||||
|
const reset = () => { |
||||
|
setDx(0); |
||||
|
setPassed(false); |
||||
|
}; |
||||
|
const refresh = () => { |
||||
|
reset(); |
||||
|
setRefreshKey((k) => k + 1); |
||||
|
}; |
||||
|
const close = () => { |
||||
|
reset(); |
||||
|
onClose(); |
||||
|
}; |
||||
|
|
||||
|
// 背景:有图片则裁切填充,否则用渐变 + 几何纹理占位(与拼图块共用同一来源保证对齐)
|
||||
|
const renderBg = (clip?: string) => |
||||
|
bg ? ( |
||||
|
<SvgImage |
||||
|
href={bg as number} |
||||
|
x={0} |
||||
|
y={0} |
||||
|
width={areaW} |
||||
|
height={AREA_H} |
||||
|
preserveAspectRatio="xMidYMid slice" |
||||
|
clipPath={clip} |
||||
|
/> |
||||
|
) : ( |
||||
|
<G clipPath={clip}> |
||||
|
<Rect x={0} y={0} width={areaW} height={AREA_H} fill="url(#capBg)" /> |
||||
|
<Circle cx={areaW * 0.25} cy={AREA_H * 0.3} r={34} fill={colors.white} opacity={0.12} /> |
||||
|
<Circle cx={areaW * 0.7} cy={AREA_H * 0.72} r={46} fill={colors.white} opacity={0.1} /> |
||||
|
<Circle cx={areaW * 0.86} cy={AREA_H * 0.2} r={20} fill={colors.white} opacity={0.14} /> |
||||
|
</G> |
||||
|
); |
||||
|
|
||||
|
return ( |
||||
|
<Modal visible={visible} onClose={onClose}> |
||||
|
{/* 拼图验证区:背景 + 缺口 + 可拖动拼图块 */} |
||||
|
<View style={styles.puzzleArea} onLayout={(e) => setAreaW(e.nativeEvent.layout.width)}> |
||||
|
{areaW > 0 && puzzle ? ( |
||||
|
<Svg width={areaW} height={AREA_H} style={styles.svg}> |
||||
|
<Defs> |
||||
|
<LinearGradient |
||||
|
id="capBg" |
||||
|
x1={0} |
||||
|
y1={0} |
||||
|
x2={areaW} |
||||
|
y2={AREA_H} |
||||
|
gradientUnits="userSpaceOnUse" |
||||
|
> |
||||
|
<Stop offset={0} stopColor={colors.primary} /> |
||||
|
<Stop offset={1} stopColor={colors.success} /> |
||||
|
</LinearGradient> |
||||
|
<ClipPath id="pieceClip"> |
||||
|
<Path d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)} /> |
||||
|
</ClipPath> |
||||
|
</Defs> |
||||
|
|
||||
|
{/* 静态背景 */} |
||||
|
{renderBg()} |
||||
|
|
||||
|
{/* 目标缺口(待填充位置) */} |
||||
|
<Path |
||||
|
d={jigsaw(puzzle.targetPieceX, puzzle.TY, puzzle.PW, puzzle.PH)} |
||||
|
fill={colors.scrim} |
||||
|
opacity={0.55} |
||||
|
stroke={colors.white} |
||||
|
strokeWidth={2} |
||||
|
/> |
||||
|
|
||||
|
{/* 可拖动拼图块 */} |
||||
|
<Path |
||||
|
d={jigsaw(pieceX + 2, puzzle.TY + 2, puzzle.PW, puzzle.PH)} |
||||
|
fill={colors.textPrimary} |
||||
|
opacity={0.18} |
||||
|
/> |
||||
|
{renderBg('url(#pieceClip)')} |
||||
|
<Path |
||||
|
d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)} |
||||
|
fill="none" |
||||
|
stroke={colors.primary} |
||||
|
strokeWidth={2.5} |
||||
|
/> |
||||
|
</Svg> |
||||
|
) : ( |
||||
|
<View style={styles.puzzlePlaceholder}> |
||||
|
<Text style={styles.puzzleHint}>拼图加载中…</Text> |
||||
|
</View> |
||||
|
)} |
||||
|
</View> |
||||
|
|
||||
|
{/* 滑块轨道 */} |
||||
|
<View style={styles.track} onLayout={(e) => setTrackW(e.nativeEvent.layout.width)}> |
||||
|
<Text style={[styles.trackHint, { opacity: dx > 8 || passed ? 0 : 1 }]}> |
||||
|
{passed ? '验证通过' : '拖动滑块完成拼图'} |
||||
|
</Text> |
||||
|
<View |
||||
|
style={[styles.knob, { transform: [{ translateX: dx }] }, passed && styles.knobPassed]} |
||||
|
{...pan.panHandlers} |
||||
|
> |
||||
|
<Ionicons |
||||
|
name={passed ? 'checkmark' : 'reorder-three'} |
||||
|
size={22} |
||||
|
color={passed ? colors.textInverse : colors.primary} |
||||
|
/> |
||||
|
</View> |
||||
|
</View> |
||||
|
|
||||
|
{/* 底部操作行 */} |
||||
|
<View style={styles.toolRow}> |
||||
|
<Pressable onPress={close} hitSlop={8}> |
||||
|
<Ionicons name="close-circle-outline" size={22} color={colors.textHint} /> |
||||
|
</Pressable> |
||||
|
<Pressable onPress={refresh} hitSlop={8}> |
||||
|
<Ionicons name="refresh" size={22} color={colors.textHint} /> |
||||
|
</Pressable> |
||||
|
<Ionicons name="volume-medium-outline" size={22} color={colors.textHint} /> |
||||
|
</View> |
||||
|
</Modal> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
puzzleArea: { |
||||
|
height: AREA_H, |
||||
|
borderRadius: 12, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
overflow: 'hidden', |
||||
|
}, |
||||
|
svg: { position: 'absolute', left: 0, top: 0 }, |
||||
|
puzzlePlaceholder: { |
||||
|
flex: 1, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
puzzleHint: { fontSize: 14, color: colors.textHint }, |
||||
|
track: { |
||||
|
height: 44, |
||||
|
borderRadius: 22, |
||||
|
backgroundColor: colors.borderLight, |
||||
|
marginTop: 16, |
||||
|
justifyContent: 'center', |
||||
|
overflow: 'hidden', |
||||
|
}, |
||||
|
trackHint: { textAlign: 'center', fontSize: 14, color: colors.textHint }, |
||||
|
knob: { |
||||
|
position: 'absolute', |
||||
|
left: 2, |
||||
|
top: 2, |
||||
|
width: KNOB, |
||||
|
height: KNOB, |
||||
|
borderRadius: 22, |
||||
|
backgroundColor: colors.white, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
shadowColor: colors.textPrimary, |
||||
|
shadowOpacity: 0.15, |
||||
|
shadowRadius: 4, |
||||
|
elevation: 3, |
||||
|
}, |
||||
|
knobPassed: { backgroundColor: colors.success }, |
||||
|
toolRow: { |
||||
|
flexDirection: 'row', |
||||
|
justifyContent: 'space-around', |
||||
|
alignItems: 'center', |
||||
|
marginTop: 14, |
||||
|
}, |
||||
|
}); |
||||
@ -0,0 +1,21 @@ |
|||||
|
{ |
||||
|
"cli": { |
||||
|
"version": ">= 16.0.0", |
||||
|
"appVersionSource": "local" |
||||
|
}, |
||||
|
"build": { |
||||
|
"development": { |
||||
|
"developmentClient": true, |
||||
|
"distribution": "internal" |
||||
|
}, |
||||
|
"preview": { |
||||
|
"distribution": "internal" |
||||
|
}, |
||||
|
"production": { |
||||
|
"autoIncrement": true |
||||
|
} |
||||
|
}, |
||||
|
"submit": { |
||||
|
"production": {} |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -0,0 +1,102 @@ |
|||||
|
/** |
||||
|
* 路由审计脚本:交叉校验代码中引用的路由 vs app/ 下实际存在的路由文件。 |
||||
|
* 用法:node scripts/check-routes.mjs |
||||
|
*/ |
||||
|
import fs from 'fs'; |
||||
|
import path from 'path'; |
||||
|
|
||||
|
const appDir = path.resolve('app'); |
||||
|
const routeFiles = []; |
||||
|
function walk(dir, rel = '') { |
||||
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) { |
||||
|
const full = path.join(dir, e.name); |
||||
|
const relPath = rel ? rel + '/' + e.name : e.name; |
||||
|
if (e.isDirectory()) walk(full, relPath); |
||||
|
else if (/\.(tsx|ts|jsx|js)$/.test(e.name)) routeFiles.push(relPath); |
||||
|
} |
||||
|
} |
||||
|
walk(appDir); |
||||
|
|
||||
|
function toRoute(file) { |
||||
|
let r = file.replace(/\.(tsx|ts|jsx|js)$/, ''); |
||||
|
r = r.split('/').filter((s) => !/^\(.*\)$/.test(s)).join('/'); |
||||
|
r = r.replace(/\/index$/, ''); |
||||
|
return '/' + r.replace(/_layout$/, '').replace(/\/$/, ''); |
||||
|
} |
||||
|
const actualRoutes = new Set(routeFiles.map(toRoute).filter(Boolean)); |
||||
|
|
||||
|
function normalize(t) { |
||||
|
const clean = t.split('?')[0].replace(/\/$/, ''); |
||||
|
// 去掉路由组前缀:expo-router 允许写 "/(tabs)/jobs" 这类完整路径
|
||||
|
return clean.split('/').filter((s) => s && !/^\(.*\)$/.test(s)).join('/'); |
||||
|
} |
||||
|
|
||||
|
function routeExists(target) { |
||||
|
const clean = target.split('?')[0].replace(/\/$/, ''); |
||||
|
if (clean === '' || clean === 'index') return true; |
||||
|
if (actualRoutes.has(clean)) return true; |
||||
|
const n = normalize(clean); |
||||
|
for (const r of actualRoutes) { |
||||
|
const rn = normalize(r); |
||||
|
if (rn === n) return true; |
||||
|
const pattern = |
||||
|
'^' + rn.replace(/\[\[?\.\.\.(\w+)\]\]?/g, '.+').replace(/\[(\w+)\]/g, '[^/]+') + '$'; |
||||
|
if (new RegExp(pattern).test(n)) return true; |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const srcDirs = ['app', 'components', 'src']; |
||||
|
const files = []; |
||||
|
function walkSrc(dir) { |
||||
|
if (!fs.existsSync(dir)) return; |
||||
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) { |
||||
|
const full = path.join(dir, e.name); |
||||
|
if (e.isDirectory()) walkSrc(full); |
||||
|
else if (/\.(tsx|ts)$/.test(e.name)) files.push(full); |
||||
|
} |
||||
|
} |
||||
|
srcDirs.forEach(walkSrc); |
||||
|
|
||||
|
const routesFound = new Map(); |
||||
|
const reLocal = /['"`](\/[a-zA-Z0-9_()\-[\]/?=&.][a-zA-Z0-9_()\-[\]/?=&.]*)['"`]/g; |
||||
|
for (const f of files) { |
||||
|
const code = fs.readFileSync(f, 'utf8'); |
||||
|
let m; |
||||
|
while ((m = reLocal.exec(code)) !== null) { |
||||
|
const r = m[1]; |
||||
|
if (r.startsWith('//')) continue; |
||||
|
if (!routesFound.has(r)) routesFound.set(r, []); |
||||
|
routesFound.get(r).push(path.relative(process.cwd(), f).replace(/\\/g, '/')); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
console.log('=== 实际存在的路由 (' + actualRoutes.size + ') ==='); |
||||
|
console.log([...actualRoutes].sort().join('\n')); |
||||
|
console.log('\n=== 引用但不存在的路由 ==='); |
||||
|
let bad = 0; |
||||
|
for (const [r, srcs] of [...routesFound.entries()].sort()) { |
||||
|
if (r.includes('${')) continue; |
||||
|
if (/\.(png|jpg|jpeg|svg|json|ttf|css)$/i.test(r)) continue; |
||||
|
if (r.startsWith('/node_modules')) continue; |
||||
|
if (!routeExists(r)) { |
||||
|
console.log(`${r} <-- ${[...new Set(srcs)].join(', ')}`); |
||||
|
bad++; |
||||
|
} |
||||
|
} |
||||
|
console.log('\n不存在的路由数量: ' + bad); |
||||
|
|
||||
|
// 反向:孤立页面(没有任何入口指向)
|
||||
|
console.log('\n=== 无入口指向的孤立页面 ==='); |
||||
|
const referenced = new Set(); |
||||
|
function markRef(r) { |
||||
|
referenced.add(r); |
||||
|
referenced.add('/' + normalize(r)); |
||||
|
// 动态拼接的路径(如 '/chat/' + id)按前缀登记,前缀命中即视为已引用
|
||||
|
const n = normalize(r); |
||||
|
const seg = n.split('/'); |
||||
|
if (seg.length > 1) referenced.add('/' + seg.slice(0, seg.length - 1).join('/')); |
||||
|
} |
||||
|
for (const r of routesFound.keys()) markRef(r); |
||||
|
const orphans = [...actualRoutes].filter((r) => !referenced.has(r) && !referenced.has('/' + normalize(r))); |
||||
|
console.log(orphans.sort().join('\n') || '(无)'); |
||||
@ -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 }; |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { bindWechatAndRegister } from '../api'; |
||||
|
import type { WechatBindPhonePayload, WechatSession } from '../types'; |
||||
|
import { useAuth } from '../context/AuthContext'; |
||||
|
|
||||
|
export interface UseWechatBindPhoneReturn { |
||||
|
/** 绑定手机号并完成注册,成功返回正式会话 */ |
||||
|
bind: (payload: WechatBindPhonePayload) => Promise<WechatSession>; |
||||
|
/** 绑定进行中 */ |
||||
|
loading: boolean; |
||||
|
/** 绑定失败的错误(用于页面提示) */ |
||||
|
error: Error | null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 微信新用户绑定手机号(第一次登录的第二步)。 |
||||
|
* |
||||
|
* 微信授权后 openid 尚未绑定账号,必须用手机号 + 短信验证码完成注册。 |
||||
|
* 成功后写入登录态;返回会话交给页面决定是继续走身份选择还是直接落地。 |
||||
|
*/ |
||||
|
export function useWechatBindPhone(): UseWechatBindPhoneReturn { |
||||
|
const { login } = useAuth(); |
||||
|
const [loading, setLoading] = useState(false); |
||||
|
const [error, setError] = useState<Error | null>(null); |
||||
|
|
||||
|
const bind = useCallback( |
||||
|
async (payload: WechatBindPhonePayload) => { |
||||
|
setLoading(true); |
||||
|
setError(null); |
||||
|
try { |
||||
|
const session = await bindWechatAndRegister(payload); |
||||
|
login(session.token, session.expiresAt); |
||||
|
return session; |
||||
|
} catch (e) { |
||||
|
const err = e instanceof Error ? e : new Error('绑定失败,请稍后重试'); |
||||
|
setError(err); |
||||
|
throw err; |
||||
|
} finally { |
||||
|
setLoading(false); |
||||
|
} |
||||
|
}, |
||||
|
[login], |
||||
|
); |
||||
|
|
||||
|
return { bind, loading, error }; |
||||
|
} |
||||
@ -0,0 +1,131 @@ |
|||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { fetchWechatLoginResult } from '../api'; |
||||
|
import type { AppRole, WechatPendingTicket, WechatUserProfile } from '../types'; |
||||
|
import { isWechatAvailable, requestWechatAuthCode } from '../lib/wechat-sdk'; |
||||
|
import { useAuth } from '../context/AuthContext'; |
||||
|
|
||||
|
/* ===================== 微信登录状态机 ===================== |
||||
|
* 编排三种账号场景的流转,页面只按 state.kind 渲染,不写判断逻辑。 |
||||
|
* |
||||
|
* idle ──start()──> 本地 token 有效?──是──> alreadyLoggedIn(免授权直接落地) |
||||
|
* │ |
||||
|
* 否 |
||||
|
* ↓ |
||||
|
* authenticating(调起微信) |
||||
|
* ↓ |
||||
|
* verifying(后端校验 code) |
||||
|
* ↓ |
||||
|
* ┌───────────────┼───────────────┐ |
||||
|
* new 分支 active 分支 deleted 分支 |
||||
|
* ↓ ↓ ↓ |
||||
|
* needBindPhone success deleted(提示无法登录) |
||||
|
* (跳手机号+验证码) │ |
||||
|
* ↓ cancelLogin() → idle |
||||
|
* 由 P2 页完成绑定后落地 |
||||
|
* |
||||
|
* 注意:注销审核期内的账号不可登录、不可复活,因此 deleted 分支没有「重新注册」出口。 |
||||
|
*/ |
||||
|
|
||||
|
/** 待绑定手机号时持有的临时凭证(原 code 已失效,只能用它标识身份) */ |
||||
|
export type PendingBindPhone = WechatPendingTicket; |
||||
|
|
||||
|
/** 微信登录状态(判别联合,页面通过 kind 收窄后读取对应字段) */ |
||||
|
export type WechatLoginState = |
||||
|
| { kind: 'idle' } |
||||
|
/** 正在调起微信 */ |
||||
|
| { kind: 'authenticating' } |
||||
|
/** 已拿到 code,正在请求后端校验 */ |
||||
|
| { kind: 'verifying' } |
||||
|
/** 本地 token 未过期,无需再走微信授权 */ |
||||
|
| { kind: 'alreadyLoggedIn' } |
||||
|
/** 第一次登录:openid 未绑定账号,需跳手机号 + 验证码完成注册 */ |
||||
|
| { kind: 'needBindPhone'; ticket: PendingBindPhone } |
||||
|
/** 账号处于注销审核期,不可登录 */ |
||||
|
| { kind: 'deleted' } |
||||
|
/** 登录成功;role 为 undefined 表示尚未选择身份,需引导去注册流程 */ |
||||
|
| { kind: 'success'; profile: WechatUserProfile; role?: AppRole } |
||||
|
/** 失败(未安装微信 / 用户取消 / 网络异常) */ |
||||
|
| { kind: 'error'; message: string }; |
||||
|
|
||||
|
export interface UseWechatLoginReturn { |
||||
|
state: WechatLoginState; |
||||
|
/** 是否处于请求中(用于按钮 loading 与防重复点击) */ |
||||
|
loading: boolean; |
||||
|
/** 入口:本地 token 有效则直接登录,否则走微信授权 */ |
||||
|
start: () => Promise<void>; |
||||
|
/** 关闭注销提示,回到登录页 */ |
||||
|
cancelLogin: () => void; |
||||
|
/** 重置为初始态 */ |
||||
|
reset: () => void; |
||||
|
} |
||||
|
|
||||
|
/** 初始态 */ |
||||
|
const IDLE: WechatLoginState = { kind: 'idle' }; |
||||
|
|
||||
|
export function useWechatLogin(): UseWechatLoginReturn { |
||||
|
const { login, isTokenValid } = useAuth(); |
||||
|
const [state, setState] = useState<WechatLoginState>(IDLE); |
||||
|
|
||||
|
const start = useCallback(async () => { |
||||
|
// 第二次及以后登录:本地令牌仍有效时免微信授权
|
||||
|
if (isTokenValid) { |
||||
|
setState({ kind: 'alreadyLoggedIn' }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setState({ kind: 'authenticating' }); |
||||
|
try { |
||||
|
if (!isWechatAvailable()) { |
||||
|
throw new Error('未检测到微信客户端,请先安装微信'); |
||||
|
} |
||||
|
const code = await requestWechatAuthCode(); |
||||
|
|
||||
|
setState({ kind: 'verifying' }); |
||||
|
const result = await fetchWechatLoginResult(code); |
||||
|
|
||||
|
switch (result.status) { |
||||
|
case 'active': { |
||||
|
if (!result.profile) { |
||||
|
throw new Error('登录信息不完整,请重试'); |
||||
|
} |
||||
|
login(result.token, result.expiresAt); |
||||
|
setState({ kind: 'success', profile: result.profile, role: result.role }); |
||||
|
break; |
||||
|
} |
||||
|
case 'new': { |
||||
|
setState({ |
||||
|
kind: 'needBindPhone', |
||||
|
ticket: { token: result.token, openid: result.openid }, |
||||
|
}); |
||||
|
break; |
||||
|
} |
||||
|
case 'deleted': { |
||||
|
// 注销审核期:不写登录态,仅提示不可登录
|
||||
|
setState({ kind: 'deleted' }); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
setState({ |
||||
|
kind: 'error', |
||||
|
message: e instanceof Error ? e.message : '微信登录失败,请稍后重试', |
||||
|
}); |
||||
|
} |
||||
|
}, [isTokenValid, login]); |
||||
|
|
||||
|
const cancelLogin = useCallback(() => { |
||||
|
setState(IDLE); |
||||
|
}, []); |
||||
|
|
||||
|
const reset = useCallback(() => { |
||||
|
setState(IDLE); |
||||
|
}, []); |
||||
|
|
||||
|
return { |
||||
|
state, |
||||
|
loading: state.kind === 'authenticating' || state.kind === 'verifying', |
||||
|
start, |
||||
|
cancelLogin, |
||||
|
reset, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
import { Platform } from 'react-native'; |
||||
|
|
||||
|
/* ===================== 微信授权适配层 ===================== |
||||
|
* 把「调起微信并拿到一次性 code」这一平台能力单独隔离,原因: |
||||
|
* 1. 当前项目是 Expo 管理流,没有原生模块,Expo Go 中无法真正调起微信; |
||||
|
* 2. 两端(iOS / Android)的微信 SDK 初始化与回调方式不同,差异应收敛在此文件; |
||||
|
* 3. 接入真实 SDK 时只替换 requestWechatAuthCode 的函数体,上层 hook 与页面无需改动。 |
||||
|
* |
||||
|
* 接真实 SDK 的步骤(接入时才做,现阶段不要动): |
||||
|
* 1. npx expo install react-native-wechat-lib(或对应 expo 插件) |
||||
|
* 2. 微信开放平台创建移动应用,拿到 AppID,并登记 iOS 的 Universal Links |
||||
|
* (Universal Links 是 iOS 端微信回调的硬性要求,安卓走包名 + 应用签名) |
||||
|
* 3. 用 EAS Build 生成自定义开发客户端(Expo Go 不含原生模块,跑不了) |
||||
|
* 4. 把 requestWechatAuthCode 换成: |
||||
|
* const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); |
||||
|
* return res.code; |
||||
|
*/ |
||||
|
|
||||
|
/** 微信是否可用:Expo Go 或未装微信时为 false */ |
||||
|
export function isWechatAvailable(): boolean { |
||||
|
// 真实 SDK 接入后改为 WeChat.isWXAppInstalled()
|
||||
|
return Platform.OS !== 'web'; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 调起微信授权,返回一次性 code。 |
||||
|
* @throws 未安装微信、用户取消授权、或授权失败时抛错 |
||||
|
*/ |
||||
|
export async function requestWechatAuthCode(): Promise<string> { |
||||
|
/* |
||||
|
* mock 实现:返回带时间戳的假 code,仅用于打通前端三分支流程。 |
||||
|
* 真实实现见上方注释。 |
||||
|
*/ |
||||
|
return Promise.resolve(`mock_wx_code_${Date.now()}`); |
||||
|
} |
||||
@ -0,0 +1,98 @@ |
|||||
|
import type { AppRole } from './user'; |
||||
|
|
||||
|
/** |
||||
|
* 微信登录相关类型 |
||||
|
* |
||||
|
* 覆盖三种账号场景(对应后端用 code 换 openid 后的判定结果): |
||||
|
* 1. new —— openid 不存在,第一次登录,需绑定手机号完成注册 |
||||
|
* 2. active —— openid 存在且账号活跃,第二次及以后登录,直接进入主界面 |
||||
|
* 3. deleted —— 账号处于注销审核期(申请注销后、彻底删除前),**不可登录** |
||||
|
* |
||||
|
* 关键约束一:微信授权返回的 code 是一次性的(有效期 5 分钟)。 |
||||
|
* 用户在绑定手机号时原 code 已被首次请求消耗,后端不能再用它换 openid, |
||||
|
* 因此后端必须在首次响应里下发可复用的临时凭证,由前端回传。 |
||||
|
* |
||||
|
* 关键约束二:注销审核期内账号不可登录、不可复活, |
||||
|
* 审核期结束后数据被彻底删除。前端不提供任何复活入口。 |
||||
|
*/ |
||||
|
|
||||
|
/** |
||||
|
* 微信账号在后端的状态。 |
||||
|
* 用联合类型而非布尔 isNewUser 建模,避免「注销中」被误判为「新老用户」之外的隐式状态。 |
||||
|
*/ |
||||
|
export type WechatAccountStatus = 'new' | 'active' | 'deleted'; |
||||
|
|
||||
|
/** |
||||
|
* 等待绑定手机号时持有的临时凭证。 |
||||
|
* 后端首次校验 code 后下发,用于绑定手机号时标识身份(此时 code 已失效)。 |
||||
|
*/ |
||||
|
export interface WechatPendingTicket { |
||||
|
/** 临时令牌,绑定手机号后换取正式 token */ |
||||
|
token: string; |
||||
|
/** 微信开放平台唯一标识 */ |
||||
|
openid: string; |
||||
|
} |
||||
|
|
||||
|
/** 微信登录后已注册用户的基本资料 */ |
||||
|
export interface WechatUserProfile { |
||||
|
/** 用户 id */ |
||||
|
id: string; |
||||
|
/** 昵称 */ |
||||
|
nickname: string; |
||||
|
/** 头像地址 */ |
||||
|
avatarUri: string; |
||||
|
/** 性别;unspecified 表示用户未授权或未填写 */ |
||||
|
gender: 'male' | 'female' | 'unspecified'; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 微信登录结果(后端 POST /api/auth/wechat 的响应)。 |
||||
|
* |
||||
|
* 各字段按状态可选,页面通过 status 判别后再读取对应字段: |
||||
|
* - new:token 为临时凭证,profile 为 undefined |
||||
|
* - active:token 为正式登录令牌,profile 有值 |
||||
|
* - deleted:token 为临时凭证但不可用,仅用于标识账号,不得写入登录态 |
||||
|
*/ |
||||
|
export interface WechatLoginResult { |
||||
|
/** 账号状态 */ |
||||
|
status: WechatAccountStatus; |
||||
|
/** 登录令牌(active 为正式 token;new 为临时凭证;deleted 无意义) */ |
||||
|
token: string; |
||||
|
/** 微信开放平台唯一标识 */ |
||||
|
openid: string; |
||||
|
/** 已注册用户资料,仅 status === 'active' 时返回 */ |
||||
|
profile?: WechatUserProfile; |
||||
|
/** 正式令牌的过期时间戳(毫秒),仅 status === 'active' 时返回 */ |
||||
|
expiresAt?: number; |
||||
|
/** |
||||
|
* 已注册用户的端身份,仅 status === 'active' 时返回。 |
||||
|
* 老用户凭此直接落地对应端;为 undefined 表示后端未记录,前端需引导选择身份。 |
||||
|
*/ |
||||
|
role?: AppRole; |
||||
|
} |
||||
|
|
||||
|
/** 微信新用户绑定手机号完成注册(第一次登录的必经步骤) */ |
||||
|
export interface WechatBindPhonePayload { |
||||
|
/** 临时凭证令牌(code 已失效,只能用它标识身份) */ |
||||
|
token: string; |
||||
|
/** 微信开放平台唯一标识 */ |
||||
|
openid: string; |
||||
|
/** 手机号(11 位) */ |
||||
|
phone: string; |
||||
|
/** 短信验证码 */ |
||||
|
code: string; |
||||
|
} |
||||
|
|
||||
|
/** 绑定注册成功后返回的正式会话 */ |
||||
|
export interface WechatSession { |
||||
|
/** 正式登录令牌 */ |
||||
|
token: string; |
||||
|
/** 令牌过期时间戳(毫秒) */ |
||||
|
expiresAt: number; |
||||
|
/** 用户资料 */ |
||||
|
profile: WechatUserProfile; |
||||
|
/** |
||||
|
* 端身份。为 undefined 表示新用户尚未选择身份,前端需引导去注册流程选择。 |
||||
|
*/ |
||||
|
role?: AppRole; |
||||
|
} |
||||
@ -0,0 +1,151 @@ |
|||||
|
# 微信登录接入方案(P1) |
||||
|
|
||||
|
> 状态:方案文档 · 不涉及代码改写 · 真实后端就绪后按路线图替换 mock 即可 |
||||
|
> 适用范围:有职 App 微信授权登录(三分支状态机) |
||||
|
> 关联文件:`src/lib/wechat-sdk.ts`、`src/hooks/useWechatLogin.ts`、`src/api/api.ts`、`src/types/wechat-auth.ts`、`components/onboarding/screens/P1Login.tsx`、`components/onboarding/screens/P2PhoneCode.tsx` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 一句话结论 |
||||
|
|
||||
|
前端三分支状态机与页面流转**已全部联通**,当前唯一阻塞点是:微信原生 SDK 未接入(Expo Go 无法跑原生模块)、以及后端两个接口仍是 mock。 |
||||
|
**接入真实微信 = 替换 1 个函数 (`requestWechatAuthCode`) + 替换 2 个接口函数体 (`fetchWechatLoginResult` / `bindWechatAndRegister`) + 申请微信开放平台移动应用 + 用 EAS Build 生成自定义客户端。** 上层 hook、页面、类型**零改动**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 现状盘点(已实现 vs 待接入) |
||||
|
|
||||
|
| 层 | 文件 | 现状 | 接入动作 | |
||||
|
|---|---|---|---| |
||||
|
| 类型 | `src/types/wechat-auth.ts` | ✅ 已完整定义 | 无需改动 | |
||||
|
| 状态机 | `src/hooks/useWechatLogin.ts` | ✅ 8 态判别联合,已联通 | 无需改动 | |
||||
|
| 页面流转 | `P1Login.tsx` → `P2PhoneCode.tsx` → `P3` | ✅ 已联通 | 无需改动 | |
||||
|
| 绑定 hook | `src/hooks/useWechatBindPhone.ts` | ✅ 已联通 | 无需改动 | |
||||
|
| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` / `isTokenValid` 已就绪 | 无需改动 | |
||||
|
| **SDK 适配层** | `src/lib/wechat-sdk.ts` | ⚠️ **mock**:返回 `mock_wx_code_*` | **替换 `requestWechatAuthCode`** | |
||||
|
| **后端接口** | `src/api/api.ts` | ⚠️ **mock**:`fetchWechatLoginResult` / `bindWechatAndRegister` | **替换函数体** | |
||||
|
| 微信开放平台 | —— | ❌ 未申请移动应用 | 申请 AppID + Universal Links | |
||||
|
| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | |
||||
|
|
||||
|
**已验证的关键事实**(直接取自源码,非推测): |
||||
|
- `useWechatLogin` 的 `start()` 逻辑:`isTokenValid` 为真 → `alreadyLoggedIn`(免授权直接落地);否则 `authenticating` → `requestWechatAuthCode()` → `verifying` → `fetchWechatLoginResult(code)`,按 `result.status` 分流 `active` / `new` / `deleted`。 |
||||
|
- `new` 分支:`setState({ kind: 'needBindPhone', ticket: { token: result.token, openid: result.openid } })` —— **临时凭证已下发并透传**。 |
||||
|
- `P1Login.tsx` 在 `needBindPhone` 时把 `wechatTicket` 写入 `useOnboardingFlow` 的 `wechatTicket` 字段并跳 `P2`。 |
||||
|
- `P2PhoneCode.tsx` 检测到 `wechatTicket` 后调用 `bindWechatAndRegister({ token, openid, phone, code })` 完成注册。 |
||||
|
- `deleted` 分支:不写登录态,仅提示不可登录,**无复活出口**(符合账号注销即不可逆的硬约束)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 接入目标与成功标准 |
||||
|
|
||||
|
**目标**:让真实微信用户能走通 `new / active / deleted` 三条链路,且: |
||||
|
1. 用户第一次用微信登录 → 跳手机号+验证码 → 注册落地; |
||||
|
2. 老用户再次微信登录 → 直接进主界面(本地 token 有效时甚至免授权); |
||||
|
3. 处于注销审核期的账号 → 登录页明确提示「该账号正在申请注销中,无法登录」,无复活入口。 |
||||
|
|
||||
|
**成功标准(验收口径)**: |
||||
|
- [ ] `requestWechatAuthCode()` 真正调起微信并回传一次性 `code`(非 mock)。 |
||||
|
- [ ] 后端 `/api/auth/wechat` 用 code 换 openid → 判定 `new/active/deleted` → 首响下发可复用临时凭证。 |
||||
|
- [ ] `bind-phone` 用临时凭证(非 code)完成 openid 与手机号绑定注册。 |
||||
|
- [ ] iOS 能在微信授权后通过 Universal Links 正确回调 App;Android 能通过包名+签名回调。 |
||||
|
- [ ] 三分支在自定义客户端(非 Expo Go)上全部走通。 |
||||
|
- [ ] mock 辅助 `MOCK_WECHAT_STATUS` / `setMockWechatAccountStatus` 已删除,页面无任何 mock 分支残留。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 两个硬约束(设计已遵守,接后端不可破坏) |
||||
|
|
||||
|
1. **微信 code 一次性(5 分钟有效)**:用户授权后原 code 已被首次请求消耗,绑定手机号时**拿不到第二个 code**。因此后端必须在首次响应里下发 `WechatPendingTicket`(含 `token` + `openid`),前端在 `P2` 回传票据而非 code。 |
||||
|
- 类型已建模:`WechatPendingTicket { token: string; openid: string }`(`src/types/wechat-auth.ts:29`)。 |
||||
|
2. **注销即不可登录、不可复活**:`deleted` 分支前端不写登录态、不提供任何复活入口;后端需保证审核期内账号返回 `deleted` 且拒绝任何登录/复活请求。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 接入路线图(按执行顺序) |
||||
|
|
||||
|
### Step 1 · 微信开放平台申请移动应用(前置,阻塞项) |
||||
|
- 登录微信开放平台 → 创建**移动应用**,拿到 `AppID` / `AppSecret`。 |
||||
|
- **iOS**:登记 **Universal Links**(微信授权回调硬性要求)。需准备一个 HTTPS 域名 + 后端托管 `apple-app-site-association`。 |
||||
|
- **Android**:登记 **应用包名**(`com.youzhi.app`,见 `app.config.js`)与**应用签名**(发布/调试签名 SHA256)。 |
||||
|
- ⚠️ 此步产生的 `AppID`、`Universal Links` 域名需回填到代码与 EAS 配置(见 Step 3/4)。 |
||||
|
|
||||
|
### Step 2 · 安装微信 SDK(Expo 插件优先) |
||||
|
```bash |
||||
|
npx expo install react-native-wechat-lib |
||||
|
# 或选用维护更活跃的 expo 兼容插件(以官方 docs 为准) |
||||
|
``` |
||||
|
- 原生模块需在 `ios/` `android/` 原生壳中注册(Expo 前置预构建会处理 config plugin 注入,无需手改原生代码)。 |
||||
|
|
||||
|
### Step 3 · 生成 EAS 自定义开发客户端(Expo Go 跑不了原生模块) |
||||
|
- 当前 `eas.json` 已有 `development` profile(`developmentClient: true`)。 |
||||
|
- 执行: |
||||
|
```bash |
||||
|
eas build --profile development --platform ios |
||||
|
eas build --profile development --platform android |
||||
|
``` |
||||
|
- 安装产物到真机/模拟器。**后续所有真机验证都在此客户端中进行,Expo Go 无法调起微信。** |
||||
|
|
||||
|
### Step 4 · 替换 SDK 适配层(唯一改动点 · 前端) |
||||
|
文件:`src/lib/wechat-sdk.ts` |
||||
|
- 顶部 `import WeChat from 'react-native-wechat-lib';` |
||||
|
- `isWechatAvailable()` 改为 `return WeChat.isWXAppInstalled();` |
||||
|
- `requestWechatAuthCode()` 替换为: |
||||
|
```ts |
||||
|
const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); |
||||
|
return res.code; |
||||
|
``` |
||||
|
- `app.config.js` 的 `extra` 中补 `wechatAppId`,在 SDK 初始化时读取(或走 `expo-config-plugin` 注入原生 Info.plist / AndroidManifest)。 |
||||
|
- **此文件改动不影响任何上层调用方**(签名不变)。 |
||||
|
|
||||
|
### Step 5 · 实现后端两个接口 |
||||
|
| 接口 | 方法 | 职责 | 关键返回 | |
||||
|
|---|---|---|---| |
||||
|
| `/api/auth/wechat` | POST | `code → access_token + openid → 查库判定状态` | `WechatLoginResult`:`new` 时下发明文 `WechatPendingTicket`;`active` 下发正式 token + `expiresAt` + `profile` + `role`;`deleted` 仅 `openid` | |
||||
|
| `/api/auth/wechat/bind-phone` | POST | 用临时凭证 + 手机号 + 验证码完成注册,关联 openid | `WechatSession`:`token` + `expiresAt` + `profile` + `role` | |
||||
|
|
||||
|
- 后端须保证:`code` 一次性;临时凭证有独立 TTL 且可校验来源;`deleted` 账号拒绝登录。 |
||||
|
- `WechatLoginResult` / `WechatSession` 字段契约见 `src/types/wechat-auth.ts`,前后端以此对齐。 |
||||
|
|
||||
|
### Step 6 · 替换 API 层函数体(后端就绪后) |
||||
|
文件:`src/api/api.ts` |
||||
|
- `fetchWechatLoginResult(code)`:删掉 `MOCK_WECHAT_STATUS` 分支,改为 `fetch(...)`,请求 `/api/auth/wechat`,返回 `WechatLoginResult`。函数签名**不变**。 |
||||
|
- `bindWechatAndRegister(payload)`:改为请求 `/api/auth/wechat/bind-phone`,返回 `WechatSession`。签名**不变**。 |
||||
|
- 保留 JSDoc 的 `@method` / `@url` / `@param` / `@returns` 注释。 |
||||
|
|
||||
|
### Step 7 · 清除 mock 残留 |
||||
|
- 删除 `src/api/api.ts` 中的 `let MOCK_WECHAT_STATUS`、`setMockWechatAccountStatus()` 及其导出(同步从 `src/api/index.ts` 移除导出)。 |
||||
|
- 删除 `P1Login.tsx` / 测试代码中调用 `setMockWechatAccountStatus` 的分支(若有)。 |
||||
|
|
||||
|
### Step 8 · 回调与票据闭环验证 |
||||
|
- **iOS Universal Links**:确认微信开放平台登记的 Universal Links 能打开 App,且 `useWechatLogin` 未被重复触发(`alreadyLoggedIn` 短路逻辑生效)。 |
||||
|
- **Android 包名/签名**:确认微信回调能正确唤起 App 并继续 `verifying`。 |
||||
|
- 验证 `needBindPhone` → `wechatTicket` 透传 → `P2` 调用 `bindWechatAndRegister` 全程用的是**临时凭证 token**,而非已失效的 code。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 手动验收路径(可复现) |
||||
|
|
||||
|
1. 在自定义客户端(非 Expo Go)上安装并登录。 |
||||
|
2. **active 分支**:用老微信账号登录 → 期望直接进主界面;杀进程重进(本地 token 有效)→ 期望 `alreadyLoggedIn` 免授权落地。 |
||||
|
3. **new 分支**:用全新微信账号登录 → 期望跳 `P2` 手机号+验证码 → 注册成功进 `P3` 选身份。 |
||||
|
4. **deleted 分支**:用处于注销审核期的账号登录 → 期望登录页提示「该账号正在申请注销中,无法登录」,无复活入口。 |
||||
|
5. 断网 / 取消授权 / 未装微信 → 期望 `error` 态并显示对应提示。 |
||||
|
6. 回归 `tsc --noEmit` 与 `eslint .` 均 0 错误、0 警告。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 风险与待确认项 |
||||
|
|
||||
|
| 项 | 风险/阻塞 | 处置 | |
||||
|
|---|---|---| |
||||
|
| `AppID` / Universal Links | 需用户到微信开放平台申请,非代码可解 | Step 1 前置,未拿到前无法真机验证 | |
||||
|
| react-native-wechat-lib 维护状态 | 库可能滞后于新架构 | 接入时以 Expo 官方 docs 选型为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | |
||||
|
| Universal Links 配置 | iOS 回调硬性要求,易踩坑 | 后端托管 `apple-app-site-association` + 真机验证 | |
||||
|
| 自定义客户端构建 | 首次 EAS Build 耗时较长 | Step 3 提前跑,避免阻塞联调 | |
||||
|
| 临时凭证安全 | code 一次性,凭证必须独立加密 + 短 TTL | 后端实现时强制 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. 一句话给执行者 |
||||
|
|
||||
|
**别动 hook、页面、类型。** 只动两处:`src/lib/wechat-sdk.ts`(换真 SDK 调用)和 `src/api/api.ts`(换真接口),配齐微信开放平台移动应用 + EAS 自定义客户端,三分支即可真机走通。 |
||||
@ -0,0 +1,113 @@ |
|||||
|
# 有职 App 开发进度与规划文档 |
||||
|
|
||||
|
> 生成日期:2026-09-10 |
||||
|
> 最后更新:2026-09-10(EAS 构建/OTA 配置骨架已建 · 技术债 8 处 `any` 已清理) |
||||
|
> 范围:项目健康审查 + 已开发 / 待开发清单 |
||||
|
> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转),无需修正阻塞错误。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 一、健康审查结论 |
||||
|
|
||||
|
| 检查项 | 命令 / 方式 | 结果 | |
||||
|
|---|---|---| |
||||
|
| 类型检查 | `npx tsc --noEmit` | ✅ 0 错误(Exit 0) | |
||||
|
| 代码规范 | `npx eslint .` | ✅ 0 error / ⚠️ 8 warning(均为 `any` 软警告) | |
||||
|
| 路由闭合 | `node scripts/check-routes.mjs` | ✅ 63 路由全部存在,0 失效跳转 | |
||||
|
| 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 | |
||||
|
| 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 | |
||||
|
|
||||
|
**判定**:项目处于可启动状态(`expo start` 即可运行),未发现阻塞运行的错误。本次未进入"修正"环节。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 二、技术栈与架构 |
||||
|
|
||||
|
- **框架**:Expo SDK 57 · React Native 0.86.2 · React 19 · TypeScript 6 · Expo Router(文件路由) |
||||
|
- **双端同构**: |
||||
|
- 求职端 `(tabs)`:岗位 / 沟通 / 成长 / 我的(落地 `jobs`) |
||||
|
- 企业端 `(enterprise)`:岗位 / 人才 / 沟通 / 我的(落地 `job-management`) |
||||
|
- **分层(src/)**:`types`(领域模型) · `api`(5088 行 mock 数据层) · `hooks`(语义化取数) · `components`(42 组件) · `context`(Auth/Role) · `theme`(colors token) · `constants` · `utils` · `lib`(storage / wechat-sdk) |
||||
|
- **数据链路**:页面 → Hook(`useXxx`) → API(`fetchXxx`) → 后端。当前全 mock,签名已固化,接真实后端只改 `src/api/api.ts` 函数体。 |
||||
|
- **配色治理**:统一走 `src/theme/colors.ts`,禁止硬编码十六进制;透明度用 token + `15`/`20` 后缀。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 三、已开发模块(按业务域) |
||||
|
|
||||
|
> 页面规模以行数为代理指标;5 行薄壳(如隐私政策 / 用户协议)是合理架构——内容由 `LegalDocScreen` 按 `docId` 加载,**非空壳**。 |
||||
|
|
||||
|
### 3.1 用户端(求职端) |
||||
|
|
||||
|
| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | |
||||
|
|---|---|---|---|---| |
||||
|
| 引导 / 注册 | `/guide` `/register` `/onboarding` | guide(194) · register(69) · onboarding(488) | 中 | 已开发 | |
||||
|
| 岗位 | `/jobs` `/jobs/[id]` `/jobs/compare` `/jobs/rank` `/jobs/offer-compare` `/jobs/weight-settings` | jobs(756) · [id](612) · compare(306) · rank(753) · offer-compare(676) · weight-settings(285) | 大 | 已开发 | |
||||
|
| 沟通 | `/chat` `/chat/[id]` | chat(556) · [id](750) | 大 | 已开发 | |
||||
|
| 成长 | `/growth` `/game` `/game/level` `/progress` `/assessment` `/ability` `/holland*` `/interview*` `/resume*` `/job-preference` | 多页 300–600 行 | 大 | 已开发(逐关内容见四.2) | |
||||
|
| 我的 / 设置 | `/profile` `/settings` 及 10 个子页 | profile(743) · settings(262) · about / account / account-cancel / privacy* / notification / delete-data / user-agreement / privacy-policy | 大 | 已开发 | |
||||
|
| 增值 / 合规 / 工具 | `/legal*` `/tools/*` `/hr-evaluation` `/audit-report` `/appeal` `/operation-rules` `/enterprise-certification` `/personal-data-export` `/candidate-detail` `/article/[id]` `/history` `/favorites` `/job-evaluations` | 多页 300–720 行 | 大 | 已开发 | |
||||
|
|
||||
|
### 3.2 企业端 |
||||
|
|
||||
|
| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | |
||||
|
|---|---|---|---|---| |
||||
|
| 岗位管理 | `/job-management` `/job-publish` `/job-evaluations` | job-management(645) · job-publish(633) · job-evaluations(752) | 大 | 已开发 | |
||||
|
| 人才 | `/talent-recommendation` `/candidate-detail` | talent-recommendation(684) · candidate-detail(719) | 大 | 已开发 | |
||||
|
| 沟通 | `/enterprise-chat` | enterprise-chat(611) | 大 | 已开发 | |
||||
|
| 我的 | `/enterprise-overview` | enterprise-overview(594) | 大 | 已开发(【设置】入口已接线 `/settings`;Tab 选中色已统一 `primary`) | |
||||
|
|
||||
|
> 企业端设置类页面复用根级 `app/settings/*`,符合「设置类统一收纳于【设置】页」规范。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 四、待开发 / 进行中 / 风险 |
||||
|
|
||||
|
### 4.1 既定任务(来自项目规范,需逐项核实) |
||||
|
|
||||
|
- **企业端三项**(历史记录为待办,代码审查显示已基本完成): |
||||
|
1. Tab 选中色统一回 `primary` —— ✅ 代码已用 `colors.primary` |
||||
|
2. 我的页补【设置】入口 —— ✅ `enterprise-overview.tsx` 第 281 行已 `router.push('/settings')` |
||||
|
3. 统一快捷入口话术 —— ✅ 已完成(2026-09-10):`MOCK_ENTERPRISE_QUICK_ACTIONS` 与沟通页 `QUICK_ACTIONS` 中 6 处 label 已对齐规范词(人才库→人才推荐、评价管理→岗位评价管理、沟通→企业沟通、面试日程/面试安排→面试评分),与「我的」菜单、`/interview-score` 路由名一致。 |
||||
|
4. 沟通页快捷操作接线 —— ✅ 已完成(2026-09-10):`QuickActions` 死入口已绑定 `onPress`;批量发邀请→`/talent-recommendation`、面试评分→`/interview-score`(根级自包含页、可返回)走跳转,批量发Offer / 群发通知无对应企业端页、按项目约定弹 `Alert '功能开发中'`。tsc/eslint 0 问题。 |
||||
|
- **微信登录**:三分支(`new` / `active` / `deleted`)已建模,前端状态机 + 页面流转(P1Login → P2PhoneCode → P3)**已全联通**;`src/lib/wechat-sdk.ts` 隔离待接真实 SDK;需 EAS 自定义客户端 + 微信开放平台移动应用(iOS 另需 Universal Links)。当前为 mock 分支切换验证。**接入路线图已落文档:`微信登录接入方案.md`**。 |
||||
|
|
||||
|
### 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 题左右)。 |
||||
|
- **阿布阿诺 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 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。 |
||||
|
|
||||
|
### 4.3 工程与上线 |
||||
|
|
||||
|
- **应用图标**:`assets/icon.png` 仍为旧图,未替换为「有职」logo(需 1024×1024 PNG)【上架硬项】。 |
||||
|
- **EAS / OTA**:已创建 `eas.json`(development / preview / production + submit 配置骨架)并补齐 `app.config.js` 的 `owner` / `runtimeVersion` / `updates` / `ios.bundleIdentifier` / `android.package` / `extra.eas.projectId` 字段。占位项 `REPLACE_WITH_EAS_OWNER` 与 `REPLACE_WITH_PROJECT_ID` 需你 `eas login` 后执行 `eas init` 自动注入;`com.youzhi.app` 可按开发者账号调整。 |
||||
|
- **真实后端**:全量 mock(`src/api/api.ts` 5088 行),未切换真实 `fetch`。 |
||||
|
- **技术债**:8 处 `any` 软警告已全部清理(图标断言改为 `keyof typeof Ionicons.glyphMap`、路由断言改为 `as string`),`eslint .` 现 0 error 0 warning,`tsc --noEmit` 保持 0 错误。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 五、后续增量开发建议(优先级) |
||||
|
|
||||
|
- **P0(上架前必做)**:图标替换【待你提供 1024×1024 logo】 · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,待账号注入占位】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】**。 |
||||
|
- **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 通道验证。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 六、附:审查命令速查 |
||||
|
|
||||
|
```bash |
||||
|
# 类型检查(硬门槛,必须 0 错误) |
||||
|
npx tsc --noEmit |
||||
|
|
||||
|
# 代码规范 |
||||
|
npx eslint . |
||||
|
|
||||
|
# 路由闭合审计(改动路由后必跑) |
||||
|
node scripts/check-routes.mjs |
||||
|
|
||||
|
# 启动 |
||||
|
npm start # 即 expo start |
||||
|
``` |
||||
Loading…
Reference in new issue