perf: 登录界面重做

This commit is contained in:
2026-03-11 20:28:42 +08:00
parent 99f792f6de
commit 0a8a7c8046
13 changed files with 726 additions and 383 deletions

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import type { ToolbarType } from './types';
import type {ToolbarType} from './types';
import { computed, ref, watch } from 'vue';
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
import {useRoute} from 'vue-router';
import { preferences, usePreferences } from '@easyflow/preferences';
import {$t} from '@easyflow/locales';
import {preferences, usePreferences} from '@easyflow/preferences';
import { Copyright } from '../basic/copyright';
import {Copyright} from '../basic/copyright';
import AuthenticationFormView from './form.vue';
import SloganIcon from './icons/slogan.vue';
import Toolbar from './toolbar.vue';
interface Props {
@@ -16,7 +17,6 @@ interface Props {
logoDark?: string;
pageTitle?: string;
pageDescription?: string;
sloganImage?: string;
toolbar?: boolean;
copyright?: boolean;
toolbarList?: ToolbarType[];
@@ -30,20 +30,14 @@ const props = withDefaults(defineProps<Props>(), {
logoDark: '',
pageDescription: '',
pageTitle: '',
sloganImage: '',
toolbar: true,
toolbarList: () => ['color', 'language', 'layout', 'theme'],
toolbarList: () => ['language', 'theme'],
clickLogo: () => {},
});
const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
usePreferences();
const { isDark } = usePreferences();
const route = useRoute();
const isSloganLoadError = ref(false);
/**
* @zh_CN 根据主题选择合适的 logo 图标
*/
const logoSrc = computed(() => {
if (isDark.value && props.logoDark) {
return props.logoDark;
@@ -51,272 +45,338 @@ const logoSrc = computed(() => {
return props.logo;
});
watch(
() => props.sloganImage,
() => {
isSloganLoadError.value = false;
},
);
const activeCapabilityIndex = ref(0);
let capabilityTimer: null | number = null;
const currentHour = ref(new Date().getHours());
function handleSloganError() {
isSloganLoadError.value = true;
}
const isLoginRoute = computed(() => route.path === '/auth/login');
const capabilityLabels = computed(() => [
$t('authentication.capabilityModel'),
$t('authentication.capabilityAgent'),
$t('authentication.capabilityWorkflow'),
$t('authentication.capabilityKnowledge'),
]);
const stageGreeting = computed(() => {
if (currentHour.value >= 5 && currentHour.value < 11) {
return $t('authentication.greetingMorning');
}
if (currentHour.value >= 11 && currentHour.value < 14) {
return $t('authentication.greetingNoon');
}
if (currentHour.value >= 14 && currentHour.value < 18) {
return $t('authentication.greetingAfternoon');
}
return $t('authentication.greetingEvening');
});
onMounted(() => {
capabilityTimer = window.setInterval(() => {
activeCapabilityIndex.value =
(activeCapabilityIndex.value + 1) % capabilityLabels.value.length;
}, 2400);
});
onBeforeUnmount(() => {
if (capabilityTimer) {
clearInterval(capabilityTimer);
}
});
</script>
<template>
<div
:class="[isDark ? 'dark' : '']"
class="auth-shell relative flex min-h-full flex-1 select-none overflow-x-hidden"
class="auth-shell relative flex min-h-full flex-1 select-none overflow-hidden"
>
<div class="auth-shell-grid absolute inset-0"></div>
<div class="auth-shell-noise absolute inset-0"></div>
<div class="auth-glow auth-glow-primary"></div>
<div class="auth-glow auth-glow-secondary"></div>
<div class="auth-glow auth-glow-tertiary"></div>
<template v-if="toolbar">
<slot name="toolbar">
<Toolbar :toolbar-list="toolbarList" />
</slot>
</template>
<AuthenticationFormView
v-if="authPanelLeft"
class="auth-panel-form min-h-full w-full flex-1 lg:w-[42%] lg:flex-initial"
data-side="left"
>
<template v-if="copyright" #copyright>
<slot name="copyright">
<Copyright
v-if="preferences.copyright.enable"
v-bind="preferences.copyright"
/>
</slot>
</template>
</AuthenticationFormView>
<slot name="logo">
<div
v-if="logoSrc || appName"
class="absolute left-0 top-0 z-20 flex flex-1"
@click="clickLogo"
>
<div
class="text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
>
<div class="auth-brand-chip text-foreground ml-4 mt-4 flex items-center sm:ml-6 sm:mt-6">
<img
v-if="logoSrc"
:key="logoSrc"
:alt="appName"
:src="logoSrc"
class="mr-2"
width="120"
class="mr-2.5"
width="112"
/>
<span v-else class="auth-brand-name">{{ appName }}</span>
</div>
</div>
</slot>
<div
v-if="!authPanelCenter"
class="auth-hero relative hidden min-h-full w-0 flex-1 overflow-hidden lg:block"
>
<div class="auth-hero-base absolute inset-0"></div>
<div class="auth-orb auth-orb-left"></div>
<div class="auth-orb auth-orb-right"></div>
<div class="auth-orb auth-orb-bottom"></div>
<main class="auth-stage relative z-10 flex min-h-full w-full items-center justify-center px-6 pb-10 pt-28 sm:px-10 sm:pt-32">
<div class="auth-stage-inner mx-auto flex w-full max-w-[1080px] flex-col items-center">
<div
v-if="isLoginRoute"
class="auth-stage-copy w-full max-w-[780px] text-center"
>
<h1 class="auth-page-title text-foreground">
{{ stageGreeting }}
</h1>
<div class="auth-stage-switcher text-muted-foreground" aria-label="同一入口能力切换">
<span class="auth-stage-switcher-label">在同一入口管理</span>
<span class="auth-stage-pill" aria-live="polite">
<Transition mode="out-in" name="auth-pill">
<span
:key="capabilityLabels[activeCapabilityIndex]"
class="auth-stage-pill-text"
>
{{ capabilityLabels[activeCapabilityIndex] }}
</span>
</Transition>
</span>
</div>
</div>
<div
:key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'"
class="auth-hero-content flex-col-center relative h-full px-12"
:class="{
'enter-x': authPanelLeft,
'-enter-x': authPanelRight,
}"
>
<div class="auth-hero-visual">
<template v-if="sloganImage && !isSloganLoadError">
<img
:alt="appName"
:src="sloganImage"
class="auth-hero-image"
@error="handleSloganError"
/>
<AuthenticationFormView
:class="[
'auth-window-host w-full',
isLoginRoute ? 'max-w-[25rem] sm:max-w-[26rem]' : 'max-w-[31rem]',
isLoginRoute ? 'mt-8 sm:mt-10' : 'mt-0 sm:mt-4',
]"
data-side="bottom"
>
<template v-if="copyright" #copyright>
<slot name="copyright">
<Copyright
v-if="preferences.copyright.enable"
v-bind="preferences.copyright"
/>
</slot>
</template>
<SloganIcon v-else :alt="appName" class="auth-hero-fallback" />
</div>
<div class="auth-page-title text-foreground">
{{ pageTitle }}
</div>
<div class="auth-page-desc text-muted-foreground">
{{ pageDescription }}
</div>
</AuthenticationFormView>
</div>
</div>
<div v-if="authPanelCenter" class="auth-center relative w-full">
<div class="auth-hero-base absolute inset-0"></div>
<div class="auth-orb auth-orb-left"></div>
<div class="auth-orb auth-orb-right"></div>
<AuthenticationFormView
class="auth-center-card shadow-float w-full rounded-3xl pb-20 md:w-2/3 lg:w-1/2 xl:w-[36%]"
data-side="bottom"
>
<template v-if="copyright" #copyright>
<slot name="copyright">
<Copyright
v-if="preferences.copyright.enable"
v-bind="preferences.copyright"
/>
</slot>
</template>
</AuthenticationFormView>
</div>
<AuthenticationFormView
v-if="authPanelRight"
class="auth-panel-form min-h-full w-full flex-1 lg:w-[42%] lg:flex-initial"
data-side="right"
>
<template v-if="copyright" #copyright>
<slot name="copyright">
<Copyright
v-if="preferences.copyright.enable"
v-bind="preferences.copyright"
/>
</slot>
</template>
</AuthenticationFormView>
</main>
</div>
</template>
<style scoped>
.auth-shell {
background: linear-gradient(180deg, #f8fbff 0%, #f1f6ff 100%);
}
.auth-panel-form {
backdrop-filter: blur(6px);
}
.auth-hero {
border-inline: 1px solid rgb(29 108 255 / 6%);
}
.auth-hero-base {
background:
radial-gradient(circle at 22% 18%, rgb(56 131 255 / 14%) 0, transparent 42%),
radial-gradient(circle at 82% 16%, rgb(88 179 255 / 12%) 0, transparent 35%),
radial-gradient(circle at 70% 86%, rgb(112 146 255 / 10%) 0, transparent 40%),
linear-gradient(145deg, #eef5ff 0%, #f4f8ff 45%, #eef4ff 100%);
radial-gradient(circle at top, rgb(255 255 255 / 78%), rgb(255 255 255 / 0) 36%),
linear-gradient(180deg, #f7faff 0%, #eef4fd 55%, #edf3fb 100%);
}
.auth-hero-content {
z-index: 2;
.auth-shell-grid {
background-image:
linear-gradient(rgb(13 74 160 / 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgb(13 74 160 / 0.06) 1px, transparent 1px);
background-position: center center;
background-size: 120px 120px;
mask-image: linear-gradient(180deg, rgb(0 0 0 / 0.28), transparent 75%);
opacity: 0.42;
}
.auth-hero-visual {
width: min(860px, 90%);
max-width: 920px;
.auth-shell-noise {
background-image:
radial-gradient(circle at 20% 20%, rgb(255 255 255 / 0.35) 0 0.9px, transparent 1.2px),
radial-gradient(circle at 80% 30%, rgb(255 255 255 / 0.22) 0 1px, transparent 1.3px),
radial-gradient(circle at 40% 70%, rgb(11 111 211 / 0.08) 0 1px, transparent 1.4px);
background-size: 180px 180px, 240px 240px, 200px 200px;
mix-blend-mode: soft-light;
opacity: 0.65;
}
.auth-hero-image {
width: 100%;
height: auto;
object-fit: contain;
filter: drop-shadow(0 24px 48px rgb(24 78 173 / 10%));
.auth-glow {
border-radius: 9999px;
pointer-events: none;
position: absolute;
}
.auth-hero-fallback {
width: 100%;
height: auto;
.auth-glow-primary {
background: radial-gradient(circle, rgb(87 150 255 / 0.24) 0%, rgb(87 150 255 / 0) 68%);
height: 26rem;
left: 50%;
top: 4rem;
transform: translateX(-50%);
width: 26rem;
}
.auth-glow-secondary {
background: radial-gradient(circle, rgb(36 189 211 / 0.18) 0%, rgb(36 189 211 / 0) 72%);
height: 20rem;
left: 14%;
top: 46%;
width: 20rem;
}
.auth-glow-tertiary {
background: radial-gradient(circle, rgb(66 116 255 / 0.14) 0%, rgb(66 116 255 / 0) 74%);
bottom: 8%;
height: 22rem;
right: 10%;
width: 22rem;
}
.auth-brand-chip {
backdrop-filter: blur(12px);
background: rgb(255 255 255 / 0.72);
border: 1px solid rgb(255 255 255 / 0.84);
border-radius: 9999px;
box-shadow: 0 20px 44px -32px rgb(11 59 132 / 0.26);
min-height: 3rem;
padding: 0.45rem 0.95rem;
}
.auth-brand-name {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.auth-page-title {
margin-top: 1.2rem;
font-size: clamp(1.9rem, 2.5vw, 2.45rem);
font-size: clamp(2.1rem, 4vw, 3.7rem);
font-weight: 700;
letter-spacing: -0.01em;
text-align: center;
letter-spacing: -0.04em;
line-height: 1.05;
margin: 0 auto;
max-width: 16ch;
}
.auth-page-desc {
margin-top: 0.9rem;
max-width: 42rem;
font-size: clamp(0.96rem, 1.1vw, 1.12rem);
line-height: 1.7;
text-align: center;
}
.auth-center {
.auth-stage-switcher {
align-items: center;
display: flex;
display: inline-flex;
flex-wrap: wrap;
gap: 0.7rem;
justify-content: center;
overflow: hidden;
margin-top: 1rem;
}
.auth-center-card {
position: relative;
z-index: 2;
.auth-stage-switcher-label {
font-size: 0.98rem;
line-height: 1.6;
}
.auth-orb {
.auth-stage-pill {
align-items: center;
backdrop-filter: blur(10px);
background: rgb(255 255 255 / 0.74);
border: 1px solid rgb(255 255 255 / 0.9);
border-radius: 9999px;
position: absolute;
box-shadow: 0 18px 34px -28px rgb(14 61 132 / 0.3);
color: hsl(var(--nav-item-active-foreground));
display: inline-flex;
font-size: 0.95rem;
font-weight: 600;
justify-content: center;
min-width: 6.5rem;
padding: 0.55rem 1rem;
}
.auth-stage-pill-text {
display: inline-flex;
justify-content: center;
min-width: 4em;
}
.auth-pill-enter-active,
.auth-pill-leave-active {
transition:
opacity 180ms ease,
transform 180ms ease;
}
.auth-pill-enter-from {
opacity: 0;
transform: translateY(8px);
}
.auth-pill-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.auth-window-host {
position: relative;
z-index: 1;
}
.auth-orb-left {
background: rgb(101 149 255 / 18%);
height: 18rem;
left: -4rem;
top: 4.5rem;
width: 18rem;
}
.auth-orb-right {
background: rgb(124 188 255 / 16%);
height: 10rem;
right: 3rem;
top: 5rem;
width: 10rem;
}
.auth-orb-bottom {
background: rgb(93 154 255 / 12%);
bottom: 2rem;
height: 14rem;
right: 5rem;
width: 14rem;
}
.dark.auth-shell {
background: linear-gradient(180deg, #05080f 0%, #070b14 100%);
background:
radial-gradient(circle at top, rgb(35 66 114 / 0.32), rgb(15 22 35 / 0) 40%),
linear-gradient(180deg, #06101b 0%, #08111d 52%, #09131f 100%);
.auth-hero {
border-inline: 1px solid rgb(125 168 255 / 10%);
.auth-shell-grid {
background-image:
linear-gradient(rgb(118 160 241 / 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgb(118 160 241 / 0.08) 1px, transparent 1px);
opacity: 0.36;
}
.auth-hero-base {
background:
radial-gradient(circle at 20% 18%, rgb(63 115 255 / 22%) 0, transparent 45%),
radial-gradient(circle at 82% 16%, rgb(56 149 255 / 18%) 0, transparent 38%),
radial-gradient(circle at 72% 86%, rgb(96 124 255 / 20%) 0, transparent 42%),
linear-gradient(150deg, #0a1326 0%, #091022 45%, #0b1529 100%);
.auth-shell-noise {
opacity: 0.38;
}
.auth-page-desc {
color: rgb(181 194 226 / 82%);
.auth-brand-chip {
background: rgb(11 19 31 / 0.68);
border-color: rgb(138 174 255 / 0.18);
box-shadow: 0 24px 48px -34px rgb(0 0 0 / 0.52);
}
.auth-hero-image {
filter: drop-shadow(0 26px 52px rgb(8 19 42 / 48%));
.auth-stage-switcher-label {
color: rgb(195 206 230 / 0.8);
}
.auth-orb-left {
background: rgb(81 126 255 / 24%);
.auth-stage-pill {
background: rgb(11 19 31 / 0.7);
border-color: rgb(138 174 255 / 0.18);
box-shadow: 0 18px 34px -28px rgb(0 0 0 / 0.46);
color: rgb(144 196 255 / 0.92);
}
.auth-orb-right {
background: rgb(59 140 248 / 22%);
.auth-glow-primary {
background: radial-gradient(circle, rgb(70 120 255 / 0.26) 0%, rgb(70 120 255 / 0) 70%);
}
.auth-orb-bottom {
background: rgb(94 105 239 / 20%);
.auth-glow-secondary {
background: radial-gradient(circle, rgb(41 170 201 / 0.18) 0%, rgb(41 170 201 / 0) 72%);
}
.auth-glow-tertiary {
background: radial-gradient(circle, rgb(95 128 255 / 0.16) 0%, rgb(95 128 255 / 0) 74%);
}
}
@media (max-width: 768px) {
.auth-page-title {
max-width: 14ch;
}
}
@media (max-width: 640px) {
.auth-stage {
padding-top: 6.75rem;
}
.auth-stage-copy {
text-align: left;
}
.auth-page-title {
margin-left: 0;
margin-right: 0;
max-width: none;
}
.auth-stage-switcher {
justify-content: flex-start;
}
}
</style>

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
import {computed} from 'vue';
import {useRoute} from 'vue-router';
defineOptions({
name: 'AuthenticationFormView',
});
@@ -6,29 +9,38 @@ defineOptions({
defineProps<{
dataSide?: 'bottom' | 'left' | 'right' | 'top';
}>();
const route = useRoute();
const isLoginRoute = computed(() => route.path === '/auth/login');
</script>
<template>
<div
class="auth-form-wrap flex-col-center bg-background dark:bg-background-deep relative min-h-full px-6 py-12 lg:px-10"
class="auth-form-wrap relative min-h-full"
>
<slot></slot>
<div :class="['auth-window-shell', { 'auth-window-shell-plain': isLoginRoute }]">
<template v-if="!isLoginRoute">
<div class="auth-window-edge auth-window-edge-top"></div>
<div class="auth-window-edge auth-window-edge-bottom"></div>
</template>
<slot></slot>
<RouterView v-slot="{ Component, route }">
<Transition appear mode="out-in" name="slide-right">
<KeepAlive :include="['Login']">
<component
:is="Component"
:key="route.fullPath"
class="side-content mt-8 w-full sm:mx-auto md:max-w-md"
:data-side="dataSide"
/>
</KeepAlive>
</Transition>
</RouterView>
<RouterView v-slot="{ Component, route }">
<Transition appear mode="out-in" name="slide-right">
<KeepAlive :include="['Login']">
<component
:is="Component"
:key="route.fullPath"
class="side-content w-full"
:data-side="dataSide"
/>
</KeepAlive>
</Transition>
</RouterView>
</div>
<div
class="text-muted-foreground absolute bottom-4 flex text-center text-xs"
class="auth-copyright text-muted-foreground absolute left-1/2 flex -translate-x-1/2 text-center text-xs"
>
<slot name="copyright"> </slot>
</div>
@@ -37,12 +49,91 @@ defineProps<{
<style scoped>
.auth-form-wrap {
background-image:
linear-gradient(180deg, rgb(255 255 255 / 98%) 0%, rgb(247 250 255 / 98%) 100%);
padding: 0 0 4.5rem;
}
.dark .auth-form-wrap {
background-image:
linear-gradient(180deg, rgb(13 20 34 / 96%) 0%, rgb(9 16 29 / 98%) 100%);
.auth-window-shell {
backdrop-filter: blur(22px);
background:
linear-gradient(180deg, rgb(255 255 255 / 0.96) 0%, rgb(248 251 255 / 0.98) 100%);
border: 1px solid rgb(255 255 255 / 0.82);
border-radius: 2rem;
box-shadow:
0 40px 80px -48px rgb(13 61 132 / 0.38),
0 18px 36px -26px rgb(13 61 132 / 0.18);
overflow: hidden;
padding: 1.25rem;
position: relative;
}
.auth-window-shell-plain {
backdrop-filter: none;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
overflow: visible;
padding: 0;
}
.auth-window-edge {
border-radius: 9999px;
pointer-events: none;
position: absolute;
}
.auth-window-edge-top {
background: linear-gradient(90deg, rgb(11 111 211 / 0.18), rgb(22 159 200 / 0.08));
height: 10rem;
left: -4rem;
top: -6rem;
width: 14rem;
}
.auth-window-edge-bottom {
background: radial-gradient(circle, rgb(84 132 255 / 0.14) 0%, rgb(84 132 255 / 0) 72%);
bottom: -4rem;
height: 11rem;
right: -4rem;
width: 11rem;
}
.auth-copyright {
bottom: 0.55rem;
width: max-content;
}
.dark .auth-window-shell {
background:
linear-gradient(180deg, rgb(10 18 30 / 0.92) 0%, rgb(9 17 29 / 0.96) 100%);
border-color: rgb(136 168 235 / 0.16);
box-shadow:
0 42px 84px -50px rgb(0 0 0 / 0.64),
0 18px 36px -28px rgb(0 0 0 / 0.42);
}
.dark .auth-window-shell-plain {
background: transparent;
border: none;
box-shadow: none;
}
.dark .auth-window-edge-top {
background: linear-gradient(90deg, rgb(69 120 255 / 0.22), rgb(28 155 197 / 0.08));
}
.dark .auth-window-edge-bottom {
background: radial-gradient(circle, rgb(92 136 255 / 0.16) 0%, rgb(92 136 255 / 0) 72%);
}
@media (max-width: 640px) {
.auth-form-wrap {
padding-bottom: 4rem;
}
.auth-window-shell {
border-radius: 1.6rem;
padding: 1rem;
}
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ToolbarType } from './types';
import type {ToolbarType} from './types';
import { computed } from 'vue';
import {computed} from 'vue';
import { preferences } from '@easyflow/preferences';
import {preferences} from '@easyflow/preferences';
import {
AuthenticationColorToggle,
@@ -21,7 +21,7 @@ defineOptions({
});
const props = withDefaults(defineProps<Props>(), {
toolbarList: () => ['color', 'language', 'layout', 'theme'],
toolbarList: () => ['language', 'theme'],
});
const showColor = computed(() => props.toolbarList.includes('color'));
@@ -35,7 +35,7 @@ const showTheme = computed(() => props.toolbarList.includes('theme'));
:class="{
'auth-toolbar': toolbarList.length > 1,
}"
class="flex-center absolute right-3 top-4 z-20"
class="flex-center absolute right-4 top-4 z-20 sm:right-6 sm:top-6"
>
<div class="hidden md:flex">
<AuthenticationColorToggle v-if="showColor" />
@@ -49,16 +49,16 @@ const showTheme = computed(() => props.toolbarList.includes('theme'));
<style scoped>
.auth-toolbar {
backdrop-filter: blur(10px);
background: rgb(255 255 255 / 72%);
border: 1px solid rgb(29 108 255 / 10%);
background: rgb(255 255 255 / 0.72);
border: 1px solid rgb(255 255 255 / 0.78);
border-radius: 9999px;
box-shadow: 0 12px 26px rgb(30 72 152 / 12%);
padding: 0.25rem 0.72rem;
box-shadow: 0 20px 42px -30px rgb(14 55 124 / 0.28);
padding: 0.25rem 0.58rem;
}
:deep(.dark) .auth-toolbar {
background: rgb(11 19 34 / 66%);
border-color: rgb(122 167 255 / 22%);
box-shadow: 0 12px 28px rgb(0 0 0 / 32%);
border-color: rgb(122 167 255 / 18%);
box-shadow: 0 20px 40px -28px rgb(0 0 0 / 0.46);
}
</style>