优化后台界面与无操作退出体验

This commit is contained in:
Cheng Zhou
2026-06-10 15:20:06 +08:00
parent ea3f19e241
commit 84e55af8fe
39 changed files with 793 additions and 608 deletions
+23 -3
View File
@@ -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);
}
}
+13 -2
View File
@@ -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))
);
+30 -5
View File
@@ -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');
});
});
-228
View File
@@ -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>