ec-admin/src/pages/login/index.vue
liuyun eff4f8b314 fix: 第二轮残留(前端)
- 移除 config.tenantCode 死配置,注册页加租户输入
- 全局 errorHandler + unhandledrejection 兜底错误提示
- selectTenant 二次登录加 loading
- 路由守卫支持 meta.freeAccess 白名单

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 17:22:16 +08:00

432 lines
12 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.

<route lang="yaml">
meta:
layout: 'blank'
requiresAuth: false
</route>
<template>
<div class="login" :class="`login--${config.layout}`" :style="rootStyle">
<div v-if="config.layout === 'split'" class="login__brand" :style="brandStyle">
<div class="login__brand-decor" aria-hidden="true">
<span class="login__brand-decor-1"></span>
<span class="login__brand-decor-2"></span>
</div>
<div class="login__brand-inner">
<div v-if="config.logo" class="login__brand-logo-box">
<img :src="config.logo" class="login__brand-logo" alt="logo" />
</div>
<h1 class="login__brand-title">{{ config.title }}</h1>
<p v-if="config.subtitle" class="login__brand-subtitle">{{ config.subtitle }}</p>
</div>
<p class="login__brand-copyright">{{ config.copyright }}</p>
</div>
<div class="login__card">
<div class="login__panel">
<div v-if="config.layout === 'card'" class="login__panel-head">
<img v-if="config.logo" :src="config.logo" class="login__panel-logo" alt="logo" />
<div class="login__panel-title">{{ config.title }}</div>
<div v-if="config.subtitle" class="login__panel-subtitle">{{ config.subtitle }}</div>
</div>
<el-form
ref="formRef"
class="login__form"
:model="model"
:rules="rules"
size="large"
@keyup.enter="onSubmit"
>
<div v-if="config.layout === 'split'" class="login__form-head">
<h2 class="login__form-title">欢迎登录</h2>
<p class="login__form-subtitle">请输入账号信息以继续</p>
</div>
<el-form-item v-for="(prop, key) in config.fields.properties" :key="key" :prop="key">
<el-input
v-model="model[key]"
:type="prop.format === 'password' ? 'password' : 'text'"
:show-password="prop.format === 'password'"
:placeholder="prop.placeholder ?? String(prop.title ?? key)"
>
<template v-if="prop.prefixIcon" #prefix>
<icon type="iconify" :icon="prop.prefixIcon" />
</template>
</el-input>
</el-form-item>
<el-form-item v-if="config.showCaptcha">
<div class="login__captcha">
<el-input v-model="captcha" placeholder="请输入验证码">
<template #prefix>
<icon type="iconify" icon="mdi:shield-check-outline" />
</template>
</el-input>
<div class="login__captcha-code" @click="refreshCaptcha">{{ captchaCode }}</div>
</div>
</el-form-item>
<div class="login__row">
<el-checkbox v-if="config.showRemember" v-model="remember">记住我</el-checkbox>
<el-link
v-if="config.showForgetPassword"
type="primary"
:underline="false"
@click="onForget"
>
忘记密码?
</el-link>
</div>
<el-button
type="primary"
size="large"
class="login__submit"
:loading="loading"
@click="onSubmit"
>
登 录
</el-button>
<div v-if="config.showRegister" class="login__register">
还没有账号?
<el-link type="primary" :underline="false" @click="onRegister">立即注册</el-link>
</div>
<p v-if="error" class="login__error">{{ error }}</p>
</el-form>
<div v-if="config.layout === 'card'" class="login__panel-copyright">
{{ config.copyright }}
</div>
</div>
</div>
<el-dialog v-model="tenantDialogVisible" title="选择登录租户" width="360px" :close-on-click-modal="false">
<div class="login__tenants">
<div
v-for="t in tenantOptions"
:key="t.tenantCode"
class="login__tenant-item"
@click="selectTenant(t.tenantCode)"
>
<span>{{ t.tenantName }}</span>
<span class="login__tenant-code">{{ t.tenantCode }}</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/modules/auth'
import { useAppStore } from '@/stores/modules/app'
import { defaultLoginConfig } from '@/config/login'
import { applyTheme } from '@/utils/theme'
import { useRequest } from '@/composables/useRequest'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const app = useAppStore()
const config = defaultLoginConfig
// 登录页不在布局组件内,手动应用主题(颜色 + 暗黑)
applyTheme(app.themeColor, app.isDark)
const formRef = ref<FormInstance>()
const model = reactive<Record<string, any>>({})
for (const key of Object.keys(config.fields.properties)) model[key] = ''
// 注册/忘记密码成功后带 username 回填
const qUsername = route.query.username
if (qUsername) model.username = String(qUsername)
const captcha = ref('')
const captchaCode = ref('')
const remember = ref(true)
const error = ref('')
const tenantOptions = ref<{ tenantCode: string; tenantName: string }[]>([])
const tenantDialogVisible = ref(false)
const rootStyle = computed(() => (config.background ? { background: config.background } : {}))
/** 品牌区背景:暗黑模式用深色底 + 主色淡染,避免 --wm-color-primary-dark-2 在暗黑下偏亮。 */
const brandStyle = computed(() => {
const primary = app.themeColor
if (app.isDark) {
return { background: `linear-gradient(160deg, color-mix(in srgb, ${primary} 24%, #101820) 0%, #0c131a 100%)` }
}
return { background: `linear-gradient(160deg, ${primary} 0%, color-mix(in srgb, ${primary} 80%, #000) 100%)` }
})
const rules = computed<FormRules>(() => {
const result: FormRules = {}
for (const [key, prop] of Object.entries(config.fields.properties)) {
const itemRules: Record<string, unknown>[] = []
if (config.fields.required?.includes(key)) {
itemRules.push({ required: true, message: `请输入${prop.title ?? key}`, trigger: 'blur' })
}
if (prop.minLength) {
itemRules.push({
min: prop.minLength,
message: `长度不能少于 ${prop.minLength} 位`,
trigger: 'blur',
})
}
if (itemRules.length) result[key] = itemRules
}
return result
})
function refreshCaptcha(): void {
captchaCode.value = String(Math.floor(1000 + Math.random() * 9000))
}
refreshCaptcha()
const { loading, run: doLogin } = useRequest(
() => auth.login(model.username, model.password),
{
manual: true,
onSuccess: (data) => {
if (data && 'needSelectTenant' in data && data.needSelectTenant) {
tenantOptions.value = data.tenants
tenantDialogVisible.value = true
} else {
router.push(config.homePath)
}
},
onError: (e) => {
error.value = (e as Error).message
},
},
)
async function selectTenant(tenantCode: string): Promise<void> {
tenantDialogVisible.value = false
loading.value = true
try {
await auth.login(model.username, model.password, tenantCode)
router.push(config.homePath)
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
async function onSubmit(): Promise<void> {
if (loading.value) return
error.value = ''
try {
await formRef.value?.validate()
} catch {
return
}
if (config.showCaptcha && captcha.value !== captchaCode.value) {
error.value = '验证码错误'
refreshCaptcha()
captcha.value = ''
return
}
await doLogin()
}
function onForget(): void {
router.push('/forgot-password')
}
function onRegister(): void {
router.push('/register')
}
</script>
<style scoped lang="scss">
.login {
@apply flex min-h-screen;
background: var(--wm-bg-color-page);
}
.login--card {
@apply items-center justify-center;
}
/* ---------- 品牌区(split) ---------- */
.login__brand {
@apply relative hidden md:flex flex-col overflow-hidden w-[44%] text-white;
}
.login__brand-decor {
@apply absolute inset-0;
}
.login__brand-decor-1 {
@apply absolute rounded-full;
width: 420px;
height: 420px;
top: -140px;
right: -140px;
background: rgba(255, 255, 255, 0.08);
}
.login__brand-decor-2 {
@apply absolute rounded-full;
width: 300px;
height: 300px;
bottom: -120px;
left: -100px;
background: rgba(255, 255, 255, 0.05);
}
.login__brand-inner {
@apply relative flex flex-1 flex-col items-center justify-center px-xl text-center;
}
.login__brand-logo-box {
@apply flex h-20 w-20 items-center justify-center rounded-lg p-sm backdrop-blur;
background: rgba(255, 255, 255, 0.16);
}
.login__brand-logo {
@apply h-full w-full object-contain;
}
.login__brand-title {
@apply mt-xl text-3xl font-semibold tracking-wide;
}
.login__brand-subtitle {
@apply mt-md text-base opacity-85;
}
.login__brand-copyright {
@apply relative mx-xl mb-lg border-t border-white/20 pt-lg text-center text-sm opacity-70;
}
/* ---------- 表单区 ---------- */
.login__card {
@apply flex flex-1 items-center justify-center px-xl;
}
.login__panel {
@apply rounded-lg border border-solid p-2xl shadow-md w-[440px] max-w-[92vw];
background: var(--wm-bg-color-base);
border-color: var(--wm-border-color-light);
}
.login--card .login__card {
@apply flex-none;
}
.login__form {
@apply w-full;
}
.login__form-head {
@apply mb-lg;
}
.login__form-title {
@apply text-2xl font-semibold text-text-primary;
}
.login__form-subtitle {
@apply mt-xs text-sm text-text-secondary;
}
/* 填充式输入框 */
.login__form :deep(.el-form-item) {
margin-bottom: 22px;
}
.login__form :deep(.el-input__wrapper) {
@apply rounded-base transition-shadow duration-200;
background-color: var(--wm-bg-color-page);
box-shadow: none;
}
.login__form :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px var(--wm-border-color-light) inset;
}
.login__form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--wm-color-primary) inset;
}
.login__captcha {
@apply flex w-full gap-sm;
}
.login__captcha-code {
@apply flex w-28 flex-shrink-0 cursor-pointer select-none items-center justify-center rounded-base font-bold text-lg tracking-[4px];
background: var(--wm-bg-color-page);
color: var(--wm-color-primary);
}
.login__row {
@apply flex items-center justify-between;
}
.login__submit {
@apply mt-lg h-11 w-full border-none text-base rounded-full tracking-[2px];
background: linear-gradient(90deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
&:hover {
@apply opacity-90;
}
}
.login__register {
@apply mt-sm text-center text-sm text-text-secondary;
}
.login__error {
@apply mt-sm text-center text-sm;
color: var(--el-color-danger);
}
/* ---------- 租户选择 ---------- */
.login__tenants {
@apply flex flex-col gap-2;
}
.login__tenant-item {
@apply flex cursor-pointer items-center justify-between rounded-base border border-solid px-4 py-3 transition-colors;
border-color: var(--wm-border-color-light);
&:hover {
@apply border-primary;
background: var(--el-color-primary-light-9);
}
}
.login__tenant-code {
@apply text-xs text-text-secondary;
}
/* ---------- 卡片布局(card) ---------- */
.login__panel-head {
@apply mb-lg text-center;
}
.login__panel-logo {
@apply mx-auto h-16 w-16 object-contain;
}
.login__panel-title {
@apply mt-md text-2xl font-semibold text-text-primary;
}
.login__panel-subtitle {
@apply mt-xs text-base text-text-secondary;
}
.login__panel-copyright {
@apply mt-xl text-center text-xs text-text-secondary;
}
</style>