- 登录页:雾灰点阵底 + 右上涟漪同心圆 + 顶部靛蓝天光;居中纸白卡片(品牌区/欢迎回来/表单);逻辑不变 - BasicLayout:侧栏深色面板改为中性灰微渐变 + 白底浮起当前项(左 2px 靛蓝条);logo 区水线;内容区雾灰点阵 - 首页:Bento Grid 仪表盘(参数 16 项/岗位 3 个为真实数据;绩效/回款/待办为业务上线前的精致空态) - AppMenu:激活项白底浮起样式,菜单变量随浅色侧栏调整 Co-Authored-By: Claude Code <noreply@anthropic.com>
395 lines
9.4 KiB
Vue
395 lines
9.4 KiB
Vue
<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>
|