diff --git a/website/package-lock.json b/website/package-lock.json index ec4ec70..d8ee1a2 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -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", diff --git a/website/package.json b/website/package.json index 55865d6..7b20f67 100644 --- a/website/package.json +++ b/website/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@rc-component/qrcode": "^2.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/website/src/platform/Login.jsx b/website/src/platform/Login.jsx index 3c793c9..4678117 100644 --- a/website/src/platform/Login.jsx +++ b/website/src/platform/Login.jsx @@ -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
加载中…
; + if (status === 'error') + return ( +
+
{error}
+ +
+ ); return ( -
-
-
-

内部工具

-

受限区域 · 需授权访问

-
- - setUsername(e.target.value)} autoComplete="username" placeholder="请输入账号" /> -
-
- - setPassword(e.target.value)} autoComplete="current-password" placeholder="请输入密码" /> -
- {error &&
{error}
} - -
仅限内部运营使用 · 请勿转发
- +
+
+ +
+

{tip}

+
+ ); +} + +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 ( +
+
+
+

内部工具

+

受限区域 · 需授权访问

+ +
+ {[['account', '账号'], ['sms', '短信'], ['wechat', '微信扫码'], ['miniprogram', '小程序']].map(([k, label]) => ( + + ))} +
+ + {mode === 'account' && ( +
+
+ + setUsername(e.target.value)} autoComplete="username" placeholder="请输入账号" /> +
+
+ + setPassword(e.target.value)} autoComplete="current-password" placeholder="请输入密码" /> +
+ {error &&
{error}
} + +
+ )} + + {mode === 'sms' && ( +
+
+ + setPhone(e.target.value)} placeholder="请输入 11 位手机号" /> +
+
+ +
+ setSmsCode(e.target.value)} placeholder="请输入验证码" /> + +
+
+ {error &&
{error}
} + +
+ )} + + {mode === 'wechat' && } + {mode === 'miniprogram' && } + +
仅限内部运营使用 · 请勿转发
+
); } diff --git a/website/src/services/auth.js b/website/src/services/auth.js index 14009bb..1dbf5a8 100644 --- a/website/src/services/auth.js +++ b/website/src/services/auth.js @@ -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); diff --git a/website/src/styles/auth.css b/website/src/styles/auth.css index 55b4568..fdbf36d 100644 --- a/website/src/styles/auth.css +++ b/website/src/styles/auth.css @@ -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; }