前端:增加测试数据模式切换
This commit is contained in:
@@ -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 () => {
|
||||
assert.equal(expectedPagePermissions.length, 17)
|
||||
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 }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<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>
|
||||
</transition>
|
||||
</router-view>
|
||||
@@ -19,9 +19,11 @@ import useTagsViewStore from '@/store/modules/tagsView'
|
||||
|
||||
const route = useRoute()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
const testBatchStore = reactive({ scopeVersion: 0 })
|
||||
|
||||
onMounted(() => {
|
||||
addIframe()
|
||||
syncTestBatchScope()
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -33,6 +35,14 @@ function addIframe() {
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -121,4 +131,3 @@ function addIframe() {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<div class="right-menu">
|
||||
<test-batch-mode-switch class="right-menu-item test-batch-mode" aria-label="测试数据模式" />
|
||||
<template v-if="appStore.device !== 'mobile'">
|
||||
<header-search id="header-search" class="right-menu-item" />
|
||||
|
||||
@@ -59,6 +60,7 @@ import Hamburger from '@/components/Hamburger'
|
||||
import Screenfull from '@/components/Screenfull'
|
||||
import SizeSelect from '@/components/SizeSelect'
|
||||
import HeaderSearch from '@/components/HeaderSearch'
|
||||
import TestBatchModeSwitch from '@/components/TestBatchModeSwitch'
|
||||
import useAppStore from '@/store/modules/app'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
@@ -205,6 +207,9 @@ async function toggleTheme(event) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus {
|
||||
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>
|
||||
|
||||
@@ -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 { isHttp, isEmpty } from "@/utils/validate"
|
||||
import defAva from '@/assets/images/profile.jpg'
|
||||
import { clearStoredTestBatchId } from '@/utils/testBatchContext'
|
||||
|
||||
const useUserStore = defineStore(
|
||||
'user',
|
||||
@@ -14,6 +15,7 @@ const useUserStore = defineStore(
|
||||
name: '',
|
||||
nickName: '',
|
||||
avatar: '',
|
||||
admin: false,
|
||||
roles: [],
|
||||
permissions: []
|
||||
}),
|
||||
@@ -53,6 +55,7 @@ const useUserStore = defineStore(
|
||||
this.name = user.userName
|
||||
this.nickName = user.nickName
|
||||
this.avatar = avatar
|
||||
this.admin = user.admin === true
|
||||
/* 初始密码提示 */
|
||||
if(res.isDefaultModifyPwd) {
|
||||
ElMessageBox.confirm('您的密码还是初始密码,请修改密码!', '安全提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => {
|
||||
@@ -78,7 +81,9 @@ const useUserStore = defineStore(
|
||||
this.token = ''
|
||||
this.roles = []
|
||||
this.permissions = []
|
||||
this.admin = false
|
||||
removeToken()
|
||||
clearStoredTestBatchId()
|
||||
resolve()
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 {
|
||||
decryptTransportErrorResponse,
|
||||
decryptTransportResponse,
|
||||
@@ -19,6 +20,19 @@ let downloadLoadingInstance;
|
||||
// 是否显示重新登录
|
||||
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实例
|
||||
const service = axios.create({
|
||||
@@ -45,6 +59,10 @@ 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)
|
||||
}
|
||||
if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
|
||||
const requestObj = {
|
||||
url: config.url,
|
||||
@@ -98,6 +116,9 @@ service.interceptors.request.use(async config => {
|
||||
service.interceptors.response.use(async res => {
|
||||
// 响应若命中了传输层加密,这里先还原为原始业务 JSON。
|
||||
res = await decryptTransportResponse(res)
|
||||
if (isTestBatchUnavailable(res.data)) {
|
||||
return rejectUnavailableTestBatch(res.data)
|
||||
}
|
||||
// 未设置状态码则默认成功状态
|
||||
const code = res.data.code || 200;
|
||||
// 获取错误信息
|
||||
@@ -135,6 +156,9 @@ service.interceptors.response.use(async res => {
|
||||
async error => {
|
||||
// 错误响应也可能是加密信封,先尝试解密再进入统一错误提示流程。
|
||||
error = await decryptTransportErrorResponse(error)
|
||||
if (isTestBatchUnavailable(error.response?.data)) {
|
||||
return rejectUnavailableTestBatch(error.response.data)
|
||||
}
|
||||
// 若后端提示密钥失效,则清空本地公钥缓存并基于原始请求重试一次。
|
||||
if (shouldRetryTransportWithFreshKey(error) && error.config && !error.config.__transportRetried) {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user