这是一个系列教程,作者将以一个 PHP 全栈工程师的身份,利用 AI 工具(claude code、codex、deepseek、豆包等):从零开始学习 golang 语言,并最终完成 ai-go-mall 项目的制作。
在上一期,我们已经完成 "前端路由初始化",本期将完成:眨眼小人登录页制作
静态登录页制作
我们之前已经建立了空白的 /@/views/admin/login.vue 登录页 vue 组件,并将其注册为了静态路由:
ts
import { adminBaseRoutePath } from '/@/router/static/adminBase'
const staticRoutes: Array<RouteRecordRaw> = [
// ...
{
// 管理员登录页 - 路由不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局
path: adminBaseRoutePath + '/login',
name: 'adminLogin',
component: () => import('/@/views/admin/login.vue'),
meta: {
title: `pageTitles.Login`,
},
},
// ...
]
接下来先让 cc 实现登录页面的制作,之前在网上关注到一款左边三个小人,右边表单带交互的开源登录页还不错:将 https://gitee.com/niumg9527/login-animation 的开源登录页,移植至 @src\views\admin\login.vue
bash
✅ 移植完成
已将开源登录动画页的核心功能移植至 src/views/admin/login.vue,编译通过。以下是实现概览:
实现的交互特性
┌───────────────────┬───────────────────────────────────────────────────────────────────┐
│ 特性 │ 实现方式 │
├───────────────────┼───────────────────────────────────────────────────────────────────┤
│ 👀 眼睛跟随鼠标 │ mousemove → 视口坐标转 SVG viewBox 坐标 → Math.atan2 计算瞳孔偏移 │
├───────────────────┼───────────────────────────────────────────────────────────────────┤
│ 😉 随机眨眼 │ setTimeout 随机间隔 2~6s,25% 概率双眨,CSS scaleY 动画 │
├───────────────────┼───────────────────────────────────────────────────────────────────┤
│ 🙈 密码聚焦遮眼 │ 手臂 SVG path 双套切换(opacity 过渡),角色遮住眼睛 │
├───────────────────┼───────────────────────────────────────────────────────────────────┤
│ 👀 密码可见时偷看 │ 粉色角色倾斜 + 右臂半透明 │
├───────────────────┼───────────────────────────────────────────────────────────────────┤
│ 😊 输入时表情变化 │ 嘴巴 d 属性根据 focusedField 动态计算 │
├───────────────────┼───────────────────────────────────────────────────────────────────┤
│ 😫 校验失败摇头 │ translateX 关键帧动画,500ms │
├───────────────────┼───────────────────────────────────────────────────────────────────┤
│ 📱 响应式 │ ≤768px 隐藏左侧动画面板,表单全宽 │
└───────────────────┴───────────────────────────────────────────────────────────────────┘
文件清单
src/views/admin/login.vue # 登录页组件(模板 + 脚本 + 样式)
src/lang/zh-cn/login.ts # 中文翻译
src/views/admin/login.vue # 登录页组件(模板 + 脚本 + 样式)
src/lang/zh-cn/login.ts # 中文翻译
src/lang/en/login.ts # 英文翻译
架构说明
- SVG 角色:两个卡通角色,蓝色(左)和粉色(右),包含身体、呆毛、腮红、脚、手臂、眼睛(巩膜 + 瞳孔 + 高光)
- 状态驱动:focusedField / showPassword / isBlinking / isShaking 驱动所有动画
- Element Plus 表单:用户名 + 密码,带前缀图标和校验规则
- 待接入:onLogin 中的 TODO 标记处替换为实际 API 调用
✻ Cogitated for 5m 24s
cc 刚开始无法访问 gitee,然后自动尝试了 github、csdn 等,都失败了,自己用了网页搜索搓出来的版本如下:

