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 (
-
-
+
+ );
+}
+
+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' && (
+
+ )}
+
+ {mode === 'sms' && (
+
+ )}
+
+ {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; }