feat(user): 新增邀请落地页与移动端注册引导

This commit is contained in:
yml2213
2026-10-30 17:25:04 +08:00
parent 2f0490c270
commit 5fd880aa7a
4 changed files with 346 additions and 0 deletions
+18
View File
@@ -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>
)
}