diff --git a/frontend/scripts/regression-data-process-wizard.mjs b/frontend/scripts/regression-data-process-wizard.mjs index 0d298fe..f6be3c5 100644 --- a/frontend/scripts/regression-data-process-wizard.mjs +++ b/frontend/scripts/regression-data-process-wizard.mjs @@ -12,6 +12,10 @@ const confirmDialogPath = path.resolve(scriptDir, '../src/components/AppConfirmD const layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue') const apiPath = path.resolve(scriptDir, '../src/api/modules/dataProcess.ts') const contractTypesPath = path.resolve(scriptDir, '../src/types/dataProcess.ts') +const requestPath = path.resolve(scriptDir, '../src/api/request.ts') +const authStorePath = path.resolve(scriptDir, '../src/stores/auth.ts') +const routerPath = path.resolve(scriptDir, '../src/router/index.ts') +const sessionActivityPath = path.resolve(scriptDir, '../src/utils/sessionActivity.ts') const sourceUploadWorkerPath = path.join(createDir, 'useDataProcessSourceUpload.ts') const viewSource = await readFile(viewPath, 'utf8') const layoutSource = await readFile(layoutPath, 'utf8') @@ -25,6 +29,18 @@ const [stateSource, generationSource, previewBuildSource, sourceUploadWorkerSour readFile(contractTypesPath, 'utf8'), ]) const implementationSource = [viewSource, stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource].join('\n') +const [requestSource, authStoreSource, routerSource, sessionActivitySource] = await Promise.all([ + readFile(requestPath, 'utf8'), + readFile(authStorePath, 'utf8'), + readFile(routerPath, 'utf8'), + readFile(sessionActivityPath, 'utf8'), +]) + +assert.match(requestSource, /if \(res\.code === 0\) \{[\s\S]*?touchSessionActivity\(\)/, '成功 API 请求没有刷新会话活跃时间') +assert.match(authStoreSource, /const loginTime = sessionActivityTime/, '认证状态没有共享请求层的会话活跃时间') +assert.match(authStoreSource, /if \(currentUser\.value\) touchSessionActivity\(\)/, '会话续期仍可能在长任务结束后失效') +assert.match(routerSource, /const auth = useAuthStore\(\)[\s\S]*?auth\.syncSession\(\)[\s\S]*?if \(!auth\.isLoggedIn\)/, '路由守卫没有在登录判断前同步 API 活跃时间') +assert.match(sessionActivitySource, /export function touchSessionActivity\(\)/, '缺少统一会话活跃续期函数') assert.ok(existsSync(confirmDialogPath), '缺少公共确认弹窗组件 AppConfirmDialog') const confirmDialogSource = await readFile(confirmDialogPath, 'utf8') diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 9c39c92..2ba341d 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -1,5 +1,6 @@ import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' +import { touchSessionActivity } from '@/utils/sessionActivity' /** * 后端统一响应格式 @@ -29,9 +30,12 @@ service.interceptors.response.use( const res = response.data as ApiResult // 二进制流等非 JSON 响应直接返回 if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') { + touchSessionActivity() return response } if (res.code === 0) { + // 生成进度轮询也属于用户正在使用系统,避免长任务结束后被误判为会话过期。 + touchSessionActivity() return res.data } // 业务错误 diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c02d992..741501c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -298,6 +298,7 @@ function requiredPermission(path: string, explicit?: unknown) { // 全局守卫:登录校验 + 会话超时 router.beforeEach((to, _from, next) => { const auth = useAuthStore() + auth.syncSession() document.title = to.meta.title ? `${to.meta.title} - 远光软件微调平台` : '远光软件微调平台' if (to.meta.public) { diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 5a3eb24..8fa9712 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -3,6 +3,13 @@ import { ref, computed } from 'vue' import { login as loginApi } from '@/api/modules/system' import { SESSION_TIMEOUT } from '@/constants' import type { PermissionCode, SystemUser } from '@/types' +import { + clearSessionActivity, + sessionActivityTime, + startSessionActivity, + syncSessionActivity, + touchSessionActivity, +} from '@/utils/sessionActivity' const USER_STORAGE_KEY = 'currentUser' @@ -60,7 +67,7 @@ export const useAuthStore = defineStore('auth', () => { if (currentUser.value?.role === 'operator') return '操作员' return '观察员' }) - const loginTime = ref(parseInt(localStorage.getItem('loginTime') || '0', 10) || 0) + const loginTime = sessionActivityTime const isLoggedIn = computed(() => { if (!loginTime.value) return false @@ -71,10 +78,9 @@ export const useAuthStore = defineStore('auth', () => { async function login(user: string, password: string) { const response = await loginApi(user, password) currentUser.value = response.user - loginTime.value = Date.now() + startSessionActivity() localStorage.setItem('username', response.user.username) localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user)) - localStorage.setItem('loginTime', String(loginTime.value)) } /** 检查当前账号是否拥有指定模块权限。 */ @@ -85,19 +91,20 @@ export const useAuthStore = defineStore('auth', () => { /** 续期会话(活跃时刷新) */ function refresh() { - if (isLoggedIn.value) { - loginTime.value = Date.now() - localStorage.setItem('loginTime', String(loginTime.value)) - } + if (currentUser.value) touchSessionActivity() + } + + /** 在路由判断前吸收其他标签页写入的最后活跃时间。 */ + function syncSession() { + syncSessionActivity() } /** 退出 */ function logout() { currentUser.value = null - loginTime.value = 0 + clearSessionActivity() localStorage.removeItem('username') localStorage.removeItem(USER_STORAGE_KEY) - localStorage.removeItem('loginTime') } return { @@ -110,6 +117,7 @@ export const useAuthStore = defineStore('auth', () => { hasPermission, login, refresh, + syncSession, logout, } }) diff --git a/frontend/src/utils/sessionActivity.ts b/frontend/src/utils/sessionActivity.ts new file mode 100644 index 0000000..d0b488a --- /dev/null +++ b/frontend/src/utils/sessionActivity.ts @@ -0,0 +1,32 @@ +import { ref } from 'vue' + +const LOGIN_TIME_STORAGE_KEY = 'loginTime' + +function storedActivityTime() { + return Number.parseInt(localStorage.getItem(LOGIN_TIME_STORAGE_KEY) || '0', 10) || 0 +} + +/** + * 会话按“最后活跃时间”计算,而不是从首次登录起固定倒计时。 + * 该 ref 被认证 store 与请求层共享,确保 API 活动可以立即影响路由守卫。 + */ +export const sessionActivityTime = ref(storedActivityTime()) + +export function startSessionActivity() { + sessionActivityTime.value = Date.now() + localStorage.setItem(LOGIN_TIME_STORAGE_KEY, String(sessionActivityTime.value)) +} + +export function touchSessionActivity() { + if (!sessionActivityTime.value) return + startSessionActivity() +} + +export function syncSessionActivity() { + sessionActivityTime.value = storedActivityTime() +} + +export function clearSessionActivity() { + sessionActivityTime.value = 0 + localStorage.removeItem(LOGIN_TIME_STORAGE_KEY) +}