新增: 登录页 UI 重构 + 品牌 logo 与 Lottie 启动动画

视觉与品牌
- public/logo.svg、public/login-illustration.svg、favicon 替换为 logo.svg。
- preferences.ts 新增 logo.source 指向 /logo.svg。
- packages/effects/layouts 导出 LoginIllustration 共享组件(浮动插画,加载失败自动隐藏)。

Auth 布局重构
- layouts/auth.vue 替换默认 AuthPageLayout:主题色渐变背景 + 左侧浮动插画 +
  品牌卡片 + 右上 LanguageToggle + 圆角登录卡片;KeepAlive 仅缓存 Login
  (CodeLogin/QrCodeLogin 需要每次刷新验证码/二维码)。
- 表单样式通过 :deep(.login-form-container) 覆盖输入框、选择框、主按钮,
  全部走 --primary 变量,与主题色联动。

登录组件
- views/_core/authentication/login.vue: 关闭默认 codeLogin/qrcodeLogin/
  thirdPartyLogin/register/docLink,改为手机 / 二维码 / 企微三按钮(IconifyIcon);
  三方登录前检查租户,缺失时 message.warning + validateField。
- packages/effects/common-ui/authentication/login.vue + types.ts:
  新增 showDocLink prop(默认 true),替代原本 HTML 注释掉 DocLink 的做法。
- sso-callback.vue: 等待提示改为 LottieLoading 动画。

Lottie 启动动画
- 新增 loading.html(注入进 index.html)+ public/loading.json + 运行时拷贝的
  public/lottie_light.min.js(.gitignore 忽略)。
- 新增 public/lottie-tint.js:共享主题色适配器(LIGHTNESSES / SHADE_MAP /
  hslToRgb / patchColors / readPrimary),同时被启动白屏脚本与 Vue 组件使用,
  消除两份几乎一致的实现。
- 新增 src/components/lottie-loading/LottieLoading.vue:按需加载 lottie-tint.js
  并根据 CSS 变量 --primary 重新上色。
- vite.config.mts 新增 copyLottiePlayer 插件:configResolved 时无条件把
  node_modules/lottie-web/.../lottie_light.min.js 拷到 public/,避免 mtime 误判。
- package.json 新增 lottie-web ^5.13.0 依赖。

i18n
- 新增 otherLoginMethods / contactSupport / weComLogin 三个文案(zh-CN / en-US)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 19:35:04 +08:00
parent 7b429d1ffd
commit e3f12e4548
22 changed files with 727 additions and 26 deletions

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
import type { AnimationItem } from 'lottie-web';
import { onMounted, onUnmounted, ref } from 'vue';
import lottie from 'lottie-web/build/player/lottie_light.js';
interface Props {
/** 容器边长,数字按 px 处理,其他字符串原样作为 CSS 长度 */
size?: number | string;
/** Lottie JSON 的 URL默认读取 public/loading.json与 app 全局 loading 共用) */
src?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 200,
src: '/loading.json',
});
interface LottieTint {
buildShades: (hsl: { h: number; l: number; s: number }) => number[][];
patchColors: (data: any, shades: number[][]) => any;
readPrimary: () => { h: number; l: number; s: number };
}
declare global {
interface Window {
__lottieTint?: LottieTint;
}
}
const container = ref<HTMLElement>();
let instance: AnimationItem | undefined;
/**
* 确保 /lottie-tint.js 已加载。
* 正常场景下 loading.html 在首屏已注入 <script src="/lottie-tint.js">
* 此函数只是 SSR/测试等兜底路径。
*/
function ensureTintScript(): Promise<LottieTint> {
if (window.__lottieTint) return Promise.resolve(window.__lottieTint);
return new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
'script[data-lottie-tint]',
);
if (existing) {
existing.addEventListener('load', () => {
window.__lottieTint
? resolve(window.__lottieTint)
: reject(new Error('lottie-tint loaded but global missing'));
});
existing.addEventListener('error', () =>
reject(new Error('lottie-tint failed to load')),
);
return;
}
const s = document.createElement('script');
s.src = '/lottie-tint.js';
s.dataset.lottieTint = '1';
s.addEventListener('load', () => {
window.__lottieTint
? resolve(window.__lottieTint)
: reject(new Error('lottie-tint loaded but global missing'));
});
s.addEventListener('error', () =>
reject(new Error('lottie-tint failed to load')),
);
document.head.append(s);
});
}
onMounted(async () => {
if (!container.value) return;
try {
const [res, tint] = await Promise.all([
fetch(props.src),
ensureTintScript(),
]);
const data = await res.json();
const shades = tint.buildShades(tint.readPrimary());
tint.patchColors(data, shades);
instance = lottie.loadAnimation({
animationData: data,
autoplay: true,
container: container.value,
loop: true,
renderer: 'svg',
});
} catch (error) {
console.error('[LottieLoading]', error);
}
});
onUnmounted(() => {
instance?.destroy();
});
</script>
<template>
<div
ref="container"
:style="{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as LottieLoading } from './LottieLoading.vue';

