PayrollManagementSystem/frontend/src/views/login/index.vue
liuwenxin e61800dcdc feat(frontend): 菜单图标与视觉设计系统落地
- 引入 @element-plus/icons-vue 官方图标库,菜单项按模块配图标,消除
  纯文字导航的辨识成本(新增依赖已评估:Element Plus 官方同源维护)
- AppMenu/BasicLayout/router 同步:图标渲染、菜单结构与路由 meta 对齐
- index.css 视觉系统化:品牌色、语义三色(ok/warn/danger 及其 wash 层次)、
  形体与材质(圆角、缓动、阴影、等宽字体)统一为 CSS 变量,并覆盖
  Element Plus 主色
- 登录页、业务页、结算页、工作台、审批页等视图随新变量适配

验证:pnpm type-check 与 pnpm build 均通过;后端接口契约未变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:45:54 +08:00

434 lines
11 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 { canAccessPath, firstAccessiblePath } from '@/router/access'
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('')
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
const brandName = ref('衍水集团')
const brandLogo = ref<string | null>(null)
async function loadBrand(): Promise<void> {
try {
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
const resp = await fetch(
`/api/v1/system/settings/public?_=${Date.now()}`,
{ cache: 'no-store' },
)
if (!resp.ok) throw new Error(String(resp.status))
const body = (await resp.json()) as {
code: number
data: { company_name?: string; logo_url?: string | null }
}
const data = body.data
if (data.company_name) brandName.value = data.company_name
brandLogo.value = data.logo_url ?? null
} catch {
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
}
}
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(),
})
// 先拉角色再解析落地页:普通用户不能回跳到受限的工作台
await userStore.fetchUserInfo()
ElMessage.success('登录成功')
const redirect = route.query.redirect
const target =
typeof redirect === 'string' && canAccessPath(router, redirect, userStore.roles)
? redirect
: firstAccessiblePath(router, userStore.roles)
await router.replace(target)
} catch {
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
await refreshCaptcha()
} finally {
submitting.value = false
}
}
onMounted(() => {
refreshCaptcha()
loadBrand()
})
</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">
<img v-if="brandLogo" :src="brandLogo" alt="" class="card-brand__logo" />
<div class="card-brand__name">{{ brandName }}</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">© {{ brandName }} · 内部系统</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: 860px;
height: 460px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
}
/* 右上涟漪:同心圆水波,部分出画 */
.login-ripple {
position: absolute;
top: -190px;
right: -150px;
width: 520px;
height: 520px;
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: 65px;
border-color: rgba(46, 75, 168, 0.075);
}
.login-ripple i:nth-child(3) {
inset: 130px;
border-color: rgba(46, 75, 168, 0.055);
}
.login-ripple i:nth-child(4) {
inset: 195px;
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 {
/*
* 卡内控件比列表页高一档(38px):登录是单任务场景,输入框更宽敞可减少误触;
* 覆盖组件基准变量,让输入框/按钮/验证码框一起长高,避免逐个子选择器硬编码。
*/
--el-component-size: 38px;
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__logo {
display: block;
max-width: 56px;
max-height: 36px;
margin: 0 auto 10px;
object-fit: contain;
}
.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: 8px 0 0;
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--ys-ink-3);
}
/* 签名元素:渐变来自全局 .waterline,此处只收窄到 40px 并水平居中 */
.waterline {
width: 40px;
margin: 14px auto 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: 104px;
height: 38px;
padding: 0;
overflow: hidden;
cursor: pointer;
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius-sm);
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);
}
/* 输入框的描边/焦点环已由全局 index.css 统一(登录卡只把控件高度调到 38px) */
@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>