登录页UI及长时间未操作锁定逻辑优化
This commit is contained in:
Generated
+973
-10
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest run --environment jsdom",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
||||
},
|
||||
@@ -21,8 +22,10 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ const pad2 = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const autoLogoutCountdown = computed(() => {
|
||||
const fallbackDeadlineAt =
|
||||
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt;
|
||||
const remainMs = Math.max(0, deadlineAt - nowTs.value);
|
||||
const remainSec = Math.floor(remainMs / 1000);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
vi.mock("../router", () => ({
|
||||
default: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/authClient", () => ({
|
||||
extendToken: vi.fn(),
|
||||
unlockSession: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("session manager idle recovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
setActivePinia(createPinia());
|
||||
const storage = (() => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
data.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
data.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
data.clear();
|
||||
},
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "BroadcastChannel", {
|
||||
value: class {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
postMessage() {}
|
||||
close() {}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("locks instead of refreshing activity after long inactivity when user activity resumes", async () => {
|
||||
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
|
||||
const { useSessionStore } = await import("../store/session");
|
||||
const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markUserActive } = await import("./sessionManager");
|
||||
const session = useSessionStore();
|
||||
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
|
||||
|
||||
session.recordUserActivity(oldTs);
|
||||
session.recordNetworkActivity(oldTs);
|
||||
|
||||
markUserActive(Date.now());
|
||||
|
||||
expect(session.locked).toBe(true);
|
||||
expect(session.lockReason).toBe("idle");
|
||||
expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("locks instead of refreshing activity after long inactivity when network activity resumes", async () => {
|
||||
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
|
||||
const { useSessionStore } = await import("../store/session");
|
||||
const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markNetworkActive } = await import("./sessionManager");
|
||||
const session = useSessionStore();
|
||||
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
|
||||
|
||||
session.recordUserActivity(oldTs);
|
||||
session.recordNetworkActivity(oldTs);
|
||||
|
||||
markNetworkActive(Date.now());
|
||||
|
||||
expect(session.locked).toBe(true);
|
||||
expect(session.lockReason).toBe("idle");
|
||||
expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,26 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
|
||||
let initialized = false;
|
||||
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
|
||||
|
||||
const getLastActiveAt = (session: ReturnType<typeof useSessionStore>) =>
|
||||
Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt);
|
||||
|
||||
const reconcileSessionState = (now: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (session.locked) return false;
|
||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const lastActiveAt = getLastActiveAt(session);
|
||||
if (now - lastActiveAt > autoLogoutMs) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return false;
|
||||
}
|
||||
if (now - lastActiveAt > idleTimeoutMs) {
|
||||
lockSession("idle");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const broadcast = (message: BroadcastMessage) => {
|
||||
if (channel) {
|
||||
channel.postMessage(message);
|
||||
@@ -81,9 +101,9 @@ const scheduleIdleCheck = () => {
|
||||
}
|
||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const baseActiveAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt);
|
||||
const idleRemain = baseActiveAt + idleTimeoutMs - now;
|
||||
const logoutRemain = baseActiveAt + autoLogoutMs - now;
|
||||
const lastActiveAt = getLastActiveAt(session);
|
||||
const idleRemain = lastActiveAt + idleTimeoutMs - now;
|
||||
const logoutRemain = lastActiveAt + autoLogoutMs - now;
|
||||
const nextCheck = Math.min(idleRemain, logoutRemain);
|
||||
if (nextCheck <= 0) {
|
||||
checkIdle();
|
||||
@@ -103,19 +123,7 @@ const checkIdle = () => {
|
||||
scheduleIdleCheck();
|
||||
return;
|
||||
}
|
||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const idleElapsed = now - session.lastUserActiveAt > idleTimeoutMs && now - session.lastNetworkActiveAt > idleTimeoutMs;
|
||||
const autoLogoutElapsed =
|
||||
now - session.lastUserActiveAt > autoLogoutMs && now - session.lastNetworkActiveAt > autoLogoutMs;
|
||||
if (autoLogoutElapsed) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
if (idleElapsed) {
|
||||
lockSession("idle");
|
||||
}
|
||||
if (!reconcileSessionState(Date.now())) return;
|
||||
scheduleIdleCheck();
|
||||
};
|
||||
|
||||
@@ -131,6 +139,14 @@ export const initSessionManager = () => {
|
||||
markUserActive(now);
|
||||
};
|
||||
events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true }));
|
||||
window.addEventListener("focus", () => {
|
||||
checkIdle();
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
checkIdle();
|
||||
}
|
||||
});
|
||||
if (channel) {
|
||||
channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage);
|
||||
}
|
||||
@@ -150,6 +166,7 @@ export const initSessionManager = () => {
|
||||
export const markUserActive = (at: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (session.locked) return;
|
||||
if (!reconcileSessionState(at)) return;
|
||||
session.recordUserActivity(at);
|
||||
broadcast({ type: "ACTIVE", at });
|
||||
scheduleIdleCheck();
|
||||
@@ -158,6 +175,7 @@ export const markUserActive = (at: number = Date.now()) => {
|
||||
export const markNetworkActive = (at: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (session.locked) return;
|
||||
if (!reconcileSessionState(at)) return;
|
||||
session.recordNetworkActivity(at);
|
||||
broadcast({ type: "NETWORK", at });
|
||||
scheduleIdleCheck();
|
||||
@@ -169,7 +187,7 @@ export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid")
|
||||
if (session.locked) return;
|
||||
const email = auth.user?.email || "";
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const deadlineAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + autoLogoutMs;
|
||||
const deadlineAt = getLastActiveAt(session) + autoLogoutMs;
|
||||
session.lock(reason, email, deadlineAt);
|
||||
broadcast({ type: "LOCK", reason, email, deadlineAt });
|
||||
scheduleIdleCheck();
|
||||
@@ -264,7 +282,7 @@ export const startTokenKeepAlive = () => {
|
||||
if (!expAt) return;
|
||||
const remaining = expAt - Date.now();
|
||||
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const active = Date.now() - session.lastUserActiveAt < timeoutMs || Date.now() - session.lastNetworkActiveAt < timeoutMs;
|
||||
const active = Date.now() - getLastActiveAt(session) < timeoutMs;
|
||||
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) {
|
||||
void extendAccessToken("early");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
type StorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const createStorage = (): StorageLike => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
data.set(key, String(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
data.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
data.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("auth store logout", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: createStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears session lock state when logging out", async () => {
|
||||
const { useAuthStore } = await import("./auth");
|
||||
const { useSessionStore } = await import("./session");
|
||||
const session = useSessionStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
session.lock("token_invalid", "demo@example.com", Date.now() + 60_000);
|
||||
auth.logout();
|
||||
|
||||
expect(session.locked).toBe(false);
|
||||
expect(session.lockReason).toBeNull();
|
||||
expect(session.lockEmail).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -43,11 +43,13 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
|
||||
const logout = () => {
|
||||
const studyStore = useStudyStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
||||
studyStore.rememberCurrentStudyForUser(userKey);
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
forceLogin.value = false;
|
||||
sessionStore.unlock();
|
||||
clearToken();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
+224
-441
@@ -1,117 +1,101 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- Vanta.js 网络背景 -->
|
||||
<div ref="vantaCanvas" id="vanta-canvas" class="vanta-background"></div>
|
||||
|
||||
<!-- 登录卡片 -->
|
||||
<div class="login-wrapper">
|
||||
<div class="max-w-md w-full card-container">
|
||||
<div class="card-content">
|
||||
<!-- 顶部装饰区域 -->
|
||||
<div class="login-header" id="vanta-header">
|
||||
<!-- 左上角:安全访问 -->
|
||||
<div class="header-left">
|
||||
<span class="secure-badge">安全访问</span>
|
||||
</div>
|
||||
|
||||
<!-- 中间:CTMS -->
|
||||
<div class="header-center">
|
||||
<div class="ctms-logo">CTMS</div>
|
||||
<div class="header-divider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单内容 -->
|
||||
<div class="login-form-section">
|
||||
<div class="form-label-section">
|
||||
<span class="auth-badge">身份验证</span>
|
||||
<h3 class="form-title">账户登录</h3>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@keyup.enter="onSubmit"
|
||||
class="login-form"
|
||||
<div class="login-wrapper">
|
||||
<div class="login-bg-shape shape-1"></div>
|
||||
<div class="login-bg-shape shape-2"></div>
|
||||
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo-box">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="logo-icon"
|
||||
>
|
||||
<!-- 邮箱字段 -->
|
||||
<div class="form-group">
|
||||
<label for="email" class="input-label">邮箱</label>
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
class="custom-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 密码字段 -->
|
||||
<div class="form-group">
|
||||
<div class="password-label-row">
|
||||
<label for="password" class="input-label">密码</label>
|
||||
<a href="#" class="forgot-link">忘记密码?</a>
|
||||
</div>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
class="custom-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 记住设备 -->
|
||||
<div class="remember-row">
|
||||
<el-checkbox id="remember" v-model="form.remember" class="custom-checkbox">
|
||||
<span class="remember-text">记住此设备</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div class="button-group">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="onSubmit"
|
||||
class="login-btn"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="btn-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
登录
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="footer-section">
|
||||
<div class="footer-divider"></div>
|
||||
<p class="footer-text">
|
||||
还没有账户? <RouterLink to="/register" class="footer-link">立即注册</RouterLink>
|
||||
</p>
|
||||
<div class="status-row">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">系统状态:正常运行</span>
|
||||
</div>
|
||||
</div>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="sys-title">CTMS 临床试验管理系统</h2>
|
||||
<p class="sys-subtitle">Log in to your account</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@keyup.enter="onSubmit"
|
||||
label-position="top"
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
size="large"
|
||||
autocomplete="email"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
size="large"
|
||||
autocomplete="current-password"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="remember-row">
|
||||
<el-checkbox id="remember" v-model="form.remember" class="remember-checkbox">
|
||||
<span class="remember-text">记住此设备</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<p class="footer-text">
|
||||
还没有账户?
|
||||
<RouterLink to="/register" class="footer-link">立即注册</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, onUnmounted } from "vue";
|
||||
import { reactive, ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
@@ -124,7 +108,6 @@ const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const vantaCanvas = ref<HTMLDivElement>();
|
||||
const form = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
@@ -140,9 +123,7 @@ const rules: FormRules<typeof form> = {
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
let vantaEffect: any = null;
|
||||
|
||||
// 初始化 Vanta.js 效果
|
||||
onMounted(() => {
|
||||
const logoutReason = consumeLogoutReason();
|
||||
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
|
||||
@@ -154,61 +135,8 @@ onMounted(() => {
|
||||
form.password = cached.password;
|
||||
form.remember = true;
|
||||
}
|
||||
|
||||
// 动态加载 Three.js 和 Vanta.js
|
||||
loadVantaEffect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (vantaEffect) {
|
||||
vantaEffect.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// 加载 Vanta.js 网络效果
|
||||
const loadVantaEffect = () => {
|
||||
// 检查是否已加载
|
||||
if ((window as any).VANTA && (window as any).THREE) {
|
||||
initVanta();
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载 Three.js
|
||||
const threeScript = document.createElement('script');
|
||||
threeScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js';
|
||||
threeScript.onload = () => {
|
||||
// 加载 Vanta.js NET 效果
|
||||
const vantaScript = document.createElement('script');
|
||||
vantaScript.src = 'https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.net.min.js';
|
||||
vantaScript.onload = () => {
|
||||
initVanta();
|
||||
};
|
||||
document.head.appendChild(vantaScript);
|
||||
};
|
||||
document.head.appendChild(threeScript);
|
||||
};
|
||||
|
||||
const initVanta = () => {
|
||||
if (vantaCanvas.value && (window as any).VANTA) {
|
||||
vantaEffect = (window as any).VANTA.NET({
|
||||
el: vantaCanvas.value,
|
||||
mouseControls: true,
|
||||
touchControls: true,
|
||||
gyroControls: false,
|
||||
minHeight: 200.00,
|
||||
minWidth: 200.00,
|
||||
scale: 1.00,
|
||||
scaleMobile: 1.00,
|
||||
color: 0xd1d5db,
|
||||
backgroundColor: 0x171717,
|
||||
points: 8,
|
||||
maxDistance: 20.00,
|
||||
spacing: 18.00,
|
||||
showDots: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate();
|
||||
@@ -251,364 +179,219 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导入 Inter 字体 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
/* 主容器 */
|
||||
.login-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #0a0a0a;
|
||||
.login-wrapper {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Vanta 背景 */
|
||||
.vanta-background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: #f8fafc;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 登录包装器 */
|
||||
.login-wrapper {
|
||||
.login-bg-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
opacity: 0.6;
|
||||
animation: float 10s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #c0cdd7;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: #dde5ec;
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(30px, 50px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
max-width: 440px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* 卡片容器 - 带渐变边框效果 */
|
||||
.card-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
.login-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px 28px;
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.card-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
background: linear-gradient(to bottom right, #525252, transparent, #262626);
|
||||
border-radius: 0.75rem;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 卡片内容 */
|
||||
.card-content {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: #171717;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 登录头部 */
|
||||
.login-header {
|
||||
height: 140px;
|
||||
position: relative;
|
||||
background: #171717;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
/* 左上角 */
|
||||
.header-left {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 中间区域 */
|
||||
.header-center {
|
||||
position: absolute;
|
||||
top: 70%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.logo-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, #3f5d75, #24496f);
|
||||
color: #ffffff;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 20px -6px rgba(63, 93, 117, 0.4);
|
||||
}
|
||||
|
||||
/* CTMS Logo */
|
||||
.ctms-logo {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 0 0 30px rgba(102, 126, 234, 0.3);
|
||||
animation: glow 3s ease-in-out infinite;
|
||||
.sys-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px rgba(102, 126, 234, 0.4));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 16px rgba(102, 126, 234, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
.secure-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(38, 38, 38, 0.8);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
height: 0.25rem;
|
||||
width: 3rem;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2, #f093fb);
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* 登录表单区域 */
|
||||
.login-form-section {
|
||||
padding: 1.5rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
.form-label-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #262626;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
color: #a3a3a3;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e5e5e5;
|
||||
.sys-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.login-form {
|
||||
margin-bottom: 1.5rem;
|
||||
.login-form :deep(.el-form-item) {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-form-item) {
|
||||
margin-bottom: 1rem;
|
||||
.login-form :deep(.el-form-item__label) {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
padding-bottom: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-form-item__error) {
|
||||
color: #f87171;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
.login-input :deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 2px 14px;
|
||||
background-color: #ffffff;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
color: #d4d4d4;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
.login-input :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px #cbd5e1;
|
||||
}
|
||||
|
||||
.password-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
.login-input :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 2px #3f5d75 !important;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
.login-input :deep(.el-input__prefix) {
|
||||
color: #94a3b8;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
color: #d4d4d4;
|
||||
.login-input :deep(.el-input__inner) {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* 自定义输入框样式 */
|
||||
.custom-input :deep(.el-input__wrapper) {
|
||||
background: #262626;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
box-shadow: none;
|
||||
transition: all 0.2s;
|
||||
.login-input :deep(.el-input__inner:-webkit-autofill),
|
||||
.login-input :deep(.el-input__inner:-webkit-autofill:hover),
|
||||
.login-input :deep(.el-input__inner:-webkit-autofill:focus) {
|
||||
-webkit-box-shadow: 0 0 0 100px white inset !important;
|
||||
-webkit-text-fill-color: #1e293b !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__wrapper:hover) {
|
||||
background: #262626;
|
||||
border-color: #525252;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__wrapper.is-focus) {
|
||||
background: #262626;
|
||||
border-color: #525252;
|
||||
box-shadow: 0 0 0 2px rgba(82, 82, 82, 0.2);
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__inner) {
|
||||
color: #e5e5e5;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__inner::placeholder) {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-icon) {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
/* 记住设备行 */
|
||||
.remember-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
justify-content: flex-start;
|
||||
margin: -6px 0 18px;
|
||||
}
|
||||
|
||||
.custom-checkbox :deep(.el-checkbox__inner) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: #262626;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.custom-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background: #262626;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.custom-checkbox :deep(.el-checkbox__inner::after) {
|
||||
border-color: #e5e5e5;
|
||||
.remember-checkbox :deep(.el-checkbox__label) {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.remember-text {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 2.75rem;
|
||||
background: #262626;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(135deg, #3f5d75, #4a6d87);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: #e5e5e5;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: #404040;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 20px -6px rgba(63, 93, 117, 0.4);
|
||||
background: linear-gradient(135deg, #365067, #3f5d75);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 底部区域 */
|
||||
.footer-section {
|
||||
padding-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #525252, transparent);
|
||||
margin-bottom: 1rem;
|
||||
.login-btn:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 4px 8px -2px rgba(63, 93, 117, 0.3);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
margin: 0 0 1rem 0;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #d4d4d4;
|
||||
color: #3f5d75;
|
||||
text-decoration: none;
|
||||
transition: text-decoration 0.2s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
text-decoration: underline;
|
||||
color: #365067;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.login-wrapper {
|
||||
padding: 0.5rem;
|
||||
.login-card {
|
||||
padding: 36px 24px 24px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.login-form-section {
|
||||
padding: 1.25rem;
|
||||
|
||||
.sys-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user