View File

@@ -1,25 +1,175 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { LanguageToggle, LoginIllustration } from '@vben/layouts';
import { preferences, usePreferences } from '@vben/preferences';
import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
const { isDark } = usePreferences();
</script>
<template>
<AuthPageLayout
:app-name="appName"
:logo="logo"
:logo-dark="logoDark"
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
<div
:class="[isDark ? 'dark' : '']"
class="relative flex h-screen w-full overflow-hidden font-sans"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
<!-- 主题色渐变背景 + 插画主题色走 CSS 变量 --primary -->
<div class="absolute inset-0 z-0 size-full">
<!-- 渐变主色 -> 主色淡 -> 主色更淡 -->
<div
class="absolute inset-0 size-full bg-gradient-to-r from-[hsl(var(--primary)/0.85)] via-[hsl(var(--primary)/0.3)] to-[hsl(var(--primary)/0.08)] dark:from-[hsl(var(--primary)/0.85)] dark:via-[hsl(var(--primary)/0.4)] dark:to-[hsl(var(--primary)/0.12)]"
>
<!-- 浮动插画 - 左侧区域 -->
<div
class="absolute -left-[5%] top-1/2 hidden h-[48rem] w-[65%] -translate-y-1/2 lg:block"
>
<LoginIllustration :alt="appName" />
</div>
</div>
</div>
<!-- 左上角品牌标识 -->
<div
class="absolute left-4 top-4 z-20 flex items-center gap-3 lg:left-6 lg:top-6"
@click.prevent
>
<div
class="flex items-center justify-center rounded-lg bg-white/90 p-0.5 shadow-[0_2px_8px_rgba(0,0,0,0.12)] backdrop-blur-md transition-all hover:bg-white/95 hover:shadow-[0_4px_12px_rgba(0,0,0,0.18)] lg:p-1"
>
<img
v-if="logo"
:src="logo"
:alt="appName"
class="size-10 lg:size-12"
/>
<span
v-else
class="text-xl text-[hsl(var(--primary))] lg:text-2xl"
>💡</span>
</div>
<span
class="relative top-[1px] text-xl font-semibold tracking-tight text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] lg:top-[2px] lg:text-2xl"
>
{{ appName }}
</span>
</div>
<!-- 右上角 Toolbar -->
<div
class="absolute right-2 top-4 z-20 flex items-center gap-1 rounded-3xl bg-accent px-3 py-1 lg:right-6 lg:top-6"
>
<LanguageToggle v-if="preferences.widget.languageToggle" />
</div>
<!-- 内容区域 -->
<div
class="relative z-10 flex w-full flex-1 items-center justify-center px-6 py-5 lg:justify-end lg:px-12"
>
<!-- 登录卡片 - 响应式居中/靠右 -->
<div class="w-full md:w-[480px] lg:mr-[10%]">
<div
class="shadow-[0_30px_60px_-15px_hsl(var(--primary)/0.2)] dark:shadow-[0_30px_60px_-15px_hsl(var(--primary)/0.3)] relative overflow-hidden rounded-[2.5rem] bg-background p-8"
>
<!-- 登录表单 - RouterView 渲染实际的登录组件
KeepAlive 只缓存 LoginCodeLogin/QrCodeLogin 需要每次进入刷新验证码/二维码不应缓存 -->
<div class="login-form-container">
<RouterView v-slot="{ Component, route }">
<Transition appear mode="out-in" name="fade">
<KeepAlive :include="['Login']">
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</Transition>
</RouterView>
</div>
<!-- 遇到问题提示纯文本无跳转如需接入客服链接再改回 <a> -->
<div class="mt-6 text-center">
<span class="text-xs text-muted-foreground">
{{ $t('authentication.contactSupport') }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 登录表单容器样式覆盖(主题色走 --primary 变量) */
:deep(.login-form-container) {
/* 标题靠左对齐 */
.mb-7,
.mb-7 h2,
.mb-7 p {
text-align: left !important;
}
/* 输入框样式 */
.vben-input,
.vben-input-password input {
@apply rounded-2xl border-slate-100 bg-slate-50 px-6 py-4 text-sm transition-all;
@apply focus:border-[hsl(var(--primary)/0.3)] focus:bg-white focus:ring-4 focus:ring-[hsl(var(--primary)/0.1)];
}
/* 深色模式输入框 */
.dark .vben-input,
.dark .vben-input-password input {
@apply border-slate-700 bg-slate-800;
@apply focus:border-[hsl(var(--primary)/0.5)] focus:bg-slate-900 focus:ring-[hsl(var(--primary)/0.2)];
}
/* 选择框样式 */
.vben-select .vben-input {
@apply rounded-2xl border-slate-100 bg-slate-50 px-6 py-4 text-sm;
}
.dark .vben-select .vben-input {
@apply border-slate-700 bg-slate-800;
}
/* 登录按钮样式 */
.vben-button[aria-label='login'] {
@apply w-full rounded-2xl bg-[hsl(var(--primary))] py-4 text-base font-bold tracking-wide text-white shadow-lg shadow-[hsl(var(--primary)/0.3)];
@apply transition-all hover:-translate-y-1 hover:bg-[hsl(var(--primary)/0.9)] active:translate-y-0;
}
.dark .vben-button[aria-label='login'] {
@apply shadow-[hsl(var(--primary)/0.4)];
}
/* 记住我和忘记密码 */
.vben-checkbox label {
@apply text-xs text-slate-400 dark:text-slate-500;
}
.vben-link {
@apply text-xs text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400;
}
}
/* 过渡动画 - 快速响应 */
.fade-enter-active {
transition:
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-leave-active {
transition:
opacity 0.15s cubic-bezier(0.4, 0, 1, 1),
transform 0.15s cubic-bezier(0.4, 0, 1, 1);
}
.fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -15,6 +15,9 @@ export const overridesPreferences = defineOverridesPreferences({
/** 首页路径:默认跳转到 IoT 首页(覆盖框架默认的 /analytics */
defaultHomePath: '/iot/home',
},
logo: {
source: '/logo.svg',
},
footer: {
/** 默认关闭 footer 页脚,因为有一定遮挡 */
enable: false,

View File

@@ -8,9 +8,12 @@ import { useRoute } from 'vue-router';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import {
checkCaptcha,
getCaptcha,
@@ -96,6 +99,12 @@ async function handleThirdLogin(type: number) {
if (type <= 0) {
return;
}
// 多租户模式下,必须先选择租户;触发表单校验以提示用户
if (tenantEnable && !accessStore.tenantId) {
message.warning($t('authentication.tenantTip'));
loginRef.value?.getFormApi().validateField('tenantId');
return;
}
try {
// 计算 redirectUri
// tricky: type、redirect 需要先 encode 一次,否则钉钉回调会丢失。配合 social-login.vue#getUrlValue() 使用
@@ -170,17 +179,71 @@ const formSchema = computed((): VbenFormSchema[] => {
</script>
<template>
<div>
<div class="custom-login-wrapper">
<AuthenticationLogin
ref="loginRef"
:form-schema="formSchema"
:loading="authStore.loginLoading"
:show-code-login="false"
:show-doc-link="false"
:show-qrcode-login="false"
:show-register="false"
:show-third-party-login="false"
@submit="handleLogin"
@third-login="handleThirdLogin"
/>
<!-- 自定义其他登录方式 -->
<div class="mt-8">
<div class="relative mb-6 flex justify-center text-xs text-slate-400">
<span
class="relative z-10 bg-background px-3 font-medium dark:bg-slate-900"
>
{{ $t('authentication.otherLoginMethods') }}
</span>
<div class="absolute inset-0 flex items-center">
<div
class="w-full border-t border-slate-100 dark:border-slate-700"
></div>
</div>
</div>
<div class="flex justify-center gap-8">
<!-- 手机登录 -->
<button
class="flex size-12 items-center justify-center rounded-2xl border border-slate-100 bg-slate-50 text-slate-400 transition-all hover:scale-110 hover:bg-white hover:text-slate-600 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
:title="$t('authentication.mobileLogin')"
type="button"
@click="$router.push('/auth/code-login')"
>
<IconifyIcon icon="lucide:smartphone" class="size-5" />
</button>
<!-- 二维码登录 -->
<button
class="flex size-12 items-center justify-center rounded-2xl border border-slate-100 bg-slate-50 text-slate-400 transition-all hover:scale-110 hover:bg-white hover:text-slate-600 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
:title="$t('authentication.qrcodeLogin')"
type="button"
@click="$router.push('/auth/qrcode-login')"
>
<IconifyIcon icon="lucide:qr-code" class="size-6" />
</button>
<!-- 企业微信扫码登录社交登录 type=30 -->
<button
class="flex size-12 items-center justify-center rounded-2xl border border-slate-100 bg-slate-50 text-slate-400 transition-all hover:scale-110 hover:bg-white hover:text-[#2BAD13] hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
:title="$t('authentication.weComLogin')"
type="button"
@click="handleThirdLogin(30)"
>
<IconifyIcon icon="simple-icons:wechat" class="size-6" />
</button>
</div>
</div>
<Verification
ref="verifyRef"
v-if="captchaEnable"
ref="verifyRef"
:captcha-type="captchaType"
:check-captcha-api="checkCaptcha"
:get-captcha-api="getCaptcha"
@@ -190,3 +253,10 @@ const formSchema = computed((): VbenFormSchema[] => {
/>
</div>
</template>
<style scoped>
/* 确保表单容器宽度 */
.custom-login-wrapper {
width: 100%;
}
</style>

View File

@@ -7,6 +7,7 @@ import { LOGIN_PATH } from '@vben/constants';
import { message } from 'ant-design-vue';
import { ssoCallback } from '#/api/core/sso';
import { LottieLoading } from '#/components/lottie-loading';
import {
IOT_CLIENT_ID,
SSO_CALLBACK_PATH,
@@ -83,8 +84,8 @@ onMounted(async () => {
</script>
<template>
<div class="flex h-screen items-center justify-center text-base">
<span v-if="errorMsg">{{ errorMsg }}</span>
<span v-else>正在登录请稍候...</span>
<div class="flex h-screen items-center justify-center">
<div v-if="errorMsg" class="text-base">{{ errorMsg }}</div>
<LottieLoading v-else :size="220" />
</div>
</template>