说实话,挺 low 的,实际体验中,也发现了动画效果一般,缺乏细节等问题,这里再次让它直接使用 git clone 把示例项目拉下来参考:
bash
已按原始项目精确重写
成功 git clone 了 niumg9527/login-animation 仓库,获取到全部 4 个源文件并据此完全重写了 login.vue。
原始项目 vs 当前实现对照
┌────────────────────────┬────────────────────────────────────────────┐
│ 原始文件 │ 当前实现 │
├────────────────────────┼────────────────────────────────────────────┤
│ LoginPage.vue │ 主模板(左右两栏布局 + Element Plus 表单) │
├────────────────────────┼────────────────────────────────────────────┤
│ AnimatedCharacters.vue │ 角色容器 + 全部动画逻辑(在同一组件内) │
├────────────────────────┼────────────────────────────────────────────┤
│ EyeBall.vue │ .eyeball + .pupil-inner CSS 类(内联实现) │
├────────────────────────┼────────────────────────────────────────────┤
│ Pupil.vue │ .pupil-dot CSS 类(内联实现) │
└────────────────────────┴────────────────────────────────────────────┘
4 个角色(CSS div,非 SVG)
┌────────┬─────────┬──────────────────────────────────────────────────┐
│ 角色 │ 颜色 │ 实现 │
├────────┼─────────┼──────────────────────────────────────────────────┤
│ Purple │ #6C3FF5 │ 180×400px,2 个 EyeBall(白眼球 + 瞳孔),可眨眼 │
├────────┼─────────┼──────────────────────────────────────────────────┤
│ Black │ #2D2D2D │ 120×310px,2 个 EyeBall,可眨眼 │
├────────┼─────────┼──────────────────────────────────────────────────┤
│ Orange │ #FF9B6B │ 240×200px 半圆,2 个 Pupil(纯瞳孔) │
├────────┼─────────┼──────────────────────────────────────────────────┤
│ Yellow │ #E8D754 │ 140×230px 半圆,2 个 Pupil + 嘴巴 │
└────────┴─────────┴──────────────────────────────────────────────────┘
交互状态机(与原始一致)
正常 → 输入用户名(isTyping) → leaning + 对视 800ms
→ 输入密码(hasSecret, !visible) → leaning
→ 密码可见(hiding) → 角色归正无倾斜 + 遮眼
→ Purple 随机偷看
- 瞳孔追踪:rAF 循环内直接操作 DOM style.transform,不触发 Vue 响应式(与原始一致,性能最优)
- 身体倾斜:skewX + translateX 跟随鼠标,通过 Vue computed styles 响应式更新
- 随机眨眼:Purple/Black 各自独立定时器,2~6s 随机间隔
- 偷看:密码可见时 Purple 角色 2~5s 后偷看 800ms(瞳孔偏移 +4px)
✻ Cogitated for 3m 50s
这一次,效果非常棒,如下:

而且左边的 4 个角色,全部都是用 div + css 画的,并未使用图片
接下来是人工 review,先找找明显的问题,让 ai 继续改,第一轮:
- body 元素自带 8px,于 src\styles 建立 app.scss 文件,去除所有标签的默认 margin、padding
- 于 src\styles 建立 index.scss 文件,其中引用 app.scss,随后在 main.ts 导入 index.scss(后续增加全局样式只需要在 index.scss 中引用即可)
- 密码输入框的 @visible-change="onPasswordVisibleChange" 是无效的,需改用其他写法
- 输入框下面,提交按钮上面增加 "记住 30 天" 选项
第二轮:
- 调淡 "记住 30 天" 的文字和多选框颜色,同账号输入框边框颜色一致
- 去除 errorMsg,后续该报错由接口返回,无需前端管理
- 为账号密码输入框绑定 ref,如果初始化后账号无值则自动聚焦账号输入框,密码无值则自动聚焦密码输入框
- 密码输入框的显示状态不再使用自定义插槽 suffix 实现,而是直接判断 inputRef.value?.passwordVisible 是值即可,可以使用 computed 包装它
手动优化了一些细节,比如 css 代码中:
scss
.submit-btn {
width: 100%;
}
:deep(.el-button--large) {
border-radius: 6px;
font-size: 16px;
height: 48px;
}
// 可优化为:
.submit-btn {
width: 100%;
--el-font-size-base: var(--el-font-size-medium);
--el-button-size: 48px;
}
最终效果如下:
- 页面刷新后鼠标未动时角色看向页面正中间
- 4个角色眼睛跟随鼠标转动
- 输入框聚焦时紫色和黑色角色对视
- 切换密码隐藏显示时,所有角色眼神转向,紫色角色偶尔偷看
- 偶尔眨眼
- ...

