diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index fa0481db..4a447132 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -39,15 +39,6 @@ class ExtendResponse(BaseModel): expiresAt: datetime -class UnlockRequest(LoginRequest): - pass - - -class UnlockResponse(BaseModel): - accessToken: str - expiresAt: datetime - - router = APIRouter() AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" AVATAR_ROOT.mkdir(parents=True, exist_ok=True) @@ -197,24 +188,6 @@ async def extend_access_token( return ExtendResponse(accessToken=new_token, expiresAt=expires_at) -@router.post("/unlock", response_model=UnlockResponse) -async def unlock_session( - payload: UnlockRequest, - db: AsyncSession = Depends(get_db_session), -) -> UnlockResponse: - db_user = await authenticate_encrypted_password(payload, db) - if db_user.status != UserStatus.ACTIVE: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用") - session_start = datetime.now(timezone.utc) - access_token = create_access_token( - user_id=str(db_user.id), - expires_minutes=None, - session_start=session_start, - ) - expires_at = session_start + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) - return UnlockResponse(accessToken=access_token, expiresAt=expires_at) - - @router.patch("/me", response_model=UserRead) async def update_me( payload: UserSelfUpdate, diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 7952c466..51f37e7b 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -358,15 +358,3 @@ async def test_login_challenge_cannot_be_reused(client_and_db): assert first.status_code == 200 assert second.status_code == 401 - -@pytest.mark.asyncio -async def test_unlock_requires_encrypted_password(client_and_db): - client, _ = client_and_db - plaintext = await client.post("/api/v1/auth/unlock", json={"email": "admin@test.com", "password": "admin123"}) - encrypted = await client.post( - "/api/v1/auth/unlock", - json=await encrypted_auth_payload(client, "admin@test.com", "admin123"), - ) - - assert plaintext.status_code == 422 - assert encrypted.status_code == 200 diff --git a/docs/audits/auth-session-acceptance.md b/docs/audits/auth-session-acceptance.md index 6e7dbec8..0a77f7e8 100644 --- a/docs/audits/auth-session-acceptance.md +++ b/docs/audits/auth-session-acceptance.md @@ -15,21 +15,17 @@ Last Updated: `2026-03-30` - 同时触发 10 个 API 请求 - 仅出现 1 次 /auth/extend,其余请求自动重放成功 -4) Idle lock at 30 minutes +4) Idle auto logout at 30 minutes - 30 分钟无鼠标/键盘/触摸/滚动且无任何请求 -- 出现锁屏,背景虚化,原页面状态保留 +- 自动退出登录并跳转登录页 -5) Unlock success -- 锁屏输入正确密码,解锁成功并继续原页面 +5) Login notice after idle logout +- 登录页展示 30 分钟无操作自动退出提示 -6) Unlock failure threshold -- 连续错误密码 >= 5 次 -- 强制登出并跳登录页 - -7) Extend failure to lock +6) Extend failure logout - 人为让 token 彻底过期,/auth/extend 返回 401 -- 不跳登录,进入锁屏并要求密码解锁 +- 强制登出并跳登录页,提示登录状态失效 -8) 403 permission vs disabled +7) 403 permission vs disabled - 权限不足接口返回 403 时不跳登录 -- /auth/extend 或 /auth/unlock 返回 403 时强制登出跳登录 +- /auth/extend 返回 403 时强制登出跳登录 diff --git a/docs/audits/storage-persistence-audit.md b/docs/audits/storage-persistence-audit.md index a08598da..7b3ea913 100644 --- a/docs/audits/storage-persistence-audit.md +++ b/docs/audits/storage-persistence-audit.md @@ -18,7 +18,6 @@ | high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2984 | `localStorage.removeItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 | | low | ui-preference | `frontend/src/components/Layout.vue` | 227 | `localStorage.getItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | | low | ui-preference | `frontend/src/components/Layout.vue` | 390 | `localStorage.setItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | -| low | auth-session | `frontend/src/components/LockScreenModal.vue` | 92 | `localStorage.getItem` | `"ctms_last_login_email"` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/components/ThreadList.vue` | 72 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 132 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 137 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 19aedb95..c3dcc97f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,18 +1,16 @@ diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts index 63a8f10f..57ca2dd8 100644 --- a/frontend/src/api/authClient.ts +++ b/frontend/src/api/authClient.ts @@ -11,11 +11,6 @@ export type ExtendResponse = { expiresAt: string; }; -export type UnlockResponse = { - accessToken: string; - expiresAt: string; -}; - export type LoginKeyResponse = { key_id: string; public_key: string; @@ -43,7 +38,4 @@ export const extendToken = (token: string): Promise> => authClient.get("/api/v1/auth/login-key"); -export const unlockSession = (payload: EncryptedPasswordRequest): Promise> => - authClient.post("/api/v1/auth/unlock", payload); - export default authClient; diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index b6bf5b29..41258d0a 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -5,8 +5,7 @@ import { getToken } from "../utils/auth"; import type { ApiError } from "../types/api"; import { useStudyStore } from "../store/study"; import { TEXT } from "../locales"; -import { extendAccessToken, forceLogout, lockSession, markNetworkActive } from "../session/sessionManager"; -import { useSessionStore } from "../store/session"; +import { extendAccessToken, forceLogout, LOGOUT_REASON_AUTH_EXPIRED } from "../session/sessionManager"; const instance: AxiosInstance = axios.create({ baseURL: "/", @@ -14,59 +13,29 @@ const instance: AxiosInstance = axios.create({ }); export type ApiRequestConfig = AxiosRequestConfig & { - ignoreIdle?: boolean; - allowWhileLocked?: boolean; suppressErrorMessage?: boolean; _retry?: boolean; }; -class LockedError extends Error { - constructor() { - super("LOCKED"); - this.name = "LockedError"; - } -} - instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => { - const session = useSessionStore(); - const reqUrl = config.url || ""; - const allowWhileLocked = - config.allowWhileLocked || - reqUrl.includes("/api/v1/auth/login") || - reqUrl.includes("/api/v1/auth/register") || - reqUrl.includes("/api/v1/auth/unlock"); - if (session.locked && !allowWhileLocked) { - return Promise.reject(new LockedError()); - } const token = getToken(); if (token) { config.headers = config.headers || {}; (config.headers as Record).Authorization = `Bearer ${token}`; } - if (!config.ignoreIdle) { - markNetworkActive(); - } return config; }); instance.interceptors.response.use( (response: AxiosResponse) => response, async (error: AxiosError) => { - if (error instanceof LockedError) { - const lockedConfig = error.config as ApiRequestConfig | undefined; - if (!lockedConfig?.suppressErrorMessage) { - ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作"); - } - return Promise.reject(error); - } const status = error.response?.status; const data = error.response?.data; const reqUrl = error.config?.url || ""; const isAuthEndpoint = reqUrl.includes("/api/v1/auth/login") || reqUrl.includes("/api/v1/auth/register") || - reqUrl.includes("/api/v1/auth/extend") || - reqUrl.includes("/api/v1/auth/unlock"); + reqUrl.includes("/api/v1/auth/extend"); if (isAuthEndpoint) { // 认证相关的错误由具体页面自行处理,避免重复提示 return Promise.reject(error); @@ -92,11 +61,11 @@ instance.interceptors.response.use( return instance(config); } if (result.authFailed) { - lockSession("token_invalid"); + forceLogout(LOGOUT_REASON_AUTH_EXPIRED); } } } else if (status === 403 && (data as any)?.detail === "账号已停用") { - forceLogout(); + forceLogout(LOGOUT_REASON_AUTH_EXPIRED); } else { const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage; if (!suppressErrorMessage) { diff --git a/frontend/src/components/FaqCategoryManager.vue b/frontend/src/components/FaqCategoryManager.vue index a7cf0a12..adb8614c 100644 --- a/frontend/src/components/FaqCategoryManager.vue +++ b/frontend/src/components/FaqCategoryManager.vue @@ -11,9 +11,9 @@ > @@ -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)) ); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 12d72a87..ed876a9e 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -180,9 +180,9 @@ {{ TEXT.menu.profile }} - + - {{ TEXT.menu.logout }} + {{ loggingOut ? "正在退出..." : TEXT.menu.logout }} @@ -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; } } diff --git a/frontend/src/components/LayoutBreadcrumb.test.ts b/frontend/src/components/LayoutBreadcrumb.test.ts index 4f679ff7..6fb0f53c 100644 --- a/frontend/src/components/LayoutBreadcrumb.test.ts +++ b/frontend/src/components/LayoutBreadcrumb.test.ts @@ -41,4 +41,15 @@ describe("Layout breadcrumbs", () => { expect(source).toContain(''); }); + + 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'); + }); }); diff --git a/frontend/src/components/LockScreenModal.vue b/frontend/src/components/LockScreenModal.vue deleted file mode 100644 index 4fdba4f1..00000000 --- a/frontend/src/components/LockScreenModal.vue +++ /dev/null @@ -1,228 +0,0 @@ - - - - - diff --git a/frontend/src/components/SessionTimeoutPrompt.test.ts b/frontend/src/components/SessionTimeoutPrompt.test.ts new file mode 100644 index 00000000..0dff1ff7 --- /dev/null +++ b/frontend/src/components/SessionTimeoutPrompt.test.ts @@ -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: '', + }, + }, + }, + }); + +const createStorage = () => { + const data = new Map(); + 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"); + }); +}); diff --git a/frontend/src/components/SessionTimeoutPrompt.vue b/frontend/src/components/SessionTimeoutPrompt.vue new file mode 100644 index 00000000..050c3146 --- /dev/null +++ b/frontend/src/components/SessionTimeoutPrompt.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 14aa07c4..0a24648c 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -51,7 +51,6 @@ export const TEXT = { noPermission: "无权限执行该操作", networkError: "网络错误,请稍后重试", fileTooLarge: "文件大小不能超过", - sessionLocked: "请解锁后继续操作", sessionExpired: "登录已过期,请重新登录", requestFailed: "请求失败,请稍后重试", invalidStateAction: "当前状态不允许该操作", diff --git a/frontend/src/session/sessionManager.test.ts b/frontend/src/session/sessionManager.test.ts index 794fd447..248f9599 100644 --- a/frontend/src/session/sessionManager.test.ts +++ b/frontend/src/session/sessionManager.test.ts @@ -9,10 +9,9 @@ vi.mock("../router", () => ({ vi.mock("../api/authClient", () => ({ extendToken: vi.fn(), - unlockSession: vi.fn(), })); -describe("session manager idle recovery", () => { +describe("session manager idle logout", () => { beforeEach(() => { vi.resetModules(); vi.useFakeTimers(); @@ -50,37 +49,47 @@ describe("session manager idle recovery", () => { }); }); - it("locks instead of refreshing activity after long inactivity when user activity resumes", async () => { + it("shows a timeout warning one minute before automatic logout", async () => { vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); const { useSessionStore } = await import("../store/session"); - const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markUserActive } = await import("./sessionManager"); + const { IDLE_TIMEOUT_MINUTES, TIMEOUT_WARNING_SECONDS, initSessionManager } = await import("./sessionManager"); + const session = useSessionStore(); + const oldTs = Date.now() - ((IDLE_TIMEOUT_MINUTES * 60 - TIMEOUT_WARNING_SECONDS) * 1000); + + session.recordUserActivity(oldTs); + initSessionManager(); + + expect(session.timeoutWarningVisible).toBe(true); + expect(session.timeoutAt).toBe(oldTs + IDLE_TIMEOUT_MINUTES * 60 * 1000); + }); + + it("logs out after long inactivity when user activity resumes", async () => { + vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); + const { useSessionStore } = await import("../store/session"); + const router = (await import("../router")).default; + const { IDLE_TIMEOUT_MINUTES, LOGOUT_REASON_TIMEOUT, markUserActive } = await import("./sessionManager"); const session = useSessionStore(); const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000); session.recordUserActivity(oldTs); - session.recordNetworkActivity(oldTs); markUserActive(Date.now()); - expect(session.locked).toBe(true); - expect(session.lockReason).toBe("idle"); - expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000); + expect(window.sessionStorage.getItem("ctms_logout_reason")).toBe(LOGOUT_REASON_TIMEOUT); + expect(router.replace).toHaveBeenCalledWith("/login"); }); - it("locks instead of refreshing activity after long inactivity when network activity resumes", async () => { + it("clears the timeout warning when user activity resumes", async () => { vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); const { useSessionStore } = await import("../store/session"); - const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markNetworkActive } = await import("./sessionManager"); + const { markUserActive } = await import("./sessionManager"); const session = useSessionStore(); - const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000); - session.recordUserActivity(oldTs); - session.recordNetworkActivity(oldTs); + session.showTimeoutWarning(Date.now() + 60_000); - markNetworkActive(Date.now()); + markUserActive(Date.now()); - expect(session.locked).toBe(true); - expect(session.lockReason).toBe("idle"); - expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000); + expect(session.timeoutWarningVisible).toBe(false); + expect(session.timeoutAt).toBe(0); }); }); diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts index a55c17c5..955995a3 100644 --- a/frontend/src/session/sessionManager.ts +++ b/frontend/src/session/sessionManager.ts @@ -2,22 +2,20 @@ import router from "../router"; import { useAuthStore } from "../store/auth"; import { useSessionStore } from "../store/session"; import { getToken, setToken, clearToken } from "../utils/auth"; -import { extendToken, getLoginKey, unlockSession } from "../api/authClient"; -import { encryptLoginPayload } from "../utils/loginCrypto"; +import { extendToken } from "../api/authClient"; import { parseJwtExp } from "./jwt"; export const IDLE_TIMEOUT_MINUTES = 30; -export const AUTO_LOGOUT_TIMEOUT_HOURS = 2; +export const TIMEOUT_WARNING_SECONDS = 60; export const EXTEND_EARLY_SECONDS = 120; export const EXTEND_MIN_INTERVAL_SECONDS = 60; -export const UNLOCK_MAX_ATTEMPTS = 5; export const LOGOUT_REASON_TIMEOUT = "timeout"; +export const LOGOUT_REASON_MANUAL = "manual"; +export const LOGOUT_REASON_AUTH_EXPIRED = "auth_expired"; const LOGOUT_REASON_STORAGE_KEY = "ctms_logout_reason"; type BroadcastMessage = | { type: "ACTIVE"; at: number } - | { type: "NETWORK"; at: number } - | { type: "LOCK"; reason: string; email?: string; deadlineAt?: number } | { type: "TOKEN_UPDATED"; token: string } | { type: "LOGOUT"; reason?: string }; @@ -26,23 +24,15 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null let initialized = false; const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null; -const getLastActiveAt = (session: ReturnType) => - Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt); +const getTimeoutAt = (session: ReturnType) => + session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000; const reconcileSessionState = (now: number = Date.now()) => { const session = useSessionStore(); - if (session.locked) return false; - const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; - const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; - const lastActiveAt = getLastActiveAt(session); - if (now - lastActiveAt > autoLogoutMs) { + if (now >= getTimeoutAt(session)) { forceLogout(LOGOUT_REASON_TIMEOUT); return false; } - if (now - lastActiveAt > idleTimeoutMs) { - lockSession("idle"); - return false; - } return true; }; @@ -61,24 +51,13 @@ const handleBroadcast = (message: BroadcastMessage) => { const session = useSessionStore(); const auth = useAuthStore(); if (message.type === "ACTIVE") { - if (session.locked) return; session.recordUserActivity(message.at); scheduleIdleCheck(); } - if (message.type === "NETWORK") { - if (session.locked) return; - session.recordNetworkActivity(message.at); - scheduleIdleCheck(); - } - if (message.type === "LOCK") { - const reason = message.reason as "idle" | "extend_failed" | "token_invalid"; - session.lock(reason || "idle", message.email, message.deadlineAt); - scheduleIdleCheck(); - } if (message.type === "TOKEN_UPDATED") { auth.setToken(message.token); setToken(message.token); - session.unlock(); + session.resetActivity(); } if (message.type === "LOGOUT") { performLogout(message.reason); @@ -91,39 +70,22 @@ const scheduleIdleCheck = () => { } const session = useSessionStore(); const now = Date.now(); - if (session.locked) { - const remain = (session.lockAutoLogoutDeadlineAt || 0) - now; - if (remain <= 0) { - checkIdle(); - return; - } - idleTimer = window.setTimeout(checkIdle, remain); - return; - } - const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; - const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; - const lastActiveAt = getLastActiveAt(session); - const idleRemain = lastActiveAt + idleTimeoutMs - now; - const logoutRemain = lastActiveAt + autoLogoutMs - now; - const nextCheck = Math.min(idleRemain, logoutRemain); - if (nextCheck <= 0) { + const timeoutAt = getTimeoutAt(session); + const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000; + if (timeoutAt <= now) { checkIdle(); return; } - idleTimer = window.setTimeout(checkIdle, nextCheck); + if (warningAt <= now) { + session.showTimeoutWarning(timeoutAt); + idleTimer = window.setTimeout(checkIdle, timeoutAt - now); + return; + } + session.clearTimeoutWarning(); + idleTimer = window.setTimeout(checkIdle, warningAt - now); }; const checkIdle = () => { - const session = useSessionStore(); - if (session.locked) { - const deadlineAt = session.lockAutoLogoutDeadlineAt || 0; - if (deadlineAt > 0 && Date.now() >= deadlineAt) { - forceLogout(LOGOUT_REASON_TIMEOUT); - return; - } - scheduleIdleCheck(); - return; - } if (!reconcileSessionState(Date.now())) return; scheduleIdleCheck(); }; @@ -166,34 +128,12 @@ export const initSessionManager = () => { export const markUserActive = (at: number = Date.now()) => { const session = useSessionStore(); - if (session.locked) return; if (!reconcileSessionState(at)) return; session.recordUserActivity(at); broadcast({ type: "ACTIVE", at }); scheduleIdleCheck(); }; -export const markNetworkActive = (at: number = Date.now()) => { - const session = useSessionStore(); - if (session.locked) return; - if (!reconcileSessionState(at)) return; - session.recordNetworkActivity(at); - broadcast({ type: "NETWORK", at }); - scheduleIdleCheck(); -}; - -export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid") => { - const session = useSessionStore(); - const auth = useAuthStore(); - if (session.locked) return; - const email = auth.user?.email || ""; - const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; - const deadlineAt = getLastActiveAt(session) + autoLogoutMs; - session.lock(reason, email, deadlineAt); - broadcast({ type: "LOCK", reason, email, deadlineAt }); - scheduleIdleCheck(); -}; - const setLogoutReason = (reason?: string) => { try { if (!reason) { @@ -221,7 +161,7 @@ const performLogout = (reason?: string) => { const auth = useAuthStore(); const session = useSessionStore(); auth.logout(); - session.unlock(); + session.resetActivity(); clearToken(); router.replace("/login"); }; @@ -259,10 +199,10 @@ export const extendAccessToken = async (reason: "early" | "response-401") => { } catch (err: any) { const status = err?.response?.status; if (status === 401) { - lockSession("extend_failed"); + forceLogout(LOGOUT_REASON_AUTH_EXPIRED); authFailed = true; } else if (status === 403) { - forceLogout(); + forceLogout(LOGOUT_REASON_AUTH_EXPIRED); authFailed = true; } return { token: null, authFailed }; @@ -278,40 +218,13 @@ export const startTokenKeepAlive = () => { const token = getToken(); if (!token) return; const session = useSessionStore(); - if (session.locked) return; const expAt = parseJwtExp(token); if (!expAt) return; const remaining = expAt - Date.now(); const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; - const active = Date.now() - getLastActiveAt(session) < timeoutMs; + const active = Date.now() - session.lastUserActiveAt < timeoutMs; if (active && remaining < EXTEND_EARLY_SECONDS * 1000) { void extendAccessToken("early"); } }, 10000); }; - -export const unlockWithPassword = async (email: string, password: string) => { - const session = useSessionStore(); - const auth = useAuthStore(); - const { data: loginKey } = await getLoginKey(); - const ciphertext = await encryptLoginPayload(loginKey.public_key, { - email, - password, - challenge: loginKey.challenge, - }); - const { data } = await unlockSession({ - key_id: loginKey.key_id, - challenge: loginKey.challenge, - ciphertext, - }); - updateToken(data.accessToken); - session.unlock(); - try { - await auth.fetchMe(); - } catch { - // token已更新但用户信息拉取失败时,避免界面进入无角色状态 - forceLogout(); - throw new Error("无法获取用户信息,请重新登录"); - } - markUserActive(); -}; diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts index 8340b132..ec9cf172 100644 --- a/frontend/src/store/auth.test.ts +++ b/frontend/src/store/auth.test.ts @@ -82,18 +82,21 @@ describe("auth store logout", () => { }); }); - it("clears session lock state when logging out", async () => { + it("resets session activity state when logging out", async () => { const { useAuthStore } = await import("./auth"); const { useSessionStore } = await import("./session"); const session = useSessionStore(); const auth = useAuthStore(); + const staleTs = Date.now() - 60_000; - session.lock("token_invalid", "user@example.com", Date.now() + 60_000); + session.recordUserActivity(staleTs); + session.setLastExtendAt(staleTs); + session.showTimeoutWarning(staleTs + 30_000); auth.logout(); - expect(session.locked).toBe(false); - expect(session.lockReason).toBeNull(); - expect(session.lockEmail).toBe(""); + expect(session.lastUserActiveAt).toBeGreaterThan(staleTs); + expect(session.lastExtendAt).toBe(0); + expect(session.timeoutWarningVisible).toBe(false); }); it("uses encrypted login by default outside a secure browser context", async () => { diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 5b0af475..9a27c502 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -26,8 +26,7 @@ export const useAuthStore = defineStore("auth", () => { : await encryptedLogin(email, password); token.value = data.access_token; setToken(data.access_token); - // 避免锁屏态下 fetchMe 被请求拦截 - useSessionStore().unlock(); + useSessionStore().resetActivity(); localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email); const me = await fetchMeAction(); const studyStore = useStudyStore(); @@ -71,7 +70,7 @@ export const useAuthStore = defineStore("auth", () => { token.value = null; user.value = null; forceLogin.value = false; - sessionStore.unlock(); + sessionStore.resetActivity(); clearToken(); studyStore.clearCurrentStudy(); }; diff --git a/frontend/src/store/session.test.ts b/frontend/src/store/session.test.ts index 1e8c7a70..af62997b 100644 --- a/frontend/src/store/session.test.ts +++ b/frontend/src/store/session.test.ts @@ -8,21 +8,20 @@ describe("session store", () => { setActivePinia(createPinia()); }); - it("resets activity timestamps when unlocking a previously stale session", async () => { + it("resets activity timestamps and token extension state", async () => { const { useSessionStore } = await import("./session"); const session = useSessionStore(); const staleTs = Date.now() - 2 * 60 * 60 * 1000 - 1_000; session.recordUserActivity(staleTs); - session.recordNetworkActivity(staleTs); session.setLastExtendAt(staleTs); - session.lock("token_invalid", "user@example.com", staleTs + 10_000); + session.showTimeoutWarning(staleTs + 30_000); - session.unlock(); + session.resetActivity(); - expect(session.locked).toBe(false); expect(session.lastUserActiveAt).toBe(Date.now()); - expect(session.lastNetworkActiveAt).toBe(Date.now()); expect(session.lastExtendAt).toBe(0); + expect(session.timeoutWarningVisible).toBe(false); + expect(session.timeoutAt).toBe(0); }); }); diff --git a/frontend/src/store/session.ts b/frontend/src/store/session.ts index da5df69b..5953394a 100644 --- a/frontend/src/store/session.ts +++ b/frontend/src/store/session.ts @@ -1,67 +1,49 @@ import { defineStore } from "pinia"; import { ref } from "vue"; -export type LockReason = "idle" | "extend_failed" | "token_invalid"; - export const useSessionStore = defineStore("session", () => { - const locked = ref(false); - const lockReason = ref(null); - const lockEmail = ref(""); - const unlockAttempts = ref(0); - const lockAutoLogoutDeadlineAt = ref(0); const lastUserActiveAt = ref(Date.now()); - const lastNetworkActiveAt = ref(Date.now()); const lastExtendAt = ref(0); + const timeoutWarningVisible = ref(false); + const timeoutAt = ref(0); const recordUserActivity = (ts: number = Date.now()) => { lastUserActiveAt.value = ts; + timeoutWarningVisible.value = false; + timeoutAt.value = 0; }; - const recordNetworkActivity = (ts: number = Date.now()) => { - lastNetworkActiveAt.value = ts; - }; - - const lock = (reason: LockReason, email?: string, autoLogoutDeadlineAt?: number) => { - locked.value = true; - lockReason.value = reason; - lockEmail.value = (email || "").trim(); - lockAutoLogoutDeadlineAt.value = autoLogoutDeadlineAt || 0; - }; - - const unlock = () => { + const resetActivity = () => { const now = Date.now(); - locked.value = false; - lockReason.value = null; - lockEmail.value = ""; - lockAutoLogoutDeadlineAt.value = 0; - unlockAttempts.value = 0; lastUserActiveAt.value = now; - lastNetworkActiveAt.value = now; lastExtendAt.value = 0; - }; - - const incrementUnlockAttempt = () => { - unlockAttempts.value += 1; + timeoutWarningVisible.value = false; + timeoutAt.value = 0; }; const setLastExtendAt = (ts: number) => { lastExtendAt.value = ts; }; + const showTimeoutWarning = (deadlineAt: number) => { + timeoutWarningVisible.value = true; + timeoutAt.value = deadlineAt; + }; + + const clearTimeoutWarning = () => { + timeoutWarningVisible.value = false; + timeoutAt.value = 0; + }; + return { - locked, - lockReason, - lockEmail, - lockAutoLogoutDeadlineAt, - unlockAttempts, lastUserActiveAt, - lastNetworkActiveAt, lastExtendAt, + timeoutWarningVisible, + timeoutAt, recordUserActivity, - recordNetworkActivity, - lock, - unlock, - incrementUnlockAttempt, + resetActivity, setLastExtendAt, + showTimeoutWarning, + clearTimeoutWarning, }; }); diff --git a/frontend/src/views/FaqPermissionConsistency.test.ts b/frontend/src/views/FaqPermissionConsistency.test.ts index f889aca8..02fe23c2 100644 --- a/frontend/src/views/FaqPermissionConsistency.test.ts +++ b/frontend/src/views/FaqPermissionConsistency.test.ts @@ -44,7 +44,6 @@ describe("FAQ project permissions", () => { expect(source).toContain("const onRowClick = (row: FaqItem) =>"); expect(source).not.toContain('column?.property !== "question"'); expect(source).toContain('@click.stop="remove(scope.row)"'); - expect(source).toContain('@click.stop="toggle(scope.row)"'); }); it("splits FAQ category create update and delete controls by backend operation permissions", () => { @@ -53,8 +52,9 @@ describe("FAQ project permissions", () => { expect(source).toContain('const canCreateCategory = computed(() => can("faq.category.create"))'); expect(source).toContain('const canUpdateCategory = computed(() => can("faq.category.update"))'); expect(source).toContain('const canDeleteCategory = computed(() => can("faq.category.delete"))'); - expect(source).toContain('action="faq.category.update"'); - expect(source).toContain('action="faq.category.delete"'); + expect(source).toContain(':can-create="canCreateCategory"'); + expect(source).toContain(':can-update="canUpdateCategory"'); + expect(source).toContain(':can-delete="canDeleteCategory"'); expect(source).not.toContain("const canDelete = computed(() => isAdmin.value)"); }); diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts index 72a54f0a..e60025c8 100644 --- a/frontend/src/views/Login.test.ts +++ b/frontend/src/views/Login.test.ts @@ -74,4 +74,17 @@ describe("Login protocol agreement", () => { expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex); expect(source).toContain("preferActive: !!auth.user?.is_admin"); }); + + it("shows persistent logout reason notices on the login card", () => { + const source = readLoginView(); + + expect(source).toContain('v-if="logoutNotice"'); + expect(source).toContain("LOGOUT_REASON_TIMEOUT"); + expect(source).toContain("LOGOUT_REASON_AUTH_EXPIRED"); + expect(source).toContain("LOGOUT_REASON_MANUAL"); + expect(source).toContain("30 分钟未检测到任何操作。为保护项目数据,请重新登录后继续。"); + expect(source).toContain("登录状态已失效"); + expect(source).toContain("已安全退出"); + expect(source).toContain(".logout-notice"); + }); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index de73eb34..9bf32e64 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -27,6 +27,23 @@

Log in to your account

+
+ +
+ {{ logoutNotice.title }} + {{ logoutNotice.message }} +
+
+ = { const loading = ref(false); const protocolDialogVisible = ref(false); +const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: string } | null>(null); const protocolSections = authProtocolSections; onMounted(async () => { const logoutReason = consumeLogoutReason(); if (logoutReason === LOGOUT_REASON_TIMEOUT) { - ElMessage.warning("长时间未操作,已自动退出登录,请重新登录"); + logoutNotice.value = { + type: "warning", + title: "已自动退出登录", + message: "30 分钟未检测到任何操作。为保护项目数据,请重新登录后继续。", + }; + } else if (logoutReason === LOGOUT_REASON_AUTH_EXPIRED) { + logoutNotice.value = { + type: "warning", + title: "登录状态已失效", + message: "当前会话无法继续使用,请重新登录。", + }; + } else if (logoutReason === LOGOUT_REASON_MANUAL) { + logoutNotice.value = { + type: "info", + title: "已安全退出", + message: "本机登录状态已清除,需要时可再次登录。", + }; } form.email = localStorage.getItem("ctms_last_login_email") || ""; form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true"; @@ -357,6 +396,62 @@ const onSubmit = async () => { margin: 0; } +.logout-notice { + display: flex; + align-items: flex-start; + gap: 10px; + margin: -14px 0 24px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid; +} + +.logout-notice--warning { + color: #8a5b12; + background: #fff7e8; + border-color: #f3d19e; +} + +.logout-notice--info { + color: #315f49; + background: #eef8f2; + border-color: #b9dec8; +} + +.logout-notice-icon { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; +} + +.logout-notice-icon svg { + width: 18px; + height: 18px; +} + +.logout-notice-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.logout-notice-body strong { + font-size: 13px; + line-height: 18px; + font-weight: 800; +} + +.logout-notice-body span { + font-size: 12px; + line-height: 18px; + font-weight: 600; +} + .login-form :deep(.el-form-item) { margin-bottom: 22px; } diff --git a/frontend/src/views/admin/AuditLogs.test.ts b/frontend/src/views/admin/AuditLogs.test.ts index d4661ff9..261351ef 100644 --- a/frontend/src/views/admin/AuditLogs.test.ts +++ b/frontend/src/views/admin/AuditLogs.test.ts @@ -5,6 +5,18 @@ import { resolve } from "node:path"; const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8"); describe("audit logs access", () => { + it("keeps the audit summary header compact", () => { + const source = readAuditLogsView(); + + expect(source).toContain("padding: 10px 16px;"); + expect(source).toContain("padding: 9px 12px;"); + expect(source).toContain("width: 32px;"); + expect(source).toContain("height: 32px;"); + expect(source).toContain(".stat-icon svg { width: 18px; height: 18px; }"); + expect(source).toContain(".stat-value { font-size: 20px;"); + expect(source).toContain(".stat-label { font-size: 11px;"); + }); + it("loads audit logs from the selected project context", () => { const source = readAuditLogsView(); diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 706f525e..4e46c7b8 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -651,17 +651,17 @@ onMounted(async () => { .stats-row { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 12px; - padding: 16px; + gap: 10px; + padding: 10px 16px; border-bottom: 1px solid var(--unified-shell-divider); } .stat-card { display: flex; align-items: center; - gap: 12px; - padding: 14px 16px; - border-radius: 12px; + gap: 10px; + padding: 9px 12px; + border-radius: 10px; background: var(--ctms-neutral-100); transition: var(--ctms-transition); } @@ -672,24 +672,24 @@ onMounted(async () => { } .stat-icon { - width: 38px; - height: 38px; - border-radius: 10px; + width: 32px; + height: 32px; + border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } -.stat-icon svg { width: 20px; height: 20px; } +.stat-icon svg { width: 18px; height: 18px; } .stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); } .stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); } .stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); } .stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); } .stat-body { display: flex; flex-direction: column; } -.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); } -.stat-label { font-size: 12px; color: var(--ctms-text-secondary); margin-top: 2px; } +.stat-value { font-size: 20px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); } +.stat-label { font-size: 11px; color: var(--ctms-text-secondary); margin-top: 2px; } /* 筛选栏 */ .audit-toolbar { diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index 5f3620ec..0994bd83 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -74,6 +74,17 @@ describe("project management access", () => { expect(source).not.toContain('fixed="right"'); }); + it("keeps the project summary header compact", () => { + const source = readProjects(); + + expect(source).toContain("padding: 10px 16px;"); + expect(source).toContain("padding: 9px 12px;"); + expect(source).toContain("width: 32px;"); + expect(source).toContain("height: 32px;"); + expect(source).toContain("font-size: 20px;"); + expect(source).toContain("font-size: 11px;"); + }); + it("renders project names as plain text and moves setup configuration to a gear action", () => { const source = readProjects(); diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index a68a4c44..621ed0ff 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -375,16 +375,16 @@ onMounted(() => { .stats-row { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 12px; - padding: 16px; + gap: 10px; + padding: 10px 16px; } .stat-card { display: flex; align-items: center; - gap: 12px; - padding: 14px 16px; - border-radius: 12px; + gap: 10px; + padding: 9px 12px; + border-radius: 10px; background: var(--ctms-neutral-100); transition: var(--ctms-transition); } @@ -395,9 +395,9 @@ onMounted(() => { } .stat-icon { - width: 38px; - height: 38px; - border-radius: 10px; + width: 32px; + height: 32px; + border-radius: 8px; display: flex; align-items: center; justify-content: center; @@ -405,8 +405,8 @@ onMounted(() => { } .stat-icon svg { - width: 20px; - height: 20px; + width: 18px; + height: 18px; } .stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); } @@ -420,14 +420,14 @@ onMounted(() => { } .stat-value { - font-size: 22px; + font-size: 20px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); } .stat-label { - font-size: 12px; + font-size: 11px; color: var(--ctms-text-secondary); margin-top: 2px; } diff --git a/frontend/src/views/admin/Users.test.ts b/frontend/src/views/admin/Users.test.ts index 72d08a55..226b7350 100644 --- a/frontend/src/views/admin/Users.test.ts +++ b/frontend/src/views/admin/Users.test.ts @@ -17,14 +17,49 @@ describe("Admin users table labels", () => { }); }); +describe("Admin users table layout", () => { + it("uses a wider name column, even remaining columns, and compact icon actions", () => { + const viewSource = readUsersView(); + + expect(viewSource).toContain('table-layout="fixed"'); + expect(viewSource).toContain(':label="TEXT.common.fields.name" width="360"'); + expect(viewSource).not.toContain('width="20%"'); + expect(viewSource).not.toContain('width="180"'); + expect(viewSource).not.toContain('width="160"'); + expect(viewSource).not.toContain('width="100"'); + expect(viewSource).not.toContain('fixed="right"'); + expect(viewSource).toContain('class="action-btn"'); + expect(viewSource).toContain("width: 28px;"); + expect(viewSource).toContain("gap: 4px;"); + }); +}); + +describe("Admin users summary header", () => { + it("keeps the management summary header compact", () => { + const viewSource = readUsersView(); + + expect(viewSource).toContain("padding: 10px 16px;"); + expect(viewSource).toContain("padding: 9px 12px;"); + expect(viewSource).toContain("width: 32px;"); + expect(viewSource).toContain("height: 32px;"); + expect(viewSource).toContain("font-size: 20px;"); + expect(viewSource).toContain("font-size: 11px;"); + }); +}); + describe("Admin users filters", () => { - it("applies keyword and status filters from the first page", () => { + it("applies keyword automatically and status filters from the first page", () => { const viewSource = readUsersView(); expect(viewSource).toContain("@keyup.enter=\"applyFilters\""); - expect(viewSource).toContain("@clear=\"applyFilters\""); expect(viewSource).toContain("@change=\"applyFilters\""); - expect(viewSource).toContain("@click=\"applyFilters\""); + expect(viewSource).not.toContain("@click=\"applyFilters\""); + expect(viewSource).not.toContain(":icon=\"Refresh\""); + expect(viewSource).not.toContain("Refresh } from"); + expect(viewSource).toContain("watch(searchKeyword, () => {"); + expect(viewSource).toContain("scheduleKeywordSearch();"); + expect(viewSource).toContain("keywordSearchTimer = setTimeout(() => {"); + expect(viewSource).toContain("}, 300);"); expect(viewSource).toContain("page.value = 1"); expect(viewSource).toContain("keyword: searchKeyword.value || undefined"); expect(viewSource).toContain("status: statusFilter.value || undefined"); diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue index 6d90bde7..03f23c1f 100644 --- a/frontend/src/views/admin/Users.vue +++ b/frontend/src/views/admin/Users.vue @@ -65,7 +65,6 @@ clearable class="filter-input-comp" :prefix-icon="Search" - @clear="applyFilters" @keyup.enter="applyFilters" /> @@ -77,9 +76,6 @@ -
- {{ TEXT.common.actions.search }} -
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }} @@ -88,7 +84,7 @@
- + - + - + - +