feat(web): 公开登录墙 AuthGate 支持「小程序扫码登录」(完整微信登录)
- 新增 components/MpQrLogin.jsx:小程序码轮询(mpQrStart/mpQrPoll) → applyAuthResult(profile) 登录 - AuthGate 改「手机号登录 / 小程序扫码」双 tab;小程序扫码已在微信小程序登录过的账号可一键登录网页端 - auth.css 加 .auth-debug 样式
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user