feat(auth): 增加 PC 注册页并支持本地 Mock 短信验证码
This commit is contained in:
@@ -155,6 +155,10 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin
|
||||
if provider == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
// 本地/测试环境固定 Mock 验证码,便于联调注册与登录;生产环境配置校验强制使用真实渠道。
|
||||
if _, isMock := provider.(*smsprovider.MockProvider); isMock {
|
||||
code = "123456"
|
||||
}
|
||||
if err := provider.SendLoginCode(ctx, phone, code); err != nil {
|
||||
if s.log != nil {
|
||||
fields := []zap.Field{
|
||||
|
||||
@@ -108,6 +108,9 @@ const ChatPage = lazy(() =>
|
||||
const LoginPage = lazy(() =>
|
||||
import('@/features/auth/LoginPage').then(module => ({ default: module.LoginPage }))
|
||||
)
|
||||
const RegisterPage = lazy(() =>
|
||||
import('@/features/auth/RegisterPage').then(module => ({ default: module.RegisterPage }))
|
||||
)
|
||||
const MobileLayout = lazy(() =>
|
||||
import('@/layouts/MobileLayout').then(module => ({ default: module.MobileLayout }))
|
||||
)
|
||||
@@ -522,6 +525,7 @@ export function AppRouter() {
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/i/:code" element={<InviteLandingPage />} />
|
||||
<Route path="/m/login" element={<LoginPage />} />
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export function LoginPage() {
|
||||
const isMobileEntry = window.location.pathname.startsWith('/m')
|
||||
const fallbackPath = isMobileEntry ? '/m' : '/'
|
||||
const targetPath = redirect && redirect.startsWith('/') ? redirect : fallbackPath
|
||||
const registerBasePath = isMobileEntry ? '/m/register' : '/register'
|
||||
|
||||
if (token) return <Navigate to={targetPath} replace />
|
||||
|
||||
@@ -78,7 +79,16 @@ export function LoginPage() {
|
||||
登录解忧商行
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
使用手机号和密码进入。还没有账号?注册流程将在下一阶段接入。
|
||||
使用手机号和密码进入。还没有账号?
|
||||
<Link
|
||||
to={
|
||||
redirect
|
||||
? `${registerBasePath}?redirect=${encodeURIComponent(redirect)}`
|
||||
: registerBasePath
|
||||
}
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Alert, App, Button, Form, Input, Space, Typography } from 'antd'
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LockOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
SafetyOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/api/types'
|
||||
|
||||
const INVITE_STORAGE_KEY = 'jieyou_invite_code'
|
||||
|
||||
interface CaptchaData {
|
||||
captcha_id: string
|
||||
image: string
|
||||
}
|
||||
|
||||
interface RegisterForm {
|
||||
phone: string
|
||||
captcha: string
|
||||
code: string
|
||||
password: string
|
||||
confirm: 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 RegisterPage() {
|
||||
const { message } = App.useApp()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [form] = Form.useForm<RegisterForm>()
|
||||
const login = useSessionStore(state => state.login)
|
||||
const token = useSessionStore(state => state.token)
|
||||
const [captchaKey, setCaptchaKey] = useState(0)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
|
||||
const redirect = searchParams.get('redirect')
|
||||
const targetPath = redirect && redirect.startsWith('/') ? redirect : '/'
|
||||
|
||||
const captchaQuery = useQuery({
|
||||
queryKey: ['auth-captcha', captchaKey],
|
||||
queryFn: () =>
|
||||
apiClient
|
||||
.get<ApiResponse<CaptchaData>>('/auth/captcha', { skipErrorHandler: true } as never)
|
||||
.then(response => response.data.data),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fromQuery = searchParams.get('invite')
|
||||
let stored = ''
|
||||
try {
|
||||
stored = sessionStorage.getItem(INVITE_STORAGE_KEY) || ''
|
||||
} catch {
|
||||
stored = ''
|
||||
}
|
||||
setInviteCode(fromQuery || stored)
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = window.setTimeout(() => setCountdown(current => current - 1), 1000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
if (token) return <Navigate to={targetPath} replace />
|
||||
|
||||
const sendSMS = async () => {
|
||||
try {
|
||||
await form.validateFields(['phone', 'captcha'])
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!captchaQuery.data?.captcha_id) {
|
||||
message.warning('图形验证码加载失败,请点击图片刷新')
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
try {
|
||||
await apiClient.post(
|
||||
'/auth/sms/send',
|
||||
{
|
||||
phone: form.getFieldValue('phone'),
|
||||
captcha_id: captchaQuery.data.captcha_id,
|
||||
captcha_code: form.getFieldValue('captcha'),
|
||||
},
|
||||
{ skipErrorHandler: true } as never
|
||||
)
|
||||
message.success('验证码已发送')
|
||||
setCountdown(60)
|
||||
setCaptchaKey(current => current + 1)
|
||||
form.setFieldValue('captcha', '')
|
||||
} catch (err) {
|
||||
setCaptchaKey(current => current + 1)
|
||||
form.setFieldValue('captcha', '')
|
||||
message.error(errorText(err))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (values: RegisterForm) => {
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
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)
|
||||
message.success('注册成功,欢迎加入解忧商行')
|
||||
navigate(targetPath, { replace: true })
|
||||
} catch (err) {
|
||||
setError(errorText(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-brand-panel">
|
||||
<div className="login-brand-panel__inner">
|
||||
<div className="login-brand__logo">
|
||||
<BrandLogo height={58} variant="dark" />
|
||||
</div>
|
||||
<Typography.Title level={1} className="brand-title login-brand__title">
|
||||
租得明白
|
||||
<br />
|
||||
还得安心
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="login-brand__copy">
|
||||
解忧商行把费用、押金、交接和结算规则逐项写清楚。每一步都有记录,每一笔账都能核对。
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical" size={10} className="login-brand__points">
|
||||
{['实名审核后才能发布与下单', '交接、归还、结账全程留痕', '账单逐项列明,争议可申诉'].map(
|
||||
item => (
|
||||
<span key={item}>
|
||||
<CheckCircleFilled /> {item}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
<div className="login-brand__seal">解忧</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="login-form-panel">
|
||||
<div className="login-form-card">
|
||||
<div className="login-form-card__top">
|
||||
<BrandLogo height={38} />
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
注册解忧商行
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
手机号注册,注册后即可登录、实名、下单或发布账号。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} className="login-alert" /> : null}
|
||||
|
||||
<Form<RegisterForm>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
onFinish={submit}
|
||||
requiredMark={false}
|
||||
>
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<PhoneOutlined />} placeholder="11 位手机号" autoComplete="tel" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="图形验证码" required>
|
||||
<div className="register-captcha-row">
|
||||
<Form.Item
|
||||
name="captcha"
|
||||
noStyle
|
||||
rules={[{ required: true, message: '请输入图形验证码' }]}
|
||||
>
|
||||
<Input prefix={<SafetyOutlined />} placeholder="输入图片字符" maxLength={8} />
|
||||
</Form.Item>
|
||||
{captchaQuery.data?.image ? (
|
||||
<img
|
||||
className="register-captcha-image"
|
||||
src={captchaQuery.data.image}
|
||||
alt="图形验证码"
|
||||
onClick={() => setCaptchaKey(current => current + 1)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="register-captcha-image register-captcha-image--loading"
|
||||
onClick={() => setCaptchaKey(current => current + 1)}
|
||||
>
|
||||
点击刷新
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="短信验证码" required>
|
||||
<div className="register-code-row">
|
||||
<Form.Item
|
||||
name="code"
|
||||
noStyle
|
||||
rules={[
|
||||
{ required: true, message: '请输入短信验证码' },
|
||||
{ pattern: /^\d{6}$/, message: '请输入 6 位数字验证码' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MessageOutlined />} placeholder="6 位验证码" maxLength={6} />
|
||||
</Form.Item>
|
||||
<Button loading={sending} disabled={countdown > 0} onClick={() => void sendSMS()}>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="设置密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 8, max: 20, message: '密码长度 8-20 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="8-20 位,建议字母+数字"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认密码"
|
||||
name="confirm"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) return Promise.resolve()
|
||||
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="再次输入密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block loading={submitting}>
|
||||
注册并登录
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
{inviteCode ? <div className="login-dev-hint">邀请码:{inviteCode}</div> : null}
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div className="login-dev-hint">
|
||||
本地短信为 Mock 渠道,验证码固定为 123456(仅开发环境)。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="login-form-card__footer">
|
||||
<Link to="/login">已有账号,去登录</Link>
|
||||
<Link to="/">先看看商品</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -140,3 +140,39 @@
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.register-captcha-row,
|
||||
.register-code-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.register-captcha-row .ant-input-affix-wrapper,
|
||||
.register-code-row .ant-input-affix-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.register-captcha-image {
|
||||
flex: 0 0 118px;
|
||||
width: 118px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.register-captcha-image--loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.register-code-row .ant-btn {
|
||||
flex: 0 0 124px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user