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:
@@ -56,7 +56,7 @@ export default function AuthGate({ title, subtitle, onAuthed }) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
{subtitle && <p className="auth-sub">{subtitle}</p>}
|
||||
|
||||
@@ -92,7 +92,6 @@ export default function AuthGate({ title, subtitle, onAuthed }) {
|
||||
<div>
|
||||
<MpQrLogin onAuthed={(user) => { if (onAuthed) onAuthed(user); }} />
|
||||
{err && <div className="auth-err">{err}</div>}
|
||||
<div className="auth-foot">已在微信小程序登录过的账号,扫码即可一键登录网页端</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,28 +8,46 @@ export default function MpQrLogin({ tip, onAuthed }) {
|
||||
const [qrImage, setQrImage] = React.useState('');
|
||||
const [qrUrl, setQrUrl] = React.useState('');
|
||||
const [status, setStatus] = React.useState('loading');
|
||||
const [expired, setExpired] = React.useState(false);
|
||||
const [error, setError] = React.useState('');
|
||||
const [left, setLeft] = React.useState(0); // 二维码剩余秒数
|
||||
const [tick, setTick] = React.useState(0); // 到期自刷新信号
|
||||
const timersRef = React.useRef({}); // 本轮定时器(刷新前必须清旧,防叠加)
|
||||
|
||||
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 () => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
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 || '');
|
||||
setQrUrl(qr_url || '');
|
||||
// 绝对截止时间驱动倒计时(纯更新 + 一次性失效处理,StrictMode 下不漂移不卡顿)
|
||||
const deadline = Date.now() + (expires_in || 120) * 1000;
|
||||
setLeft(Math.round((deadline - Date.now()) / 1000));
|
||||
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);
|
||||
if (res.status === 'done' && res.profile) {
|
||||
window.clearInterval(timer);
|
||||
clear();
|
||||
applyAuthResult(res.profile);
|
||||
if (onAuthed) onAuthed(getUser());
|
||||
} else if (res.status === 'expired') {
|
||||
window.clearInterval(timer);
|
||||
setStatus('error');
|
||||
setError('二维码已过期,请刷新');
|
||||
clear();
|
||||
setExpired(true); // 过期 → 停止轮询,等待手动刷新
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
@@ -37,24 +55,41 @@ export default function MpQrLogin({ tip, onAuthed }) {
|
||||
setError(e.message || '获取小程序码失败');
|
||||
}
|
||||
})();
|
||||
return () => { if (timer) window.clearInterval(timer); };
|
||||
return () => { cancelled = true; clear(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [tick]);
|
||||
|
||||
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>
|
||||
<button type="button" className="btn btn-dark auth-btn" onClick={() => setTick((t) => t + 1)}>点击刷新</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<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>
|
||||
{expired ? (
|
||||
<div style={{ position: 'relative', display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}>
|
||||
<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>
|
||||
{left > 0 && (
|
||||
<p className="auth-sub" style={{ fontSize: 12, color: '#9aa4c6' }}>
|
||||
二维码 {Math.floor(left / 60)}:{String(left % 60).padStart(2, '0')} 后失效,届时自动刷新
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,26 +16,46 @@ function PollingQr({ start, poll, tip, onResult }) {
|
||||
const [qrImage, setQrImage] = React.useState('');
|
||||
const [status, setStatus] = React.useState('loading');
|
||||
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(() => {
|
||||
let timer = null;
|
||||
let countdown = null;
|
||||
let countdownExpired = false;
|
||||
(async () => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
const r = await start();
|
||||
const { scene, qr_url, qr_image } = r || {};
|
||||
const { scene, qr_url, qr_image, expires_in } = r || {};
|
||||
setQrUrl(qr_url || '');
|
||||
setQrImage(qr_image || '');
|
||||
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 () => {
|
||||
const res = await poll(scene);
|
||||
if (res.status === 'done' && res.profile) {
|
||||
window.clearInterval(timer);
|
||||
window.clearInterval(countdown);
|
||||
onResult(res.profile);
|
||||
} else if (res.status === 'expired') {
|
||||
window.clearInterval(timer);
|
||||
setStatus('error');
|
||||
setError('二维码已过期,请刷新');
|
||||
window.clearInterval(countdown);
|
||||
setTick((t) => t + 1); // 过期自动刷新二维码
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
@@ -52,13 +72,22 @@ function PollingQr({ start, poll, tip, onResult }) {
|
||||
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>
|
||||
<button type="button" className="btn btn-dark auth-btn" onClick={() => setTick((t) => t + 1)}>点击刷新</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ 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={{ position: 'relative', display: 'inline-block', padding: 8, background: '#fff', borderRadius: 8 }}>
|
||||
<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>
|
||||
<p className="auth-sub">{tip}</p>
|
||||
</div>
|
||||
@@ -66,9 +95,7 @@ function PollingQr({ start, poll, tip, onResult }) {
|
||||
}
|
||||
|
||||
export default function Login({ target = 'pine' }) {
|
||||
const [mode, setMode] = React.useState('account');
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [mode, setMode] = React.useState('sms');
|
||||
const [phone, setPhone] = React.useState('');
|
||||
const [smsCode, setSmsCode] = React.useState('');
|
||||
const [smsCountdown, setSmsCountdown] = React.useState(0);
|
||||
@@ -78,14 +105,6 @@ export default function Login({ target = 'pine' }) {
|
||||
|
||||
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('');
|
||||
@@ -112,12 +131,12 @@ export default function Login({ target = 'pine' }) {
|
||||
return (
|
||||
<div className="page-auth">
|
||||
<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>
|
||||
<p className="auth-sub">受限区域 · 需授权访问</p>
|
||||
|
||||
<div className="auth-tabs">
|
||||
{[['account', '账号'], ['sms', '短信'], ['miniprogram', '小程序']].map(([k, label]) => (
|
||||
{[['sms', '手机号'], ['wechat', '微信'], ['miniprogram', '小程序']].map(([k, label]) => (
|
||||
<button key={k} type="button"
|
||||
className={'auth-tab' + (mode === k ? ' active' : '')}
|
||||
onClick={() => { setMode(k); setError(''); }}>
|
||||
@@ -126,21 +145,6 @@ export default function Login({ target = 'pine' }) {
|
||||
))}
|
||||
</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">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { API_BASE } from './base';
|
||||
/**
|
||||
* 认证服务(auth service)——统一账号
|
||||
* -------------------------------------------------------------
|
||||
@@ -9,7 +10,6 @@
|
||||
* POST /auth/login 密码登录 → 平台 JWT + 身份列表
|
||||
* 统一账号:所有登录写全局 users + 平台 JWT(与小程序一致,见 分端口同步约束.md)。
|
||||
*/
|
||||
const API_BASE = 'https://opc.pinesound.cn'; // 云超服 FastAPI 后端
|
||||
const TOKEN_KEY = 'pine_token';
|
||||
const USER_KEY = 'pine_user';
|
||||
|
||||
@@ -87,6 +87,18 @@ export async function unbind(type) {
|
||||
export async function me() {
|
||||
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() {
|
||||
|
||||
@@ -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';
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { getToken } from '@/services/auth';
|
||||
import { cached } from './cache';
|
||||
const API_BASE = 'https://opc.pinesound.cn'; // 云超服 FastAPI 后端
|
||||
import { API_BASE } from './base';
|
||||
|
||||
/** 活动详情 */
|
||||
export async function getEventDetail(id) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 企业端(甲方企业 / service 角色)任务 Portal:发标 / 提交发布 / 竞标评审 / 验收。走 /enterprise 端点。 */
|
||||
import { getToken } from '@/services/auth';
|
||||
import { API_BASE } from './base';
|
||||
|
||||
const API_BASE = 'https://opc.pinesound.cn';
|
||||
|
||||
function auth() {
|
||||
const t = getToken();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 对接后端:POST|GET /opc/certification/apply|mine、POST|GET /opc/park-transfer/apply|mine。
|
||||
*/
|
||||
import { getToken } from '@/services/auth';
|
||||
const API_BASE = 'https://opc.pinesound.cn';
|
||||
import { API_BASE } from './base';
|
||||
|
||||
async function j(path, method, payload) {
|
||||
const token = getToken();
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* 管理端点(/pine 后台)需要登录 token;公开端点(当期排期)无需。
|
||||
* 对接 server/mock-api.mjs。
|
||||
*/
|
||||
const API_BASE = 'https://opc.pinesound.cn'; // 云超服 FastAPI 后端
|
||||
const TOKEN_KEY = 'pine_token';
|
||||
import { cached } from './cache';
|
||||
import { API_BASE } from './base';
|
||||
|
||||
function authHeaders() {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
@@ -73,10 +73,11 @@ export async function getCurrentEvents() {
|
||||
}
|
||||
|
||||
/* ---------- 图像上传(管理端,multipart) ---------- */
|
||||
export async function uploadImage(file) {
|
||||
export async function uploadImage(file, dir = 'news') {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('dir', dir);
|
||||
const res = await fetch(`${API_BASE}/api/upload`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* POST /api/upload(资料上传,需登录)。
|
||||
*/
|
||||
import { getToken } from '@/services/auth';
|
||||
const API_BASE = 'https://opc.pinesound.cn';
|
||||
import { API_BASE } from './base';
|
||||
|
||||
/** 平台可见园区列表(选择园区用) */
|
||||
export async function getParks() {
|
||||
@@ -47,6 +47,7 @@ export async function uploadParkDoc(file) {
|
||||
const token = getToken();
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('dir', 'park-admission');
|
||||
const res = await fetch(`${API_BASE}/api/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { API_BASE } from './base';
|
||||
/** 园区端(carrier)任务管理:园区专用登录(租户凭证) + 本园任务列表/发单/指派/上架下架。走 /park 端点(租户token)。 */
|
||||
const API_BASE = 'https://opc.pinesound.cn';
|
||||
|
||||
function parkHeaders(token) {
|
||||
return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 任务系统(OPC 端):任务广场(待接单) / 我的接单 / 抢单。走平台 /opc 端点,需登录(opc_member)。 */
|
||||
import { getToken } from '@/services/auth';
|
||||
import { API_BASE } from './base';
|
||||
|
||||
const API_BASE = 'https://opc.pinesound.cn';
|
||||
|
||||
function authHeaders() {
|
||||
const t = getToken();
|
||||
|
||||
Reference in New Issue
Block a user