<template>
|
<div class="login">
|
<!-- 左侧:仪器控制台面板 -->
|
<aside class="login-brand">
|
<header class="brand-head">
|
<span class="brand-id">
|
<span class="brand-mark" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
|
stroke-linecap="round" stroke-linejoin="round">
|
<path d="M9 3h6" />
|
<path d="M10 3v5.2L5.4 17A2.6 2.6 0 0 0 7.8 21h8.4a2.6 2.6 0 0 0 2.4-4L14 8.2V3" />
|
<path d="M7.2 14h9.6" />
|
</svg>
|
</span>
|
<span class="brand-name">{{ title }}</span>
|
</span>
|
|
<span class="brand-console">
|
<i class="led" aria-hidden="true"></i>
|
<span>INSTRUMENT CONSOLE</span>
|
</span>
|
</header>
|
|
<!-- 量程刻度尺:纯装饰,给面板仪表感 -->
|
<div class="gauge" aria-hidden="true">
|
<div class="gauge-ticks"></div>
|
<div class="gauge-scale">
|
<span v-for="n in gaugeScale" :key="n">{{ n }}</span>
|
</div>
|
</div>
|
|
<!-- 刻度尺下方的静态读数轨迹:纯装饰 -->
|
<div class="trace" aria-hidden="true">
|
<svg viewBox="0 0 560 56" preserveAspectRatio="none" fill="none">
|
<polyline
|
points="0,50 20,50 30,48 40,50 60,50 70,44 80,50 110,50 120,20 130,8 140,20 150,50 170,50 180,30 190,50 210,50 220,46 230,50 260,50 270,38 280,50 300,50 310,50 320,14 330,6 340,16 350,50 380,50 390,34 400,50 430,50 440,49 450,50 480,50 490,26 500,50 520,50 540,50 560,50"
|
stroke="rgba(94, 234, 212, 0.5)"
|
stroke-width="1.5"
|
stroke-linejoin="round"
|
stroke-linecap="round"
|
vector-effect="non-scaling-stroke"
|
/>
|
</svg>
|
</div>
|
|
<div class="brand-copy">
|
<h1 class="brand-title">实验室信息管理系统</h1>
|
<p class="brand-sub">Laboratory Information Management System</p>
|
</div>
|
|
<ul class="brand-points">
|
<li v-for="(item, i) in capabilities" :key="item">
|
<span class="pt-idx">{{ i < 9 ? '0' + (i + 1) : i + 1 }}</span>
|
<span class="pt-txt">{{ item }}</span>
|
<span class="pt-line" aria-hidden="true"></span>
|
</li>
|
</ul>
|
|
<p class="brand-foot">{{ footerContent }}</p>
|
</aside>
|
|
<!-- 右侧:身份验证控制卡 -->
|
<main class="login-main">
|
<el-form
|
ref="loginRef"
|
:model="loginForm"
|
:rules="loginRules"
|
label-position="top"
|
class="login-form"
|
>
|
<div class="form-head">
|
<h2 class="form-title">
|
<i class="led" aria-hidden="true"></i>
|
身份验证
|
</h2>
|
<span class="form-tag">AUTH</span>
|
</div>
|
|
<el-form-item label="账号" prop="username">
|
<el-input
|
v-model="loginForm.username"
|
type="text"
|
size="large"
|
autocomplete="username"
|
placeholder="请输入账号"
|
>
|
<template #prefix><svg-icon icon-class="user" class="input-icon" /></template>
|
</el-input>
|
</el-form-item>
|
|
<el-form-item label="密码" prop="password">
|
<el-input
|
v-model="loginForm.password"
|
type="password"
|
size="large"
|
autocomplete="current-password"
|
placeholder="请输入密码"
|
@keyup.enter="handleLogin"
|
>
|
<template #prefix><svg-icon icon-class="password" class="input-icon" /></template>
|
</el-input>
|
</el-form-item>
|
|
<div class="form-actions">
|
<el-checkbox v-model="loginForm.rememberMe" class="remember">记住密码</el-checkbox>
|
<router-link v-if="register" class="link-type" :to="'/register'">立即注册</router-link>
|
</div>
|
|
<el-button
|
:loading="loading"
|
size="large"
|
type="primary"
|
class="submit-btn"
|
@click.prevent="handleLogin"
|
>
|
{{ loading ? '登 录 中' : '登 录' }}
|
</el-button>
|
</el-form>
|
</main>
|
</div>
|
</template>
|
|
<script setup>
|
import Cookies from "js-cookie"
|
import { encrypt, decrypt } from "@/utils/jsencrypt"
|
import useUserStore from '@/store/modules/user'
|
import defaultSettings from '@/settings'
|
|
const title = import.meta.env.VITE_APP_TITLE
|
const footerContent = defaultSettings.footerContent
|
// 装饰性刻度值
|
const gaugeScale = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
|
const capabilities = [
|
'样品全流程追溯',
|
'检验数据集中管理',
|
'体系文件与记录归档',
|
'设备与人员能力管控'
|
]
|
const userStore = useUserStore()
|
const route = useRoute()
|
const router = useRouter()
|
const { proxy } = getCurrentInstance()
|
|
const loginForm = ref({
|
username: "admin",
|
password: "admin123",
|
rememberMe: false
|
})
|
|
const loginRules = {
|
username: [{ required: true, trigger: "blur", message: "请输入您的账号" }],
|
password: [{ required: true, trigger: "blur", message: "请输入您的密码" }]
|
}
|
|
const loading = ref(false)
|
// 注册开关
|
const register = ref(false)
|
const redirect = ref(undefined)
|
|
watch(route, (newRoute) => {
|
redirect.value = newRoute.query && newRoute.query.redirect
|
}, { immediate: true })
|
|
function handleLogin() {
|
proxy.$refs.loginRef.validate(valid => {
|
if (valid) {
|
loading.value = true
|
// 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码
|
if (loginForm.value.rememberMe) {
|
Cookies.set("username", loginForm.value.username, { expires: 30 })
|
Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 })
|
Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 })
|
} else {
|
// 否则移除
|
Cookies.remove("username")
|
Cookies.remove("password")
|
Cookies.remove("rememberMe")
|
}
|
// 调用action的登录方法
|
userStore.login(loginForm.value).then(() => {
|
const query = route.query
|
const otherQueryParams = Object.keys(query).reduce((acc, cur) => {
|
if (cur !== "redirect") {
|
acc[cur] = query[cur]
|
}
|
return acc
|
}, {})
|
router.push({ path: redirect.value || "/", query: otherQueryParams })
|
}).catch(() => {
|
loading.value = false
|
})
|
}
|
})
|
}
|
|
function getCookie() {
|
const username = Cookies.get("username")
|
const password = Cookies.get("password")
|
const rememberMe = Cookies.get("rememberMe")
|
loginForm.value = {
|
username: username === undefined ? loginForm.value.username : username,
|
password: password === undefined ? loginForm.value.password : decrypt(password),
|
rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
|
}
|
}
|
|
getCookie()
|
</script>
|
|
<style lang='scss' scoped>
|
$console-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
.login {
|
display: flex;
|
height: 100%;
|
background: var(--lims-bg);
|
}
|
|
/* ============ 左侧:仪器控制台面板 ============ */
|
.login-brand {
|
position: relative;
|
overflow: hidden;
|
flex: 0 0 46%;
|
min-width: 400px;
|
padding: 40px 48px 32px;
|
display: flex;
|
flex-direction: column;
|
color: #fff;
|
/* 实验室笔记本细网格 + 青绿渐变。
|
底色压到 teal-700 一档:白色正文在 #0d9488 上只有 3.3:1,达不到 AA 4.5:1。 */
|
background-color: #0f766e;
|
background-image:
|
linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px),
|
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px),
|
linear-gradient(158deg, #0f766e 0%, #0d6a62 50%, #0a5750 100%);
|
background-size: 32px 32px, 32px 32px, 100% 100%;
|
}
|
|
/* --- 顶栏:标识 + 控制台铭牌 --- */
|
.brand-head {
|
flex-shrink: 0;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 16px;
|
padding-bottom: 14px;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.22);
|
|
.brand-id {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
}
|
|
.brand-mark {
|
width: 30px;
|
height: 30px;
|
flex-shrink: 0;
|
border-radius: 7px;
|
background: rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.26);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
|
svg {
|
width: 17px;
|
height: 17px;
|
}
|
}
|
|
.brand-name {
|
font-family: $console-mono;
|
font-size: 16px;
|
font-weight: 600;
|
letter-spacing: 4px;
|
color: rgba(255, 255, 255, 0.97);
|
}
|
|
.brand-console {
|
display: flex;
|
align-items: center;
|
gap: 7px;
|
font-family: $console-mono;
|
font-size: 10px;
|
letter-spacing: 1.6px;
|
/* 纯装饰铭牌,不承载信息 */
|
color: rgba(255, 255, 255, 0.78);
|
white-space: nowrap;
|
|
.led {
|
color: #5eead4;
|
}
|
}
|
}
|
|
/* LED 指示灯:颜色跟随上下文,纯装饰 */
|
.led {
|
width: 6px;
|
height: 6px;
|
flex-shrink: 0;
|
border-radius: 50%;
|
background: currentColor;
|
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 22%, transparent);
|
animation: led-pulse 2.6s ease-in-out infinite;
|
}
|
|
@keyframes led-pulse {
|
|
0%,
|
100% {
|
opacity: 1;
|
}
|
|
50% {
|
opacity: 0.35;
|
}
|
}
|
|
/* --- 量程刻度尺(装饰) --- */
|
.gauge {
|
flex-shrink: 0;
|
margin-top: 18px;
|
}
|
|
.gauge-ticks {
|
height: 14px;
|
background-image:
|
linear-gradient(to right, rgba(255, 255, 255, 0.5) 0 1px, transparent 1px),
|
linear-gradient(to right, rgba(255, 255, 255, 0.2) 0 1px, transparent 1px);
|
background-size: 28px 14px, 7px 7px;
|
background-position: 0 100%, 0 100%;
|
background-repeat: repeat-x, repeat-x;
|
}
|
|
.gauge-scale {
|
display: flex;
|
justify-content: space-between;
|
font-family: $console-mono;
|
font-size: 10px;
|
letter-spacing: 0.5px;
|
color: rgba(255, 255, 255, 0.6);
|
font-variant-numeric: tabular-nums;
|
}
|
|
/* --- 读数轨迹:刻度尺下方的静态波形(装饰) --- */
|
.trace {
|
flex-shrink: 0;
|
height: 56px;
|
margin-top: 20px;
|
|
svg {
|
display: block;
|
width: 100%;
|
height: 100%;
|
}
|
}
|
|
/* 品牌主体:上下各留一个 auto 外边距,让中段在头/脚之间居中 */
|
.brand-copy {
|
flex: 0 0 auto;
|
margin-top: auto;
|
|
.brand-title {
|
margin: 0;
|
font-size: 30px;
|
font-weight: 600;
|
line-height: 1.34;
|
letter-spacing: 1px;
|
}
|
|
.brand-sub {
|
margin: 10px 0 0;
|
font-size: 11px;
|
line-height: 1.6;
|
letter-spacing: 0.9px;
|
color: rgba(255, 255, 255, 0.88);
|
}
|
}
|
|
/* 能力清单:细线分隔 + 序号 + 点状引导线 */
|
.brand-points {
|
flex-shrink: 0;
|
margin: 26px 0 0;
|
padding: 0;
|
list-style: none;
|
|
li {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
padding: 10px 0;
|
font-size: 13.5px;
|
line-height: 1.3;
|
color: rgba(255, 255, 255, 0.95);
|
border-top: 1px solid rgba(255, 255, 255, 0.13);
|
|
&:last-child {
|
border-bottom: 1px solid rgba(255, 255, 255, 0.13);
|
}
|
}
|
|
.pt-idx {
|
flex-shrink: 0;
|
font-family: $console-mono;
|
font-size: 10.5px;
|
font-weight: 600;
|
letter-spacing: 0.5px;
|
/* 纯装饰序号,不承载信息,允许低于正文对比度 */
|
color: rgba(255, 255, 255, 0.62);
|
font-variant-numeric: tabular-nums;
|
}
|
|
.pt-txt {
|
flex-shrink: 0;
|
}
|
|
/* 点状引导线,把行右侧填满,像仪表面板上的通道标尺 */
|
.pt-line {
|
flex: 1 1 auto;
|
min-width: 12px;
|
height: 1px;
|
background-image: repeating-linear-gradient(to right,
|
rgba(255, 255, 255, 0.22) 0 2px,
|
transparent 2px 7px);
|
}
|
}
|
|
.brand-foot {
|
flex-shrink: 0;
|
margin: auto 0 0;
|
font-size: 11px;
|
line-height: 1.6;
|
color: rgba(255, 255, 255, 0.85);
|
}
|
|
/* ============ 右侧:身份验证控制卡 ============ */
|
.login-main {
|
flex: 1 1 auto;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
padding: 40px 32px;
|
overflow-y: auto;
|
background: var(--lims-bg);
|
}
|
|
.login-form {
|
position: relative;
|
width: 392px;
|
max-width: 100%;
|
padding: 0 32px 28px;
|
background: var(--lims-surface);
|
border: 1px solid var(--lims-border);
|
border-radius: var(--lims-radius);
|
box-shadow:
|
0 18px 42px -22px rgba(16, 40, 38, 0.22),
|
0 2px 8px rgba(16, 40, 38, 0.05);
|
|
/* 卡头做成仪表盘标题条 */
|
.form-head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
margin: 0 -32px 24px;
|
padding: 13px 20px;
|
background: var(--lims-surface-2);
|
border-bottom: 1px solid var(--lims-border);
|
border-radius: var(--lims-radius) var(--lims-radius) 0 0;
|
}
|
|
.form-title {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
margin: 0;
|
font-size: 15px;
|
font-weight: 600;
|
letter-spacing: 1px;
|
color: var(--lims-text);
|
|
.led {
|
color: var(--el-color-primary);
|
}
|
}
|
|
.form-tag {
|
font-family: $console-mono;
|
font-size: 10px;
|
letter-spacing: 2px;
|
color: var(--lims-text-3);
|
}
|
|
:deep(.el-form-item) {
|
margin-bottom: 16px;
|
}
|
|
:deep(.el-form-item__label) {
|
padding-bottom: 6px;
|
font-size: 12px;
|
font-weight: 600;
|
letter-spacing: 0.14em;
|
line-height: 1.4;
|
color: var(--lims-text-2);
|
}
|
|
:deep(.el-input__wrapper) {
|
border-radius: var(--lims-radius-sm);
|
padding: 1px 12px;
|
box-shadow: 0 0 0 1px var(--lims-border) inset;
|
transition: box-shadow 0.16s ease;
|
|
&:hover {
|
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
|
}
|
|
&.is-focus {
|
box-shadow:
|
0 0 0 1px var(--el-color-primary) inset,
|
0 0 0 3px var(--el-color-primary-light-9);
|
}
|
}
|
|
/* 等宽输入,贴合仪表读数 */
|
:deep(.el-input__inner) {
|
font-family: $console-mono;
|
font-variant-numeric: tabular-nums;
|
letter-spacing: 0.05em;
|
}
|
|
.input-icon {
|
width: 14px;
|
color: var(--lims-text-3);
|
}
|
|
:deep(.el-button--primary) {
|
width: 100%;
|
height: 46px;
|
font-size: 14px;
|
font-weight: 600;
|
letter-spacing: 4px;
|
border: none;
|
border-radius: var(--lims-radius-sm);
|
background-image: linear-gradient(135deg, #12a594, #0d9488 58%, #0b6b61);
|
transition: filter 0.16s ease, box-shadow 0.16s ease;
|
|
&:hover:not(.is-disabled) {
|
filter: brightness(1.06);
|
box-shadow: 0 8px 18px -10px rgba(13, 148, 136, 0.7);
|
}
|
|
&:focus-visible {
|
outline: 2px solid var(--el-color-primary-dark-2);
|
outline-offset: 2px;
|
}
|
}
|
}
|
|
/* 仪表裁切角标:对角两个 L 形细线,纯装饰 */
|
.login-form::before,
|
.login-form::after {
|
content: "";
|
position: absolute;
|
width: 12px;
|
height: 12px;
|
border: 0 solid var(--el-color-primary);
|
opacity: 0.55;
|
pointer-events: none;
|
}
|
|
.login-form::before {
|
top: -1px;
|
left: -1px;
|
border-top-width: 1px;
|
border-left-width: 1px;
|
border-top-left-radius: var(--lims-radius);
|
}
|
|
.login-form::after {
|
right: -1px;
|
bottom: -1px;
|
border-right-width: 1px;
|
border-bottom-width: 1px;
|
border-bottom-right-radius: var(--lims-radius);
|
}
|
|
/* 记住密码 + 立即注册 */
|
.form-actions {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin: 0 0 18px;
|
|
.remember {
|
color: var(--lims-text-2);
|
}
|
|
.link-type {
|
font-size: 13px;
|
}
|
|
.link-type:focus-visible {
|
outline: 2px solid var(--el-color-primary);
|
outline-offset: 2px;
|
border-radius: 3px;
|
}
|
}
|
|
/* 窄屏:收窄品牌面板,铭牌让位给标识 */
|
@media (max-width: 1024px) {
|
.login-brand {
|
flex-basis: 40%;
|
min-width: 320px;
|
padding: 30px 32px 26px;
|
|
.brand-head .brand-console {
|
display: none;
|
}
|
|
.brand-copy .brand-title {
|
font-size: 25px;
|
}
|
}
|
}
|
|
@media (max-width: 900px) {
|
.login-brand {
|
display: none;
|
}
|
|
.login-main {
|
flex: 1 1 100%;
|
}
|
}
|
|
html.dark .login-brand {
|
background-image:
|
linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
linear-gradient(158deg, #0f766e 0%, #0d5f57 55%, #10403c 100%);
|
}
|
|
html.dark .login-form {
|
background: var(--el-bg-color-overlay);
|
border-color: var(--lims-border);
|
box-shadow:
|
0 18px 42px -22px rgba(0, 0, 0, 0.6),
|
0 2px 8px rgba(0, 0, 0, 0.28);
|
}
|
</style>
|