Files
zsglpt/templates/admin_login.html
yuyx 7007f5f6f5 feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
2026-02-15 23:51:46 +08:00

454 lines
16 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理登录 - 知识管理平台</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #eef2ff 0%, #f6f7fb 45%, #ecfeff 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(800px 500px at 15% 20%, rgba(59,130,246,.18), transparent 60%),
radial-gradient(700px 420px at 85% 70%, rgba(124,58,237,.16), transparent 55%);
pointer-events: none;
}
.login-container {
background: white;
border-radius: 16px;
box-shadow: 0 18px 60px rgba(17,24,39,0.15);
width: 420px;
padding: 38px 34px;
border: 1px solid rgba(17,24,39,0.08);
position: relative;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
font-size: 24px;
color: #111827;
margin-bottom: 10px;
letter-spacing: 0.2px;
}
.login-header p {
color: #6b7280;
font-size: 14px;
}
.admin-badge {
display: inline-block;
background: rgba(59,130,246,0.10);
color: #1d4ed8;
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #111827;
font-weight: 700;
font-size: 13px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 1px solid rgba(17,24,39,0.14);
border-radius: 10px;
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
background: rgba(255,255,255,0.9);
}
.form-group input:focus {
outline: none;
border-color: rgba(59,130,246,0.7);
box-shadow: 0 0 0 4px rgba(59,130,246,0.16);
}
.btn-login {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 800;
cursor: pointer;
transition: transform 0.15s, filter 0.15s;
}
.btn-login:hover {
transform: translateY(-2px);
filter: brightness(1.02);
}
.btn-login:active {
transform: translateY(0);
}
.btn-passkey {
width: 100%;
margin-top: 10px;
padding: 11px;
border-radius: 10px;
border: 1px solid rgba(17,24,39,0.14);
background: #f8fafc;
color: #0f172a;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.btn-passkey:hover {
background: #f1f5f9;
}
.back-link {
text-align: center;
margin-top: 20px;
color: #6b7280;
}
.back-link a {
color: #2563eb;
text-decoration: none;
font-weight: 700;
}
.back-link a:hover {
text-decoration: underline;
}
.error-message {
background: rgba(239,68,68,0.10);
color: #b91c1c;
padding: 10px;
border-radius: 10px;
margin-bottom: 20px;
display: none;
border: 1px solid rgba(239,68,68,0.18);
}
.success-message {
background: rgba(16,185,129,0.10);
color: #047857;
padding: 10px;
border-radius: 10px;
margin-bottom: 20px;
display: none;
border: 1px solid rgba(16,185,129,0.18);
}
.warning-box {
background: rgba(245,158,11,0.10);
border: 1px solid rgba(245,158,11,0.18);
color: #92400e;
padding: 10px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 13px;
}
@media (max-width: 480px) {
body { padding: 12px; align-items: flex-start; padding-top: 20px; }
.login-container { width: 100%; max-width: 100%; padding: 28px 20px; border-radius: 14px; }
.login-header h1 { font-size: 22px; }
.login-header p { font-size: 13px; }
.admin-badge { font-size: 11px; padding: 4px 12px; }
.form-group { margin-bottom: 18px; }
.form-group label { font-size: 13px; }
.form-group input { padding: 11px; font-size: 16px; } /* iOS防止自动缩放 */
.btn-login { padding: 13px; font-size: 15px; }
.back-link { margin-top: 16px; font-size: 14px; }
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<span class="admin-badge">管理员登录</span>
<h1>后台管理系统</h1>
<p>知识管理平台</p>
</div>
<div id="errorMessage" class="error-message"></div>
<div id="successMessage" class="success-message"></div>
<form id="loginForm" method="POST" action="/yuyx/api/login" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="username">管理员账号</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required>
</div>
<div id="captchaGroup" class="form-group" style="display: none;">
<label for="captcha">验证码</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码" style="flex: 1;">
<img id="captchaImage" src="" alt="验证码" style="height: 50px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshCaptcha()" title="点击刷新">
<button type="button" onclick="refreshCaptcha()" style="padding: 8px 15px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">刷新</button>
</div>
</div>
<button type="submit" class="btn-login">登录后台</button>
<button type="button" class="btn-passkey" onclick="handlePasskeyLogin()">使用 Passkey 登录</button>
</form>
<div class="back-link">
<a href="/">返回用户登录</a>
</div>
</div>
<script>
let captchaSession = '';
let needCaptcha = false;
function getCookie(name) {
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1');
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : '';
}
function jsonHeaders() {
const headers = { 'Content-Type': 'application/json' };
const csrfToken = getCookie('csrf_token');
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
return headers;
}
function isPasskeyAvailable() {
return window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials;
}
function base64UrlToUint8Array(base64url) {
const value = String(base64url || '');
const padding = '='.repeat((4 - (value.length % 4)) % 4);
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = window.atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
return bytes;
}
function uint8ArrayToBase64Url(input) {
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || []);
let binary = '';
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function normalizeRequestOptions(rawOptions) {
const options = rawOptions && rawOptions.publicKey ? rawOptions.publicKey : rawOptions;
const normalized = {
...options,
challenge: base64UrlToUint8Array(options.challenge),
};
if (Array.isArray(options.allowCredentials)) {
normalized.allowCredentials = options.allowCredentials.map((item) => ({
...item,
id: base64UrlToUint8Array(item.id),
}));
}
return normalized;
}
function serializeAssertion(credential) {
return {
id: credential.id,
rawId: uint8ArrayToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment || undefined,
response: {
clientDataJSON: uint8ArrayToBase64Url(credential.response.clientDataJSON),
authenticatorData: uint8ArrayToBase64Url(credential.response.authenticatorData),
signature: uint8ArrayToBase64Url(credential.response.signature),
userHandle: credential.response.userHandle ? uint8ArrayToBase64Url(credential.response.userHandle) : null,
},
};
}
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const captchaInput = document.getElementById('captcha');
const captcha = captchaInput ? captchaInput.value.trim() : '';
const errorDiv = document.getElementById('errorMessage');
const successDiv = document.getElementById('successMessage');
errorDiv.style.display = 'none';
successDiv.style.display = 'none';
if (!username || !password) {
errorDiv.textContent = '用户名和密码不能为空';
errorDiv.style.display = 'block';
return;
}
if (needCaptcha && !captcha) {
errorDiv.textContent = '请输入验证码';
errorDiv.style.display = 'block';
return;
}
try {
const response = await fetch('/yuyx/api/login', {
method: 'POST',
credentials: 'same-origin', // 确保发送和接收cookies
headers: jsonHeaders(),
body: JSON.stringify({
username: username,
password: password,
captcha_session: captchaSession,
captcha: captcha,
need_captcha: needCaptcha
})
});
const data = await response.json();
if (response.ok) {
successDiv.textContent = '登录成功,正在跳转...';
successDiv.style.display = 'block';
// 等待1秒确保cookie设置完成
await new Promise(resolve => setTimeout(resolve, 1000));
// 使用replace避免返回按钮回到登录页
window.location.replace(data.redirect || '/yuyx/admin');
} else {
errorDiv.textContent = data.error || '登录失败';
errorDiv.style.display = 'block';
if (data.need_captcha) {
needCaptcha = true;
document.getElementById('captchaGroup').style.display = 'block';
await generateCaptcha();
}
}
} catch (error) {
errorDiv.textContent = '网络错误,请稍后重试';
errorDiv.style.display = 'block';
}
}
async function handlePasskeyLogin() {
const username = document.getElementById('username').value.trim();
const errorDiv = document.getElementById('errorMessage');
const successDiv = document.getElementById('successMessage');
errorDiv.style.display = 'none';
successDiv.style.display = 'none';
if (!isPasskeyAvailable()) {
errorDiv.textContent = '当前浏览器或环境不支持Passkey需HTTPS';
errorDiv.style.display = 'block';
return;
}
try {
const optionsResp = await fetch('/yuyx/api/passkeys/login/options', {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders(),
body: JSON.stringify(username ? { username } : {}),
});
const optionsData = await optionsResp.json();
if (!optionsResp.ok) {
throw new Error(optionsData.error || '获取Passkey挑战失败');
}
const publicKey = normalizeRequestOptions(optionsData.publicKey || {});
const assertion = await navigator.credentials.get({ publicKey });
const credential = serializeAssertion(assertion);
const verifyResp = await fetch('/yuyx/api/passkeys/login/verify', {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders(),
body: JSON.stringify(username ? { username, credential } : { credential }),
});
const verifyData = await verifyResp.json();
if (!verifyResp.ok) {
throw new Error(verifyData.error || 'Passkey登录失败');
}
successDiv.textContent = 'Passkey 登录成功,正在跳转...';
successDiv.style.display = 'block';
await new Promise(resolve => setTimeout(resolve, 500));
window.location.replace(verifyData.redirect || '/yuyx/admin');
} catch (error) {
const msg = error?.name === 'NotAllowedError'
? 'Passkey验证未完成可能取消、超时或设备未响应'
: (error?.message || 'Passkey登录失败');
errorDiv.textContent = msg;
errorDiv.style.display = 'block';
}
}
async function generateCaptcha() {
try {
const response = await fetch('/api/generate_captcha', {
method: 'POST',
headers: jsonHeaders()
});
const data = await response.json();
if (data.session_id && data.captcha_image) {
captchaSession = data.session_id;
document.getElementById('captchaImage').src = data.captcha_image;
}
} catch (error) {
console.error('生成验证码失败:', error);
}
}
async function refreshCaptcha() {
await generateCaptcha();
document.getElementById('captcha').value = '';
}
</script>
</body>
</html>