- 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→顶层跳登录、登录后回外壳默认驾驶舱。
215 lines
9.4 KiB
JavaScript
215 lines
9.4 KiB
JavaScript
/* 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: ""
|
||
};
|