优化后台界面与无操作退出体验
This commit is contained in:
@@ -11,9 +11,9 @@
|
||||
>
|
||||
<template #header>
|
||||
<div class="editor-header">
|
||||
<div class="editor-title-row">
|
||||
<div class="editor-title">分类管理</div>
|
||||
<el-button size="small" class="header-add-btn" @click="addCategory">
|
||||
<div class="editor-title-row">
|
||||
<div class="editor-title">分类管理</div>
|
||||
<el-button v-if="canCreate" size="small" class="header-add-btn" @click="addCategory">
|
||||
<el-icon><Plus /></el-icon> 新增
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -69,11 +69,13 @@
|
||||
size="small"
|
||||
placeholder="分类名称"
|
||||
class="row-name-input"
|
||||
:disabled="!cat._isNew && !canUpdate"
|
||||
@input="cat._dirty = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="cat._isNew ? canCreate : canDelete"
|
||||
class="row-delete"
|
||||
type="button"
|
||||
@click.stop="removeCategory(cat)"
|
||||
@@ -120,6 +122,9 @@ const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
categories: FaqCategory[];
|
||||
faqs?: any[];
|
||||
canCreate?: boolean;
|
||||
canUpdate?: boolean;
|
||||
canDelete?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -130,6 +135,9 @@ const emit = defineEmits<{
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
let keyCounter = 0;
|
||||
const canCreate = computed(() => props.canCreate !== false);
|
||||
const canUpdate = computed(() => props.canUpdate !== false);
|
||||
const canDelete = computed(() => props.canDelete !== false);
|
||||
|
||||
const editableCategories = ref<EditableCategory[]>([]);
|
||||
|
||||
@@ -174,6 +182,10 @@ const loadFromProps = () => {
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
if (!canCreate.value) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
editableCategories.value.push({
|
||||
_key: `new-${++keyCounter}`,
|
||||
_dirty: true,
|
||||
@@ -219,6 +231,11 @@ const moveDown = (idx: number) => {
|
||||
};
|
||||
|
||||
const removeCategory = (cat: EditableCategory) => {
|
||||
const allowed = cat._isNew ? canCreate.value : canDelete.value;
|
||||
if (!allowed) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
if (!cat._isNew) {
|
||||
const count = (props.faqs || []).filter((f: any) => f.category_id === cat.id).length;
|
||||
if (count > 0) {
|
||||
@@ -250,6 +267,7 @@ const saveAll = async () => {
|
||||
const existingIds = all.filter((c) => !c._isNew && c.id).map((c) => c.id);
|
||||
for (const orig of props.categories) {
|
||||
if (!existingIds.includes(orig.id)) {
|
||||
if (!canDelete.value) continue;
|
||||
await deleteFaqCategory(orig.id, studyId);
|
||||
}
|
||||
}
|
||||
@@ -270,9 +288,11 @@ const saveAll = async () => {
|
||||
};
|
||||
|
||||
if (cat._isNew) {
|
||||
if (!canCreate.value) continue;
|
||||
if (!cat.name.trim()) continue;
|
||||
await createFaqCategory(payload);
|
||||
} else if (cat._dirty) {
|
||||
if (!canUpdate.value) continue;
|
||||
await updateFaqCategory(cat.id, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<aside class="panel unified-shell faq-category-panel">
|
||||
<div class="header">
|
||||
<span>{{ TEXT.common.labels.category }}</span>
|
||||
<div v-if="canCreateCategory">
|
||||
<div v-if="canManageCategories">
|
||||
<el-button type="primary" size="small" class="category-create-btn" @click="openManager()">编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,7 +21,15 @@
|
||||
<span class="cat-count">{{ categoryCounts[c.id] || 0 }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<FaqCategoryManager v-model="showForm" :categories="props.categories" :faqs="props.faqs" @success="emit('refresh')" />
|
||||
<FaqCategoryManager
|
||||
v-model="showForm"
|
||||
:categories="props.categories"
|
||||
:faqs="props.faqs"
|
||||
:can-create="canCreateCategory"
|
||||
:can-update="canUpdateCategory"
|
||||
:can-delete="canDeleteCategory"
|
||||
@success="emit('refresh')"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -44,6 +52,9 @@ const showForm = ref(false);
|
||||
|
||||
const { can } = usePermission();
|
||||
const canCreateCategory = computed(() => can("faq.category.create"));
|
||||
const canUpdateCategory = computed(() => can("faq.category.update"));
|
||||
const canDeleteCategory = computed(() => can("faq.category.delete"));
|
||||
const canManageCategories = computed(() => canCreateCategory.value || canUpdateCategory.value || canDeleteCategory.value);
|
||||
const sortedCategories = computed(() =>
|
||||
[...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
);
|
||||
|
||||
@@ -180,9 +180,9 @@
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.profile }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-dropdown-item command="logout" :disabled="loggingOut" divided>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>{{ TEXT.menu.logout }}</span>
|
||||
<span>{{ loggingOut ? "正在退出..." : TEXT.menu.logout }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -218,8 +218,9 @@ import {
|
||||
User, Suitcase, House, Calendar, Flag,
|
||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
@@ -233,6 +234,7 @@ const canAccessProjectPath = (path: string) =>
|
||||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||||
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
|
||||
const loggingOut = ref(false);
|
||||
|
||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||
@@ -433,18 +435,41 @@ const toggleCollapse = () => {
|
||||
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
|
||||
};
|
||||
|
||||
const onLogout = () => {
|
||||
const logoutImmediately = () => {
|
||||
auth.logout();
|
||||
study.clearCurrentStudy();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
const onLogout = async () => {
|
||||
if (loggingOut.value) return;
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
"退出后需要重新登录才能继续访问项目数据。",
|
||||
"确认退出登录",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: "退出登录",
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
distinguishCancelAndClose: true,
|
||||
},
|
||||
).catch(() => null);
|
||||
if (!confirmed) return;
|
||||
loggingOut.value = true;
|
||||
try {
|
||||
forceLogout(LOGOUT_REASON_MANUAL);
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
loggingOut.value = false;
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.token && !auth.user) {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
onLogout();
|
||||
logoutImmediately();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,15 @@ describe("Layout breadcrumbs", () => {
|
||||
|
||||
expect(source).toContain('<component v-if="Component" :is="Component" />');
|
||||
});
|
||||
|
||||
it("confirms manual logout and records the logout reason", () => {
|
||||
const source = readLayout();
|
||||
|
||||
expect(source).toContain('command="logout" :disabled="loggingOut" divided');
|
||||
expect(source).toContain("ElMessageBox.confirm(");
|
||||
expect(source).toContain("确认退出登录");
|
||||
expect(source).toContain("退出后需要重新登录才能继续访问项目数据。");
|
||||
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
|
||||
expect(source).toContain('loggingOut ? "正在退出..." : TEXT.menu.logout');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="lock-screen" role="dialog" aria-modal="true">
|
||||
<div class="lock-backdrop" />
|
||||
<div class="lock-panel">
|
||||
<h2 class="lock-title">已锁定</h2>
|
||||
<p class="lock-desc">30 分钟无操作将锁定,2 小时无操作将自动退出登录</p>
|
||||
<p class="lock-countdown">剩余自动登出:{{ autoLogoutCountdown }}</p>
|
||||
<el-input
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="lock-password"
|
||||
placeholder="请输入登录密码"
|
||||
show-password
|
||||
@keyup.enter="onUnlock"
|
||||
/>
|
||||
<div class="lock-actions">
|
||||
<el-button type="primary" :loading="loading" @click="onUnlock">解锁</el-button>
|
||||
<el-button @click="onLogout">退出登录</el-button>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="lock-error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import {
|
||||
AUTO_LOGOUT_TIMEOUT_HOURS,
|
||||
UNLOCK_MAX_ATTEMPTS,
|
||||
unlockWithPassword,
|
||||
forceLogout,
|
||||
} from "../session/sessionManager";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
const password = ref("");
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const passwordInput = ref<{ focus?: () => void } | null>(null);
|
||||
const nowTs = ref(Date.now());
|
||||
let countdownTimer: number | null = null;
|
||||
|
||||
const pad2 = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const autoLogoutCountdown = computed(() => {
|
||||
const fallbackDeadlineAt =
|
||||
Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt;
|
||||
const remainMs = Math.max(0, deadlineAt - nowTs.value);
|
||||
const remainSec = Math.floor(remainMs / 1000);
|
||||
const hours = Math.floor(remainSec / 3600);
|
||||
const minutes = Math.floor((remainSec % 3600) / 60);
|
||||
const seconds = remainSec % 60;
|
||||
if (hours > 0) {
|
||||
return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
|
||||
}
|
||||
return `${pad2(minutes)}:${pad2(seconds)}`;
|
||||
});
|
||||
|
||||
const startCountdown = () => {
|
||||
if (countdownTimer) {
|
||||
window.clearInterval(countdownTimer);
|
||||
}
|
||||
nowTs.value = Date.now();
|
||||
countdownTimer = window.setInterval(() => {
|
||||
nowTs.value = Date.now();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (!countdownTimer) return;
|
||||
window.clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
};
|
||||
|
||||
const focusPassword = () => {
|
||||
nextTick(() => {
|
||||
passwordInput.value?.focus?.();
|
||||
});
|
||||
};
|
||||
|
||||
const resolveUnlockEmail = async () => {
|
||||
if (session.lockEmail) return session.lockEmail;
|
||||
if (auth.user?.email) return auth.user.email;
|
||||
const cachedEmail = localStorage.getItem("ctms_last_login_email");
|
||||
if (cachedEmail) return cachedEmail;
|
||||
try {
|
||||
const me = await auth.fetchMe();
|
||||
return me?.email || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const onUnlock = async () => {
|
||||
if (loading.value) return;
|
||||
errorMessage.value = "";
|
||||
const email = await resolveUnlockEmail();
|
||||
if (!email) {
|
||||
ElMessage.error("无法获取用户信息,请重新登录");
|
||||
forceLogout();
|
||||
return;
|
||||
}
|
||||
if (!password.value) {
|
||||
errorMessage.value = "请输入密码";
|
||||
focusPassword();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await unlockWithPassword(email, password.value);
|
||||
password.value = "";
|
||||
} catch (err: any) {
|
||||
session.incrementUnlockAttempt();
|
||||
const message = err?.response?.data?.detail || err?.response?.data?.message || err?.message || "密码错误";
|
||||
errorMessage.value = message;
|
||||
if (session.unlockAttempts >= UNLOCK_MAX_ATTEMPTS) {
|
||||
ElMessage.error("错误次数过多,请重新登录");
|
||||
forceLogout();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
focusPassword();
|
||||
}
|
||||
};
|
||||
|
||||
const onLogout = () => {
|
||||
forceLogout();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
focusPassword();
|
||||
if (session.locked) {
|
||||
startCountdown();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => session.locked,
|
||||
(value) => {
|
||||
if (value) {
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
focusPassword();
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
stopCountdown();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lock-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.lock-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(7, 10, 20, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.lock-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(420px, 92vw);
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 18px 48px rgba(15, 20, 35, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lock-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.lock-desc {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lock-countdown {
|
||||
margin: -4px 0 0;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lock-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lock-error {
|
||||
margin: 0;
|
||||
color: #d8342c;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import SessionTimeoutPrompt from "./SessionTimeoutPrompt.vue";
|
||||
|
||||
const sessionManagerMock = vi.hoisted(() => ({
|
||||
forceLogout: vi.fn(),
|
||||
markUserActive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session/sessionManager", () => ({
|
||||
forceLogout: sessionManagerMock.forceLogout,
|
||||
markUserActive: sessionManagerMock.markUserActive,
|
||||
LOGOUT_REASON_MANUAL: "manual",
|
||||
TIMEOUT_WARNING_SECONDS: 60,
|
||||
}));
|
||||
|
||||
const mountPrompt = () =>
|
||||
mount(SessionTimeoutPrompt, {
|
||||
global: {
|
||||
stubs: {
|
||||
transition: false,
|
||||
"el-button": {
|
||||
inheritAttrs: false,
|
||||
template: '<button type="button" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createStorage = () => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
data.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
data.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
data.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("SessionTimeoutPrompt", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-31T12:00:00.000Z"));
|
||||
setActivePinia(createPinia());
|
||||
sessionManagerMock.forceLogout.mockClear();
|
||||
sessionManagerMock.markUserActive.mockClear();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: createStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the remaining time before automatic logout", () => {
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
auth.setToken("token");
|
||||
session.showTimeoutWarning(Date.now() + 60_000);
|
||||
|
||||
const wrapper = mountPrompt();
|
||||
|
||||
expect(wrapper.text()).toContain("即将自动退出");
|
||||
expect(wrapper.text()).toContain("60 秒");
|
||||
expect(wrapper.find(".timeout-progress span").attributes("style")).toContain("width: 100%");
|
||||
});
|
||||
|
||||
it("continues the session from the warning action", async () => {
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
auth.setToken("token");
|
||||
session.showTimeoutWarning(Date.now() + 60_000);
|
||||
|
||||
const wrapper = mountPrompt();
|
||||
await wrapper.findAll("button")[1].trigger("click");
|
||||
|
||||
expect(sessionManagerMock.markUserActive).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("logs out immediately from the warning action", async () => {
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
auth.setToken("token");
|
||||
session.showTimeoutWarning(Date.now() + 60_000);
|
||||
|
||||
const wrapper = mountPrompt();
|
||||
await wrapper.findAll("button")[0].trigger("click");
|
||||
|
||||
expect(sessionManagerMock.forceLogout).toHaveBeenCalledWith("manual");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user