refactor: re-design login-related form (#3608)

#### What type of PR is this?

/kind improvement
/area console
/milestone 2.4.x

#### What this PR does / why we need it:

重新设计登录页面的样式和用户体验。

#### Which issue(s) this PR fixes:

Fixes #3572 

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
优化 Console 端登录页面样式
```
pull/3648/head
Ryan Wang 2023-03-31 00:10:15 +08:00 committed by GitHub
parent bb2b1bcae2
commit 2d56aaeb93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 295 additions and 113 deletions

View File

@ -13,12 +13,12 @@ import {
} from "overlayscrollbars-vue";
import type { FormKitConfig } from "@formkit/core";
import { i18n } from "./locales";
import { AppName } from "./constants/app";
const { t } = useI18n();
const { configMap } = storeToRefs(useSystemConfigMapStore());
const AppName = "Halo";
const route = useRoute();
const title = useTitle();

View File

@ -11,14 +11,18 @@ import { submitForm } from "@formkit/core";
import { JSEncrypt } from "jsencrypt";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
import type {
GlobalInfo,
SocialAuthProvider,
} from "@/modules/system/actuator/types";
const { t } = useI18n();
withDefaults(
defineProps<{
buttonText?: string;
}>(),
{
buttonText: "core.login.button",
}
);
const emit = defineEmits<{
(event: "succeed"): void;
}>();
@ -104,22 +108,9 @@ onMounted(() => {
handleGenerateToken();
});
// auth providers
// fixme: Needs to be saved in Pinia.
const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
queryKey: ["social-auth-providers"],
queryFn: async () => {
const { data } = await axios.get<GlobalInfo>(
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
{
withCredentials: true,
}
);
return data.socialAuthProviders;
},
refetchOnWindowFocus: false,
});
const inputClasses = {
outer: "!py-3 first:!pt-0 last:!pb-0",
};
</script>
<template>
@ -129,16 +120,18 @@ const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
name="login-form"
:actions="false"
type="form"
:classes="{
form: '!divide-none',
}"
:config="{ validationVisibility: 'submit' }"
@submit="handleLogin"
@keyup.enter="submitForm('login-form')"
>
<FormKit
:validation-messages="{
required: $t('core.login.fields.username.validation'),
}"
:classes="inputClasses"
name="username"
:placeholder="$t('core.login.fields.username.placeholder')"
:validation-label="$t('core.login.fields.username.placeholder')"
:autofocus="true"
type="text"
validation="required"
@ -146,11 +139,10 @@ const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
</FormKit>
<FormKit
id="passwordInput"
:validation-messages="{
required: $t('core.login.fields.password.validation'),
}"
:classes="inputClasses"
name="password"
:placeholder="$t('core.login.fields.password.placeholder')"
:validation-label="$t('core.login.fields.password.placeholder')"
type="password"
validation="required"
autocomplete="current-password"
@ -158,31 +150,12 @@ const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
</FormKit>
</FormKit>
<VButton
class="mt-6"
class="mt-8"
block
:loading="loading"
type="secondary"
@click="submitForm('login-form')"
>
{{ $t("core.login.button") }}
{{ $t(buttonText) }}
</VButton>
<div v-if="socialAuthProviders?.length" class="mt-3 flex items-center">
<span class="text-sm text-slate-600">
{{ $t("core.login.other_login") }}
</span>
<ul class="flex items-center">
<li
v-for="(socialAuthProvider, index) in socialAuthProviders"
:key="index"
>
<a
:href="socialAuthProvider.authenticationUrl"
class="block h-6 w-6 rounded-full bg-gray-200 p-1"
>
<img class="rounded-full" :src="socialAuthProvider.logo" />
</a>
</li>
</ul>
</div>
</template>

View File

@ -3,6 +3,7 @@ import { Toast, VModal } from "@halo-dev/components";
import LoginForm from "@/components/login/LoginForm.vue";
import { useUserStore } from "@/stores/user";
import { useI18n } from "vue-i18n";
import SocialAuthProviders from "./SocialAuthProviders.vue";
const userStore = useUserStore();
const { t } = useI18n();
@ -27,5 +28,6 @@ const onLoginSucceed = () => {
@update:visible="onVisibleChange"
>
<LoginForm v-if="userStore.loginModalVisible" @succeed="onLoginSucceed" />
<SocialAuthProviders />
</VModal>
</template>

View File

@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { SocialAuthProvider } from "@/modules/system/actuator/types";
import { ref } from "vue";
const props = withDefaults(
defineProps<{
authProvider: SocialAuthProvider;
}>(),
{}
);
const loading = ref(false);
function handleSocialLogin() {
loading.value = true;
window.location.href = props.authProvider.authenticationUrl;
}
</script>
<template>
<button
class="group inline-flex select-none flex-row items-center gap-2 rounded bg-white px-2.5 py-1.5 ring-1 ring-gray-200 transition-all hover:bg-gray-100 hover:shadow hover:ring-gray-900"
@click="handleSocialLogin"
>
<svg
v-if="loading"
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<img
v-else
:alt="authProvider.displayName"
class="h-4 w-4 rounded-full"
:src="authProvider.logo"
/>
<span class="text-xs text-gray-800 group-hover:text-gray-900">
{{ authProvider.displayName }}
</span>
</button>
</template>

View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
// auth providers
import { useGlobalInfoFetch } from "@/composables/use-global-info";
import SocialAuthProviderItem from "./SocialAuthProviderItem.vue";
const { globalInfo } = useGlobalInfoFetch();
</script>
<template>
<Transition v-if="globalInfo?.socialAuthProviders.length" appear name="fade">
<div>
<div
class="my-4 flex items-center before:ml-1 before:mt-0.5 before:flex-1 before:border-t before:border-gray-200 after:mr-1 after:mt-0.5 after:flex-1 after:border-t after:border-gray-200"
>
<p class="mx-4 mb-0 text-center text-xs dark:text-neutral-600">
{{ $t("core.components.social_auth_providers.title") }}
</p>
</div>
<ul class="flex flex-row flex-wrap justify-center gap-2">
<li
v-for="(socialAuthProvider, index) in globalInfo.socialAuthProviders"
:key="index"
>
<SocialAuthProviderItem :auth-provider="socialAuthProvider" />
</li>
</ul>
</div>
</Transition>
</template>

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { submitForm, reset } from "@formkit/core";
import { submitForm } from "@formkit/core";
import { Toast, VButton } from "@halo-dev/components";
import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router";
@ -8,6 +8,15 @@ import { useI18n } from "vue-i18n";
const { t } = useI18n();
withDefaults(
defineProps<{
buttonText?: string;
}>(),
{
buttonText: "core.signup.operations.submit.button",
}
);
const formState = ref({
password: "",
user: {
@ -23,6 +32,7 @@ const formState = ref({
},
},
});
const loading = ref(false);
const emit = defineEmits<{
(event: "succeed"): void;
@ -42,6 +52,8 @@ onMounted(() => {
const handleSignup = async () => {
try {
loading.value = true;
await apiClient.common.user.signUp({
signUpRequest: formState.value,
});
@ -51,8 +63,14 @@ const handleSignup = async () => {
emit("succeed");
} catch (error) {
console.error("Failed to sign up", error);
} finally {
loading.value = false;
}
};
const inputClasses = {
outer: "!py-3 first:!pt-0 last:!pb-0",
};
</script>
<template>
@ -73,6 +91,7 @@ const handleSignup = async () => {
name="username"
:placeholder="$t('core.signup.fields.username.placeholder')"
:validation-label="$t('core.signup.fields.username.placeholder')"
:classes="inputClasses"
:autofocus="true"
type="text"
:validation="[
@ -93,6 +112,7 @@ const handleSignup = async () => {
name="displayName"
:placeholder="$t('core.signup.fields.display_name.placeholder')"
:validation-label="$t('core.signup.fields.display_name.placeholder')"
:classes="inputClasses"
:autofocus="true"
type="text"
validation="required"
@ -103,6 +123,7 @@ const handleSignup = async () => {
name="password"
:placeholder="$t('core.signup.fields.password.placeholder')"
:validation-label="$t('core.signup.fields.password.placeholder')"
:classes="inputClasses"
type="password"
validation="required|length:0,100"
>
@ -111,17 +132,19 @@ const handleSignup = async () => {
name="password_confirm"
:placeholder="$t('core.signup.fields.password_confirm.placeholder')"
:validation-label="$t('core.signup.fields.password_confirm.placeholder')"
:classes="inputClasses"
type="password"
validation="required|confirm|length:0,100"
>
</FormKit>
</FormKit>
<VButton
class="mt-6"
class="mt-8"
block
type="secondary"
:loading="loading"
@click="submitForm('signup-form')"
>
{{ $t("core.signup.operations.submit.button") }}
{{ $t(buttonText) }}
</VButton>
</template>

View File

@ -0,0 +1,24 @@
import type { GlobalInfo } from "@/modules/system/actuator/types";
import { useQuery } from "@tanstack/vue-query";
import axios from "axios";
export function useGlobalInfoFetch() {
const { data } = useQuery<GlobalInfo>({
queryKey: ["globalinfo"],
queryFn: async () => {
const { data } = await axios.get<GlobalInfo>(
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
{
withCredentials: true,
}
);
return data;
},
refetchOnWindowFocus: false,
});
return {
globalInfo: data,
};
}

View File

@ -0,0 +1 @@
export const AppName = "Halo";

View File

@ -4,19 +4,22 @@ core:
fields:
username:
placeholder: Username
validation: Please enter username
password:
placeholder: Password
validation: Please enter password
operations:
submit:
toast_success: Login successful
toast_failed: Login failed, incorrect username or password
toast_csrf: CSRF Token expired, please try again
signup:
label: No account
button: Sign up
return_login:
label: Already have an account
button: Sign in
button: Login
modal:
title: Re-login
other_login: "Other login:"
signup:
title: Sign up
fields:
@ -34,6 +37,17 @@ core:
toast_success: Sign up successfully
binding:
title: Account binding
common:
toast:
mounted: The current login method is not bound to an account, Please bind or sign up a new account first
operations:
login_and_bind:
button: Login and Bind
signup_and_bind:
button: Signup and Bind
bind:
toast_success: Binding successfully
toast_failed: Binding failed, no enabled login method found
sidebar:
search:
placeholder: Search
@ -891,6 +905,7 @@ core:
disable_privileged:
tooltip: The authentication method reserved by the system cannot be disabled
detail:
title: Identity authentication detail
fields:
display_name: Display name
description: Description
@ -1051,6 +1066,8 @@ core:
pagination:
page_label: page
size_label: items per page
social_auth_providers:
title: Third-party login
composables:
content_cache:
toast_recovered: Recovered unsaved content from cache

View File

@ -4,19 +4,22 @@ core:
fields:
username:
placeholder: 用户名
validation: 请输入用户名
password:
placeholder: 密码
validation: 请输入密码
operations:
submit:
toast_success: 登录成功
toast_failed: 登录失败,用户名或密码错误
toast_csrf: CSRF Token 失效,请重新尝试
signup:
label: 没有账号
button: 立即注册
return_login:
label: 已有账号
button: 立即登录
button: 登录
modal:
title: 重新登录
other_login: 其他登录:
signup:
title: 注册
fields:
@ -34,6 +37,17 @@ core:
toast_success: 注册成功
binding:
title: 账号绑定
common:
toast:
mounted: 当前登录方式未绑定账号,请先绑定或注册新账号
operations:
login_and_bind:
button: 登录并绑定
signup_and_bind:
button: 注册并绑定
bind:
toast_success: 绑定成功
toast_failed: 绑定失败,没有找到已启用的登录方式
sidebar:
search:
placeholder: 搜索
@ -891,6 +905,7 @@ core:
disable_privileged:
tooltip: 系统保留的认证方式,无法禁用
detail:
title: 身份认证详情
fields:
display_name: 名称
description: 描述
@ -1051,6 +1066,8 @@ core:
pagination:
page_label:
size_label: 条 / 页
social_auth_providers:
title: 三方登录
composables:
content_cache:
toast_recovered: 已从缓存中恢复未保存的内容

View File

@ -14,7 +14,7 @@ export default definePlugin({
name: "AuthProviders",
component: AuthProviders,
meta: {
title: "认证方式",
title: "core.identity_authentication.title",
searchable: true,
},
},
@ -23,7 +23,7 @@ export default definePlugin({
name: "AuthProviderDetail",
component: AuthProviderDetail,
meta: {
title: "认证方式详情",
title: "core.identity_authentication.detail.title",
},
},
],

View File

@ -1,19 +1,19 @@
<script lang="ts" setup>
import { onBeforeMount } from "vue";
import { computed, onBeforeMount, onMounted } from "vue";
import router from "@/router";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import { useUserStore } from "@/stores/user";
import LoginForm from "@/components/login/LoginForm.vue";
import { useRoute } from "vue-router";
import type { GlobalInfo, SocialAuthProvider } from "../actuator/types";
import { useQuery } from "@tanstack/vue-query";
import axios from "axios";
import { Toast } from "@halo-dev/components";
import { useRouteQuery } from "@vueuse/router";
import SignupForm from "@/components/signup/SignupForm.vue";
import { useGlobalInfoFetch } from "@/composables/use-global-info";
import { useI18n } from "vue-i18n";
const userStore = useUserStore();
const route = useRoute();
const { t } = useI18n();
onBeforeMount(() => {
if (!userStore.isAnonymous) {
@ -21,34 +21,25 @@ onBeforeMount(() => {
}
});
const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
queryKey: ["social-auth-providers"],
queryFn: async () => {
const { data } = await axios.get<GlobalInfo>(
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
{
withCredentials: true,
}
);
const { globalInfo } = useGlobalInfoFetch();
return data.socialAuthProviders;
},
refetchOnWindowFocus: false,
onMounted(() => {
Toast.warning(t("core.binding.common.toast.mounted"));
});
function handleBinding() {
const authProvider = socialAuthProviders.value?.find(
const authProvider = globalInfo.value?.socialAuthProviders.find(
(p) => p.name === route.params.provider
);
if (!authProvider?.bindingUrl) {
Toast.error("绑定失败,没有找到已启用的登录方式");
Toast.error(t("core.binding.operations.bind.toast_failed"));
return;
}
window.location.href = authProvider?.bindingUrl;
Toast.success("绑定成功");
Toast.success(t("core.binding.operations.bind.toast_success"));
}
const type = useRouteQuery<string>("type", "");
@ -56,22 +47,42 @@ const type = useRouteQuery<string>("type", "");
function handleChangeType() {
type.value = type.value === "signup" ? "" : "signup";
}
const isLoginType = computed(() => type.value !== "signup");
</script>
<template>
<div class="flex h-screen flex-col items-center justify-center">
<div class="mb-4">
<IconLogo />
</div>
<div class="login-form flex w-72 flex-col">
<div class="mb-4 flex">
<h1 class="text-sm">{{ $t("core.binding.title") }}</h1>
</div>
<SignupForm v-if="type === 'signup'" @succeed="handleBinding" />
<LoginForm v-else @succeed="handleBinding" />
<div class="flex">
<span class="mt-4 text-sm text-indigo-600" @click="handleChangeType">
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
<IconLogo class="mb-8" />
<div class="flex w-72 flex-col">
<SignupForm
v-if="type === 'signup'"
button-text="core.binding.operations.signup_and_bind.button"
@succeed="handleBinding"
/>
<LoginForm
v-else
button-text="core.binding.operations.login_and_bind.button"
@succeed="handleBinding"
/>
<div
v-if="globalInfo?.allowRegistration"
class="flex justify-center gap-1 pt-3.5 text-xs"
>
<span class="text-slate-500">
{{
type === "signup" ? $t("core.login.title") : $t("core.signup.title")
isLoginType
? $t("core.login.operations.signup.label")
: $t("core.login.operations.return_login.label")
}}
</span>
<span
class="cursor-pointer text-secondary hover:text-gray-600"
@click="handleChangeType"
>
{{
isLoginType
? $t("core.login.operations.signup.button")
: $t("core.login.operations.return_login.button")
}}
</span>
</div>

View File

@ -1,16 +1,25 @@
<script lang="ts" setup>
import { onBeforeMount, watch } from "vue";
import { onBeforeMount, computed, watch } from "vue";
import router from "@/router";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import { useUserStore } from "@/stores/user";
import LoginForm from "@/components/login/LoginForm.vue";
import { useRouteQuery } from "@vueuse/router";
import SignupForm from "@/components/signup/SignupForm.vue";
import SocialAuthProviders from "@/components/login/SocialAuthProviders.vue";
import { useGlobalInfoFetch } from "@/composables/use-global-info";
import { useTitle } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { AppName } from "@/constants/app";
import { locales, getBrowserLanguage, i18n } from "@/locales";
import MdiTranslate from "~icons/mdi/translate";
import { useLocalStorage } from "@vueuse/core";
const userStore = useUserStore();
const { globalInfo } = useGlobalInfoFetch();
const { t } = useI18n();
const SIGNUP_TYPE = "signup";
onBeforeMount(() => {
if (!userStore.isAnonymous) {
@ -25,9 +34,23 @@ function onLoginSucceed() {
const type = useRouteQuery<string>("type", "");
function handleChangeType() {
type.value = type.value === "signup" ? "" : "signup";
type.value = type.value === SIGNUP_TYPE ? "" : SIGNUP_TYPE;
}
const isLoginType = computed(() => type.value !== SIGNUP_TYPE);
// page title
const title = useTitle();
watch(
() => type.value,
(value) => {
const routeTitle = t(
`core.${value === SIGNUP_TYPE ? SIGNUP_TYPE : "login"}.title`
);
title.value = [routeTitle, AppName].join(" - ");
}
);
// setup locale
const currentLocale = useLocalStorage(
"locale",
@ -45,15 +68,31 @@ watch(
);
</script>
<template>
<div class="flex h-screen flex-col items-center justify-center">
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
<IconLogo class="mb-8" />
<div class="login-form flex w-72 flex-col">
<div class="flex w-72 flex-col">
<SignupForm v-if="type === 'signup'" @succeed="onLoginSucceed" />
<LoginForm v-else @succeed="onLoginSucceed" />
<div class="flex">
<span class="mt-4 text-sm text-indigo-600" @click="handleChangeType">
<SocialAuthProviders />
<div
v-if="globalInfo?.allowRegistration"
class="flex justify-center gap-1 pt-3.5 text-xs"
>
<span class="text-slate-500">
{{
type === "signup" ? $t("core.login.title") : $t("core.signup.title")
isLoginType
? $t("core.login.operations.signup.label")
: $t("core.login.operations.return_login.label")
}}
</span>
<span
class="cursor-pointer text-secondary hover:text-gray-600"
@click="handleChangeType"
>
{{
isLoginType
? $t("core.login.operations.signup.button")
: $t("core.login.operations.return_login.button")
}}
</span>
</div>

View File

@ -77,6 +77,7 @@ watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.user) formState.value = cloneDeep(props.user);
setFocus(isUpdateMode.value ? "displayNameInput" : "userNameInput");
} else {
handleResetForm();
@ -84,20 +85,6 @@ watch(
}
);
watch(
() => props.user,
(user) => {
if (user) {
formState.value = cloneDeep(user);
} else {
handleResetForm();
}
},
{
immediate: true,
}
);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {