Files
ctms/frontend/src/components/SessionTimeoutPrompt.vue
T
2026-06-10 15:20:06 +08:00

200 lines
4.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<transition name="session-timeout-fade">
<div v-if="visible" class="session-timeout-prompt" role="status" aria-live="polite">
<div class="timeout-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 7v5l3 2"></path>
</svg>
</div>
<div class="timeout-copy">
<strong>即将自动退出</strong>
<span>{{ countdownText }} 后将退出登录以保护项目数据</span>
</div>
<div class="timeout-actions">
<el-button size="small" @click="logoutNow">立即退出</el-button>
<el-button size="small" type="primary" @click="continueSession">继续使用</el-button>
</div>
<div class="timeout-progress" aria-hidden="true">
<span :style="{ width: progressWidth }"></span>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import {
forceLogout,
LOGOUT_REASON_MANUAL,
markUserActive,
TIMEOUT_WARNING_SECONDS,
} from "../session/sessionManager";
const auth = useAuthStore();
const session = useSessionStore();
const nowTs = ref(Date.now());
let timer: number | null = null;
const visible = computed(() => Boolean(auth.token && session.timeoutWarningVisible));
const countdownText = computed(() => {
const remainSeconds = Math.max(0, Math.ceil((session.timeoutAt - nowTs.value) / 1000));
return `${remainSeconds} 秒`;
});
const progressWidth = computed(() => {
const remainSeconds = Math.max(0, Math.ceil((session.timeoutAt - nowTs.value) / 1000));
const ratio = Math.min(1, remainSeconds / TIMEOUT_WARNING_SECONDS);
return `${Math.round(ratio * 100)}%`;
});
const stopTimer = () => {
if (!timer) return;
window.clearInterval(timer);
timer = null;
};
const startTimer = () => {
stopTimer();
nowTs.value = Date.now();
timer = window.setInterval(() => {
nowTs.value = Date.now();
}, 1000);
};
const continueSession = () => {
markUserActive();
};
const logoutNow = () => {
forceLogout(LOGOUT_REASON_MANUAL);
};
watch(
visible,
(value) => {
if (value) {
startTimer();
return;
}
stopTimer();
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopTimer();
});
</script>
<style scoped>
.session-timeout-prompt {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 2200;
width: min(420px, calc(100vw - 32px));
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(197, 139, 42, 0.24);
background: rgba(255, 252, 246, 0.96);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.72);
backdrop-filter: blur(16px);
color: #3b2b12;
}
.timeout-progress {
grid-column: 1 / -1;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(197, 139, 42, 0.14);
}
.timeout-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #d59a35, #f2b84f);
box-shadow: 0 0 12px rgba(242, 184, 79, 0.36);
transition: width 220ms ease;
}
.timeout-icon {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 11px;
color: var(--ctms-warning);
background: #fff3dc;
box-shadow: inset 0 0 0 1px rgba(197, 139, 42, 0.16);
}
.timeout-icon svg {
width: 20px;
height: 20px;
}
.timeout-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.timeout-copy strong {
font-size: 13px;
line-height: 18px;
font-weight: 800;
color: #182235;
}
.timeout-copy span {
font-size: 12px;
line-height: 18px;
font-weight: 600;
color: #7a5a23;
}
.timeout-actions {
display: inline-flex;
gap: 8px;
align-items: center;
}
.session-timeout-fade-enter-active,
.session-timeout-fade-leave-active {
transition: opacity 160ms ease, transform 160ms ease;
}
.session-timeout-fade-enter-from,
.session-timeout-fade-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (max-width: 640px) {
.session-timeout-prompt {
left: 16px;
right: 16px;
bottom: 16px;
width: auto;
grid-template-columns: 34px minmax(0, 1fr);
}
.timeout-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
</style>