前端:增加测试数据模式切换

This commit is contained in:
焦龙言
2026-09-23 15:37:33 +08:00
parent 1aaed57825
commit 169d9889f3
9 changed files with 353 additions and 2 deletions
@@ -834,6 +834,35 @@ 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\(\)/)
})
test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => { test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => {
assert.equal(expectedPagePermissions.length, 17) assert.equal(expectedPagePermissions.length, 17)
assert.equal(expectedButtonPermissions.length, 44) assert.equal(expectedButtonPermissions.length, 44)
@@ -0,0 +1,9 @@
import request from '@/utils/request'
// 查询当前用户可用的测试数据批次。
export function listAvailableTestBatches() {
return request({
url: '/system/test-batch/options',
method: 'get'
})
}
@@ -0,0 +1,185 @@
<template>
<div class="test-batch-mode-switch">
<el-tag
v-if="isTestMode"
class="mode-tag test-mode-tag"
type="danger"
effect="dark"
>
测试数据 · {{ testBatchState.currentBatchName || '已选批次' }}
</el-tag>
<el-tag v-else class="mode-tag" type="info" effect="plain">正式数据</el-tag>
<el-radio-group v-model="selectedMode" class="mode-selector" size="small" @change="handleModeChange">
<el-radio-button label="formal">正式</el-radio-button>
<el-radio-button label="test" :disabled="isLoading || hasLoadError || !activeOptions.length">测试</el-radio-button>
</el-radio-group>
<el-select
v-if="selectedMode === 'test'"
v-model="selectedBatchId"
class="batch-selector"
size="small"
:loading="isLoading"
:disabled="isLoading || hasLoadError || !activeOptions.length"
placeholder="选择测试批次"
@change="handleBatchSelect"
>
<el-option
v-for="batch in activeOptions"
:key="batch.batchId"
:label="batch.batchName"
:value="String(batch.batchId)"
/>
</el-select>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const testBatchState = reactive({
options: [],
currentBatchId: '',
currentBatchName: '',
scopeVersion: 0
})
const selectedMode = ref('formal')
const selectedBatchId = ref('')
const isLoading = ref(true)
const hasLoadError = ref(false)
let testBatchStore
const isTestMode = computed(() => Boolean(testBatchState.currentBatchId))
const activeOptions = computed(() => testBatchState.options.filter(item => item.status === 'ACTIVE'))
watch(
isTestMode,
value => {
selectedMode.value = value ? 'test' : 'formal'
selectedBatchId.value = value ? String(testBatchState.currentBatchId) : ''
},
{ immediate: true }
)
onMounted(async () => {
hasLoadError.value = false
try {
const { default: useTestBatchStore } = await import('@/store/modules/testBatch')
testBatchStore = useTestBatchStore()
watch(
() => ({
options: testBatchStore.options,
currentBatchId: testBatchStore.currentBatchId,
currentBatchName: testBatchStore.currentBatchName,
scopeVersion: testBatchStore.scopeVersion
}),
value => Object.assign(testBatchState, value),
{ deep: true, immediate: true }
)
await testBatchStore.refreshOptions()
} catch (error) {
testBatchState.options = []
hasLoadError.value = true
ElMessage.error('测试批次选项加载失败')
} finally {
isLoading.value = false
}
})
async function confirmModeSwitch() {
try {
await ElMessageBox.confirm('切换数据模式将清空当前页面未提交内容', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
return true
} catch {
return false
}
}
async function handleModeChange(mode) {
if (mode === 'test') {
if (!activeOptions.value.length) {
selectedMode.value = 'formal'
ElMessage.warning('暂无可用测试批次')
}
return
}
if (!isTestMode.value) {
return
}
if (await confirmModeSwitch()) {
testBatchStore.leaveTestMode()
} else {
selectedMode.value = 'test'
}
}
async function handleBatchSelect(batchId) {
const batch = activeOptions.value.find(item => String(item.batchId) === String(batchId))
if (!batch || String(batch.batchId) === String(testBatchState.currentBatchId)) {
return
}
if (!(await confirmModeSwitch())) {
selectedBatchId.value = isTestMode.value ? String(testBatchState.currentBatchId) : ''
selectedMode.value = isTestMode.value ? 'test' : 'formal'
return
}
try {
testBatchStore.selectBatch(batch)
} catch (error) {
selectedBatchId.value = isTestMode.value ? String(testBatchState.currentBatchId) : ''
selectedMode.value = isTestMode.value ? 'test' : 'formal'
ElMessage.error(error.message)
}
}
</script>
<style lang="scss" scoped>
.test-batch-mode-switch {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
height: 100%;
}
.mode-tag {
flex: 0 1 auto;
min-width: 0;
max-width: 154px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.test-mode-tag {
font-weight: 600;
}
.mode-selector {
flex: 0 0 auto;
}
.batch-selector {
width: 144px;
flex: 0 1 144px;
}
@media screen and (max-width: 767px) {
.mode-selector,
.batch-selector {
display: none;
}
.mode-tag {
max-width: 116px;
}
}
</style>
@@ -3,7 +3,7 @@
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<transition name="fade-transform" mode="out-in"> <transition name="fade-transform" mode="out-in">
<keep-alive :include="tagsViewStore.cachedViews"> <keep-alive :include="tagsViewStore.cachedViews">
<component v-if="!route.meta.link" :is="Component" :key="route.path"/> <component v-if="!route.meta.link" :is="Component" :key="`${route.path}:${testBatchStore.scopeVersion}`"/>
</keep-alive> </keep-alive>
</transition> </transition>
</router-view> </router-view>
@@ -19,9 +19,11 @@ import useTagsViewStore from '@/store/modules/tagsView'
const route = useRoute() const route = useRoute()
const tagsViewStore = useTagsViewStore() const tagsViewStore = useTagsViewStore()
const testBatchStore = reactive({ scopeVersion: 0 })
onMounted(() => { onMounted(() => {
addIframe() addIframe()
syncTestBatchScope()
}) })
watchEffect(() => { watchEffect(() => {
@@ -33,6 +35,14 @@ function addIframe() {
useTagsViewStore().addIframeView(route) useTagsViewStore().addIframeView(route)
} }
} }
async function syncTestBatchScope() {
const { default: useTestBatchStore } = await import('@/store/modules/testBatch')
const store = useTestBatchStore()
watch(() => store.scopeVersion, value => {
testBatchStore.scopeVersion = value
}, { immediate: true })
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -121,4 +131,3 @@ function addIframe() {
border-radius: 3px; border-radius: 3px;
} }
</style> </style>
@@ -9,6 +9,7 @@
</template> </template>
<div class="right-menu"> <div class="right-menu">
<test-batch-mode-switch class="right-menu-item test-batch-mode" aria-label="测试数据模式" />
<template v-if="appStore.device !== 'mobile'"> <template v-if="appStore.device !== 'mobile'">
<header-search id="header-search" class="right-menu-item" /> <header-search id="header-search" class="right-menu-item" />
@@ -59,6 +60,7 @@ import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull' import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect' import SizeSelect from '@/components/SizeSelect'
import HeaderSearch from '@/components/HeaderSearch' import HeaderSearch from '@/components/HeaderSearch'
import TestBatchModeSwitch from '@/components/TestBatchModeSwitch'
import useAppStore from '@/store/modules/app' import useAppStore from '@/store/modules/app'
import useUserStore from '@/store/modules/user' import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings' import useSettingsStore from '@/store/modules/settings'
@@ -205,6 +207,9 @@ async function toggleTheme(event) {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: auto; margin-left: auto;
min-width: 0;
flex-shrink: 1;
overflow: hidden;
&:focus { &:focus {
outline: none; outline: none;
@@ -277,6 +282,25 @@ async function toggleTheme(event) {
} }
} }
} }
.test-batch-mode {
display: flex;
align-items: center;
min-width: 0;
font-size: 14px;
}
}
}
@media screen and (max-width: 767px) {
.navbar .right-menu {
.avatar-container .avatar-wrapper .user-nickname {
display: none;
}
.avatar-container .avatar-wrapper .user-avatar {
margin-right: 0;
}
} }
} }
</style> </style>
@@ -0,0 +1,53 @@
import { defineStore } from 'pinia'
import { listAvailableTestBatches } from '@/api/system/testBatch'
import {
clearStoredTestBatchId,
getStoredTestBatchId,
setStoredTestBatchId
} from '@/utils/testBatchContext'
const useTestBatchStore = defineStore('testBatch', {
state: () => ({
options: [],
currentBatchId: getStoredTestBatchId(),
currentBatchName: '',
scopeVersion: 0
}),
getters: {
isTestMode: state => Boolean(state.currentBatchId)
},
actions: {
async refreshOptions() {
const response = await listAvailableTestBatches()
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()
} else {
this.currentBatchName = current?.batchName || ''
}
},
selectBatch(batch) {
const allowed = this.options.some(item => item.batchId === batch.batchId && item.status === 'ACTIVE')
if (!allowed) {
throw new Error('所选测试批次当前不可用')
}
this.currentBatchId = String(batch.batchId)
this.currentBatchName = batch.batchName
setStoredTestBatchId(this.currentBatchId)
this.scopeVersion += 1
},
leaveTestMode() {
this.currentBatchId = ''
this.currentBatchName = ''
clearStoredTestBatchId()
this.scopeVersion += 1
},
reset() {
this.options = []
this.leaveTestMode()
}
}
})
export default useTestBatchStore
@@ -4,6 +4,7 @@ import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth' import { getToken, setToken, removeToken } from '@/utils/auth'
import { isHttp, isEmpty } from "@/utils/validate" import { isHttp, isEmpty } from "@/utils/validate"
import defAva from '@/assets/images/profile.jpg' import defAva from '@/assets/images/profile.jpg'
import { clearStoredTestBatchId } from '@/utils/testBatchContext'
const useUserStore = defineStore( const useUserStore = defineStore(
'user', 'user',
@@ -14,6 +15,7 @@ const useUserStore = defineStore(
name: '', name: '',
nickName: '', nickName: '',
avatar: '', avatar: '',
admin: false,
roles: [], roles: [],
permissions: [] permissions: []
}), }),
@@ -53,6 +55,7 @@ const useUserStore = defineStore(
this.name = user.userName this.name = user.userName
this.nickName = user.nickName this.nickName = user.nickName
this.avatar = avatar this.avatar = avatar
this.admin = user.admin === true
/* 初始密码提示 */ /* 初始密码提示 */
if(res.isDefaultModifyPwd) { if(res.isDefaultModifyPwd) {
ElMessageBox.confirm('您的密码还是初始密码,请修改密码!', '安全提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { ElMessageBox.confirm('您的密码还是初始密码,请修改密码!', '安全提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => {
@@ -78,7 +81,9 @@ const useUserStore = defineStore(
this.token = '' this.token = ''
this.roles = [] this.roles = []
this.permissions = [] this.permissions = []
this.admin = false
removeToken() removeToken()
clearStoredTestBatchId()
resolve() resolve()
}).catch(error => { }).catch(error => {
reject(error) reject(error)
@@ -6,6 +6,7 @@ import { tansParams, blobValidate } from '@/utils/shierfeng'
import cache from '@/plugins/cache' import cache from '@/plugins/cache'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import useUserStore from '@/store/modules/user' import useUserStore from '@/store/modules/user'
import { clearStoredTestBatchId, getStoredTestBatchId } from '@/utils/testBatchContext'
import { import {
decryptTransportErrorResponse, decryptTransportErrorResponse,
decryptTransportResponse, decryptTransportResponse,
@@ -19,6 +20,19 @@ let downloadLoadingInstance;
// 是否显示重新登录 // 是否显示重新登录
export let isRelogin = { show: false }; 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))
}
function isTestBatchUnavailable(data) {
return data?.reason === 'TEST_BATCH_UNAVAILABLE'
}
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8' axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例 // 创建axios实例
const service = axios.create({ const service = axios.create({
@@ -45,6 +59,10 @@ service.interceptors.request.use(async config => {
if (getToken() && !isToken) { if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
} }
const testBatchId = getStoredTestBatchId()
if (getToken() && testBatchId) {
config.headers['X-Test-Batch-Id'] = String(testBatchId)
}
if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) { if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
const requestObj = { const requestObj = {
url: config.url, url: config.url,
@@ -98,6 +116,9 @@ service.interceptors.request.use(async config => {
service.interceptors.response.use(async res => { service.interceptors.response.use(async res => {
// 响应若命中了传输层加密,这里先还原为原始业务 JSON。 // 响应若命中了传输层加密,这里先还原为原始业务 JSON。
res = await decryptTransportResponse(res) res = await decryptTransportResponse(res)
if (isTestBatchUnavailable(res.data)) {
return rejectUnavailableTestBatch(res.data)
}
// 未设置状态码则默认成功状态 // 未设置状态码则默认成功状态
const code = res.data.code || 200; const code = res.data.code || 200;
// 获取错误信息 // 获取错误信息
@@ -135,6 +156,9 @@ service.interceptors.response.use(async res => {
async error => { async error => {
// 错误响应也可能是加密信封,先尝试解密再进入统一错误提示流程。 // 错误响应也可能是加密信封,先尝试解密再进入统一错误提示流程。
error = await decryptTransportErrorResponse(error) error = await decryptTransportErrorResponse(error)
if (isTestBatchUnavailable(error.response?.data)) {
return rejectUnavailableTestBatch(error.response.data)
}
// 若后端提示密钥失效,则清空本地公钥缓存并基于原始请求重试一次。 // 若后端提示密钥失效,则清空本地公钥缓存并基于原始请求重试一次。
if (shouldRetryTransportWithFreshKey(error) && error.config && !error.config.__transportRetried) { if (shouldRetryTransportWithFreshKey(error) && error.config && !error.config.__transportRetried) {
invalidateTransportKeyMeta() invalidateTransportKeyMeta()
@@ -0,0 +1,13 @@
const TEST_BATCH_STORAGE_KEY = 'test-batch-id'
export function getStoredTestBatchId() {
return sessionStorage.getItem(TEST_BATCH_STORAGE_KEY) || ''
}
export function setStoredTestBatchId(batchId) {
sessionStorage.setItem(TEST_BATCH_STORAGE_KEY, String(batchId))
}
export function clearStoredTestBatchId() {
sessionStorage.removeItem(TEST_BATCH_STORAGE_KEY)
}