diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index a6d42f0..4e931b3 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -3,6 +3,7 @@ import { lstat, readFile, realpath } from 'node:fs/promises' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import path from 'node:path' +import * as testBatchContext from '../src/utils/testBatchContext.js' const frontendRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -837,30 +838,108 @@ function hasExpectedRequest(expectedRequest) { const source = (relativePath) => readFile(path.join(frontendRoot, relativePath), 'utf8') test('Web 测试模式统一附加请求头并通过路由重建清空页面状态', async () => { - const requestSource = await source('src/utils/request.js') const appMainSource = await source('src/layout/components/AppMain.vue') const navbarSource = await source('src/layout/components/Navbar.vue') - assert.match(requestSource, /X-Test-Batch-Id/) - assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/) assert.match(appMainSource, /scopeVersion/) assert.match(navbarSource, /TestBatchModeSwitch/) assert.match(navbarSource, /测试数据/) }) test('Web 测试批次上下文仅持久化 ID,登出清除 ID 且管理员标记严格来自后端布尔值', async () => { - const contextSource = await source('src/utils/testBatchContext.js') const storeSource = await source('src/store/modules/testBatch.js') const userSource = await source('src/store/modules/user.js') - assert.match(contextSource, /sessionStorage/) - assert.doesNotMatch(contextSource, /batchName|status|admin|permission/i) assert.match(storeSource, /getStoredTestBatchId/) assert.match(storeSource, /setStoredTestBatchId/) assert.match(storeSource, /clearStoredTestBatchId/) assert.match(userSource, /admin:\s*false/) assert.match(userSource, /this\.admin\s*=\s*user\.admin\s*===\s*true/) - assert.match(userSource, /clearStoredTestBatchId\(\)/) + assert.match(userSource, /logoutWithLocalCleanup/) +}) + +test('Web 测试批次真实上下文逻辑只在登录测试模式附加请求头,并对并发失效做一次回退', async () => { + const storage = new Map() + globalThis.sessionStorage = { + getItem: key => storage.get(key) || null, + setItem: (key, value) => storage.set(key, value), + removeItem: key => storage.delete(key) + } + + const formalConfig = { headers: {} } + testBatchContext.applyTestBatchHeader(formalConfig, '', 'batch-1') + assert.deepEqual(formalConfig.headers, {}) + + const testConfig = { headers: {} } + testBatchContext.applyTestBatchHeader(testConfig, 'token', 'batch-1') + assert.equal(testConfig.headers['X-Test-Batch-Id'], 'batch-1') + + testBatchContext.setStoredTestBatchId('batch-1') + let loadCount = 0 + let leaveCount = 0 + let notifyCount = 0 + const loadStore = async () => { + loadCount += 1 + await Promise.resolve() + return { leaveTestMode: () => { leaveCount += 1 } } + } + const recovery = { + loadStore, + notify: () => { notifyCount += 1 }, + message: '测试批次不可用' + } + await Promise.all([ + testBatchContext.recoverUnavailableTestBatch(recovery), + testBatchContext.recoverUnavailableTestBatch(recovery), + testBatchContext.recoverUnavailableTestBatch(recovery) + ]) + assert.equal(loadCount, 1) + assert.equal(leaveCount, 1) + assert.equal(notifyCount, 1) + assert.equal(testBatchContext.getStoredTestBatchId(), '') + + await testBatchContext.recoverUnavailableTestBatch(recovery) + assert.equal(loadCount, 2) + assert.equal(leaveCount, 2) + assert.equal(notifyCount, 2) +}) + +test('刷新失效批次复用统一回退提示,登出无论远端结果都清理管理员与批次上下文', async () => { + const storeSource = await source('src/store/modules/testBatch.js') + const userSource = await source('src/store/modules/user.js') + assert.match(storeSource, /recoverUnavailableTestBatch/) + assert.match(storeSource, /当前测试批次已不可用/) + assert.match(userSource, /logoutWithLocalCleanup/) + + globalThis.sessionStorage.setItem('test-batch-id', 'batch-2') + const state = { admin: true } + await testBatchContext.logoutWithLocalCleanup(() => Promise.resolve('ok'), state) + assert.equal(state.admin, false) + assert.equal(testBatchContext.getStoredTestBatchId(), '') + + globalThis.sessionStorage.setItem('test-batch-id', 'batch-3') + state.admin = true + const remoteError = new Error('远端登出失败') + await assert.rejects( + testBatchContext.logoutWithLocalCleanup(() => Promise.reject(remoteError), state), + remoteError + ) + assert.equal(state.admin, false) + assert.equal(testBatchContext.getStoredTestBatchId(), '') +}) + +test('窄屏模式控件仍保留正式退出与测试批次切换入口', async () => { + const switchSource = await source('src/components/TestBatchModeSwitch/index.vue') + const navbarSource = await source('src/layout/components/Navbar.vue') + const mobileSection = switchSource.slice(switchSource.indexOf('@media screen and (max-width: 767px)')) + const navbarMobileSection = navbarSource.slice(navbarSource.indexOf('@media screen and (max-width: 767px)')) + assert.match(mobileSection, /\.mode-selector[\s\S]*\.batch-selector/) + assert.doesNotMatch(mobileSection, /display:\s*none/) + assert.match(mobileSection, /max-width:\s*48px/) + assert.match(mobileSection, /width:\s*72px/) + assert.match(mobileSection, /padding-right:\s*2px/) + assert.match(mobileSection, /padding-left:\s*2px/) + assert.match(navbarMobileSection, /\.test-batch-mode\s*\{\s*padding:\s*0/) }) test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => { diff --git a/shierfeng-fastapi-frontend/src/components/TestBatchModeSwitch/index.vue b/shierfeng-fastapi-frontend/src/components/TestBatchModeSwitch/index.vue index e6390fc..ac00e53 100644 --- a/shierfeng-fastapi-frontend/src/components/TestBatchModeSwitch/index.vue +++ b/shierfeng-fastapi-frontend/src/components/TestBatchModeSwitch/index.vue @@ -82,7 +82,9 @@ onMounted(async () => { } catch (error) { testBatchState.options = [] hasLoadError.value = true - ElMessage.error('测试批次选项加载失败') + if (error?.reason !== 'TEST_BATCH_UNAVAILABLE') { + ElMessage.error('测试批次选项加载失败') + } } finally { isLoading.value = false } @@ -173,13 +175,22 @@ async function handleBatchSelect(batchId) { } @media screen and (max-width: 767px) { - .mode-selector, - .batch-selector { - display: none; + .mode-tag { + max-width: 48px; } - .mode-tag { - max-width: 116px; + .mode-selector { + gap: 0; + } + + .batch-selector { + width: 72px; + flex-basis: 72px; + } + + :deep(.mode-selector .el-radio-button__inner) { + padding-right: 2px; + padding-left: 2px; } } diff --git a/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue index 951a372..e766a27 100644 --- a/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue +++ b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue @@ -294,6 +294,10 @@ async function toggleTheme(event) { @media screen and (max-width: 767px) { .navbar .right-menu { + .test-batch-mode { + padding: 0; + } + .avatar-container .avatar-wrapper .user-nickname { display: none; } diff --git a/shierfeng-fastapi-frontend/src/store/modules/testBatch.js b/shierfeng-fastapi-frontend/src/store/modules/testBatch.js index efb5361..c737f2e 100644 --- a/shierfeng-fastapi-frontend/src/store/modules/testBatch.js +++ b/shierfeng-fastapi-frontend/src/store/modules/testBatch.js @@ -1,8 +1,10 @@ import { defineStore } from 'pinia' +import { ElMessage } from 'element-plus' import { listAvailableTestBatches } from '@/api/system/testBatch' import { clearStoredTestBatchId, getStoredTestBatchId, + recoverUnavailableTestBatch, setStoredTestBatchId } from '@/utils/testBatchContext' @@ -22,7 +24,11 @@ const useTestBatchStore = defineStore('testBatch', { this.options = Array.isArray(response.data) ? response.data : [] const current = this.options.find(item => String(item.batchId) === String(this.currentBatchId)) if (this.currentBatchId && (!current || current.status !== 'ACTIVE')) { - this.leaveTestMode() + await recoverUnavailableTestBatch({ + loadStore: async () => this, + notify: message => ElMessage({ message, type: 'warning', duration: 5 * 1000 }), + message: '当前测试批次已不可用,已切回正式数据模式' + }) } else { this.currentBatchName = current?.batchName || '' } diff --git a/shierfeng-fastapi-frontend/src/store/modules/user.js b/shierfeng-fastapi-frontend/src/store/modules/user.js index caa814e..a7706e5 100644 --- a/shierfeng-fastapi-frontend/src/store/modules/user.js +++ b/shierfeng-fastapi-frontend/src/store/modules/user.js @@ -4,7 +4,7 @@ import { login, logout, getInfo } from '@/api/login' import { getToken, setToken, removeToken } from '@/utils/auth' import { isHttp, isEmpty } from "@/utils/validate" import defAva from '@/assets/images/profile.jpg' -import { clearStoredTestBatchId } from '@/utils/testBatchContext' +import { logoutWithLocalCleanup } from '@/utils/testBatchContext' const useUserStore = defineStore( 'user', @@ -76,18 +76,11 @@ const useUserStore = defineStore( }, // 退出系统 logOut() { - return new Promise((resolve, reject) => { - logout(this.token).then(() => { - this.token = '' - this.roles = [] - this.permissions = [] - this.admin = false - removeToken() - clearStoredTestBatchId() - resolve() - }).catch(error => { - reject(error) - }) + return logoutWithLocalCleanup(() => logout(this.token), this).finally(() => { + this.token = '' + this.roles = [] + this.permissions = [] + removeToken() }) } } diff --git a/shierfeng-fastapi-frontend/src/utils/request.js b/shierfeng-fastapi-frontend/src/utils/request.js index ba48bd6..65366fe 100644 --- a/shierfeng-fastapi-frontend/src/utils/request.js +++ b/shierfeng-fastapi-frontend/src/utils/request.js @@ -6,7 +6,11 @@ import { tansParams, blobValidate } from '@/utils/shierfeng' import cache from '@/plugins/cache' import { saveAs } from 'file-saver' import useUserStore from '@/store/modules/user' -import { clearStoredTestBatchId, getStoredTestBatchId } from '@/utils/testBatchContext' +import { + applyTestBatchHeader, + getStoredTestBatchId, + recoverUnavailableTestBatch +} from '@/utils/testBatchContext' import { decryptTransportErrorResponse, decryptTransportResponse, @@ -22,11 +26,17 @@ export let isRelogin = { show: false }; async function rejectUnavailableTestBatch(data) { const message = data.msg || '当前测试批次不可用,已切回正式数据模式' - clearStoredTestBatchId() - const { default: useTestBatchStore } = await import('@/store/modules/testBatch') - useTestBatchStore().leaveTestMode() - ElMessage({ message, type: 'error', duration: 5 * 1000 }) - return Promise.reject(new Error(message)) + await recoverUnavailableTestBatch({ + loadStore: async () => { + const { default: useTestBatchStore } = await import('@/store/modules/testBatch') + return useTestBatchStore() + }, + notify: recoveryMessage => ElMessage({ message: recoveryMessage, type: 'error', duration: 5 * 1000 }), + message + }) + const error = new Error(message) + error.reason = data.reason + return Promise.reject(error) } function isTestBatchUnavailable(data) { @@ -59,10 +69,7 @@ service.interceptors.request.use(async config => { if (getToken() && !isToken) { config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 } - const testBatchId = getStoredTestBatchId() - if (getToken() && testBatchId) { - config.headers['X-Test-Batch-Id'] = String(testBatchId) - } + applyTestBatchHeader(config, getToken(), getStoredTestBatchId()) if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) { const requestObj = { url: config.url, diff --git a/shierfeng-fastapi-frontend/src/utils/testBatchContext.js b/shierfeng-fastapi-frontend/src/utils/testBatchContext.js index 32b8da1..efb771e 100644 --- a/shierfeng-fastapi-frontend/src/utils/testBatchContext.js +++ b/shierfeng-fastapi-frontend/src/utils/testBatchContext.js @@ -1,4 +1,5 @@ const TEST_BATCH_STORAGE_KEY = 'test-batch-id' +let unavailableBatchRecoveryPromise export function getStoredTestBatchId() { return sessionStorage.getItem(TEST_BATCH_STORAGE_KEY) || '' @@ -11,3 +12,36 @@ export function setStoredTestBatchId(batchId) { export function clearStoredTestBatchId() { sessionStorage.removeItem(TEST_BATCH_STORAGE_KEY) } + +export function applyTestBatchHeader(config, token, batchId) { + if (token && batchId) { + config.headers = config.headers || {} + config.headers['X-Test-Batch-Id'] = String(batchId) + } + return config +} + +export function clearLocalUserContext(state) { + state.admin = false + clearStoredTestBatchId() +} + +export function logoutWithLocalCleanup(remoteLogout, state) { + return Promise.resolve().then(remoteLogout).finally(() => { + clearLocalUserContext(state) + }) +} + +export function recoverUnavailableTestBatch({ loadStore, notify, message }) { + if (!unavailableBatchRecoveryPromise) { + unavailableBatchRecoveryPromise = (async () => { + clearStoredTestBatchId() + const store = await loadStore() + store.leaveTestMode() + notify(message) + })().finally(() => { + unavailableBatchRecoveryPromise = undefined + }) + } + return unavailableBatchRecoveryPromise +}