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>
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import { useEffect, useRef, useState, type RefObject } from 'react';
|
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import AppImage from '@dukang/shared-ui/AppImage';
|
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
|
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
|
import { routeAfterShopLogin } from './SelectStorePage';
|
|
import {
|
|
bindShopWechatAfterSmsLogin,
|
|
fetchClientConfig,
|
|
handleShopWechatCallback,
|
|
handleShopWechatLoginResult,
|
|
loginShopWithWechat,
|
|
} from '../lib/wechat-auth';
|
|
import { isWechatEnv } from '../lib/weixin';
|
|
|
|
function maskPhone(phone: string) {
|
|
if (phone.length < 7) return phone;
|
|
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
|
}
|
|
|
|
function formatWechatError(e: unknown): string {
|
|
const text = e instanceof Error ? e.message : '微信登录失败';
|
|
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
|
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function ShopAgreementCheckbox({
|
|
agreed,
|
|
onChange,
|
|
labelRef,
|
|
}: {
|
|
agreed: boolean;
|
|
onChange: (next: boolean) => void;
|
|
labelRef?: RefObject<HTMLLabelElement | null>;
|
|
}) {
|
|
return (
|
|
<label className="shop-login-agreement" ref={labelRef}>
|
|
<input
|
|
type="checkbox"
|
|
checked={agreed}
|
|
onChange={(e) => onChange(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>
|
|
);
|
|
}
|
|
|
|
export default function LoginPage() {
|
|
const navigate = useNavigate();
|
|
const { applySession } = useStoreSession();
|
|
const [params, setSearchParams] = useSearchParams();
|
|
const quick = params.get('quick') === '1';
|
|
const savedProfile = getStoreProfile();
|
|
const [phone, setPhone] = useState(getLastPhone());
|
|
const [code, setCode] = useState('');
|
|
const [agreed, setAgreed] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [wxLoading, setWxLoading] = useState(false);
|
|
const [msg, setMsg] = useState('');
|
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
|
|
|
useEffect(() => {
|
|
fetchClientConfig()
|
|
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
|
.catch(() => setWxAuthorize(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
|
void handleShopWechatCallback()
|
|
.then((result) => {
|
|
if (!result) return;
|
|
const session = handleShopWechatLoginResult(result);
|
|
if (session) {
|
|
applySession(session);
|
|
stripOAuthParamsFromLocation();
|
|
setSearchParams({}, { replace: true });
|
|
routeAfterShopLogin(session, navigate);
|
|
}
|
|
})
|
|
.catch((e) => setMsg(formatWechatError(e)));
|
|
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
|
|
|
|
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
|
const quickPhone = savedProfile?.phone || phone;
|
|
|
|
function ensureAgreed() {
|
|
if (!agreed) {
|
|
setMsg('请先阅读并同意用户协议');
|
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function sendCode() {
|
|
if (!ensureAgreed()) return;
|
|
setMsg('');
|
|
try {
|
|
await request('SHOP_H5', '/shop/auth/sms/send', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
|
});
|
|
setMsg('验证码已发送');
|
|
setCodeCooldown(60);
|
|
const timer = setInterval(() => {
|
|
setCodeCooldown((c) => {
|
|
if (c <= 1) {
|
|
clearInterval(timer);
|
|
return 0;
|
|
}
|
|
return c - 1;
|
|
});
|
|
}, 1000);
|
|
} catch (e) {
|
|
setMsg(e instanceof Error ? e.message : '发送失败');
|
|
}
|
|
}
|
|
|
|
async function login() {
|
|
if (!ensureAgreed()) return;
|
|
setLoading(true);
|
|
setMsg('');
|
|
try {
|
|
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ phone, code }),
|
|
});
|
|
saveAuth(data);
|
|
applySession(data);
|
|
if (isWechatEnv() && wxAuthorize) {
|
|
setMsg('登录成功,正在关联微信…');
|
|
await bindShopWechatAfterSmsLogin();
|
|
return;
|
|
}
|
|
routeAfterShopLogin(data, navigate);
|
|
} catch (e) {
|
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function wechatLogin() {
|
|
if (!ensureAgreed()) return;
|
|
setMsg('');
|
|
if (!isWechatEnv()) {
|
|
setMsg('请在微信内打开以使用微信一键登录');
|
|
return;
|
|
}
|
|
setWxLoading(true);
|
|
try {
|
|
const session = await loginShopWithWechat();
|
|
if (session) {
|
|
applySession(session);
|
|
routeAfterShopLogin(session, navigate);
|
|
}
|
|
} catch (e) {
|
|
setMsg(formatWechatError(e));
|
|
} finally {
|
|
setWxLoading(false);
|
|
}
|
|
}
|
|
|
|
if (quick) {
|
|
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
|
|
|
|
return (
|
|
<div className="shop-quick-login-page">
|
|
<header className="shop-quick-header">
|
|
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
|
|
<h1 className="shop-quick-welcome">欢迎回来</h1>
|
|
<div className="shop-quick-welcome-line" />
|
|
</header>
|
|
|
|
<section className="shop-quick-store-card">
|
|
<div className="shop-quick-store-inner">
|
|
<div className="shop-quick-store-icon">
|
|
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
|
</div>
|
|
<div>
|
|
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
|
|
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
|
|
</div>
|
|
<span className="shop-quick-verified">
|
|
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
|
认证门店
|
|
</span>
|
|
<div className="shop-quick-switch">
|
|
<Link to="/login">
|
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
|
|
切换账号
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="shop-quick-actions">
|
|
{msg && <p className="shop-login-msg">{msg}</p>}
|
|
<ShopAgreementCheckbox
|
|
agreed={agreed}
|
|
onChange={setAgreed}
|
|
labelRef={agreementRef}
|
|
/>
|
|
{canWechatQuick ? (
|
|
<button
|
|
type="button"
|
|
className="shop-quick-login-btn shop-quick-login-btn--wechat"
|
|
disabled={wxLoading}
|
|
onClick={() => void wechatLogin()}
|
|
>
|
|
<span className="material-symbols-outlined">chat</span>
|
|
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
</button>
|
|
) : (
|
|
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
|
|
{wxAuthorize && !isWechatEnv()
|
|
? '请在微信内打开以使用一键登录'
|
|
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
|
</p>
|
|
)}
|
|
{!canWechatQuick && (
|
|
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
|
验证码登录
|
|
</Link>
|
|
)}
|
|
<div className="shop-quick-secure">
|
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
|
|
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<footer className="shop-quick-footer">
|
|
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
|
|
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 杜康酒业门店管理系统</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="shop-login-page">
|
|
<header className="shop-login-hero">
|
|
<div className="shop-login-logo-wrap">
|
|
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="app-image--fill" fit="contain" />
|
|
</div>
|
|
<h1 className="shop-login-brand">杜康好客</h1>
|
|
<p className="shop-login-tagline">门店管理系统</p>
|
|
</header>
|
|
|
|
<main className="shop-login-main">
|
|
<div className="shop-login-card">
|
|
<div className="shop-login-field">
|
|
<label htmlFor="phone">手机号码</label>
|
|
<div className="shop-login-input-wrap">
|
|
<span className="material-symbols-outlined">phone_iphone</span>
|
|
<input
|
|
id="phone"
|
|
type="tel"
|
|
maxLength={11}
|
|
placeholder="请输入您的手机号"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shop-login-field">
|
|
<label htmlFor="code">验证码</label>
|
|
<div className="shop-login-code-row">
|
|
<div className="shop-login-input-wrap">
|
|
<span className="material-symbols-outlined">shield</span>
|
|
<input
|
|
id="code"
|
|
type="text"
|
|
maxLength={6}
|
|
placeholder="验证码"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="shop-login-code-btn"
|
|
disabled={codeCooldown > 0}
|
|
onClick={sendCode}
|
|
>
|
|
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{msg && <p className="shop-login-msg">{msg}</p>}
|
|
|
|
<ShopAgreementCheckbox
|
|
agreed={agreed}
|
|
onChange={setAgreed}
|
|
labelRef={agreementRef}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
className="shop-login-submit"
|
|
disabled={loading}
|
|
onClick={() => void login()}
|
|
>
|
|
<span>{loading ? '登录中...' : '登录'}</span>
|
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
|
</button>
|
|
|
|
{wxAuthorize && (
|
|
<>
|
|
<div className="shop-login-divider">
|
|
<span className="shop-login-divider-line" />
|
|
<span className="shop-login-divider-text">或者</span>
|
|
<span className="shop-login-divider-line" />
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="shop-login-wechat"
|
|
disabled={wxLoading}
|
|
onClick={() => void wechatLogin()}
|
|
>
|
|
<span className="material-symbols-outlined">chat</span>
|
|
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<footer className="shop-login-footer">
|
|
<p className="shop-login-footer-brand">Secured by DUKANG HERITAGE</p>
|
|
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
|
security
|
|
</span>
|
|
{hasShopWxSession() && savedProfile && (
|
|
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
|
<Link to="/login?quick=1" className="text-primary body-md">微信快捷登录</Link>
|
|
</p>
|
|
)}
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|