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>
|
</template>
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.toast-container {
|
.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 {
|
.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 {
|
.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 { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
|
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
|
||||||
|
import LoginModal from "@/components/login/LoginModal.vue";
|
||||||
import { coreMenuGroups } from "@/router/routes.config";
|
import { coreMenuGroups } from "@/router/routes.config";
|
||||||
import sortBy from "lodash.sortby";
|
import sortBy from "lodash.sortby";
|
||||||
import { useRoleStore } from "@/stores/role";
|
import { useRoleStore } from "@/stores/role";
|
||||||
|
@ -354,6 +355,7 @@ onMounted(generateMenus);
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GlobalSearchModal v-model:visible="globalSearchVisible" />
|
<GlobalSearchModal v-model:visible="globalSearchVisible" />
|
||||||
|
<LoginModal />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -1,130 +1,23 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
import { onBeforeMount } from "vue";
|
||||||
IconShieldUser,
|
|
||||||
IconUserLine,
|
|
||||||
VButton,
|
|
||||||
Toast,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import qs from "qs";
|
|
||||||
import { onBeforeMount, onMounted, ref } from "vue";
|
|
||||||
import { submitForm } from "@formkit/vue";
|
|
||||||
import router from "@/router";
|
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 IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||||
import { randomUUID } from "@/utils/id";
|
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import LoginForm from "@/components/login/LoginForm.vue";
|
||||||
interface LoginForm {
|
|
||||||
_csrf: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
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(() => {
|
onBeforeMount(() => {
|
||||||
if (!userStore.isAnonymous) {
|
if (!userStore.isAnonymous) {
|
||||||
router.push({ name: "Dashboard" });
|
router.push({ name: "Dashboard" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
handleGenerateToken();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen flex-col items-center justify-center">
|
<div class="flex h-screen flex-col items-center justify-center">
|
||||||
<IconLogo class="mb-8" />
|
<IconLogo class="mb-8" />
|
||||||
<div class="login-form flex w-72 flex-col gap-4">
|
<div class="login-form flex w-72 flex-col">
|
||||||
<FormKit
|
<LoginForm @succeed="router.go(0)" />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -5,12 +5,14 @@ import { defineStore } from "pinia";
|
||||||
interface UserStoreState {
|
interface UserStoreState {
|
||||||
currentUser?: User;
|
currentUser?: User;
|
||||||
isAnonymous: boolean;
|
isAnonymous: boolean;
|
||||||
|
loginModalVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUserStore = defineStore("user", {
|
export const useUserStore = defineStore("user", {
|
||||||
state: (): UserStoreState => ({
|
state: (): UserStoreState => ({
|
||||||
currentUser: undefined,
|
currentUser: undefined,
|
||||||
isAnonymous: true,
|
isAnonymous: true,
|
||||||
|
loginModalVisible: false,
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async fetchCurrentUser() {
|
async fetchCurrentUser() {
|
||||||
|
|
|
@ -34,7 +34,8 @@ import {
|
||||||
} from "@halo-dev/api-client";
|
} from "@halo-dev/api-client";
|
||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
import axios 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;
|
const baseURL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
@ -49,10 +50,10 @@ axiosInstance.interceptors.response.use(
|
||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error.response.status === 401) {
|
if (error.response.status === 401) {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
userStore.loginModalVisible = true;
|
||||||
|
Toast.warning("登录已过期,请重新登录");
|
||||||
localStorage.removeItem("logged_in");
|
localStorage.removeItem("logged_in");
|
||||||
router.push({
|
|
||||||
name: "Login",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue