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 React from 'react';
|
||||||
import { sendCode, phoneLogin } from '@/services/auth';
|
import { sendCode, phoneLogin } from '@/services/auth';
|
||||||
|
import MpQrLogin from '@/components/MpQrLogin';
|
||||||
import '@/styles/auth.css';
|
import '@/styles/auth.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录 / 注册墙(AuthGate)· 手机号 + 短信验证码
|
* 登录 / 注册墙(AuthGate)· 手机号 + 短信验证码 / 小程序扫码登录
|
||||||
* 用于"测评结果需登录"等场景。手机号即账号,未注册自动注册,无密码。
|
* 用于"测评结果需登录"等场景。手机号即账号,未注册自动注册,无密码;小程序扫码为统一微信登录。
|
||||||
* props:
|
* props:
|
||||||
* title — 卡片标题(覆盖默认)
|
* title — 卡片标题(覆盖默认)
|
||||||
* subtitle — 一句话说明
|
* subtitle — 一句话说明
|
||||||
* onAuthed(user) — 登录 / 注册成功回调(已写入 token 与用户信息)
|
* onAuthed(user) — 登录 / 注册成功回调(已写入 token 与用户信息)
|
||||||
*/
|
*/
|
||||||
export default function AuthGate({ title, subtitle, onAuthed }) {
|
export default function AuthGate({ title, subtitle, onAuthed }) {
|
||||||
|
const [mode, setMode] = React.useState('phone'); // phone | mp
|
||||||
const [phone, setPhone] = React.useState('');
|
const [phone, setPhone] = React.useState('');
|
||||||
const [code, setCode] = React.useState('');
|
const [code, setCode] = React.useState('');
|
||||||
const [debug, setDebug] = React.useState('');
|
const [debug, setDebug] = React.useState('');
|
||||||
const [err, setErr] = React.useState('');
|
const [err, setErr] = React.useState('');
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const [cd, setCd] = React.useState(0); // 发送倒计时
|
const [cd, setCd] = React.useState(0);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!/^1\d{10}$/.test(phone)) { setErr('请输入正确的 11 位手机号'); return; }
|
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 (
|
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>
|
<div className="auth-logo"><i className="fa-solid fa-tree" /></div>
|
||||||
<h1>{title || '手机号登录 / 注册'}</h1>
|
<h1>{title || '登录 / 注册'}</h1>
|
||||||
{subtitle && <p className="auth-sub">{subtitle}</p>}
|
{subtitle && <p className="auth-sub">{subtitle}</p>}
|
||||||
|
|
||||||
<div className="field">
|
<div className="auth-tabs" style={{ marginBottom: 18 }}>
|
||||||
<label>手机号</label>
|
{TABS.map(([k, l]) => (
|
||||||
<input
|
<button type="button" key={k} className={`auth-tab${mode === k ? ' active' : ''}`} onClick={() => { setErr(''); setMode(k); }}>
|
||||||
value={phone}
|
{l}
|
||||||
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>
|
</button>
|
||||||
</div>
|
))}
|
||||||
</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>}
|
{mode === 'phone' ? (
|
||||||
{err && <div className="auth-err">{err}</div>}
|
<form onSubmit={submit}>
|
||||||
<button className="btn btn-cta auth-btn" disabled={busy}>{busy ? '处理中…' : '登录 / 注册'}</button>
|
<div className="field">
|
||||||
<div className="auth-foot">未注册的手机号将自动注册为账号</div>
|
<label>手机号</label>
|
||||||
</form>
|
<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;
|
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; }
|
.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