// 单点登录(SSO)落地页 // 参考:qd-ai-fe/admin-growth-matrix 的 Login.vue iframe 包装模式 // - 远端 SSO 登录页通过 iframe 嵌入 // - iframe 登录成功后向父窗口 postMessage `{type:"sso_token", data:}` // - 回跳兜底:URL query `?sso_token=xxx` 直接消费(镜像 postMessage 成功路径) // - 登录成功 → localStorage 存 token → 跳回 `?from=`(缺省回 "/") // // 配置:本文件下方 ENV_TABLE 三档 dev/test/prod。SSO URL 不暴露到 window。 // 未配置时页面给出明确提示,不静默失败。 const SSO_TOKEN_MESSAGE_TYPE = "sso_token"; const SSO_TOKEN_QUERY_KEY = "token"; const SSO_REDIRECT_QUERY_KEY = "redirect"; const SSO_LOGIN_TYPE_QUERY_KEY = "loginType"; const SSO_LOGIN_TYPE_VALUE = "wecom"; const TOKEN_STORAGE_KEY = "sso_token"; const USER_STORAGE_KEY = "sso_user"; // ===== 环境配置(闭包持有,[MUST NOT] 暴露到 window) ===== // 优先级:window.__ENV_OVERRIDE__(后端注入 DEPLOY_ENV)> DEFAULT_ENV(兜底) const DEFAULT_ENV = "dev"; const ENV_TABLE = Object.freeze({ dev: Object.freeze({ SSO_LOGIN_URL: "http://10.1.2.99:8888/login", USER_API_BASE: "https://test.ai-qidian.com/a-api", }), test: Object.freeze({ SSO_LOGIN_URL: "https://common-test.ai-qidian.com/login", USER_API_BASE: "https://test.ai-qidian.com/a-api", }), prod: Object.freeze({ SSO_LOGIN_URL: "https://common.ai-qidian.com/login", USER_API_BASE: "https://www.ai-qidian.com/a-api", }), }); const __envCfg = (() => { const override = typeof window !== "undefined" ? window.__ENV_OVERRIDE__ : ""; const env = ENV_TABLE[override] ? override : DEFAULT_ENV; return ENV_TABLE[env] || ENV_TABLE[DEFAULT_ENV]; })(); // 用户信息接口(对齐 @qd-ai/api-admin → GetLoginUserInfo) // GET {USER_API_BASE}/account/v1/login_userinfo // Authorization: Bearer // resp: { code: 200, message, data: { id, roleId, username, avatar, ... } } const USER_INFO_PATH = "/account/v1/login_userinfo"; const BUSINESS_SUCCESS_CODE = 200; /** 拉取用户信息(成功返回 user 对象;失败抛错) */ async function fetchUserInfo(token) { const base = (__envCfg.USER_API_BASE || "").replace(/\/+$/, ""); if (!base) throw new Error("USER_API_BASE 未配置"); if (!token) throw new Error("缺少 token"); const url = base + USER_INFO_PATH; const resp = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const body = await resp.json(); if (body && body.code !== BUSINESS_SUCCESS_CODE) { throw new Error(body.message || `业务错误码 ${body.code}`); } return body && body.data; } // 暴露给其他 jsx 使用的轻量 auth 工具,[MUST NOT] 视为正式 auth 模块 const Auth = { getToken: () => { try { return localStorage.getItem(TOKEN_STORAGE_KEY) || ""; } catch (_) { return ""; } }, setToken: (t) => { try { if (t) { localStorage.setItem(TOKEN_STORAGE_KEY, t); document.cookie = "sso_token=" + encodeURIComponent(t) + "; path=/; SameSite=Lax; max-age=2592000"; } } catch (_) {} }, clearToken: () => { try { localStorage.removeItem(TOKEN_STORAGE_KEY); localStorage.removeItem(USER_STORAGE_KEY); document.cookie = "sso_token=; path=/; max-age=0"; } catch (_) {} }, isLogin: () => !!Auth.getToken(), getUser: () => { try { const raw = localStorage.getItem(USER_STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch (_) { return null; } }, setUser: (u) => { try { if (u) localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(u)); } catch (_) {} }, /** 主动刷新用户信息(依赖现有 token) */ loadUserInfo: async () => { const token = Auth.getToken(); if (!token) return null; const user = await fetchUserInfo(token); Auth.setUser(user); return user; }, }; function resolveFromQuery() { try { const u = new URL(window.location.href); const raw = u.searchParams.get("from"); if (!raw) return "/"; const decoded = decodeURIComponent(raw); // 防 open-redirect:仅允许站内相对路径 if (!decoded.startsWith("/") || decoded.startsWith("//")) return "/"; if (decoded.startsWith("/login")) return "/"; return decoded; } catch (_) { return "/"; } } function buildSsoUrl(base) { if (!base) return ""; try { const url = new URL(base, window.location.origin); // 把当前完整地址作为 redirect 一并带给 SSO,方便其失败兜底回跳 url.searchParams.set(SSO_REDIRECT_QUERY_KEY, window.location.href); url.searchParams.set(SSO_LOGIN_TYPE_QUERY_KEY, SSO_LOGIN_TYPE_VALUE); return url.toString(); } catch (_) { return base; } } function ssoOriginOf(base) { if (!base) return ""; try { return new URL(base, window.location.origin).origin; } catch (_) { return ""; } } function LoginPage() { const ssoBase = __envCfg.SSO_LOGIN_URL || ""; const ssoUrl = React.useMemo(() => buildSsoUrl(ssoBase), [ssoBase]); const ssoOrigin = React.useMemo(() => ssoOriginOf(ssoBase), [ssoBase]); const [errorMsg, setErrorMsg] = React.useState(""); /** 落 token + 拉用户信息 + 跳转。失败时清 token 让用户重登 */ const finalizeLogin = React.useCallback(async (token) => { if (!token) return; Auth.setToken(token); try { await Auth.loadUserInfo(); } catch (e) { Auth.clearToken(); setErrorMsg(`获取用户信息失败:${e && e.message ? e.message : e}`); return; } const to = resolveFromQuery(); window.history.replaceState({}, "", to); window.dispatchEvent(new PopStateEvent("popstate")); }, []); // URL query 兜底:携 token 直接登录(镜像 postMessage 成功路径) // 已登录到达 /login → 直接跳走(不重复拉用户信息) React.useEffect(() => { try { const u = new URL(window.location.href); const token = u.searchParams.get(SSO_TOKEN_QUERY_KEY); if (token) { u.searchParams.delete(SSO_TOKEN_QUERY_KEY); window.history.replaceState({}, "", u.pathname + u.search + u.hash); finalizeLogin(token); } else if (Auth.isLogin()) { const to = resolveFromQuery(); window.history.replaceState({}, "", to); window.dispatchEvent(new PopStateEvent("popstate")); } } catch (_) {} }, [finalizeLogin]); // postMessage 监听 React.useEffect(() => { if (!ssoOrigin) return; const onMessage = (event) => { if (event.origin !== ssoOrigin) return; const payload = event.data; if (!payload || payload.type !== SSO_TOKEN_MESSAGE_TYPE || !payload.data) return; finalizeLogin(payload.data); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [ssoOrigin, finalizeLogin]); if (!ssoUrl) { return (
未配置 SSO 登录地址
当前环境 SSO_LOGIN_URL 为空。请在 app/static/login.jsxENV_TABLE 内补全对应环境配置。
); } return ( <>