Files
dukang/apps/mini-user/src/pages/login/index.tsx
T
jacy 233ed0af3b
CI / verify (pull_request) Has been cancelled
v3.5.1 版本更新
2026-08-19 15:54:51 +08:00

568 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}