From efd9e62264254b460294e94578a6a191c0ddb2b9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 1 Nov 2026 15:45:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=A2=9E=E5=8A=A0=20PC=20?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E9=A1=B5=E5=B9=B6=E6=94=AF=E6=8C=81=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20Mock=20=E7=9F=AD=E4=BF=A1=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/auth/service.go | 4 + frontend/src/app/AppRouter.tsx | 4 + frontend/src/features/auth/LoginPage.tsx | 12 +- frontend/src/features/auth/RegisterPage.tsx | 304 ++++++++++++++++++++ frontend/src/styles/auth.css | 36 +++ 5 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/auth/RegisterPage.tsx diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index 438606b..817ff03 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -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{ diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 2562a2d..b9f878b 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> + } /> } /> } /> diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index a6731f4..70d1ef6 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -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 @@ -78,7 +79,16 @@ export function LoginPage() { 登录解忧商行 - 使用手机号和密码进入。还没有账号?注册流程将在下一阶段接入。 + 使用手机号和密码进入。还没有账号? + + 立即注册 + diff --git a/frontend/src/features/auth/RegisterPage.tsx b/frontend/src/features/auth/RegisterPage.tsx new file mode 100644 index 0000000..154f2b1 --- /dev/null +++ b/frontend/src/features/auth/RegisterPage.tsx @@ -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() + 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>('/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 + + 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 ( +
+
+
+
+ +
+ + 租得明白 +
+ 还得安心 +
+ + 解忧商行把费用、押金、交接和结算规则逐项写清楚。每一步都有记录,每一笔账都能核对。 + + + {['实名审核后才能发布与下单', '交接、归还、结账全程留痕', '账单逐项列明,争议可申诉'].map( + item => ( + + {item} + + ) + )} + +
解忧
+
+
+ +
+
+
+ + + 注册解忧商行 + + + 手机号注册,注册后即可登录、实名、下单或发布账号。 + +
+ + {error ? : null} + + + form={form} + layout="vertical" + size="large" + onFinish={submit} + requiredMark={false} + > + + } placeholder="11 位手机号" autoComplete="tel" /> + + + +
+ + } placeholder="输入图片字符" maxLength={8} /> + + {captchaQuery.data?.image ? ( + 图形验证码 setCaptchaKey(current => current + 1)} + /> + ) : ( + + )} +
+
+ + +
+ + } placeholder="6 位验证码" maxLength={6} /> + + +
+
+ + + } + placeholder="8-20 位,建议字母+数字" + autoComplete="new-password" + /> + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) return Promise.resolve() + return Promise.reject(new Error('两次输入的密码不一致')) + }, + }), + ]} + > + } + placeholder="再次输入密码" + autoComplete="new-password" + /> + + + + + + {inviteCode ?
邀请码:{inviteCode}
: null} + + {import.meta.env.DEV ? ( +
+ 本地短信为 Mock 渠道,验证码固定为 123456(仅开发环境)。 +
+ ) : null} + +
+ 已有账号,去登录 + 先看看商品 +
+
+
+
+ ) +} diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css index 8436728..4338143 100644 --- a/frontend/src/styles/auth.css +++ b/frontend/src/styles/auth.css @@ -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; +} +