diff --git a/website/src/components/AuthGate.jsx b/website/src/components/AuthGate.jsx index 04e0b48..1ea4071 100644 --- a/website/src/components/AuthGate.jsx +++ b/website/src/components/AuthGate.jsx @@ -56,7 +56,7 @@ export default function AuthGate({ title, subtitle, onAuthed }) { return (
-
+
云超服

{title || '登录 / 注册'}

{subtitle &&

{subtitle}

} @@ -92,7 +92,6 @@ export default function AuthGate({ title, subtitle, onAuthed }) {
{ if (onAuthed) onAuthed(user); }} /> {err &&
{err}
} -
已在微信小程序登录过的账号,扫码即可一键登录网页端
)}
diff --git a/website/src/components/MpQrLogin.jsx b/website/src/components/MpQrLogin.jsx index 17ac44d..8c4a78c 100644 --- a/website/src/components/MpQrLogin.jsx +++ b/website/src/components/MpQrLogin.jsx @@ -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
加载中…
; if (status === 'error') return (
{error}
- +
); return (
-
- {qrImage ? 小程序码 : } -
+ {expired ? ( +
+
+ {qrImage ? : } +
+
+ 二维码已失效 + { e.preventDefault(); setExpired(false); setTick((t) => t + 1); }} style={{ fontSize: 13, color: '#2f5bf6' }}>刷新二维码 +
+
+ ) : ( +
+ {qrImage ? 小程序码 : } +
+ )}

{tip || '使用微信扫一扫打开小程序,在小程序内确认登录'}

+ {left > 0 && ( +

+ 二维码 {Math.floor(left / 60)}:{String(left % 60).padStart(2, '0')} 后失效,届时自动刷新 +

+ )}
); } diff --git a/website/src/platform/Login.jsx b/website/src/platform/Login.jsx index 59a0b26..a94a7ec 100644 --- a/website/src/platform/Login.jsx +++ b/website/src/platform/Login.jsx @@ -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 (
{error}
- +
); return (
-
- {qrImage ? 小程序码 : } +
+
+ {qrImage ? 小程序码 : } +
+ {expired && ( +
+ 二维码已失效 + +
+ )}

{tip}

@@ -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 (
-
+
云超服

内部工具

受限区域 · 需授权访问

- {[['account', '账号'], ['sms', '短信'], ['miniprogram', '小程序']].map(([k, label]) => ( + {[['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' && (
diff --git a/website/src/services/auth.js b/website/src/services/auth.js index 3036fbb..fbcb7f9 100644 --- a/website/src/services/auth.js +++ b/website/src/services/auth.js @@ -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() { diff --git a/website/src/services/base.js b/website/src/services/base.js new file mode 100644 index 0000000..bd8efe9 --- /dev/null +++ b/website/src/services/base.js @@ -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'; diff --git a/website/src/services/booking.js b/website/src/services/booking.js index 390e786..66aa6da 100644 --- a/website/src/services/booking.js +++ b/website/src/services/booking.js @@ -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) { diff --git a/website/src/services/enterprise.js b/website/src/services/enterprise.js index f8d41d3..c8a72dc 100644 --- a/website/src/services/enterprise.js +++ b/website/src/services/enterprise.js @@ -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(); diff --git a/website/src/services/opcProfile.js b/website/src/services/opcProfile.js index 6e4d1ed..9c6b236 100644 --- a/website/src/services/opcProfile.js +++ b/website/src/services/opcProfile.js @@ -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(); diff --git a/website/src/services/ops.js b/website/src/services/ops.js index 903ebf4..87f14b3 100644 --- a/website/src/services/ops.js +++ b/website/src/services/ops.js @@ -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}` }, diff --git a/website/src/services/park.js b/website/src/services/park.js index c0f8965..72149a4 100644 --- a/website/src/services/park.js +++ b/website/src/services/park.js @@ -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}` } : {}, diff --git a/website/src/services/parkTask.js b/website/src/services/parkTask.js index 187ad36..caa6860 100644 --- a/website/src/services/parkTask.js +++ b/website/src/services/parkTask.js @@ -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}` } : {}) }; diff --git a/website/src/services/tasks.js b/website/src/services/tasks.js index 2ed194e..6492a3e 100644 --- a/website/src/services/tasks.js +++ b/website/src/services/tasks.js @@ -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();