feat(auth): 网站登录页支持四种登录(账号/短信/微信扫码/小程序扫码)

- Login.jsx 改四方式Tab,PollingQr 用 @rc-component/qrcode 渲染+轮询
- auth.js 加 wxQrStart/wxQrPoll/mpQrStart/mpQrPoll/applyAuthResult;auth.css 加 tab/code-row 样式
- package.json 加 @rc-component/qrcode(锁文件只提交 npm package-lock.json)
This commit is contained in:
Pine
2026-08-25 22:47:46 +08:00
parent 18a2ad04f7
commit b05a606df8
5 changed files with 210 additions and 35 deletions
+26
View File
@@ -8,6 +8,7 @@
"name": "opc-training-web",
"version": "0.1.0",
"dependencies": {
"@rc-component/qrcode": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -250,6 +251,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
@@ -759,6 +769,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@rc-component/qrcode": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-2.0.0.tgz",
"integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.7"
},
"engines": {
"node": ">=8.x"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@rc-component/qrcode": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
+153 -35
View File
@@ -1,50 +1,168 @@
import React from 'react';
import { login } from '@/services/auth';
import { QRCodeSVG } from '@rc-component/qrcode';
import { login, phoneLogin, sendCode, wxQrStart, wxQrPoll, mpQrStart, mpQrPoll, applyAuthResult } from '@/services/auth';
import '@/styles/auth.css';
/**
* 登录门(内部工具 /pine 守卫)
* 不进任何导航/按钮,仅由访问 /pine 路由且未登录时触发。
* props.target:登录成功后要回到的内部路径(如 'pine/tools')。
* 支持四种登录:账号 / 短信验证码 / 微信扫码 / 小程序扫码。
*/
export default function Login({ target = 'pine' }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
/** 二维码轮询登录(微信扫码 / 小程序扫码共用) */
function PollingQr({ start, poll, tip, onResult }) {
const [qrUrl, setQrUrl] = React.useState('');
const [status, setStatus] = React.useState('loading');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = async (e) => {
e.preventDefault();
setBusy(true);
setError('');
try {
await login(username.trim(), password);
window.location.hash = '#/' + target;
} catch (err) {
setError(err.message || '登录失败');
} finally {
setBusy(false);
}
};
React.useEffect(() => {
let timer = null;
(async () => {
setStatus('loading');
try {
const { scene, qr_url } = await start();
setQrUrl(qr_url);
setStatus('pending');
timer = window.setInterval(async () => {
const res = await poll(scene);
if (res.status === 'done' && res.profile) {
window.clearInterval(timer);
onResult(res.profile);
} else if (res.status === 'expired') {
window.clearInterval(timer);
setStatus('error');
setError('二维码已过期,请刷新');
}
}, 2000);
} catch (err) {
setStatus('error');
setError(err.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 className="page-auth">
<form className="auth-card" onSubmit={submit}>
<div className="auth-logo"><i className="fa-solid fa-tree" /></div>
<h1>内部工具</h1>
<p className="auth-sub">受限区域 · 需授权访问</p>
<div className="field">
<label>账号</label>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" placeholder="请输入账号" />
</div>
<div className="field">
<label>密码</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" placeholder="请输入密码" />
</div>
{error && <div className="auth-err">{error}</div>}
<button className="btn btn-cta auth-btn" disabled={busy}>{busy ? '登录中…' : '登 录'}</button>
<div className="auth-foot">仅限内部运营使用 · 请勿转发</div>
</form>
<div style={{ textAlign: 'center' }}>
<div style={{ display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}>
<QRCodeSVG value={qrUrl} size={180} />
</div>
<p className="auth-sub">{tip}</p>
</div>
);
}
export default function Login({ target = 'pine' }) {
const [mode, setMode] = React.useState('account');
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [phone, setPhone] = React.useState('');
const [smsCode, setSmsCode] = React.useState('');
const [smsCountdown, setSmsCountdown] = React.useState(0);
const [smsStub, setSmsStub] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const go = () => { window.location.hash = '#/' + target; };
async function onAccount(e) {
e.preventDefault();
setBusy(true); setError('');
try { await login(username.trim(), password); go(); }
catch (err) { setError(err.message || '登录失败'); }
finally { setBusy(false); }
}
async function onSms(e) {
e.preventDefault();
setBusy(true); setError('');
try { applyAuthResult(await phoneLogin(phone.trim(), smsCode.trim())); go(); }
catch (err) { setError(err.message || '登录失败'); }
finally { setBusy(false); }
}
async function onSendCode() {
setError('');
if (!/^1\d{10}$/.test(phone.trim())) { setError('请输入 11 位手机号'); return; }
try {
const res = await sendCode(phone.trim());
setSmsStub(res.debugCode || '');
setSmsCountdown(60);
const timer = window.setInterval(() => {
setSmsCountdown((c) => { if (c <= 1) { window.clearInterval(timer); return 0; } return c - 1; });
}, 1000);
} catch (err) { setError(err.message || '发送失败'); }
}
function onQrResult(profile) { applyAuthResult(profile); go(); }
return (
<div className="page-auth">
<div className="auth-card">
<div className="auth-logo"><i className="fa-solid fa-tree" /></div>
<h1>内部工具</h1>
<p className="auth-sub">受限区域 · 需授权访问</p>
<div className="auth-tabs">
{[['account', '账号'], ['sms', '短信'], ['wechat', '微信扫码'], ['miniprogram', '小程序']].map(([k, label]) => (
<button key={k} type="button"
className={'auth-tab' + (mode === k ? ' active' : '')}
onClick={() => { setMode(k); setError(''); }}>
{label}
</button>
))}
</div>
{mode === 'account' && (
<form onSubmit={onAccount}>
<div className="field">
<label>账号</label>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" placeholder="请输入账号" />
</div>
<div className="field">
<label>密码</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" placeholder="请输入密码" />
</div>
{error && <div className="auth-err">{error}</div>}
<button className="btn btn-cta auth-btn" disabled={busy}>{busy ? '登录中…' : '登 录'}</button>
</form>
)}
{mode === 'sms' && (
<form onSubmit={onSms}>
<div className="field">
<label>手机号</label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="请输入 11 位手机号" />
</div>
<div className="field">
<label>{smsStub ? `验证码(演示:${smsStub}` : '验证码'}</label>
<div className="auth-code-row">
<input value={smsCode} onChange={(e) => setSmsCode(e.target.value)} placeholder="请输入验证码" />
<button type="button" className="btn btn-dark" disabled={smsCountdown > 0} onClick={onSendCode}>
{smsCountdown > 0 ? `${smsCountdown}s` : '发送验证码'}
</button>
</div>
</div>
{error && <div className="auth-err">{error}</div>}
<button className="btn btn-cta auth-btn" disabled={busy}>{busy ? '登录中…' : '登 录'}</button>
</form>
)}
{mode === 'wechat' && <PollingQr start={wxQrStart} poll={wxQrPoll} tip="使用微信扫一扫,在手机上确认登录" onResult={onQrResult} />}
{mode === 'miniprogram' && <PollingQr start={mpQrStart} poll={mpQrPoll} tip="使用微信扫一扫打开小程序,在小程序内确认登录" onResult={onQrResult} />}
<div className="auth-foot">仅限内部运营使用 · 请勿转发</div>
</div>
</div>
);
}
+19
View File
@@ -66,6 +66,25 @@ export async function login(username, password) {
return data;
}
/* ---------------- 微信扫码 / 小程序扫码登录 ---------------- */
export async function wxQrStart() {
return req('/auth/wx-qr/start', { method: 'GET' });
}
export async function wxQrPoll(scene) {
return req(`/auth/wx-qr/poll?scene=${encodeURIComponent(scene)}`, { method: 'GET' });
}
export async function mpQrStart() {
return req('/auth/mp-qr/start', { method: 'GET' });
}
export async function mpQrPoll(scene) {
return req(`/auth/mp-qr/poll?scene=${encodeURIComponent(scene)}`, { method: 'GET' });
}
/** 扫码/短信登录成功:落 session 后返回完整平台 profile */
export function applyAuthResult(data) {
if (data && data.token) saveSession(data);
return data;
}
export function logout() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
+11
View File
@@ -35,6 +35,17 @@
.auth-btn { width: 100%; margin-top: 6px; }
.auth-foot { font-size: 11.5px; color: #5b5b5b; margin-top: 16px; }
/* 四种登录:方式切换 + 验证码行 */
.auth-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 4px; border-radius: 12px; background: #161618; border: 1px solid #2a2a2d; }
.auth-tabs .auth-tab {
flex: 1; padding: 9px 0; border: 0; background: transparent; color: #9b9b9b;
font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; border-radius: 9px;
}
.auth-tabs .auth-tab.active { background: #fff; color: #111; }
.auth-code-row { display: flex; gap: 8px; align-items: center; }
.auth-code-row input { flex: 1; }
.auth-code-row .btn { white-space: nowrap; }
/* 内部工具索引 */
.pine-intro { max-width: 680px; color: #c4c2c3; font-size: 14px; line-height: 1.8; margin-bottom: 4px; }
.pine-intro b { color: #fff; }