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:
Generated
+26
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user