200 lines
4.5 KiB
Vue
200 lines
4.5 KiB
Vue
<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>
|