- 移除 config.tenantCode 死配置,注册页加租户输入 - 全局 errorHandler + unhandledrejection 兜底错误提示 - selectTenant 二次登录加 loading - 路由守卫支持 meta.freeAccess 白名单 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
432 lines
12 KiB
Vue
432 lines
12 KiB
Vue
<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>
|