PayrollManagementSystem/frontend/src/views/login/index.vue
liuwenxin d7fbec1177 feat(system): 登录页重做、布局白化与首页 Bento 仪表盘(视觉体系统一落地)
- 登录页:雾灰点阵底 + 右上涟漪同心圆 + 顶部靛蓝天光;居中纸白卡片(品牌区/欢迎回来/表单);逻辑不变
- BasicLayout:侧栏深色面板改为中性灰微渐变 + 白底浮起当前项(左 2px 靛蓝条);logo 区水线;内容区雾灰点阵
- 首页:Bento Grid 仪表盘(参数 16 项/岗位 3 个为真实数据;绩效/回款/待办为业务上线前的精致空态)
- AppMenu:激活项白底浮起样式,菜单变量随浅色侧栏调整

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 00:09:41 +08:00

395 lines
9.4 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.

<script setup lang="ts">
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/api/auth'
import { useUserStore } from '@/store/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const captchaLoading = ref(false)
const form = reactive({
username: '',
password: '',
captcha_code: '',
})
const captchaId = ref('')
const captchaImage = ref('')
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
captcha_code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 4, message: '验证码为 4 位', trigger: 'blur' },
],
}
/** 加载或刷新验证码。点击图片亦可触发。 */
async function refreshCaptcha(): Promise<void> {
captchaLoading.value = true
try {
const data = await getCaptcha()
captchaId.value = data.captcha_id
// 后端返回纯 base64,data URL 前缀由前端拼接
captchaImage.value = `data:image/png;base64,${data.image_base64}`
form.captcha_code = ''
} catch {
// 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态
} finally {
captchaLoading.value = false
}
}
async function handleSubmit(): Promise<void> {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await userStore.login({
username: form.username.trim(),
password: form.password,
captcha_id: captchaId.value,
captcha_code: form.captcha_code.trim(),
})
ElMessage.success('登录成功')
await router.replace((route.query.redirect as string) || '/dashboard')
} catch {
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
await refreshCaptcha()
} finally {
submitting.value = false
}
}
onMounted(refreshCaptcha)
</script>
<template>
<div class="login">
<!-- 背景装饰:右上涟漪同心圆(「衍水」品牌意象);顶部天光由 .login::before 承担 -->
<div class="login-ripple" aria-hidden="true">
<i></i><i></i><i></i><i></i>
</div>
<div class="login-wrap">
<div class="login-card">
<div class="card-brand">
<div class="card-brand__name">衍水集团</div>
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
<span class="waterline"></span>
</div>
<div class="card-head">
<h2 class="card-title">欢迎回来</h2>
<p class="card-hint">请输入账号密码进入系统</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@keyup.enter="handleSubmit"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
autocomplete="username"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
autocomplete="current-password"
show-password
/>
</el-form-item>
<el-form-item label="验证码" prop="captcha_code">
<div class="captcha">
<el-input
v-model="form.captcha_code"
placeholder="图中字符"
maxlength="4"
autocomplete="off"
class="captcha__input"
/>
<button
type="button"
class="captcha__image"
:aria-busy="captchaLoading"
title="点击换一张"
@click="refreshCaptcha"
>
<img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" />
<span v-else class="captcha__placeholder">加载中</span>
</button>
</div>
</el-form-item>
<el-form-item class="form-actions">
<el-button type="primary" class="submit" :loading="submitting" @click="handleSubmit">
登 录
</el-button>
</el-form-item>
</el-form>
</div>
<p class="login-foot">© 衍水集团 · 内部系统</p>
</div>
</div>
</template>
<style scoped>
/* 视觉体系(2026-10-12 确认):企业级扁平 · 轻质感 · 品牌色点缀 · 微动效 */
.login {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40px 24px;
overflow: hidden;
background-color: #f2f4f8;
/* 点阵网格:秩序与密度 */
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
background-size: 24px 24px;
}
/* 顶部天光:靛蓝微光从上方洒下,给页面空气感 */
.login::before {
content: '';
position: absolute;
top: -180px;
left: 50%;
width: 900px;
height: 480px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
}
/* 右上涟漪:同心圆水波,部分出画 */
.login-ripple {
position: absolute;
top: -190px;
right: -150px;
width: 560px;
height: 560px;
pointer-events: none;
}
.login-ripple i {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(46, 75, 168, 0.1);
}
.login-ripple i:nth-child(2) {
inset: 70px;
border-color: rgba(46, 75, 168, 0.075);
}
.login-ripple i:nth-child(3) {
inset: 140px;
border-color: rgba(46, 75, 168, 0.055);
}
.login-ripple i:nth-child(4) {
inset: 210px;
border: none;
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
}
.login-wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
.login-card {
width: 400px;
max-width: 100%;
padding: 30px 36px 32px;
overflow: hidden;
animation: card-rise 320ms cubic-bezier(0.25, 0.1, 0.25, 1) backwards;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid rgba(28, 35, 51, 0.09);
border-radius: 14px;
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.04),
0 16px 40px rgba(28, 35, 51, 0.1);
}
@keyframes card-rise {
from {
opacity: 0;
transform: translateY(8px);
}
}
.card-brand {
text-align: center;
}
.card-brand__name {
font-family: var(--ys-font-brand);
font-size: 17px;
font-weight: 500;
letter-spacing: 0.34em;
color: var(--ys-ink);
}
.card-brand__slogan {
margin-top: 8px;
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--ys-ink-3);
}
/* 签名元素:渐隐靛蓝水线 */
.waterline {
display: block;
width: 40px;
height: 2px;
margin: 14px auto 0;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.card-head {
margin-top: 26px;
}
.card-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
}
.card-hint {
margin: 4px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.captcha {
display: flex;
gap: 10px;
width: 100%;
}
.captcha__input {
flex: 1;
}
.captcha__input :deep(input) {
font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.captcha__image {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 120px;
height: 40px;
padding: 0;
overflow: hidden;
cursor: pointer;
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px solid var(--ys-line);
border-radius: 7px;
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
transition: border-color 150ms var(--ys-ease, ease);
}
.captcha__image:hover {
border-color: var(--ys-indigo);
}
.captcha__image img {
display: block;
width: 100%;
height: 100%;
user-select: none;
}
.captcha__placeholder {
font-size: 12px;
color: var(--ys-ink-3);
}
.form-actions :deep(.el-form-item__content) {
margin-top: 4px;
}
.submit {
width: 100%;
letter-spacing: 0.4em;
}
.login-foot {
margin: 16px 0 0;
font-size: 11px;
letter-spacing: 0.08em;
color: var(--ys-ink-3);
}
/* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
.login :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px rgba(28, 35, 51, 0.13) inset;
}
.login :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px var(--ys-indigo) inset,
0 0 0 3px rgba(46, 75, 168, 0.13);
}
@media (max-width: 640px) {
.login {
padding: 24px 16px;
}
.login-card {
padding: 26px 24px 28px;
}
/* 窄屏上涟漪出画更多,避免挤压卡片 */
.login-ripple {
top: -260px;
right: -240px;
}
}
@media (prefers-reduced-motion: reduce) {
.login-card {
animation: none;
}
}
</style>