Files
PayrollManagementSystem/frontend/src/views/login/index.vue
T
liuwenxinandClaude Code fc8a42bcd9 test(m3): 集成/e2e 增补三模块用例并更新测试记录
- 新增 e2e:settings_flow(改名→登出→登录页即时生效→finally 还原防污染)、
  secondary_allocation_flow(建客户/员→录两行 30%+40%→编辑回显与合计条
  余额提示→删除清理;宽视口防弹窗遮挡、alloc-table 专属类防选择器错位)
- 修复 e2e 首跑抓出的两处缺陷:① public 端点 300s 响应缓存让登录页拿旧
  公司名(登录页改时间戳 fetch + cache:no-store,手动解包信封)② logo 同
  扩展名覆盖时先写后删(清理移到写入前且仅异扩展名执行)
- 分配表加 alloc-table 专属类(与产品明细 items-table 区分)
- 全量回归 697 passed(基线 625 + 72);e2e 七脚本全过(既有五脚本回归 +
  两个新增);pnpm type-check 零错误
- tests/README.md 增 M3 节(44 例明细)/运行记录/缺陷 3 行;frontend/
  README.md 增 M3 e2e 节

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 12:01:52 +08:00

433 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 { 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(),
})
ElMessage.success('登录成功')
await router.replace((route.query.redirect as string) || '/dashboard')
} 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: 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__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-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>