Skip to content

Commit 651f72b

Browse files
authored
Merge pull request #820 from xiaomakuaiz/feat/mobile-profile-email
feat(mobile): add profile email binding
2 parents e8af7a9 + 497c2a4 commit 651f72b

4 files changed

Lines changed: 165 additions & 17 deletions

File tree

mobile/app/(tabs)/profile.tsx

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as Clipboard from 'expo-clipboard';
22
import { useFocusEffect, useRouter } from 'expo-router';
33
import React, { useCallback, useEffect, useState } from 'react';
4-
import { ActivityIndicator, Alert, Image, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
4+
import { ActivityIndicator, Alert, AppState, Image, KeyboardAvoidingView, Linking, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
55
import { useSafeAreaInsets } from 'react-native-safe-area-context';
6-
import { getCheckinStatus, getSubscription, getWallet, listInvitations, resolveAssetUrl, submitCheckin } from '@/api/client';
6+
import { getCheckinStatus, getSubscription, getWallet, listInvitations, resolveAssetUrl, sendBindEmailVerification, submitCheckin } from '@/api/client';
77
import { checkAppUpdate, checkOta, currentOtaId, downloadAndApplyOta, installedAppVersion } from '@/updates/useOtaUpdate';
88
import { obtainCaptchaToken } from '@/api/captcha';
99
import type { InvitationItem, Subscription, Wallet } from '@/api/types';
@@ -103,7 +103,7 @@ function About({ t }: { t: Theme }) {
103103
{ text: '取消', style: 'cancel' },
104104
{ text: '立即更新', onPress: applyOtaNow },
105105
]);
106-
}, [otaBusy, appVersion, applyOtaNow]);
106+
}, [otaBusy, applyOtaNow, verLine]);
107107
return (
108108
<Card style={{ padding: 16 }}>
109109
<Text style={{ fontSize: 12, fontWeight: '700', color: t.tx3, letterSpacing: 0.5, marginBottom: 13 }}>关于</Text>
@@ -145,6 +145,86 @@ function About({ t }: { t: Theme }) {
145145
);
146146
}
147147

148+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
149+
150+
function BindEmailSheet({
151+
visible,
152+
email,
153+
busy,
154+
onChangeEmail,
155+
onClose,
156+
onSubmit,
157+
}: {
158+
visible: boolean;
159+
email: string;
160+
busy: boolean;
161+
onChangeEmail: (value: string) => void;
162+
onClose: () => void;
163+
onSubmit: () => void;
164+
}) {
165+
const t = useTheme();
166+
const insets = useSafeAreaInsets();
167+
const [focused, setFocused] = useState(false);
168+
169+
useEffect(() => {
170+
if (!visible) setFocused(false);
171+
}, [visible]);
172+
173+
return (
174+
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
175+
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1, justifyContent: 'flex-end' }}>
176+
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
177+
<View style={{ backgroundColor: t.bg2, borderTopLeftRadius: 24, borderTopRightRadius: 24, borderTopWidth: StyleSheet.hairlineWidth, borderColor: t.line2, paddingHorizontal: spacing.pad, paddingBottom: insets.bottom + 16 }}>
178+
<View style={{ width: 38, height: 4, borderRadius: 99, backgroundColor: t.line2, alignSelf: 'center', marginTop: 10, marginBottom: 14 }} />
179+
<Text style={{ fontSize: 18, fontWeight: '800', color: t.tx }}>绑定邮箱</Text>
180+
<Text style={{ fontSize: 13, color: t.tx2, fontWeight: '600', marginTop: 18, marginBottom: 8 }}>邮箱地址</Text>
181+
<TextInput
182+
value={email}
183+
onChangeText={onChangeEmail}
184+
placeholder="name@example.com"
185+
placeholderTextColor={t.tx3}
186+
keyboardType="email-address"
187+
autoCapitalize="none"
188+
autoCorrect={false}
189+
editable={!busy}
190+
returnKeyType="send"
191+
onFocus={() => setFocused(true)}
192+
onBlur={() => setFocused(false)}
193+
onSubmitEditing={onSubmit}
194+
style={{
195+
backgroundColor: t.bg3,
196+
borderWidth: 1,
197+
borderColor: focused ? t.ac : t.line2,
198+
borderRadius: 14,
199+
paddingHorizontal: 14,
200+
paddingVertical: Platform.OS === 'ios' ? 13 : 9,
201+
color: t.tx,
202+
fontSize: 15,
203+
}}
204+
/>
205+
<View style={{ flexDirection: 'row', gap: 10, marginTop: 18 }}>
206+
<Pressable
207+
onPress={onClose}
208+
disabled={busy}
209+
style={({ pressed }) => [{ flex: 1, height: 46, borderRadius: 15, alignItems: 'center', justifyContent: 'center', backgroundColor: t.bg3 }, pressed && { opacity: 0.65 }, busy && { opacity: 0.5 }]}
210+
>
211+
<Text style={{ color: t.tx2, fontSize: 14.5, fontWeight: '700' }}>取消</Text>
212+
</Pressable>
213+
<Pressable
214+
onPress={onSubmit}
215+
disabled={busy || !email.trim()}
216+
style={({ pressed }) => [{ flex: 1.45, height: 46, borderRadius: 15, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 7, backgroundColor: t.ac }, pressed && { transform: [{ scale: 0.98 }] }, (busy || !email.trim()) && { opacity: 0.45 }]}
217+
>
218+
{busy ? <ActivityIndicator size="small" color={t.acInk} /> : <Icons.mail size={16} color={t.acInk} sw={2.1} />}
219+
<Text style={{ color: t.acInk, fontSize: 14.5, fontWeight: '800' }}>发送验证邮件</Text>
220+
</Pressable>
221+
</View>
222+
</View>
223+
</KeyboardAvoidingView>
224+
</Modal>
225+
);
226+
}
227+
148228
const INVITE_REWARD = 5000;
149229

