Files
iAOP/web/auth/login.html
T

443 lines
15 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<script>
/* Epic #163 · C1(issue #176):旧登录页收口到 FBA UI(/fba-admin 原生登录)。 */
(function () {
fetch('/fba-admin/', { method: 'HEAD' })
.then(function (r) { if (r.ok || r.redirected) location.replace('/fba-admin/'); })
.catch(function () { /* FBA 离线:保留本页兜底 */ });
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · iAOP 云美工业AI优化平台</title>
<style>
/* iAOP 登录页(Ant Design Pro 风格重构,2026-08)。
* 布局对齐 Ant Design Pro 登录页:顶部品牌区 + 居中白卡 + 底部版权。
* 认证走 shared/session.js「后端优先 + 本地账号降级」双轨。 */
:root {
--ant-primary: #1677ff;
--ant-primary-hover: #4096ff;
--ant-text: rgba(0, 0, 0, 0.88);
--ant-text-secondary: rgba(0, 0, 0, 0.65);
--ant-text-tertiary: rgba(0, 0, 0, 0.45);
--ant-border: #d9d9d9;
--ant-split: rgba(5, 5, 5, 0.06);
--ant-bg-layout: #f0f2f5;
--ant-error: #ff4d4f;
--ant-success: #52c41a;
--ant-radius: 6px;
--ant-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "Noto Sans", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--ant-font);
background: var(--ant-bg-layout);
color: var(--ant-text);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
}
/* ---- 品牌区(AntD Pro 登录页:logo + 标题 + slogan 居中) ------------- */
.brand {
text-align: center;
padding: 72px 24px 24px;
}
.brand .logo-row {
display: inline-flex;
align-items: center;
gap: 12px;
}
.brand .logo-badge {
width: 44px; height: 44px;
border-radius: 10px;
background: var(--ant-primary);
color: #fff; font-size: 20px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.35);
}
.brand h1 { font-size: 28px; font-weight: 600; letter-spacing: 0.5px; }
.brand .slogan {
margin-top: 12px;
font-size: 14px;
color: var(--ant-text-tertiary);
}
/* ---- 登录卡片 ---------------------------------------------------------- */
main { flex: 1; display: flex; justify-content: center; padding: 16px 24px 48px; }
.login-card {
width: 368px;
max-width: 100%;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0,0,0,.03), 0 1px 6px -1px rgba(0,0,0,.02),
0 2px 4px 0 rgba(0,0,0,.02);
padding: 32px 32px 24px;
align-self: flex-start;
}
.login-card h2 {
font-size: 16px;
font-weight: 600;
text-align: center;
padding-bottom: 12px;
margin-bottom: 20px;
border-bottom: 1px solid var(--ant-split);
color: var(--ant-text);
}
.login-card h2 .active-tab { color: var(--ant-primary); }
.alert {
display: none;
font-size: 13px;
padding: 8px 12px;
border-radius: var(--ant-radius);
margin-bottom: 16px;
line-height: 1.5;
}
.alert.show { display: block; }
.alert-error { color: #cf1322; background: #fff2f0; border: 1px solid #ffccc7; }
.alert-ok { color: #237804; background: #f6ffed; border: 1px solid #b7eb8f; }
.field { margin-bottom: 20px; }
.field label {
display: block;
font-size: 13px;
color: var(--ant-text-secondary);
margin-bottom: 6px;
}
.field .input-wrap { position: relative; }
.field .input-icon {
position: absolute;
left: 11px; top: 50%;
transform: translateY(-50%);
color: var(--ant-text-tertiary);
font-size: 14px;
pointer-events: none;
}
.field input {
width: 100%;
height: 40px;
padding: 4px 40px 4px 34px;
font-size: 14px;
border: 1px solid var(--ant-border);
border-radius: var(--ant-radius);
transition: border-color .2s, box-shadow .2s;
}
.field input:hover { border-color: var(--ant-primary-hover); }
.field input:focus {
border-color: var(--ant-primary);
outline: none;
box-shadow: 0 0 0 2px rgba(22, 119, 255, .1);
}
.field input[aria-invalid="true"] { border-color: var(--ant-error); }
.field .pwd-toggle {
position: absolute;
right: 4px; top: 50%;
transform: translateY(-50%);
border: none; background: none;
cursor: pointer; font-size: 14px;
padding: 6px 8px;
color: var(--ant-text-tertiary);
}
.field .pwd-toggle:hover { color: var(--ant-primary); }
/* ---- FBA 图形验证码(Epic #159 Phase 1) -------------------------------- */
.captcha-row {
display: flex;
gap: 8px;
align-items: stretch;
}
.captcha-row .input-wrap { flex: 1; }
.captcha-img {
height: 40px;
min-width: 110px;
border: 1px solid var(--ant-border);
border-radius: var(--ant-radius);
cursor: pointer;
object-fit: cover;
background: #fafafa;
}
.captcha-img:hover { border-color: var(--ant-primary-hover); }
.btn-login {
width: 100%;
height: 40px;
font-size: 16px;
border: none;
border-radius: var(--ant-radius);
background: var(--ant-primary);
color: #fff;
cursor: pointer;
transition: background .2s;
font-family: inherit;
}
.btn-login:hover { background: var(--ant-primary-hover); }
.btn-login:disabled { opacity: .6; cursor: not-allowed; }
.btn-sso {
width: 100%;
height: 36px;
margin-top: 12px;
font-size: 14px;
border: 1px solid var(--ant-border);
border-radius: var(--ant-radius);
background: #fff;
color: var(--ant-text-secondary);
cursor: pointer;
font-family: inherit;
}
.btn-sso:hover { color: var(--ant-primary); border-color: var(--ant-primary); }
/* ---- 演示账号提示(本地降级模式) --------------------------------------- */
.demo-accounts {
margin-top: 20px;
padding: 12px;
background: #fafafa;
border: 1px solid var(--ant-split);
border-radius: var(--ant-radius);
font-size: 12px;
color: var(--ant-text-tertiary);
line-height: 1.8;
}
.demo-accounts summary { cursor: pointer; color: var(--ant-text-secondary); }
.demo-accounts code {
background: rgba(0,0,0,.04);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
color: var(--ant-text-secondary);
}
.demo-accounts .fill-link {
color: var(--ant-primary);
cursor: pointer;
margin-left: 6px;
}
/* ---- 页脚 --------------------------------------------------------------- */
footer {
text-align: center;
padding: 24px;
font-size: 12px;
color: var(--ant-text-tertiary);
line-height: 1.8;
}
footer .links a {
color: var(--ant-text-tertiary);
text-decoration: none;
margin: 0 10px;
}
footer .links a:hover { color: var(--ant-primary); }
</style>
</head>
<body>
<!--
iAOP 登录页(issue #150/#134 + 2026-08 Ant Design Pro 风格重构 + Epic #159 Phase 1)。
认证三轨(shared/session.js):
· FBA 可用(/fba/api/v1 探测通过)→ FBA JWT 登录(默认强制图形验证码);
· 旧后端 core/auth 可达 → POST /auth/login(HMAC 会话,HttpOnly cookie);
· 纯静态部署 → UserStore 本地账号(首次访问自动播种演示账号)。
角色三级(PRD 8.2):readonly 只读 / engineer 可配置 / admin 可发布回滚,
FBA 侧角色按 session.js 的 FBA_ROLE_MAP 命名即可映射到三级角色,
登录后门户按角色过滤可见模块。
-->
<div class="brand">
<div class="logo-row">
<span class="logo-badge">iA</span>
<h1>iAOP 云美工业AI优化平台</h1>
</div>
<div class="slogan">iAOP-Core 通用内核 · Template-Ti(海绵钛)一期 · 配置化驾驶舱 / 模板配置台 / LLM 助手</div>
</div>
<main>
<form class="login-card" id="loginForm" autocomplete="on" novalidate>
<h2><span class="active-tab">账号密码登录</span></h2>
<div class="alert" id="alert" role="alert" aria-live="polite"></div>
<div class="field">
<label for="username">用户名</label>
<div class="input-wrap">
<span class="input-icon">👤</span>
<input id="username" name="username" type="text" required
autocomplete="username" placeholder="请输入用户名" maxlength="64">
</div>
</div>
<div class="field">
<label for="password">密码</label>
<div class="input-wrap">
<span class="input-icon">🔒</span>
<input id="password" name="password" type="password" required
autocomplete="current-password" placeholder="请输入密码" minlength="8">
<button type="button" class="pwd-toggle" id="togglePwd"
title="显示/隐藏密码" aria-label="显示或隐藏密码">👁</button>
</div>
</div>
<!-- FBA 图形验证码:仅当 FBA 认证可用且开启验证码时显示(session.js fbaStatus) -->
<div class="field" id="captchaField" hidden>
<label for="captcha">图形验证码</label>
<div class="captcha-row">
<div class="input-wrap">
<span class="input-icon">🛡</span>
<input id="captcha" name="captcha" type="text"
autocomplete="off" placeholder="请输入验证码" maxlength="8">
</div>
<img class="captcha-img" id="captchaImg" alt="点击刷新验证码"
title="看不清?点击刷新">
</div>
</div>
<button type="submit" class="btn-login" id="loginBtn">登 录</button>
<!-- issue #152:OIDC SSO 双轨入口——配置了 issuer/client_id 时显示 -->
<button type="button" class="btn-sso" id="ssoBtn" hidden>企业 SSO 登录(OIDC)</button>
<details class="demo-accounts">
<summary>演示账号(纯静态部署·本地账号模式)</summary>
<div>
管理员 <code>admin / Admin@12345</code><span class="fill-link" data-u="admin" data-p="Admin@12345">填入</span><br>
工程师 <code>engineer / Engineer@12</code><span class="fill-link" data-u="engineer" data-p="Engineer@12">填入</span><br>
只读 <code>viewer / Viewer@1234</code><span class="fill-link" data-u="viewer" data-p="Viewer@1234">填入</span><br>
不同角色登录后可见模块不同;生产环境请立即改密。
</div>
</details>
</form>
</main>
<footer>
<div class="links">
<a href="../cockpit/">驾驶舱</a>
<a href="../chat/assistant.html">对话助手</a>
<a href="../mobile/">移动端</a>
</div>
iAOP-Core v1.0 · 认证后端 core/auth(PBKDF2-HMAC-SHA256)· 部署底座 K8s/Helm
</footer>
<script src="user_store.js"></script>
<script src="../shared/session.js"></script>
<script>
(function () {
"use strict";
function $(id) { return document.getElementById(id); }
function showAlert(msg, kind) {
var el = $("alert");
el.textContent = msg || "";
el.className = "alert show " + (kind === "ok" ? "alert-ok" : "alert-error");
}
function clearAlert() { $("alert").className = "alert"; }
function nextUrl() {
// 登录后默认进 SPA 外壳(无门户页;外壳默认加载驾驶舱)
return new URLSearchParams(location.search).get("next") || "../index.html";
}
document.addEventListener("DOMContentLoaded", function () {
// 首次访问播种本地演示账号(幂等;后端模式不影响)
UserStore.seedDefaults().catch(function () {});
/* Epic #159 Phase 1:FBA 可用且开启图形验证码时显示验证码字段 */
var captchaState = { enabled: false, uuid: null };
function refreshCaptcha() {
IAOP_SESSION.getCaptcha().then(function (c) {
if (c) {
captchaState.uuid = c.uuid;
$("captchaImg").src = c.imageSrc;
$("captcha").value = "";
}
});
}
IAOP_SESSION.fbaStatus().then(function (st) {
if (st.available && st.captchaEnabled) {
captchaState.enabled = true;
$("captchaField").hidden = false;
refreshCaptcha();
}
if (st.available) {
// FBA 模式下隐藏本地演示账号提示,避免误导
document.querySelector(".demo-accounts").hidden = true;
}
});
$("captchaImg").addEventListener("click", refreshCaptcha);
// 已登录直接跳转
IAOP_SESSION.currentUser().then(function (u) {
if (u) location.href = nextUrl();
});
$("loginForm").addEventListener("submit", function (e) {
e.preventDefault();
var u = $("username").value.trim();
var p = $("password").value;
$("username").removeAttribute("aria-invalid");
$("password").removeAttribute("aria-invalid");
$("captcha").removeAttribute("aria-invalid");
if (!u) {
$("username").setAttribute("aria-invalid", "true");
showAlert("请输入用户名", "error");
return;
}
if (!p || p.length < 8) {
$("password").setAttribute("aria-invalid", "true");
showAlert("密码不少于 8 位", "error");
return;
}
var captchaOpt = null;
if (captchaState.enabled) {
var c = $("captcha").value.trim();
if (!c) {
$("captcha").setAttribute("aria-invalid", "true");
showAlert("请输入图形验证码", "error");
return;
}
captchaOpt = { uuid: captchaState.uuid, captcha: c };
}
$("loginBtn").disabled = true;
clearAlert();
IAOP_SESSION.login(u, p, captchaOpt).then(function (user) {
showAlert("登录成功,欢迎 " + user.username + "(" +
(IAOP_SESSION.ROLE_LABELS[user.role] || user.role) + ")", "ok");
setTimeout(function () { location.href = nextUrl(); }, 400);
}).catch(function (err) {
$("password").setAttribute("aria-invalid", "true");
showAlert(err.message || "登录失败", "error");
// FBA 验证码一次性有效,失败后必须刷新
if (captchaState.enabled) refreshCaptcha();
}).finally(function () {
$("loginBtn").disabled = false;
});
});
$("togglePwd").addEventListener("click", function () {
var pwd = $("password");
pwd.type = pwd.type === "password" ? "text" : "password";
});
// 演示账号一键填入
Array.prototype.forEach.call(
document.querySelectorAll(".fill-link"), function (link) {
link.addEventListener("click", function () {
$("username").value = link.getAttribute("data-u");
$("password").value = link.getAttribute("data-p");
$("password").focus();
});
});
// issue #152:OIDC 双轨——已配置 issuer/client_id 则显示 SSO 入口
var url = UserStore.oidcAuthorizeUrl("/auth/login.html");
if (url) {
var btn = $("ssoBtn");
btn.hidden = false;
btn.addEventListener("click", function () { location.href = url; });
}
});
})();
</script>
</body>
</html>