14b867a3a5
CI / verify (pull_request) Has been cancelled
Quick login required agreement without a checkbox; move the consent row above CTAs across partner/shop/user/mini login pages so it stays visible on short screens. Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
8.3 KiB
TypeScript
244 lines
8.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
|
import AppImage from '@dukang/shared-ui/AppImage';
|
|
import type { WechatLoginResult } from '@dukang/shared-types';
|
|
import { SmsScene } from '@dukang/shared-types';
|
|
import { request, type SessionPayload } from '../lib/api';
|
|
import { fetchClientConfig } from '../lib/pay-wechat';
|
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
|
import { useSmsCode } from '../lib/use-sms-code';
|
|
import { isWechatEnv } from '../lib/weixin';
|
|
import { loginWithWechatSdk, handleWechatAuthCallback as handleWechatOAuthCallback } from '../lib/wechat-auth';
|
|
import { useUserSession } from '../contexts/UserSessionContext';
|
|
import { touchPromoIfNeeded } from '../lib/promo';
|
|
|
|
async function finishLogin(navigate: (path: string) => void, returnTo: string) {
|
|
await touchPromoIfNeeded();
|
|
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
|
}
|
|
|
|
export default function LoginPage() {
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const returnTo = searchParams.get('return') || '/';
|
|
const { applySession } = useUserSession();
|
|
const [phone, setPhone] = useState('');
|
|
const [code, setCode] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [agreed, setAgreed] = useState(false);
|
|
const [msg, setMsg] = useState('');
|
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
|
const [bindMode, setBindMode] = useState(false);
|
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
|
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
|
useSmsCode();
|
|
|
|
useEffect(() => {
|
|
fetchClientConfig()
|
|
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
|
.catch(() => setWxAuthorize(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isWechatEnv() || !wxAuthorize) return;
|
|
handleWechatOAuthCallback()
|
|
.then((result) => {
|
|
if (!result) return;
|
|
handleWechatLoginResult(result);
|
|
})
|
|
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
|
|
}, [wxAuthorize]);
|
|
|
|
function handleWechatLoginResult(result: WechatLoginResult) {
|
|
if (result.accessToken) {
|
|
applySession({
|
|
accessToken: result.accessToken,
|
|
refreshToken: result.refreshToken ?? '',
|
|
deviceKey: result.deviceKey,
|
|
phoneVerified: !!result.phoneVerified,
|
|
user: result.user as SessionPayload['user'],
|
|
});
|
|
void finishLogin(navigate, returnTo);
|
|
return;
|
|
}
|
|
if (result.needBindPhone && result.wxSessionKey) {
|
|
setBindMode(true);
|
|
setWxSessionKey(result.wxSessionKey);
|
|
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
|
|
return;
|
|
}
|
|
}
|
|
|
|
function ensureAgreed() {
|
|
if (!agreed) {
|
|
setMsg('请先勾选并同意用户协议');
|
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function onSendCode() {
|
|
if (!ensureAgreed()) return;
|
|
clearMessages();
|
|
setMsg('');
|
|
await sendCode(phone, bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN);
|
|
}
|
|
|
|
async function login() {
|
|
if (!ensureAgreed()) return;
|
|
const phoneCheck = validateMobilePhone(phone);
|
|
if (!phoneCheck.ok) {
|
|
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
|
return;
|
|
}
|
|
if (!code.trim()) {
|
|
setMsg('请输入验证码');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setMsg('');
|
|
setSmsError('');
|
|
try {
|
|
if (bindMode && wxSessionKey) {
|
|
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ wxSessionKey, phone, code }),
|
|
});
|
|
handleWechatLoginResult(data);
|
|
return;
|
|
}
|
|
const data = await request<SessionPayload>('USER_H5', '/auth/login/sms', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ phone, code }),
|
|
});
|
|
applySession(data);
|
|
await finishLogin(navigate, returnTo);
|
|
} catch (e) {
|
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function wechatLogin() {
|
|
if (!ensureAgreed()) return;
|
|
setMsg('');
|
|
try {
|
|
const result = await loginWithWechatSdk();
|
|
if (result) handleWechatLoginResult(result);
|
|
} catch (e) {
|
|
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
|
}
|
|
}
|
|
|
|
const displayMsg = msg || smsError;
|
|
|
|
return (
|
|
<div className="login-page">
|
|
<header className="login-header">
|
|
<div className="login-logo-wrap">
|
|
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="login-logo" fit="contain" />
|
|
<span className="login-logo-badge">官方</span>
|
|
</div>
|
|
<div className="login-welcome">
|
|
<h1 className="login-welcome-title">欢迎来到杜康好客</h1>
|
|
<p className="login-welcome-sub">买美酒,享好礼</p>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="login-main">
|
|
<div className="login-card">
|
|
<h3 className="login-card-title">{bindMode ? '绑定手机号' : '手机验证码登录'}</h3>
|
|
<div className="login-field">
|
|
<span className="login-field-prefix">+86</span>
|
|
<input
|
|
type="tel"
|
|
className="login-field-input"
|
|
placeholder="请输入手机号"
|
|
maxLength={11}
|
|
inputMode="numeric"
|
|
value={phone}
|
|
onChange={(e) => {
|
|
setPhone(normalizePhoneInput(e.target.value));
|
|
setMsg('');
|
|
clearMessages();
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="login-field">
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
className="login-field-input"
|
|
placeholder="请输入验证码"
|
|
maxLength={6}
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
|
disabled={codeCooldown > 0 || sending}
|
|
onClick={onSendCode}
|
|
>
|
|
{sending
|
|
? '发送中...'
|
|
: codeCooldown > 0
|
|
? `${codeCooldown}s 后重新获取`
|
|
: '获取验证码'}
|
|
</button>
|
|
</div>
|
|
{(displayMsg || sentHint) && (
|
|
<p className={`login-msg${sentHint && !displayMsg ? ' login-msg--hint' : ''}`}>
|
|
{displayMsg || sentHint}
|
|
</p>
|
|
)}
|
|
<label className="login-agreement" ref={agreementRef}>
|
|
<input
|
|
type="checkbox"
|
|
checked={agreed}
|
|
onChange={(e) => setAgreed(e.target.checked)}
|
|
/>
|
|
<span>
|
|
我已阅读并同意
|
|
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
|
《用户协议》
|
|
</Link>
|
|
和
|
|
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
|
《隐私政策》
|
|
</Link>
|
|
</span>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="login-sms-btn"
|
|
disabled={loading}
|
|
onClick={login}
|
|
>
|
|
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
|
</button>
|
|
</div>
|
|
|
|
{!bindMode && wxAuthorize && (
|
|
<>
|
|
<div className="login-divider">
|
|
<span className="login-divider-line" />
|
|
<span className="login-divider-text">或者</span>
|
|
<span className="login-divider-line" />
|
|
</div>
|
|
|
|
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
|
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
|
<span>微信一键授权</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|