feat(website): API基址统一+扫码登录倒计时/失效蒙版+绑定状态修复

- 新增 services/base.js 统一 API_BASE(VITE_API_BASE 可覆盖),9 个 service 去硬编码
- 登录页去账密(手机号/微信/小程序);二维码倒计时+失效模糊蒙版+手动刷新(绝不自动刷新、失效即停轮询)
- AuthGate/Login logo 换标准 /logo.png;Profile 挂载即刷新绑定状态;Header refreshMe 同步头像
This commit is contained in:
Pine
2026-08-28 17:20:31 +08:00
parent c6def26b52
commit 9b5eb3fbbf
12 changed files with 116 additions and 58 deletions
+1 -2
View File
@@ -56,7 +56,7 @@ export default function AuthGate({ title, subtitle, onAuthed }) {
return ( return (
<div className="auth-card" style={{ margin: '0 auto' }}> <div className="auth-card" style={{ margin: '0 auto' }}>
<div className="auth-logo"><i className="fa-solid fa-tree" /></div> <div className="auth-logo"><img src="/logo.png" alt="云超服" style={{ width: 44, height: 44, objectFit: 'contain' }} /></div>
<h1>{title || '登录 / 注册'}</h1> <h1>{title || '登录 / 注册'}</h1>
{subtitle && <p className="auth-sub">{subtitle}</p>} {subtitle && <p className="auth-sub">{subtitle}</p>}
@@ -92,7 +92,6 @@ export default function AuthGate({ title, subtitle, onAuthed }) {
<div> <div>
<MpQrLogin onAuthed={(user) => { if (onAuthed) onAuthed(user); }} /> <MpQrLogin onAuthed={(user) => { if (onAuthed) onAuthed(user); }} />
{err && <div className="auth-err">{err}</div>} {err && <div className="auth-err">{err}</div>}
<div className="auth-foot">已在微信小程序登录过的账号扫码即可一键登录网页端</div>
</div> </div>
)} )}
</div> </div>
+48 -13
View File
@@ -8,28 +8,46 @@ export default function MpQrLogin({ tip, onAuthed }) {
const [qrImage, setQrImage] = React.useState(''); const [qrImage, setQrImage] = React.useState('');
const [qrUrl, setQrUrl] = React.useState(''); const [qrUrl, setQrUrl] = React.useState('');
const [status, setStatus] = React.useState('loading'); const [status, setStatus] = React.useState('loading');
const [expired, setExpired] = React.useState(false);
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [left, setLeft] = React.useState(0); // 二维码剩余秒数
const [tick, setTick] = React.useState(0); // 到期自刷新信号
const timersRef = React.useRef({}); // 本轮定时器(刷新前必须清旧,防叠加)
React.useEffect(() => { React.useEffect(() => {
let timer = null; const clear = () => { if (timersRef.current.timer) window.clearInterval(timersRef.current.timer); if (timersRef.current.countdown) window.clearInterval(timersRef.current.countdown); timersRef.current = {}; };
clear(); // 关键:先清上一轮
let cancelled = false;
(async () => { (async () => {
setStatus('loading'); setStatus('loading');
try { try {
const r = await mpQrStart(); const r = await mpQrStart();
const { scene, qr_image, qr_url } = r || {}; if (cancelled) return;
const { scene, qr_image, qr_url, expires_in } = r || {};
setQrImage(qr_image || ''); setQrImage(qr_image || '');
setQrUrl(qr_url || ''); setQrUrl(qr_url || '');
// 绝对截止时间驱动倒计时(纯更新 + 一次性失效处理,StrictMode 下不漂移不卡顿)
const deadline = Date.now() + (expires_in || 120) * 1000;
setLeft(Math.round((deadline - Date.now()) / 1000));
setStatus('pending'); setStatus('pending');
timer = window.setInterval(async () => { timersRef.current.countdown = window.setInterval(() => {
const v = Math.max(0, Math.round((deadline - Date.now()) / 1000));
setLeft((prev) => (prev === v ? prev : v));
if (v <= 0 && !timersRef.current.expiredHandled) {
timersRef.current.expiredHandled = true;
clear();
setExpired(true); // 到期 → 停止轮询,等待手动刷新
}
}, 500);
timersRef.current.timer = window.setInterval(async () => {
const res = await mpQrPoll(scene); const res = await mpQrPoll(scene);
if (res.status === 'done' && res.profile) { if (res.status === 'done' && res.profile) {
window.clearInterval(timer); clear();
applyAuthResult(res.profile); applyAuthResult(res.profile);
if (onAuthed) onAuthed(getUser()); if (onAuthed) onAuthed(getUser());
} else if (res.status === 'expired') { } else if (res.status === 'expired') {
window.clearInterval(timer); clear();
setStatus('error'); setExpired(true); // 过期 → 停止轮询,等待手动刷新
setError('二维码已过期,请刷新');
} }
}, 2000); }, 2000);
} catch (e) { } catch (e) {
@@ -37,24 +55,41 @@ export default function MpQrLogin({ tip, onAuthed }) {
setError(e.message || '获取小程序码失败'); setError(e.message || '获取小程序码失败');
} }
})(); })();
return () => { if (timer) window.clearInterval(timer); }; return () => { cancelled = true; clear(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [tick]);
if (status === 'loading') return <div className="auth-err" style={{ textAlign: 'center' }}>加载中</div>; if (status === 'loading') return <div className="auth-err" style={{ textAlign: 'center' }}>加载中</div>;
if (status === 'error') if (status === 'error')
return ( return (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div className="auth-err">{error}</div> <div className="auth-err">{error}</div>
<button type="button" className="btn btn-dark auth-btn" onClick={() => window.location.reload()}>点击刷新</button> <button type="button" className="btn btn-dark auth-btn" onClick={() => setTick((t) => t + 1)}>点击刷新</button>
</div> </div>
); );
return ( return (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}> {expired ? (
{qrImage ? <img src={qrImage} alt="小程序码" style={{ width: 200, height: 200 }} /> : <QRCodeSVG value={qrUrl} size={200} />} <div style={{ position: 'relative', display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}>
</div> <div style={{ filter: 'blur(4px)', opacity: .45 }}>
{qrImage ? <img src={qrImage} alt="" style={{ width: 200, height: 200 }} /> : <QRCodeSVG value={qrUrl} size={200} />}
</div>
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span style={{ fontSize: 13, color: '#5a6aa3', fontWeight: 600 }}>二维码已失效</span>
<a href="#refresh" onClick={(e) => { e.preventDefault(); setExpired(false); setTick((t) => t + 1); }} style={{ fontSize: 13, color: '#2f5bf6' }}>刷新二维码</a>
</div>
</div>
) : (
<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> <p className="auth-sub">{tip || '使用微信扫一扫打开小程序,在小程序内确认登录'}</p>
{left > 0 && (
<p className="auth-sub" style={{ fontSize: 12, color: '#9aa4c6' }}>
二维码 {Math.floor(left / 60)}:{String(left % 60).padStart(2, '0')} 后失效届时自动刷新
</p>
)}
</div> </div>
); );
} }
+38 -34
View File
@@ -16,26 +16,46 @@ function PollingQr({ start, poll, tip, onResult }) {
const [qrImage, setQrImage] = React.useState(''); const [qrImage, setQrImage] = React.useState('');
const [status, setStatus] = React.useState('loading'); const [status, setStatus] = React.useState('loading');
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [left, setLeft] = React.useState(0); // 二维码剩余秒数
const [tick, setTick] = React.useState(0); // 手动刷新信号
const [expired, setExpired] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
let timer = null; let timer = null;
let countdown = null;
let countdownExpired = false;
(async () => { (async () => {
setStatus('loading'); setStatus('loading');
try { try {
const r = await start(); const r = await start();
const { scene, qr_url, qr_image } = r || {}; const { scene, qr_url, qr_image, expires_in } = r || {};
setQrUrl(qr_url || ''); setQrUrl(qr_url || '');
setQrImage(qr_image || ''); setQrImage(qr_image || '');
setStatus('pending'); setStatus('pending');
// 截止时间驱动倒计时:纯更新 + 一次性失效处理(到期停止轮询,等待手动刷新)
const deadline = Date.now() + (expires_in || 120) * 1000;
setLeft(Math.round((deadline - Date.now()) / 1000));
setStatus('pending');
countdown = window.setInterval(() => {
const v = Math.max(0, Math.round((deadline - Date.now()) / 1000));
setLeft((prev) => (prev === v ? prev : v));
if (v <= 0 && !countdownExpired) {
countdownExpired = true;
if (countdown) window.clearInterval(countdown);
if (timer) window.clearInterval(timer);
setExpired(true);
}
}, 500);
timer = window.setInterval(async () => { timer = window.setInterval(async () => {
const res = await poll(scene); const res = await poll(scene);
if (res.status === 'done' && res.profile) { if (res.status === 'done' && res.profile) {
window.clearInterval(timer); window.clearInterval(timer);
window.clearInterval(countdown);
onResult(res.profile); onResult(res.profile);
} else if (res.status === 'expired') { } else if (res.status === 'expired') {
window.clearInterval(timer); window.clearInterval(timer);
setStatus('error'); window.clearInterval(countdown);
setError('二维码已过期,请刷新'); setTick((t) => t + 1); // 过期自动刷新二维码
} }
}, 2000); }, 2000);
} catch (err) { } catch (err) {
@@ -52,13 +72,22 @@ function PollingQr({ start, poll, tip, onResult }) {
return ( return (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div className="auth-err">{error}</div> <div className="auth-err">{error}</div>
<button type="button" className="btn btn-dark auth-btn" onClick={() => window.location.reload()}>点击刷新</button> <button type="button" className="btn btn-dark auth-btn" onClick={() => setTick((t) => t + 1)}>点击刷新</button>
</div> </div>
); );
return ( return (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}> <div style={{ position: 'relative', display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}>
{qrImage ? <img src={qrImage} alt="小程序码" style={{ width: 180, height: 180 }} /> : <QRCodeSVG value={qrUrl} size={180} />} <div style={expired ? { filter: 'blur(4px)', opacity: .45 } : undefined}>
{qrImage ? <img src={qrImage} alt="小程序码" style={{ width: 180, height: 180 }} /> : <QRCodeSVG value={qrUrl} size={180} />}
</div>
{expired && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span style={{ fontSize: 13, color: '#5a6aa3', fontWeight: 600 }}>二维码已失效</span>
<button type="button" style={{ border: 'none', background: 'none', color: '#2f5bf6', fontSize: 13, cursor: 'pointer' }}
onClick={() => { setExpired(false); setTick((t) => t + 1); }}>刷新二维码</button>
</div>
)}
</div> </div>
<p className="auth-sub">{tip}</p> <p className="auth-sub">{tip}</p>
</div> </div>
@@ -66,9 +95,7 @@ function PollingQr({ start, poll, tip, onResult }) {
} }
export default function Login({ target = 'pine' }) { export default function Login({ target = 'pine' }) {
const [mode, setMode] = React.useState('account'); const [mode, setMode] = React.useState('sms');
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [phone, setPhone] = React.useState(''); const [phone, setPhone] = React.useState('');
const [smsCode, setSmsCode] = React.useState(''); const [smsCode, setSmsCode] = React.useState('');
const [smsCountdown, setSmsCountdown] = React.useState(0); const [smsCountdown, setSmsCountdown] = React.useState(0);
@@ -78,14 +105,6 @@ export default function Login({ target = 'pine' }) {
const go = () => { window.location.hash = '#/' + target; }; 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) { async function onSms(e) {
e.preventDefault(); e.preventDefault();
setBusy(true); setError(''); setBusy(true); setError('');
@@ -112,12 +131,12 @@ export default function Login({ target = 'pine' }) {
return ( return (
<div className="page-auth"> <div className="page-auth">
<div className="auth-card"> <div className="auth-card">
<div className="auth-logo"><i className="fa-solid fa-tree" /></div> <div className="auth-logo"><img src="/logo.png" alt="云超服" style={{ width: 44, height: 44, objectFit: 'contain' }} /></div>
<h1>内部工具</h1> <h1>内部工具</h1>
<p className="auth-sub">受限区域 · 需授权访问</p> <p className="auth-sub">受限区域 · 需授权访问</p>
<div className="auth-tabs"> <div className="auth-tabs">
{[['account', '号'], ['sms', '信'], ['miniprogram', '小程序']].map(([k, label]) => ( {[['sms', '手机号'], ['wechat', '信'], ['miniprogram', '小程序']].map(([k, label]) => (
<button key={k} type="button" <button key={k} type="button"
className={'auth-tab' + (mode === k ? ' active' : '')} className={'auth-tab' + (mode === k ? ' active' : '')}
onClick={() => { setMode(k); setError(''); }}> onClick={() => { setMode(k); setError(''); }}>
@@ -126,21 +145,6 @@ export default function Login({ target = 'pine' }) {
))} ))}
</div> </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' && ( {mode === 'sms' && (
<form onSubmit={onSms}> <form onSubmit={onSms}>
<div className="field"> <div className="field">
+13 -1
View File
@@ -1,3 +1,4 @@
import { API_BASE } from './base';
/** /**
* 认证服务(auth service)——统一账号 * 认证服务(auth service)——统一账号
* ------------------------------------------------------------- * -------------------------------------------------------------
@@ -9,7 +10,6 @@
* POST /auth/login 密码登录 → 平台 JWT + 身份列表 * POST /auth/login 密码登录 → 平台 JWT + 身份列表
* 统一账号:所有登录写全局 users + 平台 JWT(与小程序一致,见 分端口同步约束.md)。 * 统一账号:所有登录写全局 users + 平台 JWT(与小程序一致,见 分端口同步约束.md)。
*/ */
const API_BASE = 'https://opc.pinesound.cn'; // 云超服 FastAPI 后端
const TOKEN_KEY = 'pine_token'; const TOKEN_KEY = 'pine_token';
const USER_KEY = 'pine_user'; const USER_KEY = 'pine_user';
@@ -87,6 +87,18 @@ export async function unbind(type) {
export async function me() { export async function me() {
return req('/auth/me', { method: 'GET', auth: true }); return req('/auth/me', { method: 'GET', auth: true });
} }
/** 刷新本地用户缓存(头像/昵称/绑定状态),登录态下各页共用,返回最新 user 或 null */
export async function refreshMe() {
try {
const r = await me();
if (r && r.username) {
const u = pickUser(r);
saveUser(u);
return u;
}
} catch { /* 未登录/过期静默 */ }
return null;
}
/* ---------------- 微信扫码 / 小程序扫码登录 ---------------- */ /* ---------------- 微信扫码 / 小程序扫码登录 ---------------- */
export async function wxQrStart() { export async function wxQrStart() {
+6
View File
@@ -0,0 +1,6 @@
/** API 基址统一出口(全站唯一)。
* 开发联调:`VITE_API_BASE=http://192.168.1.3:8090 pnpm dev` 覆盖;
* 生产默认 https://opc.pinesound.cn */
export const API_BASE =
(typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_API_BASE) ||
'https://opc.pinesound.cn';
+1 -1
View File
@@ -7,7 +7,7 @@
*/ */
import { getToken } from '@/services/auth'; import { getToken } from '@/services/auth';
import { cached } from './cache'; import { cached } from './cache';
const API_BASE = 'https://opc.pinesound.cn'; // 云超服 FastAPI 后端 import { API_BASE } from './base';
/** 活动详情 */ /** 活动详情 */
export async function getEventDetail(id) { export async function getEventDetail(id) {
+1 -1
View File
@@ -1,7 +1,7 @@
/** 企业端(甲方企业 / service 角色)任务 Portal:发标 / 提交发布 / 竞标评审 / 验收。走 /enterprise 端点。 */ /** 企业端(甲方企业 / service 角色)任务 Portal:发标 / 提交发布 / 竞标评审 / 验收。走 /enterprise 端点。 */
import { getToken } from '@/services/auth'; import { getToken } from '@/services/auth';
import { API_BASE } from './base';
const API_BASE = 'https://opc.pinesound.cn';
function auth() { function auth() {
const t = getToken(); const t = getToken();
+1 -1
View File
@@ -3,7 +3,7 @@
* 对接后端:POST|GET /opc/certification/apply|mine、POST|GET /opc/park-transfer/apply|mine。 * 对接后端:POST|GET /opc/certification/apply|mine、POST|GET /opc/park-transfer/apply|mine。
*/ */
import { getToken } from '@/services/auth'; import { getToken } from '@/services/auth';
const API_BASE = 'https://opc.pinesound.cn'; import { API_BASE } from './base';
async function j(path, method, payload) { async function j(path, method, payload) {
const token = getToken(); const token = getToken();
+3 -2
View File
@@ -4,9 +4,9 @@
* 管理端点(/pine 后台)需要登录 token;公开端点(当期排期)无需。 * 管理端点(/pine 后台)需要登录 token;公开端点(当期排期)无需。
* 对接 server/mock-api.mjs。 * 对接 server/mock-api.mjs。
*/ */
const API_BASE = 'https://opc.pinesound.cn'; // 云超服 FastAPI 后端
const TOKEN_KEY = 'pine_token'; const TOKEN_KEY = 'pine_token';
import { cached } from './cache'; import { cached } from './cache';
import { API_BASE } from './base';
function authHeaders() { function authHeaders() {
const t = localStorage.getItem(TOKEN_KEY); const t = localStorage.getItem(TOKEN_KEY);
@@ -73,10 +73,11 @@ export async function getCurrentEvents() {
} }
/* ---------- 图像上传(管理端,multipart ---------- */ /* ---------- 图像上传(管理端,multipart ---------- */
export async function uploadImage(file) { export async function uploadImage(file, dir = 'news') {
const token = localStorage.getItem(TOKEN_KEY); const token = localStorage.getItem(TOKEN_KEY);
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
fd.append('dir', dir);
const res = await fetch(`${API_BASE}/api/upload`, { const res = await fetch(`${API_BASE}/api/upload`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
+2 -1
View File
@@ -5,7 +5,7 @@
* POST /api/upload(资料上传,需登录)。 * POST /api/upload(资料上传,需登录)。
*/ */
import { getToken } from '@/services/auth'; import { getToken } from '@/services/auth';
const API_BASE = 'https://opc.pinesound.cn'; import { API_BASE } from './base';
/** 平台可见园区列表(选择园区用) */ /** 平台可见园区列表(选择园区用) */
export async function getParks() { export async function getParks() {
@@ -47,6 +47,7 @@ export async function uploadParkDoc(file) {
const token = getToken(); const token = getToken();
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
fd.append('dir', 'park-admission');
const res = await fetch(`${API_BASE}/api/upload`, { const res = await fetch(`${API_BASE}/api/upload`, {
method: 'POST', method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
+1 -1
View File
@@ -1,5 +1,5 @@
import { API_BASE } from './base';
/** 园区端(carrier)任务管理:园区专用登录(租户凭证) + 本园任务列表/发单/指派/上架下架。走 /park 端点(租户token)。 */ /** 园区端(carrier)任务管理:园区专用登录(租户凭证) + 本园任务列表/发单/指派/上架下架。走 /park 端点(租户token)。 */
const API_BASE = 'https://opc.pinesound.cn';
function parkHeaders(token) { function parkHeaders(token) {
return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }; return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
+1 -1
View File
@@ -1,7 +1,7 @@
/** 任务系统(OPC 端):任务广场(待接单) / 我的接单 / 抢单。走平台 /opc 端点,需登录(opc_member)。 */ /** 任务系统(OPC 端):任务广场(待接单) / 我的接单 / 抢单。走平台 /opc 端点,需登录(opc_member)。 */
import { getToken } from '@/services/auth'; import { getToken } from '@/services/auth';
import { API_BASE } from './base';
const API_BASE = 'https://opc.pinesound.cn';
function authHeaders() { function authHeaders() {
const t = getToken(); const t = getToken();