feat(web): 公开登录墙 AuthGate 支持「小程序扫码登录」(完整微信登录)

- 新增 components/MpQrLogin.jsx:小程序码轮询(mpQrStart/mpQrPoll) → applyAuthResult(profile) 登录
- AuthGate 改「手机号登录 / 小程序扫码」双 tab;小程序扫码已在微信小程序登录过的账号可一键登录网页端
- auth.css 加 .auth-debug 样式
This commit is contained in:
Pine
2026-08-27 20:36:27 +08:00
parent 67f66abbc4
commit 2251eb36a8
3 changed files with 105 additions and 36 deletions
+42 -36
View File
@@ -1,22 +1,24 @@
import React from 'react';
import { sendCode, phoneLogin } from '@/services/auth';
import MpQrLogin from '@/components/MpQrLogin';
import '@/styles/auth.css';
/**
* 登录 / 注册墙(AuthGate)· 手机号 + 短信验证码
* 用于"测评结果需登录"等场景。手机号即账号,未注册自动注册,无密码。
* 登录 / 注册墙(AuthGate)· 手机号 + 短信验证码 / 小程序扫码登录
* 用于"测评结果需登录"等场景。手机号即账号,未注册自动注册,无密码;小程序扫码为统一微信登录
* props:
* title — 卡片标题(覆盖默认)
* subtitle — 一句话说明
* onAuthed(user) — 登录 / 注册成功回调(已写入 token 与用户信息)
*/
export default function AuthGate({ title, subtitle, onAuthed }) {
const [mode, setMode] = React.useState('phone'); // phone | mp
const [phone, setPhone] = React.useState('');
const [code, setCode] = React.useState('');
const [debug, setDebug] = React.useState('');
const [err, setErr] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [cd, setCd] = React.useState(0); // 发送倒计时
const [cd, setCd] = React.useState(0);
const send = async () => {
if (!/^1\d{10}$/.test(phone)) { setErr('请输入正确的 11 位手机号'); return; }
@@ -50,45 +52,49 @@ export default function AuthGate({ title, subtitle, onAuthed }) {
}
};
const TABS = [['phone', '手机号登录'], ['mp', '小程序扫码']];
return (
<form className="auth-card" style={{ margin: '0 auto' }} onSubmit={submit}>
<div className="auth-card" style={{ margin: '0 auto' }}>
<div className="auth-logo"><i className="fa-solid fa-tree" /></div>
<h1>{title || '手机号登录 / 注册'}</h1>
<h1>{title || '登录 / 注册'}</h1>
{subtitle && <p className="auth-sub">{subtitle}</p>}
<div className="field">
<label>手机号</label>
<input
value={phone}
onChange={(e) => setPhone(e.target.value)}
inputMode="numeric"
maxLength={11}
placeholder="11 位手机号"
autoComplete="tel"
/>
</div>
<div className="field">
<label>验证码</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
maxLength={6}
placeholder="6 位验证码"
style={{ flex: 1 }}
autoComplete="one-time-code"
/>
<button type="button" className="btn btn-dark" onClick={send} disabled={cd > 0 || busy} style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{cd > 0 ? `${cd}s 后重发` : '发送验证码'}
<div className="auth-tabs" style={{ marginBottom: 18 }}>
{TABS.map(([k, l]) => (
<button type="button" key={k} className={`auth-tab${mode === k ? ' active' : ''}`} onClick={() => { setErr(''); setMode(k); }}>
{l}
</button>
</div>
))}
</div>
{debug && <div style={{ margin: '6px 0', fontSize: 12, color: '#a8f0c8', background: 'rgba(120,220,160,.1)', borderRadius: 8, padding: '6px 10px' }}>{debug}</div>}
{err && <div className="auth-err">{err}</div>}
<button className="btn btn-cta auth-btn" disabled={busy}>{busy ? '处理中…' : '登录 / 注册'}</button>
<div className="auth-foot">未注册的手机号将自动注册为账</div>
</form>
{mode === 'phone' ? (
<form onSubmit={submit}>
<div className="field">
<label>手机</label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} inputMode="numeric" maxLength={11} placeholder="11 位手机号" autoComplete="tel" />
</div>
<div className="field">
<label>验证码</label>
<div style={{ display: 'flex', gap: 8 }}>
<input value={code} onChange={(e) => setCode(e.target.value)} inputMode="numeric" maxLength={6} placeholder="6 位验证码" style={{ flex: 1 }} autoComplete="one-time-code" />
<button type="button" className="btn btn-dark" onClick={send} disabled={cd > 0 || busy} style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{cd > 0 ? `${cd}s 后重发` : '发送验证码'}
</button>
</div>
</div>
{debug && <div className="auth-debug">{debug}</div>}
{err && <div className="auth-err">{err}</div>}
<button className="btn btn-cta auth-btn" disabled={busy}>{busy ? '处理中…' : '登录 / 注册'}</button>
<div className="auth-foot">未注册的手机号将自动注册为账号</div>
</form>
) : (
<div>
<MpQrLogin onAuthed={(user) => { if (onAuthed) onAuthed(user); }} />
{err && <div className="auth-err">{err}</div>}
<div className="auth-foot">已在微信小程序登录过的账号扫码即可一键登录网页端</div>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import React from 'react';
import { QRCodeSVG } from '@rc-component/qrcode';
import { mpQrStart, mpQrPoll, applyAuthResult, getUser } from '@/services/auth';
/** 小程序扫码登录:微信扫小程序码 → 打开小程序确认 → web 轮询拿 token。
* props: tip, onAuthed(user) — 登录成功回调(已写入 token 与用户信息)。 */
export default function MpQrLogin({ tip, onAuthed }) {
const [qrImage, setQrImage] = React.useState('');
const [qrUrl, setQrUrl] = React.useState('');
const [status, setStatus] = React.useState('loading');
const [error, setError] = React.useState('');
React.useEffect(() => {
let timer = null;
(async () => {
setStatus('loading');
try {
const r = await mpQrStart();
const { scene, qr_image, qr_url } = r || {};
setQrImage(qr_image || '');
setQrUrl(qr_url || '');
setStatus('pending');
timer = window.setInterval(async () => {
const res = await mpQrPoll(scene);
if (res.status === 'done' && res.profile) {
window.clearInterval(timer);
applyAuthResult(res.profile);
if (onAuthed) onAuthed(getUser());
} else if (res.status === 'expired') {
window.clearInterval(timer);
setStatus('error');
setError('二维码已过期,请刷新');
}
}, 2000);
} catch (e) {
setStatus('error');
setError(e.message || '获取小程序码失败');
}
})();
return () => { if (timer) window.clearInterval(timer); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (status === 'loading') return <div className="auth-err" style={{ textAlign: 'center' }}>加载中</div>;
if (status === 'error')
return (
<div style={{ textAlign: 'center' }}>
<div className="auth-err">{error}</div>
<button type="button" className="btn btn-dark auth-btn" onClick={() => window.location.reload()}>点击刷新</button>
</div>
);
return (
<div style={{ textAlign: 'center' }}>
<div style={{ display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}>
{qrImage ? <img src={qrImage} alt="小程序码" style={{ width: 200, height: 200 }} /> : <QRCodeSVG value={qrUrl} size={200} />}
</div>
<p className="auth-sub">{tip || '使用微信扫一扫打开小程序,在小程序内确认登录'}</p>
</div>
);
}
+3
View File
@@ -56,3 +56,6 @@
background: var(--panel); border: 1px dashed var(--line); border-radius: 12px; padding: 13px 16px;
}
.pine-note .logout-link { margin-left: 8px; color: var(--primary); cursor: pointer; text-decoration: underline; }
/* 登录调试信息(演示验证码提示) */
.auth-debug { margin: 6px 0; font-size: 12px; color: var(--success); background: var(--success-soft); border-radius: 8px; padding: 6px 10px; }