150230
type PlanKey = 'basic' | 'pro' | 'ultra';
@@ -226,7 +306,7 @@ export default function ProfileScreen() {
226306
const t = useTheme();
227307
const router = useRouter();
228308
const insets = useSafeAreaInsets();
229-
const { user, baseUrl, logout, deleteAccount, appleSession } = useAuth();
309+
const { user, baseUrl, logout, deleteAccount, appleSession, refreshUser } = useAuth();
230310
const [busy, setBusy] = useState(false);
231311
const [wallet, setWallet] = useState<Wallet | null>(null);
232312
const [subscription, setSubscription] = useState<Subscription | null>(null);
@@ -238,11 +318,14 @@ export default function ProfileScreen() {
238318
const [avatarBroken, setAvatarBroken] = useState(false);
239319
const [checkedIn, setCheckedIn] = useState<boolean | null>(null);
240320
const [checkingIn, setCheckingIn] = useState(false);
321+
const [bindEmailOpen, setBindEmailOpen] = useState(false);
322+
const [bindEmail, setBindEmail] = useState('');
323+
const [bindingEmail, setBindingEmail] = useState(false);
241324

242325
useFocusEffect(
243326
useCallback(() => {
244327
let active = true;
245-
Promise.allSettled([getWallet(), getSubscription(), listInvitations(), getCheckinStatus()]).then(([w, s, inv, c]) => {
328+
Promise.allSettled([refreshUser(), getWallet(), getSubscription(), listInvitations(), getCheckinStatus()]).then(([, w, s, inv, c]) => {
246329
if (!active) return;
247330
if (w.status === 'fulfilled') setWallet(w.value);
248331
if (s.status === 'fulfilled') setSubscription(s.value);
@@ -251,7 +334,7 @@ export default function ProfileScreen() {
251334
setLoadingWallet(false);
252335
});
253336
return () => { active = false; };
254-
}, []),
337+
}, [refreshUser]),
255338
);
256339

257340
const showToast = useCallback((msg: string) => {
@@ -264,6 +347,33 @@ export default function ProfileScreen() {
264347
showToast(msg);
265348
}, [showToast]);
266349

350+
const closeBindEmail = useCallback(() => {
351+
if (bindingEmail) return;
352+
setBindEmailOpen(false);
353+
setBindEmail('');
354+
}, [bindingEmail]);
355+
356+
const handleBindEmail = useCallback(async () => {
357+
if (bindingEmail) return;
358+
const nextEmail = bindEmail.trim();
359+
if (!EMAIL_RE.test(nextEmail)) {
360+
Alert.alert('邮箱格式不正确', '请输入有效的邮箱地址。');
361+
return;
362+
}
363+
364+
setBindingEmail(true);
365+
try {
366+
await sendBindEmailVerification(nextEmail);
367+
setBindEmailOpen(false);
368+
setBindEmail('');
369+
Alert.alert('验证邮件已发送', `请前往 ${nextEmail} 查收验证邮件,完成验证后邮箱会显示在本页。`);
370+
} catch (e) {
371+
Alert.alert('绑定邮箱失败', e instanceof Error && e.message ? e.message : '请稍后重试');
372+
} finally {
373+
setBindingEmail(false);
374+
}
375+
}, [bindEmail, bindingEmail]);
376+
267377
const doCheckin = useCallback(async () => {
268378
if (checkingIn || checkedIn) return;
269379
setCheckingIn(true);
@@ -320,6 +430,13 @@ export default function ProfileScreen() {
320430
const email = user?.email || '';
321431
const avatarUrl = resolveAssetUrl(user?.avatar_url || user?.avatar);
322432
useEffect(() => { setAvatarBroken(false); }, [avatarUrl]);
433+
useEffect(() => {
434+
if (email) return;
435+
const sub = AppState.addEventListener('change', (state) => {
436+
if (state === 'active') refreshUser().catch(() => undefined);
437+
});
438+
return () => sub.remove();
439+
}, [email, refreshUser]);
323440
const credits = Math.floor((wallet?.balance ?? 0) / 1000).toLocaleString('zh-CN');
324441
const inviteLink = user?.id ? `${baseUrl}/?ic=${user.id}` : '';
325442
const planKey = normalizePlan(subscription?.plan);
@@ -341,20 +458,30 @@ export default function ProfileScreen() {
341458
<View style={{ paddingHorizontal: spacing.pad, paddingTop: 12, gap: spacing.gap }}>
342459
{/* identity */}
343460
<Card style={{ padding: 16, flexDirection: 'row', alignItems: 'center', gap: 14 }}>
344-
<View style={[{ width: 60, height: 60, borderRadius: 99, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', backgroundColor: avatarUrl && !avatarBroken ? t.ac : t.dark ? t.bg3 : '#fff' }, t.shCard]}>
461+
<View style={[{ width: 60, height: 60, borderRadius: 18, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', backgroundColor: avatarUrl && !avatarBroken ? t.ac : t.dark ? t.bg3 : '#fff' }, t.shCard]}>
345462
{avatarUrl && !avatarBroken
346-
? <Image source={{ uri: avatarUrl }} onError={() => setAvatarBroken(true)} style={{ width: 60, height: 60, borderRadius: 99 }} />
463+
? <Image source={{ uri: avatarUrl }} onError={() => setAvatarBroken(true)} style={{ width: 60, height: 60, borderRadius: 18 }} />
347464
: <MonkeyLogo size={52} />}
348465
</View>
349466
<View style={{ flex: 1, minWidth: 0 }}>
350-
<Text numberOfLines={1} style={{ fontSize: 18, fontWeight: '700', color: t.tx }}>{name}</Text>
351-
{email ? <Text numberOfLines={1} style={{ marginTop: 3, fontSize: 13, color: t.tx2 }}>{email}</Text> : null}
352-
{user?.id ? (
353-
<Pressable onPress={() => copy(user.id!, '用户 ID 已复制')} style={({ pressed }) => [{ flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 8, alignSelf: 'flex-start', paddingHorizontal: 9, paddingVertical: 4, borderRadius: 99, backgroundColor: t.bg3 }, pressed && { opacity: 0.6 }]}>
354-
<Text style={{ fontSize: 11, color: t.tx3, fontFamily: 'monospace' }}>ID {user.id.slice(0, 8)}</Text>
355-
<Icons.copy size={11} color={t.tx3} sw={1.8} />
356-
</Pressable>
357-
) : null}
467+
<Text numberOfLines={1} style={{ fontSize: 18, fontWeight: '800', color: t.tx }}>{name}</Text>
468+
<View style={{ marginTop: 7, gap: 5 }}>
469+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 7, minWidth: 0 }}>
470+
<Icons.mail size={13} color={t.tx3} sw={1.8} />
471+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, fontSize: 12.5, color: t.tx3, fontWeight: '500' }}>{email || '未绑定邮箱'}</Text>
472+
{!email ? (
473+
<Pressable onPress={() => setBindEmailOpen(true)} hitSlop={8} style={({ pressed }) => [{ paddingHorizontal: 4, paddingVertical: 2 }, pressed && { opacity: 0.55 }]}>
474+
<Text style={{ color: t.acTx, fontSize: 12.5, fontWeight: '700' }}>绑定</Text>
475+
</Pressable>
476+
) : null}
477+
</View>
478+
{user?.id ? (
479+
<Pressable onPress={() => copy(user.id!, '用户 ID 已复制')} style={({ pressed }) => [{ flexDirection: 'row', alignItems: 'center', gap: 7, alignSelf: 'flex-start' }, pressed && { opacity: 0.55 }]}>
480+
<Icons.copy size={13} color={t.tx3} sw={1.8} />
481+
<Text style={{ fontSize: 12.5, color: t.tx3, fontFamily: 'monospace' }}>{user.id.slice(0, 8)}</Text>
482+
</Pressable>
483+
) : null}
484+
</View>
358485
</View>
359486
</Card>
360487

@@ -430,6 +557,7 @@ export default function ProfileScreen() {
430557
</ScrollView>
431558

432559
<GlassTop title="我的" collapsed={collapsed} />
560+
<BindEmailSheet visible={bindEmailOpen} email={bindEmail} busy={bindingEmail} onChangeEmail={setBindEmail} onClose={closeBindEmail} onSubmit={handleBindEmail} />
433561
{toast ? <Toast text={toast} bottom={insets.bottom + 116} /> : null}
434562
</View>
435563
);

mobile/src/api/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,14 @@ export async function getUserStatus(): Promise<UserStatus> {
222222
return resp.data?.user ?? {};
223223
}
224224

225+
/** 发送邮箱绑定验证邮件。验证链接被点击后,后端才会把邮箱写入用户信息。 */
226+
export function sendBindEmailVerification(email: string) {
227+
return request('/api/v1/users/email/bind-request', {
228+
method: 'PUT',
229+
body: { email },
230+
});
231+
}
232+
225233
export async function getWallet(): Promise<Wallet | null> {
226234
const resp = await request<Wallet>('/api/v1/users/wallet');
227235
return resp.data ?? null;

mobile/src/auth/AuthContext.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface AuthState {
3737
basicAuth: string; // 测试环境的 HTTP Basic Auth("user:pass"),可选
3838
savedEmail: string;
3939
savedPassword: string; // 上次登录成功的密码,用于自动填充
40+
refreshUser: () => Promise<UserStatus>;
4041
login: (email: string, password: string) => Promise<void>;
4142
loginWithApple: (params: { identity_token: string; authorization_code?: string; full_name?: string }) => Promise<void>;
4243
completeOAuthLogin: () => Promise<void>; // 百智云 OAuth 在 WebView 完成后,用会话 Cookie 确认登录
@@ -144,6 +145,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
144145
[baseUrl],
145146
);
146147

148+
const refreshUser = useCallback(async () => {
149+
const u = await getUserStatus();
150+
if (u && (u.id || u.email || u.username)) {
151+
setUser(u);
152+
setAuthenticated(true);
153+
}
154+
return u;
155+
}, []);
156+
147157
// 注销账号:后端删除成功后,账号已不存在——清掉保存的自动填充凭据,
148158
// 其余登录态清理复用 doLogout(保持与退出登录完全一致的清场顺序)。
149159
const deleteAccount = useCallback(async () => {
@@ -222,6 +232,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
222232
basicAuth,
223233
savedEmail,
224234
savedPassword,
235+
refreshUser,
225236
login,
226237
loginWithApple,
227238
completeOAuthLogin,
@@ -230,7 +241,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
230241
updateBaseUrl,
231242
updateBasicAuth,
232243
}),
233-
[ready, authenticated, appleSession, user, baseUrl, basicAuth, savedEmail, savedPassword, login, loginWithApple, completeOAuthLogin, doLogout, deleteAccount, updateBaseUrl, updateBasicAuth],
244+
[ready, authenticated, appleSession, user, baseUrl, basicAuth, savedEmail, savedPassword, refreshUser, login, loginWithApple, completeOAuthLogin, doLogout, deleteAccount, updateBaseUrl, updateBasicAuth],
234245
);
235246

236247
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

mobile/src/components/Icons.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const Icons: Record<string, IconFn> = {
4949
stop: (p) => <Ic {...p}><Rect x={7} y={7} width={10} height={10} rx={2.2} fill={p.color} stroke="none" /></Ic>,
5050
diamond: (p) => <Ic {...p}><Path d="M5 9.5l3-4.5h8l3 4.5-7 9z M5 9.5h14" /></Ic>,
5151
logout: (p) => <Ic {...p}><Path d="M14 7V5.5A1.5 1.5 0 0 0 12.5 4H6a1.5 1.5 0 0 0-1.5 1.5v13A1.5 1.5 0 0 0 6 20h6.5a1.5 1.5 0 0 0 1.5-1.5V17" /><Path d="M10 12h10m0 0l-3-3m3 3l-3 3" /></Ic>,
52+
mail: (p) => <Ic {...p}><Rect x={3.5} y={5.5} width={17} height={13} rx={2.4} /><Path d="M5 8l7 5.2L19 8" /></Ic>,
5253
at: (p) => <Ic {...p}><Circle cx={12} cy={12} r={3.8} /><Path d="M15.8 12v1.5a2.5 2.5 0 0 0 5 0V12a8.5 8.5 0 1 0-3.3 6.7" /></Ic>,
5354
slash: (p) => <Ic {...p}><Path d="M15 4L9 20" /></Ic>,
5455
attach: (p) => <Ic {...p}><Path d="M19 11.5l-7.1 7.1a4 4 0 0 1-5.7-5.7l7.7-7.7a2.6 2.6 0 0 1 3.7 3.7l-7.6 7.6a1.2 1.2 0 0 1-1.7-1.7l6.9-6.9" /></Ic>,

0 commit comments

Comments
 (0)