- 引入 @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>
434 lines
11 KiB
Vue
434 lines
11 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 { 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>
|