feat(user): 新增邀请落地页与移动端注册引导
This commit is contained in:
@@ -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() {
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/i/:code" element={<InviteLandingPage />} />
|
||||
<Route path="/m/login" element={<LoginPage />} />
|
||||
|
||||
<Route path="/m" element={<MobileLayout />}>
|
||||
@@ -448,6 +464,8 @@ export function AppRouter() {
|
||||
<Route path="announcements" element={<MobileAnnouncementsPage />} />
|
||||
<Route path="notifications" element={<MobileNotificationsPage />} />
|
||||
<Route path="invitations" element={<MobileInvitationsPage />} />
|
||||
<Route path="register" element={<MobileRegisterPage />} />
|
||||
<Route path="add-home-guide" element={<MobileAddHomeGuidePage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={`${adminBase}/login`} element={<AdminLoginPage />} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="wallet-page">
|
||||
<section className="wallet-hero">
|
||||
<div>
|
||||
<Typography.Text className="wallet-eyebrow">JIEYOU · 邀请</Typography.Text>
|
||||
<Typography.Title level={1} className="brand-title wallet-title">
|
||||
解忧商行邀请你加入
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="wallet-desc">
|
||||
注册并完成实名后即可租号、出租账号;邀请你的好友还能获得首月 / 次月 / 后续返利。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="wallet-hero__action">
|
||||
<BrandLogo height={44} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card className="wallet-ledger-card">
|
||||
{infoQuery.isPending ? (
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
) : infoQuery.isError || !info?.valid ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="邀请码无效或已失效"
|
||||
description="你仍然可以正常注册使用解忧商行,只是不会绑定邀请关系。"
|
||||
/>
|
||||
) : (
|
||||
<Space size={10} wrap align="center">
|
||||
<Tag color="gold" icon={<GiftOutlined />}>
|
||||
邀请码 {info.invite_code}
|
||||
</Tag>
|
||||
<Typography.Text>
|
||||
邀请人:<strong>{info.inviter_nickname || '解忧商行用户'}</strong>
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Space size={10} wrap style={{ marginTop: 20 }}>
|
||||
<Button type="primary" onClick={() => navigate(`/m/register${code ? `?invite=${code}` : ''}`)}>
|
||||
立即注册
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/m/login')}>已有账号,去登录</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mobile-wallet">
|
||||
<NavBar onBack={() => navigate('/m/profile')}>添加到桌面</NavBar>
|
||||
|
||||
<Card title="为什么添加到桌面">
|
||||
<div className="mobile-wallet__sub" style={{ lineHeight: 1.9 }}>
|
||||
添加后可以像 App 一样从桌面直接进入解忧商行,下单、查订单、联系客服都更快。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="操作步骤">
|
||||
<Steps direction="vertical" current={-1}>
|
||||
<Step title="iPhone / Safari" description="点击底部「分享」按钮,选择「添加到主屏幕」,再点击「添加」。" />
|
||||
<Step title="Android / Chrome" description="点击右上角「⋮」菜单,选择「添加到主屏幕」或「安装应用」。" />
|
||||
<Step title="微信内打开" description="点击右上角「…」,选择「在浏览器打开」,再按上面步骤添加到桌面。" />
|
||||
</Steps>
|
||||
</Card>
|
||||
|
||||
<Button block color="primary" onClick={() => navigate('/m')}>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<RegisterFormValues>()
|
||||
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<ApiResponse<CaptchaData>>('/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 (
|
||||
<div className="mobile-wallet">
|
||||
<NavBar onBack={() => navigate('/m/login')}>注册账号</NavBar>
|
||||
|
||||
<Card title="手机号注册">
|
||||
<Form form={form} layout="horizontal" footer={null}>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input placeholder="11 位手机号" type="tel" maxLength={11} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="captcha"
|
||||
label="图形验证码"
|
||||
rules={[{ required: true, message: '请输入图形验证码' }]}
|
||||
extra={
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 }}>
|
||||
{captchaQuery.data?.image ? (
|
||||
<Image
|
||||
src={captchaQuery.data.image}
|
||||
width={110}
|
||||
height={38}
|
||||
fit="fill"
|
||||
onClick={() => setCaptchaCode(`${Date.now()}`)}
|
||||
style={{ borderRadius: 6 }}
|
||||
/>
|
||||
) : null}
|
||||
<span style={{ fontSize: 12, color: '#8a948e' }}>点击图片刷新</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Input placeholder="输入图片中的字符" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="短信验证码"
|
||||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||||
extra={
|
||||
<Button
|
||||
size="mini"
|
||||
color="primary"
|
||||
fill="outline"
|
||||
loading={sending}
|
||||
disabled={countdown > 0}
|
||||
onClick={() => void sendSMS()}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Input placeholder="6 位短信验证码" type="number" maxLength={6} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="设置密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 8, max: 20, message: '密码 8-20 位' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="8-20 位,建议字母+数字" type="password" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{inviteCode ? (
|
||||
<div style={{ fontSize: 12, color: '#8a948e', marginTop: 4 }}>邀请码:{inviteCode}</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
loading={submitting}
|
||||
onClick={() => void submit()}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<Button size="mini" fill="none" onClick={() => navigate('/m/login')}>
|
||||
已有账号,去登录
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user