mirror of https://github.com/halo-dev/halo-admin
feat: support for opening login modal after login session expiration (#715)
#### What type of PR is this? /kind feature /milestone 2.0 #### What this PR does / why we need it: 支持在登录会话失效之后打开登录弹窗,而不是直接跳转到登录页面,防止正在编辑的内容丢失。 #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2783 #### Screenshots: <img width="541" alt="image" src="https://user-images.githubusercontent.com/21301288/204085654-5c90627b-fbbd-4b04-ac92-f75bab28a1b0.png"> #### Special notes for your reviewer: 测试方式: 1. 打开 Console 之后登录进入到控制台。 2. 重启 Halo 或者等待会话失效。 3. 随意切换控制台界面,观察是否打开了登录弹窗。 4. 重新登录,检查是否成功。 #### Does this PR introduce a user-facing change? ```release-note Console 端支持在登录会话失效之后打开登录弹窗。 ```pull/728/head
parent
0fc4815a67
commit
6901cdb812
|
@ -134,10 +134,10 @@ defineExpose({ close });
|
|||
</template>
|
||||
<style lang="scss">
|
||||
.toast-container {
|
||||
@apply fixed pointer-events-none flex flex-col box-border transition-all w-full left-0 top-0 items-center justify-center p-4 gap-3;
|
||||
@apply fixed pointer-events-none flex z-[9999] flex-col box-border transition-all w-full left-0 top-0 items-center justify-center p-4 gap-3;
|
||||
|
||||
.toast-wrapper {
|
||||
@apply inline-block max-w-xs z-50 pointer-events-auto relative;
|
||||
@apply inline-block max-w-xs pointer-events-auto relative;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
<script lang="ts" setup>
|
||||
import { setFocus } from "@/formkit/utils/focus";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { randomUUID } from "@/utils/id";
|
||||
import axios from "axios";
|
||||
import { Toast, VButton } from "@halo-dev/components";
|
||||
import { onMounted, ref } from "vue";
|
||||
import qs from "qs";
|
||||
import { submitForm } from "@formkit/core";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "succeed"): void;
|
||||
}>();
|
||||
|
||||
interface LoginForm {
|
||||
_csrf: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const loginForm = ref<LoginForm>({
|
||||
_csrf: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const handleGenerateToken = async () => {
|
||||
const token = randomUUID();
|
||||
loginForm.value._csrf = token;
|
||||
document.cookie = `XSRF-TOKEN=${token}; Path=/;`;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await axios.post(
|
||||
`${import.meta.env.VITE_API_URL}/login`,
|
||||
qs.stringify(loginForm.value),
|
||||
{
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await userStore.fetchCurrentUser();
|
||||
|
||||
localStorage.setItem("logged_in", "true");
|
||||
|
||||
emit("succeed");
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
Toast.error("登录失败,用户名或密码错误");
|
||||
loginForm.value.password = "";
|
||||
setFocus("passwordInput");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleGenerateToken();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormKit
|
||||
id="login-form"
|
||||
v-model="loginForm"
|
||||
name="login-form"
|
||||
:actions="false"
|
||||
type="form"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
@submit="handleLogin"
|
||||
@keyup.enter="submitForm('login-form')"
|
||||
>
|
||||
<FormKit
|
||||
:validation-messages="{
|
||||
required: '请输入用户名',
|
||||
}"
|
||||
name="username"
|
||||
placeholder="用户名"
|
||||
type="text"
|
||||
validation="required"
|
||||
>
|
||||
<template #prefixIcon>
|
||||
<IconUserLine />
|
||||
</template>
|
||||
</FormKit>
|
||||
<FormKit
|
||||
id="passwordInput"
|
||||
:validation-messages="{
|
||||
required: '请输入密码',
|
||||
}"
|
||||
name="password"
|
||||
placeholder="密码"
|
||||
type="password"
|
||||
validation="required"
|
||||
>
|
||||
<template #prefixIcon>
|
||||
<IconShieldUser />
|
||||
</template>
|
||||
</FormKit>
|
||||
</FormKit>
|
||||
<VButton
|
||||
class="mt-6"
|
||||
block
|
||||
:loading="loading"
|
||||
type="secondary"
|
||||
@click="submitForm('login-form')"
|
||||
>
|
||||
登录
|
||||
</VButton>
|
||||
</template>
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts" setup>
|
||||
import { Toast, VModal } from "@halo-dev/components";
|
||||
import LoginForm from "@/components/login/LoginForm.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const onVisibleChange = (visible: boolean) => {
|
||||
userStore.loginModalVisible = visible;
|
||||
};
|
||||
|
||||
const onLoginSucceed = () => {
|
||||
onVisibleChange(false);
|
||||
Toast.success("登录成功");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
:visible="userStore.loginModalVisible"
|
||||
:mount-to-body="true"
|
||||
:width="400"
|
||||
:centered="true"
|
||||
title="重新登录"
|
||||
@update:visible="onVisibleChange"
|
||||
>
|
||||
<LoginForm v-if="userStore.loginModalVisible" @succeed="onLoginSucceed" />
|
||||
</VModal>
|
||||
</template>
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import axios from "axios";
|
||||
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
|
||||
import LoginModal from "@/components/login/LoginModal.vue";
|
||||
import { coreMenuGroups } from "@/router/routes.config";
|
||||
import sortBy from "lodash.sortby";
|
||||
import { useRoleStore } from "@/stores/role";
|
||||
|
@ -354,6 +355,7 @@ onMounted(generateMenus);
|
|||
</div>
|
||||
</div>
|
||||
<GlobalSearchModal v-model:visible="globalSearchVisible" />
|
||||
<LoginModal />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -1,130 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
IconShieldUser,
|
||||
IconUserLine,
|
||||
VButton,
|
||||
Toast,
|
||||
} from "@halo-dev/components";
|
||||
import qs from "qs";
|
||||
import { onBeforeMount, onMounted, ref } from "vue";
|
||||
import { submitForm } from "@formkit/vue";
|
||||
import { onBeforeMount } from "vue";
|
||||
import router from "@/router";
|
||||
import axios from "axios";
|
||||
import { setFocus } from "@/formkit/utils/focus";
|
||||
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||
import { randomUUID } from "@/utils/id";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
interface LoginForm {
|
||||
_csrf: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
import LoginForm from "@/components/login/LoginForm.vue";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const loginForm = ref<LoginForm>({
|
||||
_csrf: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const handleGenerateToken = async () => {
|
||||
const token = randomUUID();
|
||||
loginForm.value._csrf = token;
|
||||
document.cookie = `XSRF-TOKEN=${token}; Path=/;`;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await axios.post(
|
||||
`${import.meta.env.VITE_API_URL}/login`,
|
||||
qs.stringify(loginForm.value),
|
||||
{
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await userStore.fetchCurrentUser();
|
||||
|
||||
localStorage.setItem("logged_in", "true");
|
||||
router.go(0);
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
Toast.error("登录失败,用户名或密码错误");
|
||||
loginForm.value.password = "";
|
||||
setFocus("passwordInput");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (!userStore.isAnonymous) {
|
||||
router.push({ name: "Dashboard" });
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
handleGenerateToken();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex h-screen flex-col items-center justify-center">
|
||||
<IconLogo class="mb-8" />
|
||||
<div class="login-form flex w-72 flex-col gap-4">
|
||||
<FormKit
|
||||
id="login-form"
|
||||
v-model="loginForm"
|
||||
name="login-form"
|
||||
:actions="false"
|
||||
type="form"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
@submit="handleLogin"
|
||||
@keyup.enter="submitForm('login-form')"
|
||||
>
|
||||
<FormKit
|
||||
:validation-messages="{
|
||||
required: '请输入用户名',
|
||||
}"
|
||||
name="username"
|
||||
placeholder="用户名"
|
||||
type="text"
|
||||
validation="required"
|
||||
>
|
||||
<template #prefixIcon>
|
||||
<IconUserLine />
|
||||
</template>
|
||||
</FormKit>
|
||||
<FormKit
|
||||
id="passwordInput"
|
||||
:validation-messages="{
|
||||
required: '请输入密码',
|
||||
}"
|
||||
name="password"
|
||||
placeholder="密码"
|
||||
type="password"
|
||||
validation="required"
|
||||
>
|
||||
<template #prefixIcon>
|
||||
<IconShieldUser />
|
||||
</template>
|
||||
</FormKit>
|
||||
</FormKit>
|
||||
<VButton
|
||||
block
|
||||
:loading="loading"
|
||||
type="secondary"
|
||||
@click="submitForm('login-form')"
|
||||
>
|
||||
登录
|
||||
</VButton>
|
||||
<div class="login-form flex w-72 flex-col">
|
||||
<LoginForm @succeed="router.go(0)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -5,12 +5,14 @@ import { defineStore } from "pinia";
|
|||
interface UserStoreState {
|
||||
currentUser?: User;
|
||||
isAnonymous: boolean;
|
||||
loginModalVisible: boolean;
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore("user", {
|
||||
state: (): UserStoreState => ({
|
||||
currentUser: undefined,
|
||||
isAnonymous: true,
|
||||
loginModalVisible: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchCurrentUser() {
|
||||
|
|
|
@ -34,7 +34,8 @@ import {
|
|||
} from "@halo-dev/api-client";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import axios from "axios";
|
||||
import router from "@/router";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { Toast } from "@halo-dev/components";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL;
|
||||
|
||||
|
@ -49,10 +50,10 @@ axiosInstance.interceptors.response.use(
|
|||
},
|
||||
async (error) => {
|
||||
if (error.response.status === 401) {
|
||||
const userStore = useUserStore();
|
||||
userStore.loginModalVisible = true;
|
||||
Toast.warning("登录已过期,请重新登录");
|
||||
localStorage.removeItem("logged_in");
|
||||
router.push({
|
||||
name: "Login",
|
||||
});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue