diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 05ba243..69322f2 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -80,6 +80,11 @@ const InvitationsPage = lazy(() => default: module.InvitationsPage, })) ) +const InviteLandingPage = lazy(() => + import('@/features/invitations/InviteLandingPage').then(module => ({ + default: module.InviteLandingPage, + })) +) const MessagesPage = lazy(() => import('@/features/messages/MessagesPage').then(module => ({ default: module.MessagesPage })) ) @@ -125,6 +130,16 @@ const MobileInvitationsPage = lazy(() => default: module.MobileInvitationsPage, })) ) +const MobileRegisterPage = lazy(() => + import('@/features/mobile/MobileRegisterPage').then(module => ({ + default: module.MobileRegisterPage, + })) +) +const MobileAddHomeGuidePage = lazy(() => + import('@/features/mobile/MobileAddHomeGuidePage').then(module => ({ + default: module.MobileAddHomeGuidePage, + })) +) const MobileOrdersPage = lazy(() => import('@/features/mobile/MobileOrdersPage').then(module => ({ default: module.MobileOrdersPage, @@ -428,6 +443,7 @@ export function AppRouter() { } /> + } /> } /> }> @@ -448,6 +464,8 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/features/invitations/InviteLandingPage.tsx b/frontend/src/features/invitations/InviteLandingPage.tsx new file mode 100644 index 0000000..d5d0d20 --- /dev/null +++ b/frontend/src/features/invitations/InviteLandingPage.tsx @@ -0,0 +1,81 @@ +import { useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Alert, Button, Card, Skeleton, Space, Tag, Typography } from 'antd' +import { GiftOutlined } from '@ant-design/icons' +import { useNavigate, useParams } from 'react-router-dom' + +import { fetchInviteCodeInfo } from '@/features/invitations/api' +import { BrandLogo } from '@/components/BrandLogo' + +const INVITE_STORAGE_KEY = 'jieyou_invite_code' + +export function InviteLandingPage() { + const { code } = useParams() + const navigate = useNavigate() + const infoQuery = useQuery({ + queryKey: ['invite-code-info', code], + queryFn: () => fetchInviteCodeInfo(code as string), + enabled: Boolean(code), + retry: false, + }) + + useEffect(() => { + if (code) { + try { + sessionStorage.setItem(INVITE_STORAGE_KEY, code) + } catch { + // 忽略存储失败 + } + } + }, [code]) + + const info = infoQuery.data + + return ( +
+
+
+ JIEYOU · 邀请 + + 解忧商行邀请你加入 + + + 注册并完成实名后即可租号、出租账号;邀请你的好友还能获得首月 / 次月 / 后续返利。 + +
+
+ +
+
+ + + {infoQuery.isPending ? ( + + ) : infoQuery.isError || !info?.valid ? ( + + ) : ( + + }> + 邀请码 {info.invite_code} + + + 邀请人:{info.inviter_nickname || '解忧商行用户'} + + + )} + + + + + + +
+ ) +} diff --git a/frontend/src/features/mobile/MobileAddHomeGuidePage.tsx b/frontend/src/features/mobile/MobileAddHomeGuidePage.tsx new file mode 100644 index 0000000..4c4f2c6 --- /dev/null +++ b/frontend/src/features/mobile/MobileAddHomeGuidePage.tsx @@ -0,0 +1,32 @@ +import { Button, Card, NavBar, Steps } from 'antd-mobile' +import { useNavigate } from 'react-router-dom' + +const { Step } = Steps + +export function MobileAddHomeGuidePage() { + const navigate = useNavigate() + + return ( +
+ navigate('/m/profile')}>添加到桌面 + + +
+ 添加后可以像 App 一样从桌面直接进入解忧商行,下单、查订单、联系客服都更快。 +
+
+ + + + + + + + + + +
+ ) +} diff --git a/frontend/src/features/mobile/MobileRegisterPage.tsx b/frontend/src/features/mobile/MobileRegisterPage.tsx new file mode 100644 index 0000000..273fe2b --- /dev/null +++ b/frontend/src/features/mobile/MobileRegisterPage.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Button, Card, Form, Image, Input, NavBar, Toast } from 'antd-mobile' +import { useNavigate, useSearchParams } from 'react-router-dom' + +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/api/types' +import { useSessionStore } from '@/stores/session' + +const INVITE_STORAGE_KEY = 'jieyou_invite_code' + +interface CaptchaData { + captcha_id: string + image: string +} + +interface RegisterFormValues { + phone: string + captcha: string + code: string + password: string +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + return response?.data?.message || (error instanceof Error ? error.message : '请求失败') +} + +export function MobileRegisterPage() { + const navigate = useNavigate() + const [params] = useSearchParams() + const [form] = Form.useForm() + const [captchaCode, setCaptchaCode] = useState('') + const [countdown, setCountdown] = useState(0) + const [sending, setSending] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [inviteCode, setInviteCode] = useState('') + const login = useSessionStore(state => state.login) + + const captchaQuery = useQuery({ + queryKey: ['auth-captcha', captchaCode], + queryFn: () => + apiClient + .get>('/auth/captcha', { skipErrorHandler: true } as never) + .then(response => response.data.data), + staleTime: 0, + }) + + useEffect(() => { + const fromQuery = params.get('invite') + let stored = '' + try { + stored = sessionStorage.getItem(INVITE_STORAGE_KEY) || '' + } catch { + stored = '' + } + setInviteCode(fromQuery || stored) + }, [params]) + + useEffect(() => { + if (countdown <= 0) return + const timer = window.setTimeout(() => setCountdown(current => current - 1), 1000) + return () => window.clearTimeout(timer) + }, [countdown]) + + const sendSMS = async () => { + const phone = form.getFieldValue('phone') as string + const captcha = form.getFieldValue('captcha') as string + if (!phone) { + Toast.show({ content: '请先填写手机号' }) + return + } + if (!captcha) { + Toast.show({ content: '请先填写图形验证码' }) + return + } + if (!captchaQuery.data?.captcha_id) { + Toast.show({ content: '验证码加载失败,请点击图片刷新' }) + return + } + setSending(true) + try { + await apiClient.post( + '/auth/sms/send', + { + phone, + captcha_id: captchaQuery.data.captcha_id, + captcha_code: captcha, + }, + { skipErrorHandler: true } as never + ) + Toast.show({ icon: 'success', content: '验证码已发送' }) + setCountdown(60) + setCaptchaCode(`${Date.now()}`) + form.setFieldValue('captcha', '') + } catch (error) { + Toast.show({ content: errorText(error) }) + setCaptchaCode(`${Date.now()}`) + } finally { + setSending(false) + } + } + + const submit = async () => { + const values = await form.validateFields() + setSubmitting(true) + try { + await apiClient.post( + '/auth/password/register', + { + phone: values.phone.trim(), + code: values.code.trim(), + password: values.password, + invite_code: inviteCode || undefined, + }, + { skipErrorHandler: true } as never + ) + await login(values.phone.trim(), values.password) + Toast.show({ icon: 'success', content: '注册成功' }) + navigate('/m', { replace: true }) + } catch (error) { + Toast.show({ content: errorText(error) }) + } finally { + setSubmitting(false) + } + } + + return ( +
+ navigate('/m/login')}>注册账号 + + +
+ + + + + + {captchaQuery.data?.image ? ( + setCaptchaCode(`${Date.now()}`)} + style={{ borderRadius: 6 }} + /> + ) : null} + 点击图片刷新 +
+ } + > + + + + 0} + onClick={() => void sendSMS()} + style={{ marginTop: 6 }} + > + {countdown > 0 ? `${countdown}s 后重发` : '获取验证码'} + + } + > + + + + + + + + + {inviteCode ? ( +
邀请码:{inviteCode}
+ ) : null} + + +
+ +
+ + + ) +}