Files
ctms/frontend/src/views/Login.vue
T
Cheng Zhou 9cac75e85c
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
完善桌面端界面、发布检查与邮箱域名同步
2026-07-01 10:49:31 +08:00

1296 lines
36 KiB
Vue
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.
<template>
<div class="login-wrapper">
<!-- 左右分栏双面板容器 -->
<div class="login-split-container">
<!-- 左侧明亮科技感极光大面板 -->
<div class="login-left-brand">
<!-- 方案D化学分子科技网格插图 -->
<div class="brand-bg-pattern"></div>
<div class="brand-content-wrapper">
<!-- 顶部系统微标 -->
<div class="brand-mini-logo">
<div class="mini-logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<span class="mini-logo-text">华邦制药 · CTMS</span>
</div>
<!-- 中部标语区 -->
<div class="brand-slogan-block">
<h1 class="brand-main-title">
<span class="system-name">临床试验管理系统</span>
</h1>
<p class="brand-sub-desc">
全流程覆盖临床试验立项执行监查与归档以规范化管理保障数据质量助力研究团队高效推进项目合规落地
</p>
</div>
<!-- 特征卡片网格 -->
<div class="brand-features-grid">
<div class="feature-card delay-1">
<div class="feature-icon-wrapper">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</svg>
</div>
<div class="feature-info">
<h3 class="feature-title">项目全流程管理</h3>
<p class="feature-desc">覆盖立项审批方案配置人员分工到进度追踪多中心协同操作信息集中透明可查</p>
</div>
</div>
<div class="feature-card delay-2">
<div class="feature-icon-wrapper">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<div class="feature-info">
<h3 class="feature-title">合规核查与数据溯源</h3>
<p class="feature-desc">深度契合 GCP NMPA 规范记录完整操作轨迹与数据变更历史确保每一条记录真实可追溯</p>
</div>
</div>
<div class="feature-card delay-3">
<div class="feature-icon-wrapper">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M3 3v18h18M18.7 8l-5.1 5.2-2.8-2.7L7 14.3"/>
</svg>
</div>
<div class="feature-info">
<h3 class="feature-title">偏差与安全事件管理</h3>
<p class="feature-desc">结构化记录研究偏差与不良事件支持分级处理与闭环追踪保障受试者安全与项目数据完整性</p>
</div>
</div>
</div>
<!-- 左侧底部声明 -->
<div class="brand-legal-info">
<p class="legal-item">Compliance with GCP & NMPA Regulations</p>
</div>
</div>
</div>
<!-- 右侧极简登录面板 -->
<div class="login-right-form">
<!-- 兼容提示已去除 -->
<div class="login-card-container">
<!-- 顶部 Logo 组合 -->
<div class="login-brand-header">
<div class="brand-logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<div class="brand-divider"></div>
<span class="brand-system-text">CTMS</span>
</div>
<!-- 当前登录方式 -->
<div class="login-tabs">
<span class="tab-item active">账号登录</span>
</div>
<div v-if="showDesktopServerSettings" class="desktop-server-status">
<span class="desktop-server-label">服务器</span>
<code>{{ desktopServerUrl || "未配置" }}</code>
<RouterLink to="/desktop/server-settings" class="desktop-server-action">设置</RouterLink>
</div>
<!-- 退出通知 -->
<div v-if="logoutNotice" class="logout-notice" :class="'logout-notice--' + logoutNotice.type">
<div class="ln-icon">
<svg v-if="logoutNotice.type === 'warning'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 7v6"/><path d="M12 17h.01"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6 9 17l-5-5"/>
</svg>
</div>
<div class="ln-body">
<strong>{{ logoutNotice.title }}</strong>
<span>{{ logoutNotice.message }}</span>
</div>
</div>
<!-- 登录错误横幅 -->
<transition name="error-slide">
<div v-if="loginError" class="login-error-banner">
<div class="leb-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/>
</svg>
</div>
<div class="leb-body">
<strong>{{ loginError.title }}</strong>
<span v-if="loginError.message">{{ loginError.message }}</span>
</div>
<button class="leb-close" @click="loginError = null" aria-label="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</div>
</transition>
<!-- 登录表单 -->
<el-form ref="formRef" :model="form" :rules="rules" @keyup.enter="onSubmit" label-position="top" class="login-form">
<el-form-item label="账号" prop="email">
<div class="email-unified-box">
<input
id="email"
v-model="form.emailLocal"
type="text"
placeholder="请输入账号"
name="username"
autocomplete="username"
class="email-local-field"
@paste="handleAccountPaste"
/>
<span class="email-at-sign">@</span>
<div class="email-domain-wrapper">
<select
v-model="form.emailDomain"
class="email-domain-field"
aria-label="邮箱域名"
:disabled="availableEmailDomains.length === 0"
>
<option v-for="domain in availableEmailDomains" :key="domain" :value="domain">{{ domain }}</option>
</select>
<svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
id="password" v-model="form.password" type="password"
placeholder="请输入密码" show-password size="large"
name="ctms-login-password" autocomplete="new-password" class="login-input">
</el-input>
</el-form-item>
<div class="login-options">
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text">
我已阅读并同意
<button type="button" class="protocol-link" @click.stop.prevent="openProtocolDialog">用户协议</button>
登录即代表同意当前协议
</span>
</el-checkbox>
</div>
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
<span> </span>
</el-button>
<!-- 忘记密码与注册 -->
<div class="forgot-register-row">
<RouterLink to="/forgot-password" class="forgot-link">忘记密码</RouterLink>
<RouterLink to="/register" class="register-link">新用户注册</RouterLink>
</div>
</el-form>
<!-- 右侧底部客户支持与版权 -->
<div class="login-right-footer">
<div class="footer-row">
<span class="footer-label">技术开发</span>
<a href="mailto:zhouchengcheng@huapont.cn" class="footer-link-text">医学事务部·周成成 (zhouchengcheng@huapont.cn)</a>
</div>
<div class="footer-row copyright-line">
<span class="lang-switch">CHINA(中国) <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg></span>
<span class="meta-sep">|</span>
<span class="copyright-text">© {{ currentYear }} 重庆华邦制药有限公司 版权所有</span>
<span class="meta-sep">|</span>
<span class="privacy-link">隐私政策</span>
</div>
<div class="footer-row compliance-line">
<span class="version-tag">v{{ appVersion }}</span>
<span class="meta-sep">|</span>
<span class="compliance-text">本系统依照 GCP NMPA 规范管理数据</span>
</div>
</div>
</div>
</div>
</div>
<!-- 协议弹窗 -->
<el-dialog v-model="protocolDialogVisible" title="用户协议" width="860px"
class="protocol-dialog" append-to-body align-center>
<div class="protocol-content">
<section v-for="section in protocolSections" :key="section.title" class="protocol-section">
<h3>{{ section.title }}</h3>
<p v-for="p in section.paragraphs" :key="p">{{ p }}</p>
</section>
</div>
<template #footer>
<el-button type="primary" size="large" class="protocol-confirm-btn" @click="confirmProtocol"> </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onUnmounted, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
// 从 package.json 读取版本号,构建时由 Vite 注入
const appVersion = (import.meta.env.VITE_APP_VERSION as string) || "1.0.0";
// 动态获取当前年份,避免硬编码
const currentYear = new Date().getFullYear();
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime";
import {
consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED,
LOGOUT_REASON_MANUAL,
LOGOUT_REASON_TIMEOUT,
} from "../session/sessionManager";
import { authProtocolSections } from "../content/authProtocol";
const auth = useAuthStore();
const router = useRouter();
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
const formRef = ref<FormInstance>();
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false });
const configuredEmailDomains = ref<string[]>([]);
const rules: FormRules<typeof form> = {
email: [
{ required: true, message: requiredMessage(TEXT.modules.auth.emailLabel), trigger: "blur" },
{ type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
],
password: [{ required: true, message: requiredMessage(TEXT.modules.auth.passwordLabel), trigger: "blur" }],
};
const loading = ref(false);
const protocolDialogVisible = ref(false);
const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: string } | null>(null);
const loginError = ref<{ title: string; message?: string } | null>(null);
const protocolSections = authProtocolSections;
const showDesktopServerSettings = isTauriRuntime();
const desktopServerUrl = ref(getDesktopServerUrl());
const refreshDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl();
};
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
const availableEmailDomains = computed(() => configuredEmailDomains.value);
const syncEmailFromParts = () => {
const local = form.emailLocal.trim().toLowerCase();
const domain = normalizeDomain(form.emailDomain);
form.emailDomain = domain;
form.email = local && domain ? `${local}@${domain}` : "";
};
const applyEmailValue = (email: string) => {
const normalized = email.trim().toLowerCase();
const separator = normalized.lastIndexOf("@");
if (separator > 0) {
form.emailLocal = normalized.slice(0, separator);
const domain = normalizeDomain(normalized.slice(separator + 1));
form.emailDomain = configuredEmailDomains.value.includes(domain)
? domain
: configuredEmailDomains.value[0] || "";
} else {
form.emailLocal = normalized;
form.emailDomain = configuredEmailDomains.value[0] || "";
}
syncEmailFromParts();
};
const loadEmailDomains = async () => {
try {
const { data } = await fetchEmailDomains();
configuredEmailDomains.value = Array.from(new Set(
data.items.map(normalizeDomain).filter(Boolean)
));
if (!configuredEmailDomains.value.includes(form.emailDomain)) {
form.emailDomain = configuredEmailDomains.value[0] || "";
}
} catch {
configuredEmailDomains.value = [];
form.emailDomain = "";
}
};
const handleAccountPaste = (event: ClipboardEvent) => {
const pasted = event.clipboardData?.getData("text").trim() || "";
if (!pasted.includes("@")) return;
event.preventDefault();
applyEmailValue(pasted);
};
onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
await loadEmailDomains();
const reason = consumeLogoutReason();
if (reason === LOGOUT_REASON_TIMEOUT) {
logoutNotice.value = { type: "warning", title: "已自动退出登录", message: "30 分钟未检测到任何操作。为保护项目数据,请重新登录后继续。" };
} else if (reason === LOGOUT_REASON_AUTH_EXPIRED) {
logoutNotice.value = { type: "warning", title: "登录状态已失效", message: "当前会话无法继续使用,请重新登录。" };
} else if (reason === LOGOUT_REASON_MANUAL) {
logoutNotice.value = { type: "info", title: "已安全退出", message: "本机登录状态已清除,需要时可再次登录。" };
}
applyEmailValue(localStorage.getItem("ctms_last_login_email") || "");
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
});
onUnmounted(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
});
watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
const openProtocolDialog = () => { protocolDialogVisible.value = true; };
const confirmProtocol = () => { form.agreeProtocol = true; protocolDialogVisible.value = false; };
const onSubmit = async () => {
if (!formRef.value) return;
const valid = await formRef.value.validate();
if (!valid) return;
if (!form.agreeProtocol) { ElMessage.warning("请先阅读并同意用户协议"); return; }
loginError.value = null; // 清除上次错误
loading.value = true;
try {
await auth.login(form.email, form.password);
const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
if (studyStore.currentStudy) {
router.push("/project/overview");
} else {
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
}
} catch (error: any) {
const status = error?.response?.status;
const detail: string = error?.response?.data?.detail || error?.response?.data?.message || "";
// 根据状态码和后端信息做差异化映射
if (!status || status >= 500) {
loginError.value = { title: "服务暂时不可用", message: "请稍后重试,如持续出现请联系管理员" };
} else if (status === 429) {
loginError.value = { title: "操作过于频繁", message: "登录尝试次数过多,请稍后再试" };
} else if (status === 403 || detail.includes("未激活") || detail.includes("审核") || detail.includes("pending")) {
loginError.value = { title: "账号不可用", message: "请联系系统管理员确认账号状态" };
} else if (detail.includes("锁定") || detail.includes("locked") || detail.includes("disabled")) {
loginError.value = { title: "账号已被锁定", message: "请联系系统管理员解锁账号" };
} else if (status === 401) {
loginError.value = { title: "邮箱或密码不正确", message: "请检查后重新输入,多次失败可能导致账号锁定" };
} else {
loginError.value = { title: "登录失败", message: detail || TEXT.modules.auth.authFailed };
}
} finally {
loading.value = false;
}
};
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;600;800&display=swap");
/* ═══════════════════════
根容器
═══════════════════════ */
.login-wrapper {
position: relative;
min-height: 100vh;
display: flex;
align-items: stretch;
justify-content: stretch;
overflow: hidden;
background: #ffffff;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
}
.login-split-container {
display: flex;
width: 100vw;
min-height: 100vh;
}
/* ═══════════════════════
左侧:明亮极光科技面板
═══════════════════════ */
.login-left-brand {
flex: 1;
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
padding: 60px 80px;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 渐变圆形球光效 */
.brand-glow-sphere {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
z-index: 1;
}
.sphere-1 {
width: 480px;
height: 480px;
background: rgba(99, 102, 241, 0.16);
top: 15%;
left: -10%;
animation: floatSphere1 20s infinite alternate ease-in-out;
}
.sphere-2 {
width: 420px;
height: 420px;
background: rgba(6, 182, 212, 0.14);
bottom: 10%;
right: -5%;
animation: floatSphere2 25s infinite alternate ease-in-out;
}
@keyframes floatSphere1 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(30px, 40px) scale(1.1); }
}
@keyframes floatSphere2 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(-40px, -30px) scale(1.08); }
}
.brand-bg-pattern {
position: absolute;
bottom: 5%;
right: -5%;
width: 440px;
height: 440px;
background-image: url("data:image/svg+xml,%3Csvg width='380' height='380' viewBox='0 0 380 380' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M60 120 L130 80 L200 120 L200 200 L130 240 L60 200 Z' stroke='rgba(99, 102, 241, 0.12)' stroke-width='1.2'/%3E%3Cpath d='M200 120 L270 80 L340 120 L340 200 L270 240 L200 200' stroke='rgba(6, 182, 212, 0.1)' stroke-width='1.2'/%3E%3Ccircle cx='60' cy='120' r='4' fill='rgba(99, 102, 241, 0.25)'/%3E%3Ccircle cx='60' cy='120' r='8' stroke='rgba(99, 102, 241, 0.08)' stroke-width='1'/%3E%3Ccircle cx='130' cy='80' r='3' fill='rgba(99, 102, 241, 0.18)'/%3E%3Ccircle cx='200' cy='120' r='4' fill='rgba(99, 102, 241, 0.25)'/%3E%3Ccircle cx='200' cy='200' r='3' fill='rgba(99, 102, 241, 0.18)'/%3E%3Ccircle cx='130' cy='240' r='4' fill='rgba(99, 102, 241, 0.2)'/%3E%3Ccircle cx='60' cy='200' r='3' fill='rgba(99, 102, 241, 0.15)'/%3E%3Ccircle cx='270' cy='80' r='4' fill='rgba(6, 182, 212, 0.2)'/%3E%3Ccircle cx='340' cy='120' r='5' fill='rgba(6, 182, 212, 0.25)'/%3E%3Ccircle cx='340' cy='120' r='10' stroke='rgba(6, 182, 212, 0.08)' stroke-width='1'/%3E%3Ccircle cx='340' cy='200' r='3' fill='rgba(6, 182, 212, 0.15)'/%3E%3Ccircle cx='270' cy='240' r='4' fill='rgba(6, 182, 212, 0.2)'/%3E%3Cpath d='M130 80 L130 30 M270 80 L270 30' stroke='rgba(99, 102, 241, 0.08)' stroke-width='1.2' stroke-dasharray='2 2'/%3E%3Ccircle cx='130' cy='30' r='2' fill='rgba(99, 102, 241, 0.12)'/%3E%3Ccircle cx='270' cy='30' r='2' fill='rgba(6, 182, 212, 0.12)'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
pointer-events: none;
z-index: 1;
opacity: 0.8;
animation: bgPatternFloat 30s infinite alternate ease-in-out;
}
@keyframes bgPatternFloat {
0% { transform: translate(0, 0) rotate(0deg); }
100% { transform: translate(-10px, 15px) rotate(2deg); }
}
.brand-content-wrapper {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
}
/* 迷你Logo */
.brand-mini-logo {
display: flex;
align-items: center;
gap: 14px;
}
.mini-logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 55px;
height: 55px;
background: #2563eb;
border-radius: 14px;
color: white;
}
.mini-logo-text {
font-size: 26px;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.5px;
}
/* Slogan 区 */
.brand-slogan-block {
margin: 32px 0 0;
max-width: 520px;
}
.brand-main-title {
display: flex;
flex-direction: column;
gap: 12px;
font-size: 38px;
font-weight: 800;
line-height: 1.25;
letter-spacing: -1px;
margin: 0 0 20px;
color: #1e293b;
}
.company-name {
background: linear-gradient(135deg, #1d4ed8, #0891b2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.system-name {
color: #0f172a;
}
.brand-sub-desc {
font-size: 15px;
color: #64748b;
line-height: 1.8;
margin: 0;
font-weight: 500;
}
/* 特征卡片网格 */
.brand-features-grid {
display: flex;
flex-direction: column;
gap: 16px;
margin: 32px 0;
width: 100%;
max-width: 520px;
}
.feature-card {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 18px 22px;
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 16px;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
cursor: default;
opacity: 0;
transform: translateY(20px);
animation: cardFadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.feature-card.delay-1 { animation-delay: 0.15s; }
.feature-card.delay-2 { animation-delay: 0.3s; }
.feature-card.delay-3 { animation-delay: 0.45s; }
@keyframes cardFadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.feature-card:hover {
background: rgba(255, 255, 255, 0.55);
border-color: rgba(37, 99, 235, 0.22);
transform: translateY(-3px);
box-shadow:
0 12px 30px -10px rgba(37, 99, 235, 0.08),
0 4px 12px -2px rgba(0, 0, 0, 0.02);
}
.feature-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
background: rgba(37, 99, 235, 0.06);
border-radius: 10px;
color: #2563eb;
flex-shrink: 0;
transition: all 0.3s ease;
}
.feature-card:hover .feature-icon-wrapper {
background: #2563eb;
color: #ffffff;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35);
}
.feature-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.feature-title {
font-size: 15px;
font-weight: 700;
color: #1e293b;
margin: 0;
line-height: 1.4;
transition: color 0.3s;
}
.feature-card:hover .feature-title {
color: #0f172a;
}
.feature-desc {
font-size: 12px;
color: #64748b;
line-height: 1.6;
margin: 0;
font-weight: 500;
}
/* 底部声明 */
.brand-legal-info {
border-top: 1px solid rgba(148, 163, 184, 0.15);
padding-top: 24px;
}
.legal-item {
font-family: 'Outfit', sans-serif;
font-size: 11px;
color: #94a3b8;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin: 0;
}
/* ═══════════════════════
右侧:极简白底登录面板
═══════════════════════ */
.login-right-form {
flex: 1;
background: #ffffff;
padding: 60px 80px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* 上方浏览器兼容建议 */
.browser-tip {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 600px;
text-align: center;
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.download-link {
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
.download-link:hover {
text-decoration: underline;
}
/* 卡片容器 */
.login-card-container {
width: clamp(480px, 34vw, 560px);
max-width: calc(100vw - 40px);
display: flex;
flex-direction: column;
}
/* 顶部品牌Logo */
.login-brand-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 40px;
}
.brand-logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
background: #2563eb;
border-radius: 9px;
color: white;
}
.brand-divider {
width: 1.5px;
height: 18px;
background: #e2e8f0;
}
.brand-system-text {
font-family: 'Outfit', sans-serif;
font-size: 20px;
font-weight: 800;
color: #1e293b;
letter-spacing: -0.5px;
}
/* Tabs */
.login-tabs {
display: flex;
gap: 24px;
margin-bottom: 36px;
border-bottom: 1.5px solid #f1f5f9;
}
.tab-item {
font-size: 15px;
font-weight: 600;
color: #94a3b8;
padding-bottom: 10px;
cursor: pointer;
position: relative;
transition: color 0.2s;
user-select: none;
}
.tab-item.active {
color: #2563eb;
}
.tab-item.active::after {
content: "";
position: absolute;
bottom: -1.5px;
left: 0;
width: 100%;
height: 2px;
background: #2563eb;
border-radius: 2px;
}
.desktop-server-status {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
margin: -18px 0 24px;
padding: 10px 12px;
border: 1px solid #dbe7f5;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.desktop-server-label {
color: #64748b;
font-weight: 700;
}
.desktop-server-status code {
min-width: 0;
overflow-wrap: anywhere;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.desktop-server-action {
color: #2563eb;
font-weight: 700;
text-decoration: none;
}
.desktop-server-action:hover {
color: #1d4ed8;
}
/* ═══════════════════════
通知与错误提示
═══════════════════════ */
.logout-notice {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 24px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid;
}
.logout-notice--warning {
color: #b45309;
background: #fffbeb;
border-color: #fde68a;
}
.logout-notice--info {
color: #047857;
background: #f0fdf4;
border-color: #bbf7d0;
}
.ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
.ln-icon svg { width: 100%; height: 100%; }
.ln-body { display: flex; flex-direction: column; gap: 2px; }
.ln-body strong { font-size: 12px; font-weight: 700; }
.ln-body span { font-size: 11px; opacity: 0.9; }
.login-error-banner {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 20px;
padding: 12px 14px;
border-radius: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
.leb-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
.leb-icon svg { width: 100%; height: 100%; }
.leb-body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.leb-body strong { font-size: 12px; font-weight: 700; }
.leb-body span { font-size: 11px; opacity: 0.9; }
.leb-close {
flex-shrink: 0;
appearance: none;
background: none;
border: none;
padding: 2px;
cursor: pointer;
color: #f87171;
display: flex;
align-items: center;
justify-content: center;
}
.leb-close:hover { color: #b91c1c; }
.leb-close svg { width: 12px; height: 12px; }
/* ═══════════════════════
表单输入框 (下划线风格)
═══════════════════════ */
.login-form :deep(.el-form-item) {
margin-bottom: 22px;
}
.login-form :deep(.el-form-item__label) {
font-weight: 600;
color: #475569;
font-size: 13px;
padding-bottom: 6px;
line-height: 1;
}
.login-form :deep(.el-form-item__error) {
margin-top: 6px;
font-size: 11px;
color: #ef4444;
}
.login-input :deep(.el-input__wrapper) {
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border-bottom: 1.5px solid #e2e8f0;
padding: 6px 0 !important;
transition: border-color 0.25s ease;
}
.login-input :deep(.el-input__wrapper:hover) {
border-bottom-color: #cbd5e1;
}
.login-input :deep(.el-input__wrapper.is-focus) {
border-bottom-color: #2563eb !important;
}
.login-input :deep(.el-input__inner) {
height: 38px;
font-size: 15px;
color: #0f172a;
}
.login-input :deep(.el-input__inner::placeholder) {
color: #94a3b8;
}
/* 账号与邮箱域名组合输入 */
.email-unified-box {
display: flex;
align-items: center;
width: 100%;
border-bottom: 1.5px solid #e2e8f0;
transition: border-color 0.25s ease;
padding: 4px 0;
}
.email-unified-box:focus-within {
border-bottom-color: #2563eb;
}
.email-local-field,
.email-domain-field {
min-width: 0;
background: transparent;
border: none;
outline: none;
font-family: inherit;
font-size: 15px;
line-height: 1.5;
}
.email-local-field {
flex: 1;
color: #0f172a;
padding: 6px 0;
}
.email-local-field::placeholder {
color: #94a3b8;
}
.email-at-sign {
flex-shrink: 0;
padding: 0 4px;
color: #64748b;
font-size: 15px;
font-weight: 600;
user-select: none;
}
.email-domain-wrapper {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
max-width: 55%;
}
.email-domain-field {
width: auto;
max-width: 100%;
appearance: none;
-webkit-appearance: none;
color: #1e3a5f;
font-weight: 600;
padding: 6px 22px 6px 4px;
cursor: pointer;
}
.email-domain-field:disabled {
color: #94a3b8;
cursor: not-allowed;
}
.email-domain-chevron {
position: absolute;
right: 2px;
top: 50%;
width: 14px;
height: 14px;
color: #94a3b8;
pointer-events: none;
transform: translateY(-50%);
}
/* 协议区 */
.login-options {
margin: 4px 0 24px;
}
.protocol-checkbox :deep(.el-checkbox__input .el-checkbox__inner) {
border-color: #cbd5e1;
border-radius: 4px;
}
.protocol-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background: #2563eb;
border-color: #2563eb;
}
.protocol-checkbox :deep(.el-checkbox__label) {
padding-left: 8px;
white-space: normal;
line-height: 1.6;
}
.protocol-text {
color: #64748b;
font-size: 12px;
}
.protocol-link {
appearance: none;
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-weight: 600;
cursor: pointer;
color: #2563eb;
}
.protocol-link:hover {
text-decoration: underline;
}
/* 按钮 */
.login-btn {
width: 100%;
height: 48px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
background: #2563eb;
border: none;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
transition: all 0.2s ease;
}
.login-btn:hover {
background: #1d4ed8;
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.3);
transform: translateY(-1px);
}
.login-btn:active {
transform: translateY(0);
}
/* 注册与忘记密码左右分开 */
.forgot-register-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
}
.forgot-link, .register-link {
font-size: 13px;
color: #2563eb;
text-decoration: none;
font-weight: 500;
transition: color 0.15s;
}
.forgot-link:hover, .register-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
/* ═══════════════════════
右侧底部页脚 (支持与版权)
═══════════════════════ */
.login-right-footer {
margin-top: 40px;
border-top: 1px solid #f1f5f9;
padding-top: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.footer-row {
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: 11px;
color: #94a3b8;
line-height: 1.5;
}
.footer-label {
font-weight: 500;
color: #64748b;
}
.footer-link-text {
color: #64748b;
text-decoration: none;
font-weight: 500;
transition: all 0.2s;
}
.footer-link-text:hover {
text-decoration: underline;
color: #2563eb;
}
.copyright-line {
margin-top: 2px;
}
.lang-switch {
font-weight: 600;
color: #64748b;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.meta-sep {
color: #e2e8f0;
margin: 0 8px;
}
.privacy-link {
cursor: pointer;
transition: color 0.2s;
}
.privacy-link:hover {
color: #475569;
}
.compliance-line {
color: #94a3b8;
}
.version-tag {
font-weight: 600;
color: #64748b;
}
.compliance-text {
opacity: 0.85;
}
/* ═══════════════════════
协议弹窗
═══════════════════════ */
.protocol-content {
max-height: 58vh;
overflow-y: auto;
padding: 2px 8px 2px 0;
}
.protocol-section { margin-bottom: 26px; }
.protocol-section:last-child { margin-bottom: 0; }
.protocol-section h3 { margin: 0 0 14px; font-size: 18px; font-weight: 800; line-height: 1.4; color: #111827; }
.protocol-section p { margin: 0 0 10px; font-size: 15px; font-weight: 400; line-height: 1.8; color: #6b7280; }
.protocol-section p:last-child { margin-bottom: 0; }
.protocol-confirm-btn {
min-width: 112px;
height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
background: #2563eb;
border: none;
box-shadow: 0 8px 20px -4px rgba(37, 99, 235, 0.4);
}
.protocol-confirm-btn:hover {
background: #1d4ed8;
}
:global(.protocol-dialog .el-dialog) {
border-radius: 20px;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.1);
}
:global(.protocol-dialog .el-dialog__header) {
padding: 24px 28px 12px;
margin-right: 0;
background: #fafafa;
}
:global(.protocol-dialog .el-dialog__title) {
font-size: 18px;
font-weight: 800;
color: #111827;
}
:global(.protocol-dialog .el-dialog__body) { padding: 22px 36px 10px; }
:global(.protocol-dialog .el-dialog__footer) { padding: 18px 28px 24px; }
/* ═══════════════════════
响应式断点适配
═══════════════════════ */
/* 大屏保持 1:1 分栏,通过扩大有效内容区改善内容密度 */
@media (min-width: 1440px) {
.login-left-brand {
flex: 1;
padding: clamp(64px, 5vw, 96px);
}
.login-right-form {
flex: 1;
padding: clamp(64px, 6vw, 112px);
}
.brand-slogan-block,
.brand-features-grid {
max-width: 640px;
}
.brand-main-title {
font-size: clamp(38px, 2.4vw, 48px);
}
.brand-sub-desc {
font-size: 16px;
}
.feature-card {
padding: 20px 24px;
}
.login-card-container {
max-width: 440px;
}
}
@media (max-width: 960px) {
.login-left-brand {
display: none;
}
.login-right-form {
padding: 40px 24px;
width: 100%;
}
.browser-tip {
display: none;
}
.login-card-container {
max-width: 440px;
}
.login-right-footer {
margin-top: 40px;
}
}
@media (max-width: 480px) {
.login-brand-header {
margin-bottom: 28px;
}
.login-tabs {
margin-bottom: 24px;
}
.login-right-footer {
margin-top: 32px;
}
}
</style>