568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { View, Text, Input, Image } from '@tarojs/components';
|
||
import '../../styles/login.css';
|
||
import Taro, { useRouter } from '@tarojs/taro';
|
||
import {
|
||
SmsScene,
|
||
isWxAuthorizeEnabled,
|
||
type ClientRuntimeConfig,
|
||
type WechatLoginResult,
|
||
} from '@dukang/shared-types';
|
||
import PageShell from '../../components/PageShell';
|
||
import WechatLoginButton from '../../components/WechatLoginButton';
|
||
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
||
import {
|
||
applyBrandFromClientConfig,
|
||
getBrandAssetsSync,
|
||
loadBrandAssets,
|
||
} from '../../lib/brand-assets';
|
||
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
||
import {
|
||
bindWechatForUser,
|
||
loginWithWechat,
|
||
} from '../../lib/wechat-auth';
|
||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||
import {
|
||
getCachedWxProfile,
|
||
syncMiniWechatProfile,
|
||
type MiniWechatProfile,
|
||
} from '../../lib/mini-wechat-profile';
|
||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||
import { touchStoredPromoAfterLogin } from '../../lib/promo';
|
||
|
||
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
|
||
|
||
function normalizePhone(value: string) {
|
||
return value.replace(/\D/g, '').slice(0, 11);
|
||
}
|
||
|
||
function isValidPhone(phone: string) {
|
||
return /^1[3-9]\d{9}$/.test(phone);
|
||
}
|
||
|
||
function AgreementRow({
|
||
agreed,
|
||
onToggle,
|
||
}: {
|
||
agreed: boolean;
|
||
onToggle: () => void;
|
||
}) {
|
||
return (
|
||
<View className="login-agreement" onClick={onToggle}>
|
||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||
{agreed ? <Text>✓</Text> : null}
|
||
</View>
|
||
<Text className="login-agreement-text">
|
||
请阅读并勾选同意
|
||
<Text
|
||
className="login-agreement-link"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||
}}
|
||
>
|
||
《用户服务协议》
|
||
</Text>
|
||
和
|
||
<Text
|
||
className="login-agreement-link"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||
}}
|
||
>
|
||
《隐私政策》
|
||
</Text>
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export default function LoginPage() {
|
||
const router = useRouter();
|
||
const returnTo = router.params.return || '';
|
||
const needPhone = router.params.needPhone === '1';
|
||
const needWechat = router.params.needWechat === '1';
|
||
const initialBindMode = router.params.bindMode === '1';
|
||
const initialWxSessionKey = router.params.wxSessionKey || null;
|
||
|
||
const [phone, setPhone] = useState('');
|
||
const [code, setCode] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [wxLoading, setWxLoading] = useState(false);
|
||
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
|
||
const [sending, setSending] = useState(false);
|
||
const [cooldown, setCooldown] = useState(0);
|
||
/** 须用户主动勾选,禁止默认同意 */
|
||
const [agreed, setAgreed] = useState(false);
|
||
const [msg, setMsg] = useState('');
|
||
const [sentHint, setSentHint] = useState('');
|
||
const [bindMode, setBindMode] = useState(initialBindMode);
|
||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
||
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
|
||
|
||
useEffect(() => {
|
||
request<ClientRuntimeConfig>('/common/client-config')
|
||
.then((config) => {
|
||
setWxAuthorize(isWxAuthorizeEnabled(config));
|
||
setLogoWideUrl(applyBrandFromClientConfig(config).brandLogoWideUrl);
|
||
})
|
||
.catch(() => {
|
||
setWxAuthorize(true);
|
||
void loadBrandAssets().then((b) => setLogoWideUrl(b.brandLogoWideUrl));
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isLoggedIn()) {
|
||
setCompleteMode(null);
|
||
return;
|
||
}
|
||
if (!needPhone && !needWechat) {
|
||
setCompleteMode(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
fetchUserProfile()
|
||
.then((me) => {
|
||
if (cancelled) return;
|
||
if (needPhone && !me.phoneVerified) {
|
||
setCompleteMode('phone');
|
||
setShowSmsForm(true);
|
||
return;
|
||
}
|
||
if (needWechat && !me.hasWechat) {
|
||
setCompleteMode('wechat');
|
||
return;
|
||
}
|
||
finishLoginNavigate(returnTo);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setCompleteMode(null);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [needPhone, needWechat, returnTo]);
|
||
|
||
useEffect(() => {
|
||
if (cooldown <= 0) return;
|
||
const timer = setTimeout(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
|
||
return () => clearTimeout(timer);
|
||
}, [cooldown]);
|
||
|
||
function ensureAgreed() {
|
||
if (!agreed) {
|
||
setMsg('请先阅读并勾选同意《用户服务协议》和《隐私政策》');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function cancelLogin() {
|
||
const pages = Taro.getCurrentPages();
|
||
if (pages.length > 1) {
|
||
Taro.navigateBack().catch(() => {
|
||
Taro.switchTab({ url: '/pages/home/index' });
|
||
});
|
||
return;
|
||
}
|
||
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
|
||
Taro.reLaunch({ url: '/pages/home/index' });
|
||
});
|
||
}
|
||
|
||
function applySessionAndLeave(
|
||
data: SessionPayload | WechatLoginResult,
|
||
phoneValue?: string,
|
||
wxInfo?: MiniWechatProfile | null,
|
||
successToast = '登录成功',
|
||
) {
|
||
if (!data.accessToken) return;
|
||
if (phoneValue) saveUserPhone(phoneValue);
|
||
saveAuth({
|
||
accessToken: data.accessToken,
|
||
refreshToken: data.refreshToken,
|
||
});
|
||
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
||
void touchStoredPromoAfterLogin();
|
||
if (!phoneValue) {
|
||
void fetchUserProfile()
|
||
.then((me) => resolveDefaultUserPhone(me))
|
||
.catch(() => {});
|
||
}
|
||
toast(successToast, 'success');
|
||
if (data.accountMerged) {
|
||
forceReloadAfterAccountMerge(returnTo);
|
||
return;
|
||
}
|
||
finishLoginNavigate(returnTo);
|
||
}
|
||
|
||
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
|
||
if (result.accessToken) {
|
||
applySessionAndLeave(result, undefined, wxInfo);
|
||
return;
|
||
}
|
||
if (result.needBindPhone && result.wxSessionKey) {
|
||
setBindMode(true);
|
||
setWxSessionKey(result.wxSessionKey);
|
||
setShowSmsForm(true);
|
||
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
|
||
setSentHint('');
|
||
return;
|
||
}
|
||
setMsg('登录未完成,请重试或使用手机号登录');
|
||
}
|
||
|
||
async function onPhoneQuickLogin(phoneCode: string) {
|
||
if (!ensureAgreed()) return;
|
||
setPhoneQuickLoading(true);
|
||
setMsg('');
|
||
setSentHint('');
|
||
try {
|
||
let loginCode: string | undefined;
|
||
try {
|
||
const loginRes = await Taro.login();
|
||
loginCode = loginRes.code || undefined;
|
||
} catch {
|
||
/* openId 绑定失败不阻断手机号登录 */
|
||
}
|
||
const data = await request<WechatLoginResult>('/auth/login/wechat-phone', {
|
||
method: 'POST',
|
||
data: {
|
||
phoneCode,
|
||
...(loginCode ? { loginCode } : {}),
|
||
platform: 'mini',
|
||
},
|
||
});
|
||
if (!data?.accessToken) {
|
||
setMsg('登录成功但未返回令牌,请重试');
|
||
return;
|
||
}
|
||
const profilePhone =
|
||
typeof data.user === 'object' && data.user && 'phone' in data.user
|
||
? String((data.user as { phone?: string }).phone || '')
|
||
: '';
|
||
applySessionAndLeave(data, profilePhone || undefined);
|
||
} catch (e) {
|
||
setMsg(e instanceof Error ? e.message : '手机号快捷登录失败');
|
||
} finally {
|
||
setPhoneQuickLoading(false);
|
||
}
|
||
}
|
||
|
||
async function onSendCode() {
|
||
if (!ensureAgreed()) return;
|
||
if (cooldown > 0 || sending) return;
|
||
const normalized = phone.trim();
|
||
if (!isValidPhone(normalized)) {
|
||
setMsg('请输入正确的手机号');
|
||
return;
|
||
}
|
||
setMsg('');
|
||
setSentHint('');
|
||
setSending(true);
|
||
try {
|
||
const scene =
|
||
bindMode || completeMode === 'phone' ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN;
|
||
await request('/auth/sms/send', {
|
||
method: 'POST',
|
||
data: { phone: normalized, scene },
|
||
});
|
||
setCooldown(60);
|
||
setSentHint('验证码已发送');
|
||
} catch (e) {
|
||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
async function login() {
|
||
if (!ensureAgreed()) return;
|
||
const normalized = phone.trim();
|
||
if (!isValidPhone(normalized)) {
|
||
setMsg('请输入正确的手机号');
|
||
return;
|
||
}
|
||
if (!code.trim()) {
|
||
setMsg('请输入验证码');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setMsg('');
|
||
setSentHint('');
|
||
try {
|
||
if (bindMode && wxSessionKey) {
|
||
const data = await request<WechatLoginResult>('/auth/wechat/bind-phone', {
|
||
method: 'POST',
|
||
data: { wxSessionKey, phone: normalized, code: code.trim() },
|
||
});
|
||
handleWechatLoginResult(data);
|
||
saveUserPhone(normalized);
|
||
return;
|
||
}
|
||
if (completeMode === 'phone' && isLoggedIn()) {
|
||
const data = await request<SessionPayload>('/auth/phone/bind', {
|
||
method: 'POST',
|
||
data: { phone: normalized, code: code.trim() },
|
||
});
|
||
if (!data?.accessToken) {
|
||
setMsg('手机号验证成功但会话未返回,请重新登录');
|
||
return;
|
||
}
|
||
applySessionAndLeave(data, normalized, null, '手机号验证成功');
|
||
return;
|
||
}
|
||
const data = await request<SessionPayload>('/auth/login/sms', {
|
||
method: 'POST',
|
||
data: { phone: normalized, code: code.trim() },
|
||
});
|
||
if (!data?.accessToken) {
|
||
setMsg('登录成功但未返回令牌,请重试');
|
||
return;
|
||
}
|
||
applySessionAndLeave(data, normalized);
|
||
} catch (e) {
|
||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function wechatLogin() {
|
||
if (!ensureAgreed()) return;
|
||
setMsg('');
|
||
setSentHint('');
|
||
setWxLoading(true);
|
||
try {
|
||
const wxInfo = getCachedWxProfile();
|
||
|
||
if (completeMode === 'wechat' && isLoggedIn()) {
|
||
const result = await bindWechatForUser(wxInfo);
|
||
if (!result.ok && 'redirecting' in result && result.redirecting) {
|
||
return;
|
||
}
|
||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||
setBindMode(true);
|
||
setWxSessionKey(result.wxSessionKey);
|
||
setCompleteMode('phone');
|
||
setShowSmsForm(true);
|
||
setMsg('请绑定手机号完成认证');
|
||
return;
|
||
}
|
||
if (result.ok) {
|
||
if (wxInfo) await syncMiniWechatProfile(wxInfo);
|
||
toast('授权成功', 'success');
|
||
finishLoginNavigate(returnTo);
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
const result = await loginWithWechat();
|
||
if (result) handleWechatLoginResult(result, wxInfo);
|
||
} catch (e) {
|
||
const raw = e instanceof Error ? e.message : '授权登录失败';
|
||
const hint = /invalid code/i.test(raw)
|
||
? process.env.TARO_ENV === 'weapp'
|
||
? '授权失败:请确认后端小程序 AppID 配置正确'
|
||
: '授权失败:请确认公众号网页授权域名配置正确'
|
||
: raw;
|
||
setMsg(hint);
|
||
} finally {
|
||
setWxLoading(false);
|
||
}
|
||
}
|
||
|
||
const displayMsg = msg || sentHint;
|
||
const codeDisabled = cooldown > 0 || sending;
|
||
const showAuthLogin =
|
||
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) &&
|
||
(IS_WEAPP || wxAuthorize);
|
||
const showPhoneQuick =
|
||
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
|
||
const cardTitle =
|
||
completeMode === 'phone'
|
||
? '验证手机号'
|
||
: bindMode
|
||
? '绑定手机号'
|
||
: completeMode === 'wechat'
|
||
? '授权登录'
|
||
: '手机号快捷登录';
|
||
|
||
return (
|
||
<PageShell variant="plain" className="login-page">
|
||
<View className="login-nav">
|
||
<View className="login-nav-back" onClick={cancelLogin}>
|
||
<Text className="login-nav-back-icon">‹</Text>
|
||
<Text>返回</Text>
|
||
</View>
|
||
</View>
|
||
<View className="login-header">
|
||
<View className="login-logo-wrap">
|
||
<View className="login-logo">
|
||
<Image className="login-logo-img" src={logoWideUrl} mode="aspectFit" />
|
||
</View>
|
||
<Text className="login-logo-badge">官方</Text>
|
||
</View>
|
||
<View className="login-welcome">
|
||
<Text className="login-welcome-title">
|
||
{completeMode === 'phone'
|
||
? '建议绑定手机号'
|
||
: completeMode === 'wechat'
|
||
? '完成授权登录'
|
||
: '欢迎来到杜康好客'}
|
||
</Text>
|
||
<Text className="login-welcome-sub">
|
||
{completeMode === 'phone'
|
||
? '便于订单通知与售后,也可稍后绑定'
|
||
: completeMode === 'wechat'
|
||
? '完成后将返回继续支付'
|
||
: '买美酒,享好礼'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View className="login-main">
|
||
{completeMode === 'wechat' ? (
|
||
<View className="login-card">
|
||
<Text className="login-card-title">授权登录</Text>
|
||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||
使用支付功能前需完成授权登录
|
||
</Text>
|
||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||
{displayMsg ? (
|
||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||
{displayMsg}
|
||
</Text>
|
||
) : null}
|
||
{showAuthLogin ? (
|
||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||
) : null}
|
||
</View>
|
||
) : (
|
||
<View className="login-card">
|
||
<Text className="login-card-title">{cardTitle}</Text>
|
||
|
||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||
|
||
{showPhoneQuick ? (
|
||
<PhoneQuickLoginButton
|
||
loading={phoneQuickLoading}
|
||
agreed={agreed}
|
||
onRequireAgree={() => ensureAgreed()}
|
||
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||
onFail={(message) => setMsg(message)}
|
||
/>
|
||
) : null}
|
||
|
||
{showPhoneQuick ? (
|
||
<View className="login-divider" style={{ marginTop: 20 }}>
|
||
<View className="login-divider-line" />
|
||
<Text
|
||
className="login-divider-text"
|
||
onClick={() => setShowSmsForm((v) => !v)}
|
||
>
|
||
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
||
</Text>
|
||
<View className="login-divider-line" />
|
||
</View>
|
||
) : null}
|
||
|
||
{(showSmsForm || !showPhoneQuick) && (
|
||
<>
|
||
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
|
||
<Text className="login-field-prefix">+86</Text>
|
||
<Input
|
||
className="login-field-input"
|
||
type="number"
|
||
maxlength={11}
|
||
placeholder="请输入手机号"
|
||
value={phone}
|
||
onInput={(e) => {
|
||
setPhone(normalizePhone(e.detail.value));
|
||
setMsg('');
|
||
setSentHint('');
|
||
}}
|
||
/>
|
||
</View>
|
||
|
||
<View className="login-field">
|
||
<Input
|
||
className="login-field-input"
|
||
type="number"
|
||
maxlength={6}
|
||
placeholder="请输入验证码"
|
||
value={code}
|
||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||
/>
|
||
<Text
|
||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||
onClick={() => void onSendCode()}
|
||
>
|
||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||
</Text>
|
||
</View>
|
||
|
||
<View
|
||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||
onClick={loading ? undefined : () => void login()}
|
||
>
|
||
<Text className="login-sms-btn__text">
|
||
{loading
|
||
? '处理中...'
|
||
: completeMode === 'phone'
|
||
? '完成验证'
|
||
: bindMode
|
||
? '绑定并登录'
|
||
: '验证码登录'}
|
||
</Text>
|
||
</View>
|
||
</>
|
||
)}
|
||
|
||
{displayMsg ? (
|
||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||
{displayMsg}
|
||
</Text>
|
||
) : null}
|
||
|
||
{completeMode === 'phone' ? (
|
||
<View
|
||
className="login-skip-bind"
|
||
onClick={() => finishLoginNavigate(returnTo)}
|
||
style={{ marginTop: 12, textAlign: 'center' }}
|
||
>
|
||
<Text className="u-muted" style={{ fontSize: 14 }}>
|
||
暂不绑定,继续下单
|
||
</Text>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
)}
|
||
|
||
{showAuthLogin && completeMode !== 'wechat' ? (
|
||
<>
|
||
<View className="login-divider">
|
||
<View className="login-divider-line" />
|
||
<Text className="login-divider-text">或者</Text>
|
||
<View className="login-divider-line" />
|
||
</View>
|
||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||
</>
|
||
) : null}
|
||
|
||
<View className="login-cancel-btn" onClick={cancelLogin}>
|
||
<Text>暂不登录,继续浏览</Text>
|
||
</View>
|
||
<Text className="login-cancel-hint">无需登录也可浏览商品和门店</Text>
|
||
</View>
|
||
</PageShell>
|
||
);
|
||
}
|