Files
iAOP/web/shared/session.js
T
bot_dev1 cfa976cac3 feat: SPA 外壳——菜单切换零刷新,对齐 Ant Design Pro 体验(issue #131)
- web/index.html 重写为 SPA 外壳:Sider/顶栏常驻,模块页在
  #module-frame iframe 内容区加载,点菜单只换 iframe 地址,
  彻底消灭整页刷新与白闪(此前只是铺底色缓解)
- 新增 shared/shell.js:IAOP_SHELL.navigate() 导航、外壳 hash
  (#/admin/#models) 与 iframe 地址双向同步、登录守卫、移动端
  模块新标签打开;iframe 地址用 location.replace 避免污染
  joint history;250ms 轮询回同步(replace 不触发 load 事件);
  导航时乐观高亮(后台标签轮询被节流不能作为唯一途径);
  前进/后退若恢复了 iframe 旧地址则以外壳 hash 为准强制对齐
- pro-header.js 三模式:外壳(菜单点击走 IAOP_SHELL、导出
  IAOP_PRO.setActive)/被嵌(iframe 内,隐藏页内跨页链接与重复
  用户标识、保留 Tab-hash 联动)/独立(原行为);菜单渲染后派发
  pro:menu-rendered 事件消除导航先于渲染的高亮竞态
- session.js:logout/守卫重定向作用于顶层窗口(iframe 内不再
  只跳 iframe 自己)
- 修复 iframe 替换元素宽高 bug:fixed 时左右定位不拉伸(回退
  固有 300x150),改显式 calc 宽高
- 登录页 next 默认回外壳 ../index.html

实测(本地 http.server + WebBridge 真实浏览器):菜单切换/
子菜单切换/前进/后退/直达链接(#/admin/#models) URL、iframe、
菜单高亮、子菜单高亮全链路一致,Sider 节点全程不重建;
登录守卫 logout→顶层跳登录、登录后回外壳默认驾驶舱。
2026-08-05 22:43:47 +08:00

215 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* iAOP 全站统一会话层(Ant Design Pro 风格重构,2026-08)。
*
* 背景:#150 的 auth.js 只对接 core/auth 后端(/auth/login|logout|me),
* 后端未部署时登录态完全不可用,门户只能裸奔展示全部模块。
* 本文件提供「后端优先 + 本地降级」双轨会话:
* 1. 后端可达(同源 /auth/*)→ 走后端 HMAC 会话(HttpOnly cookie);
* 2. 后端不可达(纯静态部署,如 nginx :8090)→ 走 UserStore 本地账号
* (PBKDF2 降级为 salt+SHA-256 的 Demo 级校验,见 user_store.js),
* 会话落 localStorage(iaop.session.v1)。
* 两种模式下 currentUser() 返回结构一致:{username, role, active}。
*
* 角色→模块映射(PRD 8.2 三级 RBAC,门户按此过滤可见模块):
* readonly(Viewer) : 驾驶舱 / 对话助手 / 移动端
* engineer(Editor) : + 模板配置台
* admin(Publisher) : + 管理控制台 / 用户与角色(全部 6 模块)
*
* 同时导出 window.IAOP_AUTH 兼容层(与 #150 auth.js 同签名),
* studio/admin/users 等既有页面无需修改业务代码即可获得本地降级能力。
*/
"use strict";
var IAOP_SESSION = (function () {
var SESSION_KEY = "iaop.session.v1";
var BACKEND_TIMEOUT_MS = 1500; // /auth/* 探测超时,超时即降级本地
/* ---- 模块注册表(门户卡片 + 访问控制单一数据源) ---------------------- */
var MODULES = [
{ key: "cockpit", title: "配置化驾驶舱", icon: "🚀", url: "cockpit/",
desc: "四状态工艺流程视图、实时趋势、KPI 卡片、告警面板、NL 查询(PRD 5.5)",
roles: ["readonly", "engineer", "admin"], tag: "只读", tagColor: "default" },
{ key: "chat", title: "对话助手", icon: "💬", url: "chat/assistant.html",
desc: "NL 工艺查询 / 报警智能解释 / 交接班报告生成(PRD 5.4)",
roles: ["readonly", "engineer", "admin"], tag: "AI 助手", tagColor: "blue" },
{ key: "studio", title: "模板配置台", icon: "🧩", url: "studio/",
desc: "点位字典导入 / 模型超参 / RAG 知识库 / 布局编排 / 版本发布(PRD 5.7)",
roles: ["engineer", "admin"], tag: "可配置", tagColor: "green",
children: [
{ key: "import", title: "点位导入", hash: "#import" },
{ key: "hyper", title: "模型超参", hash: "#hyper" },
{ key: "layout", title: "驾驶舱编排", hash: "#layout" },
{ key: "version", title: "版本发布", hash: "#version" }
] },
{ key: "admin", title: "管理控制台", icon: "⚙️", url: "admin/",
desc: "模型管理(版本/阶段/回滚)、知识库管理、告警确认、审计查询",
roles: ["admin"], tag: "管理员", tagColor: "gold",
children: [
{ key: "models", title: "模型管理", hash: "#models" },
{ key: "kb", title: "知识库管理", hash: "#kb" },
{ key: "alerts", title: "告警确认", hash: "#alerts" },
{ key: "audit", title: "审计查询", hash: "#audit" }
] },
{ key: "users", title: "用户与角色", icon: "👤", url: "auth/users.html",
desc: "用户管理、角色分配(readonly / engineer / admin)、审计(PRD 8.2)",
roles: ["admin"], tag: "管理员", tagColor: "gold" },
{ key: "mobile", title: "移动端驾驶舱", icon: "📱", url: "mobile/",
desc: "移动端只读驾驶舱与交接班摘要(iOS14+ / Android 10+)",
roles: ["readonly", "engineer", "admin"], tag: "移动端", tagColor: "default" }
];
var ROLE_LABELS = { readonly: "Viewer · 只读", engineer: "Editor · 配置",
admin: "Publisher · 管理" };
var ROLE_TAG_COLORS = { readonly: "default", engineer: "green", admin: "gold" };
/* ---- 本地会话存取 ------------------------------------------------------ */
function saveSession(user) {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify({
username: user.username, role: user.role,
loginAt: new Date().toISOString()
}));
} catch (e) { /* 隐私模式等场景忽略 */ }
}
function readSession() {
try {
var v = localStorage.getItem(SESSION_KEY);
return v ? JSON.parse(v) : null;
} catch (e) { return null; }
}
function clearSession() {
try { localStorage.removeItem(SESSION_KEY); } catch (e) {}
}
/* ---- 后端探测(带超时) ------------------------------------------------ */
function fetchWithTimeout(url, opts) {
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () { reject(new Error("timeout")); },
BACKEND_TIMEOUT_MS);
fetch(url, opts).then(function (r) {
clearTimeout(timer); resolve(r);
}, function (e) {
clearTimeout(timer); reject(e);
});
});
}
/* ---- 当前登录用户:后端优先,本地会话降级 ------------------------------ */
function currentUser() {
return fetchWithTimeout("/auth/me", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.user) return d.user;
throw new Error("no backend session");
})
.catch(function () {
// 后端不可达或无后端会话 → 本地会话降级,并用 UserStore 复核用户仍有效
var s = readSession();
if (!s || !s.username) return null;
if (typeof UserStore === "undefined") return s; // 无存储层时信任会话
var u = UserStore.find(s.username);
if (!u || !u.active) { clearSession(); return null; }
return { username: u.username, role: u.role, active: u.active };
});
}
/* ---- 登录:后端优先,本地账号降级 -------------------------------------- */
function login(username, password) {
return fetchWithTimeout("/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password })
}).then(function (r) {
if (r.ok) return r.json().then(function (d) {
saveSession(d.user);
return d.user;
});
// 4xx = 后端在线且拒绝(真实鉴权失败);5xx/网关错误 = 后端不可用,降级本地
if (r.status >= 400 && r.status < 500) {
return r.text().then(function (t) {
var msg = "用户名或密码错误";
try { msg = (JSON.parse(t) || {}).error || msg; } catch (e) {}
var err = new Error(msg);
err.authReject = true;
throw err;
});
}
throw new Error("backend-http-" + r.status);
}).catch(function (e) {
if (e && e.authReject) throw e; // 后端鉴权失败,不降级
// 超时 / 网络错误 / 5xx / 响应非 JSON 等一切后端不可用情形 → 本地账号校验
if (typeof UserStore === "undefined") {
throw new Error("认证服务不可用,且本地账号存储未加载");
}
return UserStore.seedDefaults().then(function () {
return UserStore.verify(username, password);
}).then(function (u) {
if (!u) throw new Error("用户名或密码错误");
saveSession(u);
return u;
});
});
}
/* 被 iframe 嵌入时(SPA 外壳)重定向要作用于顶层窗口 */
function topWindow() {
try { return window.self !== window.top ? window.top : window; }
catch (e) { return window; }
}
/* ---- 登出:双轨都清 ----------------------------------------------------- */
function logout(loginUrl) {
clearSession();
try {
fetch("/auth/logout", { method: "POST", credentials: "include" })
.catch(function () {});
} catch (e) {}
var next = encodeURIComponent(location.pathname + location.search);
topWindow().location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
}
/* ---- 路由守卫(与 #150 IAOP_AUTH 同语义) ------------------------------- */
function requireLoginElseRedirect(loginUrl) {
return currentUser().then(function (u) {
if (!u) {
// 回跳地址取顶层窗口(SPA 外壳 hash 内含模块/子页路径)
var top = topWindow();
var next = encodeURIComponent(
top.location.pathname + top.location.search + top.location.hash);
top.location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
return false;
}
return u;
});
}
/* ---- 角色→模块 ---------------------------------------------------------- */
function modulesFor(role) {
return MODULES.filter(function (m) { return m.roles.indexOf(role) >= 0; });
}
function canAccess(moduleKey, role) {
var m = MODULES.find(function (x) { return x.key === moduleKey; });
return !!m && m.roles.indexOf(role) >= 0;
}
return {
MODULES: MODULES,
ROLE_LABELS: ROLE_LABELS,
ROLE_TAG_COLORS: ROLE_TAG_COLORS,
currentUser: currentUser,
login: login,
logout: logout,
requireLoginElseRedirect: requireLoginElseRedirect,
modulesFor: modulesFor,
canAccess: canAccess
};
})();
/* 兼容层:与 #150 auth.js 导出同签名(studio/admin/users 直接复用)。
* 若页面同时加载了 auth.js,本兼容层在后加载时覆盖之,获得本地降级能力。 */
window.IAOP_AUTH = {
currentUser: IAOP_SESSION.currentUser,
requireLoginElseRedirect: IAOP_SESSION.requireLoginElseRedirect,
AUTH_BASE: ""
};