最终核心代码如下:
vue
<template>
<div class="login-page">
<!-- ==================== 左侧:角色动画面板 ==================== -->
<div class="left-panel" :style="{ background: `linear-gradient(135deg, ${primaryColor}e6, ${primaryColor}, ${primaryColor}cc)` }">
<!-- 站点名称 -->
<div class="site-name">
<span>AI GO MALL</span>
</div>
<!-- 角色区域 -->
<div class="characters-area">
<div class="characters-container">
<!-- Purple 角色 -->
<div ref="purpleRef" class="char purple" :style="purpleBodyStyle">
<div class="eyes" :style="purpleEyesStyle">
<div
v-for="i in 2"
:key="'pe' + i"
:ref="(el) => setEyeRef(el as HTMLElement, 'purple', i - 1)"
class="eyeball"
:class="{ blinking: isPurpleBlinking }"
data-max-dist="5"
>
<div v-if="!isPurpleBlinking" class="pupil-inner" :data-eye-key="'purple-' + (i - 1)" />
</div>
</div>
</div>
<!-- Black 角色 -->
<div ref="blackRef" class="char black" :style="blackBodyStyle">
<div class="eyes" :style="blackEyesStyle">
<div
v-for="i in 2"
:key="'be' + i"
:ref="(el) => setEyeRef(el as HTMLElement, 'black', i - 1)"
class="eyeball"
:class="{ blinking: isBlackBlinking }"
data-max-dist="4"
>
<div v-if="!isBlackBlinking" class="pupil-inner" :data-eye-key="'black-' + (i - 1)" />
</div>
</div>
</div>
<!-- Orange 角色 -->
<div ref="orangeRef" class="char orange" :style="orangeBodyStyle">
<div class="eyes" :style="orangeEyesStyle">
<div
v-for="i in 2"
:key="'oe' + i"
:ref="(el) => setEyeRef(el as HTMLElement, 'orange', i - 1)"
class="pupil-dot"
:data-eye-key="'orange-' + (i - 1)"
data-max-dist="5"
/>
</div>
</div>
<!-- Yellow 角色 -->
<div ref="yellowRef" class="char yellow" :style="yellowBodyStyle">
<div class="eyes" :style="yellowEyesStyle">
<div
v-for="i in 2"
:key="'ye' + i"
:ref="(el) => setEyeRef(el as HTMLElement, 'yellow', i - 1)"
class="pupil-dot"
:data-eye-key="'yellow-' + (i - 1)"
data-max-dist="5"
/>
</div>
<div class="mouth" :style="yellowMouthStyle" />
</div>
</div>
</div>
<!-- 底部链接 -->
<div class="footer-links">
<a href="#">©{{ new Date().getFullYear() }} AI GO MALL</a>
<a href="http://beian.miit.gov.cn/" target="_blank">渝ICP备8888888号-1</a>
</div>
<!-- 装饰 -->
<div class="deco-grid" />
<div class="deco-circle deco-circle-1" />
<div class="deco-circle deco-circle-2" />
</div>
<!-- ==================== 右侧:登录表单 ==================== -->
<div class="right-panel">
<div class="form-wrapper">
<div class="form-header">
<h1>{{ $t('login.login') }}</h1>
<p>{{ $t('login.welcomePrompt', { siteName: 'AI GO MALL' }) }}</p>
</div>
<el-form ref="formRef" :model="loginForm" :rules="rules" size="large" @keyup.enter="onSubmit">
<el-form-item prop="username">
<el-input
ref="usernameInputRef"
v-model="loginForm.username"
:placeholder="t('login.username')"
:prefix-icon="User"
clearable
@focus="isTyping = true"
@blur="isTyping = false"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
ref="passwordInputRef"
v-model="loginForm.password"
type="password"
show-password
:placeholder="t('login.password')"
:prefix-icon="Lock"
clearable
/>
</el-form-item>
<div class="options">
<label class="remember">
<el-checkbox v-model="loginForm.remember" :label="t('login.remember')" />
</label>
</div>
<el-form-item>
<el-button size="large" type="primary" :loading="submitLoading" class="submit-btn" @click="onSubmit">
{{ submitLoading ? t('login.loggingIn') : t('login.login') }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Lock, User } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, InputInstance } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// ==================== 表单相关 ====================
interface LoginForm {
username: string
password: string
remember: boolean
}
const loginForm = reactive<LoginForm>({
username: '',
password: '',
remember: false,
})
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const usernameInputRef = ref<InputInstance>()
const passwordInputRef = ref<InputInstance>()
const rules: FormRules<LoginForm> = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' },
],
}
async function onSubmit() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
submitLoading.value = true
try {
// TODO: 接入实际登录 API
await new Promise((resolve) => setTimeout(resolve, 1500))
} catch {
// 登录失败由接口返回处理
} finally {
submitLoading.value = false
}
}
// ==================== 角色动画状态 ====================
const primaryColor = '#409eff'
// 核心状态
const isTyping = ref(false)
const isPurpleBlinking = ref(false)
const isBlackBlinking = ref(false)
const isLookingAtEachOther = ref(false)
const isPurplePeeking = ref(false)
// 派生状态
const showPassword = computed(() => passwordInputRef.value?.passwordVisible ?? false)
const hasSecret = computed(() => !!loginForm.password)
const hiding = computed(() => hasSecret.value && showPassword.value)
const leaning = computed(() => isTyping.value || (hasSecret.value && !showPassword.value))
// 鼠标位置和角色 DOM refs
const mouseX = ref(0)
const mouseY = ref(0)
const purpleRef = ref<HTMLElement | null>(null)
const blackRef = ref<HTMLElement | null>(null)
const orangeRef = ref<HTMLElement | null>(null)
const yellowRef = ref<HTMLElement | null>(null)
// 角色身体位置状态
const purplePos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const blackPos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const orangePos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const yellowPos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
// 眼睛元素引用收集
const eyeElements = new Map<string, HTMLElement>()
function setEyeRef(el: HTMLElement | null, char: string, index: number) {
const key = `${char}-${index}`
if (el) {
eyeElements.set(key, el)
} else {
eyeElements.delete(key)
}
}
// ==================== 鼠标追踪与 rAF 循环 ====================
function calcPos(el: HTMLElement | null, target: { faceX: number; faceY: number; bodySkew: number }) {
if (!el) return
const r = el.getBoundingClientRect()
const dx = mouseX.value - (r.left + r.width / 2)
const dy = mouseY.value - (r.top + r.height / 3)
target.faceX = Math.max(-15, Math.min(15, dx / 20))
target.faceY = Math.max(-10, Math.min(10, dy / 30))
target.bodySkew = Math.max(-6, Math.min(6, -dx / 120))
}
let rafId = 0
function tick() {
// 更新角色身体位置
calcPos(purpleRef.value, purplePos)
calcPos(blackRef.value, blackPos)
calcPos(orangeRef.value, orangePos)
calcPos(yellowRef.value, yellowPos)
// 更新所有眼睛瞳孔位置
for (const [key, el] of eyeElements) {
// 检查是否需要强制看向某个方向
let forceX: number | undefined
let forceY: number | undefined
if (key.startsWith('purple-')) {
if (hiding.value) {
forceX = isPurplePeeking.value ? 4 : -4
forceY = isPurplePeeking.value ? 5 : -4
} else if (isLookingAtEachOther.value) {
forceX = 3
forceY = 4
}
} else if (key.startsWith('black-')) {
if (hiding.value) {
forceX = -4
forceY = -4
} else if (isLookingAtEachOther.value) {
forceX = 0
forceY = -4
}
} else if (hiding.value) {
forceX = -5
forceY = -4
}
const maxDist = parseFloat(el.dataset.maxDist || '5')
let pupilEl: HTMLElement | null
if (el.classList.contains('eyeball')) {
pupilEl = el.querySelector('.pupil-inner')
} else {
pupilEl = el
}
if (!pupilEl) continue
if (forceX !== undefined && forceY !== undefined) {
pupilEl.style.transform = `translate(${forceX}px, ${forceY}px)`
} else {
const r = el.getBoundingClientRect()
const dx = mouseX.value - (r.left + r.width / 2)
const dy = mouseY.value - (r.top + r.height / 2)
const dist = Math.min(Math.sqrt(dx * dx + dy * dy), maxDist)
const angle = Math.atan2(dy, dx)
pupilEl.style.transform = `translate(${Math.cos(angle) * dist}px, ${Math.sin(angle) * dist}px)`
}
}
rafId = requestAnimationFrame(tick)
}
function onMouseMove(e: MouseEvent) {
mouseX.value = e.clientX
mouseY.value = e.clientY
}
// ==================== 眨眼逻辑 ====================
function setupBlink(target: { value: boolean }) {
let timer: number
const go = () => {
timer = window.setTimeout(
() => {
target.value = true
window.setTimeout(() => {
target.value = false
go()
}, 150)
},
Math.random() * 4000 + 3000
)
}
go()
return () => clearTimeout(timer)
}
let stopPurpleBlink: (() => void) | undefined
let stopBlackBlink: (() => void) | undefined
// ==================== 相互对视 ====================
watch(isTyping, (v) => {
if (v) {
isLookingAtEachOther.value = true
setTimeout(() => {
isLookingAtEachOther.value = false
}, 800)
} else {
isLookingAtEachOther.value = false
}
})
// ==================== 紫色角色偷看 ====================
let peekTimer: number | undefined
watch([hasSecret, showPassword], () => {
clearTimeout(peekTimer)
if (hasSecret.value && showPassword.value) {
peekTimer = window.setTimeout(
() => {
isPurplePeeking.value = true
setTimeout(() => {
isPurplePeeking.value = false
}, 800)
},
Math.random() * 3000 + 2000
)
} else {
isPurplePeeking.value = false
}
})
// ==================== 角色样式计算 ====================
const purpleBodyStyle = computed(() => ({
height: leaning.value ? '440px' : '400px',
transform: hiding.value
? 'skewX(0deg)'
: leaning.value
? `skewX(${purplePos.bodySkew - 12}deg) translateX(40px)`
: `skewX(${purplePos.bodySkew}deg)`,
}))
const purpleEyesStyle = computed(() => ({
left: hiding.value ? '20px' : isLookingAtEachOther.value ? '55px' : `${45 + purplePos.faceX}px`,
top: hiding.value ? '35px' : isLookingAtEachOther.value ? '65px' : `${40 + purplePos.faceY}px`,
gap: '32px',
}))
const blackBodyStyle = computed(() => ({
transform: hiding.value
? 'skewX(0deg)'
: isLookingAtEachOther.value
? `skewX(${blackPos.bodySkew * 1.5 + 10}deg) translateX(20px)`
: leaning.value
? `skewX(${blackPos.bodySkew * 1.5}deg)`
: `skewX(${blackPos.bodySkew}deg)`,
}))
const blackEyesStyle = computed(() => ({
left: hiding.value ? '10px' : isLookingAtEachOther.value ? '32px' : `${26 + blackPos.faceX}px`,
top: hiding.value ? '28px' : isLookingAtEachOther.value ? '12px' : `${32 + blackPos.faceY}px`,
gap: '24px',
}))
const orangeBodyStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)' : `skewX(${orangePos.bodySkew}deg)`,
}))
const orangeEyesStyle = computed(() => ({
left: hiding.value ? '50px' : `${82 + orangePos.faceX}px`,
top: hiding.value ? '85px' : `${90 + orangePos.faceY}px`,
gap: '32px',
}))
const yellowBodyStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)' : `skewX(${yellowPos.bodySkew}deg)`,
}))
const yellowEyesStyle = computed(() => ({
left: hiding.value ? '20px' : `${52 + yellowPos.faceX}px`,
top: hiding.value ? '35px' : `${40 + yellowPos.faceY}px`,
gap: '24px',
}))
const yellowMouthStyle = computed(() => ({
left: hiding.value ? '10px' : `${40 + yellowPos.faceX}px`,
top: hiding.value ? '88px' : `${88 + yellowPos.faceY}px`,
}))
// ==================== 生命周期 ====================
onMounted(async () => {
window.addEventListener('mousemove', onMouseMove)
stopPurpleBlink = setupBlink(isPurpleBlinking)
stopBlackBlink = setupBlink(isBlackBlinking)
rafId = requestAnimationFrame(tick)
nextTick(() => {
if (!loginForm.username) {
usernameInputRef.value?.focus()
} else if (!loginForm.password) {
passwordInputRef.value?.focus()
}
})
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(rafId)
stopPurpleBlink?.()
stopBlackBlink?.()
clearTimeout(peekTimer)
})
</script>
<style scoped lang="scss">
// ==================== 整体布局 ====================
.login-page {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100vh;
}
// ==================== 左侧面板 ====================
.left-panel {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 48px;
color: white;
overflow: hidden;
}
.site-name {
position: relative;
font-size: 18px;
font-weight: 600;
}
// ==================== 角色容器 ====================
.characters-area {
position: relative;
z-index: 20;
display: flex;
align-items: flex-end;
justify-content: center;
height: 500px;
}
.characters-container {
position: relative;
width: 550px;
height: 400px;
}
// ==================== 角色通用样式 ====================
.char {
position: absolute;
bottom: 0;
transition: all 0.7s ease-in-out;
transform-origin: bottom center;
}
.eyes {
position: absolute;
display: flex;
transition: all 0.7s ease-in-out;
}
// ==================== Purple 角色 ====================
.purple {
left: 70px;
width: 180px;
background: #6c3ff5;
border-radius: 10px 10px 0 0;
z-index: 1;
}
// ==================== Black 角色 ====================
.black {
left: 240px;
width: 120px;
height: 310px;
background: #2d2d2d;
border-radius: 8px 8px 0 0;
z-index: 2;
}
// ==================== Orange 角色 ====================
.orange {
left: 0;
width: 240px;
height: 200px;
background: #ff9b6b;
border-radius: 120px 120px 0 0;
z-index: 3;
}
// ==================== Yellow 角色 ====================
.yellow {
left: 310px;
width: 140px;
height: 230px;
background: #e8d754;
border-radius: 70px 70px 0 0;
z-index: 4;
}
.mouth {
position: absolute;
width: 80px;
height: 4px;
background: #2d2d2d;
border-radius: 4px;
transition: all 0.2s ease-out;
}
// ==================== 眼睛组件 ====================
.eyeball {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
overflow: hidden;
background: white;
&.blinking {
height: 2px !important;
}
}
.purple .eyeball {
width: 18px;
height: 18px;
}
.black .eyeball {
width: 16px;
height: 16px;
}
.pupil-inner {
border-radius: 50%;
transition: transform 0.1s ease-out;
}
.purple .pupil-inner {
width: 7px;
height: 7px;
background: #2d2d2d;
}
.black .pupil-inner {
width: 6px;
height: 6px;
background: #2d2d2d;
}
.pupil-dot {
border-radius: 50%;
transition: transform 0.1s ease-out;
width: 12px;
height: 12px;
background: #2d2d2d;
}
// ==================== 底部链接 ====================
.footer-links {
position: relative;
z-index: 20;
display: flex;
gap: 16px;
font-size: 14px;
align-items: center;
a {
color: rgba(255, 255, 255, 0.6);
transition: color 0.2s;
text-decoration: none;
&:hover {
color: white;
}
}
}
// ==================== 装饰元素 ====================
.deco-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
}
.deco-circle {
position: absolute;
border-radius: 50%;
filter: blur(48px);
}
.deco-circle-1 {
top: 25%;
right: 25%;
width: 256px;
height: 256px;
background: rgba(255, 255, 255, 0.1);
}
.deco-circle-2 {
bottom: 25%;
left: 25%;
width: 384px;
height: 384px;
background: rgba(255, 255, 255, 0.05);
}
// ==================== 右侧面板 ====================
.right-panel {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: #fff;
}
.form-wrapper {
width: 100%;
max-width: 420px;
}
.form-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
margin: 0 0 8px;
}
p {
color: #71717a;
font-size: 14px;
margin: 0;
}
}
.options {
display: flex;
align-items: center;
font-size: 14px;
margin-bottom: 16px;
.remember {
display: flex;
align-items: center;
color: #d4d4d8;
cursor: pointer;
:deep(.el-checkbox) {
--el-checkbox-text-color: var(--el-text-color-placeholder);
}
}
}
.submit-btn {
width: 100%;
--el-font-size-base: var(--el-font-size-medium);
--el-button-size: 48px;
}
// ==================== 响应式 ====================
@media (max-width: 1023px) {
.login-page {
grid-template-columns: 1fr;
}
.left-panel {
display: none;
}
}
</style>