feat: 新增公司切换与请求上下文

- 新增 company 请求上下文工具、organization store 与公司切换器
- axios 拦截器为组织级请求写入 X-Organization-ID 并校验上下文版本
- 切换公司用 AbortController 取消旧读请求,丢弃旧响应且不弹提示
- 路由与页面加公司初始化屏障,组织级页面按公司 ID + 版本挂载
This commit is contained in:
文睿 张 2026-10-09 10:19:19 +08:00
parent 8f4460b84a
commit 6cc3cd5dda
14 changed files with 1009 additions and 15 deletions

View File

@ -5,7 +5,11 @@
<div class="app-container"> <div class="app-container">
<AppSidebar :active-menu="currentRoute" @menu-select="handleMenuSelect" /> <AppSidebar :active-menu="currentRoute" @menu-select="handleMenuSelect" />
<main class="app-main"> <main class="app-main">
<router-view /> <!-- 公司尚未确定时用屏障占位,不挂载业务页面,避免组件 onMounted 发出缺少组织头的请求 -->
<div v-if="organizationBarrier" class="organization-barrier">
<el-empty description="请先选择要操作的公司" />
</div>
<router-view v-else :key="viewKey" />
</main> </main>
</div> </div>
</template> </template>
@ -19,15 +23,29 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useOrganizationStore } from './stores/organization'
import AppHeader from './components/layout/AppHeader.vue' import AppHeader from './components/layout/AppHeader.vue'
import AppSidebar from './components/layout/AppSidebar.vue' import AppSidebar from './components/layout/AppSidebar.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const organizationStore = useOrganizationStore()
const currentRoute = computed(() => route.path) const currentRoute = computed(() => route.path)
// 组织级页面在公司未确定时不挂载:由 AppHeader 的切换器引导用户先选公司。
const organizationBarrier = computed(
() => route.meta.organizationScoped === true && organizationStore.currentOrganizationId == null
)
// 组织级页面用“公司 ID + 上下文版本”作为挂载 key:切换公司即重新挂载,旧页面状态随之清空。
// 全局的用户、组织、角色管理页不作为公司数据页,key 固定、不随公司变化。
const viewKey = computed(() => {
if (route.meta.organizationScoped !== true) return 'global'
return `organization-${organizationStore.currentOrganizationId}-${organizationStore.contextVersion}`
})
function handleMenuSelect(path) { function handleMenuSelect(path) {
router.push(path) router.push(path)
} }
@ -53,4 +71,10 @@ function handleMenuSelect(path) {
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
} }
.organization-barrier {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style> </style>

View File

@ -6,5 +6,16 @@ export const authApi = {
}, },
getCurrentUser() { getCurrentUser() {
return request.get('/auth/me') return request.get('/auth/me')
},
// 当前用户被授权的公司列表,不需要组织头。
listOrganizations() {
return request.get('/auth/organizations')
},
// 切换公司上下文:服务端校验授权并返回目标公司的仓库摘要,不需要组织头。
switchOrganizationContext(organizationId, previousOrganizationId = 0) {
return request.post('/auth/organization-context', {
organization_id: organizationId,
previous_organization_id: previousOrganizationId
})
} }
} }

View File

@ -2,6 +2,13 @@ import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '../router' import router from '../router'
import { hasChinese, responseErrorText } from '../utils/errorText' import { hasChinese, responseErrorText } from '../utils/errorText'
import {
captureOrganizationContext,
currentReadSignal,
getOrganizationId,
isCurrentOrganizationContext,
OrganizationContextSupersededError
} from '../utils/organizationContext'
const request = axios.create({ const request = axios.create({
baseURL: '/api', baseURL: '/api',
@ -11,6 +18,25 @@ const request = axios.create({
} }
}) })
// 不需要公司上下文的接口白名单:认证、用户/组织/角色管理。
// 其余业务接口一律带 X-Organization-ID;websocket-ticket 属于组织级,不在白名单内。
const ORGANIZATION_EXEMPT_PATHS = [
'/auth/login',
'/auth/logout',
'/auth/me',
'/auth/organizations',
'/auth/organization-context',
'/organizations',
'/roles',
'/permissions',
'/users'
]
function isOrganizationExempt(url) {
const path = (url || '').split('?')[0]
return ORGANIZATION_EXEMPT_PATHS.some(exempt => path === exempt || path.startsWith(`${exempt}/`))
}
// Request interceptor - add auth token // Request interceptor - add auth token
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
@ -18,6 +44,19 @@ request.interceptors.request.use(
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
if (!isOrganizationExempt(config.url)) {
const organizationId = getOrganizationId()
if (organizationId) {
config.headers['X-Organization-ID'] = String(organizationId)
}
// 记下发起时的上下文快照,响应回来时用它判断是否已被切换作废。
config.__organizationContext = captureOrganizationContext()
// 读请求挂上共享信号:切换公司时统一 abort,尚未返回的旧读请求被取消。
const method = (config.method || 'get').toLowerCase()
if (!config.signal && method === 'get') {
config.signal = currentReadSignal()
}
}
console.log('API请求:', config.method?.toUpperCase(), config.url) console.log('API请求:', config.method?.toUpperCase(), config.url)
return config return config
}, },
@ -27,6 +66,11 @@ request.interceptors.request.use(
// Response interceptor - handle errors and 401 // Response interceptor - handle errors and 401
request.interceptors.response.use( request.interceptors.response.use(
(response) => { (response) => {
// 响应属于已被切换掉的旧公司:丢弃且不提示,页面只应关心新公司的数据。
const context = response.config?.__organizationContext
if (context && !isCurrentOrganizationContext(context)) {
return Promise.reject(new OrganizationContextSupersededError())
}
const res = response.data const res = response.data
if (res.code === 0) { if (res.code === 0) {
return res return res
@ -35,6 +79,15 @@ request.interceptors.response.use(
return Promise.reject(new Error(res.message)) return Promise.reject(new Error(res.message))
}, },
(error) => { (error) => {
// 主动取消(切换公司)不是失败,静默丢弃,不要弹提示。
if (axios.isCancel(error)) {
return Promise.reject(error)
}
// 旧公司的延迟错误同样不得影响新页面,也不能弹提示。
const context = error.config?.__organizationContext
if (context && !isCurrentOrganizationContext(context)) {
return Promise.reject(new OrganizationContextSupersededError())
}
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('wmms_token') localStorage.removeItem('wmms_token')
localStorage.removeItem('wmms_user') localStorage.removeItem('wmms_user')

View File

@ -1,6 +1,10 @@
<template> <template>
<el-menu mode="horizontal" :ellipsis="false" class="app-header"> <el-menu mode="horizontal" :ellipsis="false" class="app-header">
<div class="app-title">仓库物料管理系统</div> <div class="header-left">
<div class="app-title">仓库物料管理系统</div>
<!-- 公司切换器放在左上区域,清楚显示当前公司 -->
<OrganizationSwitcher />
</div>
<div class="user-info"> <div class="user-info">
<span class="username">{{ authStore.user?.username }}</span> <span class="username">{{ authStore.user?.username }}</span>
<el-tag v-if="authStore.isAdmin" type="danger" size="small">管理员</el-tag> <el-tag v-if="authStore.isAdmin" type="danger" size="small">管理员</el-tag>
@ -12,12 +16,17 @@
<script setup> <script setup>
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useOrganizationStore } from '../../stores/organization'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import OrganizationSwitcher from './OrganizationSwitcher.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const organizationStore = useOrganizationStore()
const router = useRouter() const router = useRouter()
function handleLogout() { function handleLogout() {
// 登出前清空公司状态,避免下个账号在同一标签页继承旧公司。
organizationStore.reset()
authStore.logout() authStore.logout()
router.push('/login') router.push('/login')
} }
@ -29,6 +38,11 @@ function handleLogout() {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.app-title { .app-title {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;

View File

@ -6,11 +6,21 @@
> >
<el-sub-menu v-for="section in navigation.sections" :key="section.key" :index="section.key"> <el-sub-menu v-for="section in navigation.sections" :key="section.key" :index="section.key">
<template #title>{{ section.label }}</template> <template #title>{{ section.label }}</template>
<el-menu-item v-for="item in section.items" :key="item.path" :index="item.path"> <el-menu-item
v-for="item in section.items"
:key="item.path"
:index="item.path"
:disabled="isOrganizationItemDisabled(item.path)"
>
{{ item.label }} {{ item.label }}
</el-menu-item> </el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item v-for="item in navigation.standalone" :key="item.path" :index="item.path"> <el-menu-item
v-for="item in navigation.standalone"
:key="item.path"
:index="item.path"
:disabled="isOrganizationItemDisabled(item.path)"
>
{{ item.label }} {{ item.label }}
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
@ -19,7 +29,9 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useOrganizationStore } from '../../stores/organization'
import { getNavigationMenu } from '../../navigation/menu' import { getNavigationMenu } from '../../navigation/menu'
import { routes } from '../../router'
const props = defineProps({ const props = defineProps({
activeMenu: String activeMenu: String
@ -28,9 +40,20 @@ const props = defineProps({
const emit = defineEmits(['menu-select']) const emit = defineEmits(['menu-select'])
const authStore = useAuthStore() const authStore = useAuthStore()
const organizationStore = useOrganizationStore()
// 哪些菜单属于“按公司隔离”的业务页:取自路由 meta.organizationScoped,避免和路由定义脱节。
const organizationScopedPaths = new Set(
routes.filter(route => route.meta?.organizationScoped).map(route => route.path)
)
const navigation = computed(() => getNavigationMenu({ isAdmin: authStore.isAdmin })) const navigation = computed(() => getNavigationMenu({ isAdmin: authStore.isAdmin }))
// 公司尚未确定时组织级页面不可进入,先让用户在公司切换器里选好公司。
function isOrganizationItemDisabled(path) {
return organizationScopedPaths.has(path) && organizationStore.currentOrganizationId == null
}
function handleSelect(path) { function handleSelect(path) {
emit('menu-select', path) emit('menu-select', path)
} }

View File

@ -0,0 +1,126 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import OrganizationSwitcher from './OrganizationSwitcher.vue'
import { useOrganizationStore } from '../../stores/organization'
// 用替身 store 替换真实 store,专注测试切换器的渲染与交互。
// 用 reactive 包一层模拟 Pinia setup store:组件里访问的就是解包后的值。
vi.mock('../../stores/organization', async () => {
const { reactive } = await import('vue')
const state = reactive({
organizations: [],
currentOrganizationId: null,
currentWarehouse: null,
switching: false,
switchOrganization: vi.fn()
})
return { useOrganizationStore: () => state }
})
const store = useOrganizationStore()
// 真实 ElSelect 在 jsdom 下会因状态双向绑定反复触发更新而报错,用轻量替身绕开。
const SelectStub = {
props: ['modelValue', 'placeholder', 'disabled'],
emits: ['update:modelValue', 'change'],
template: '<div data-test="select"><slot /></div>'
}
const OptionStub = {
props: ['label', 'value'],
template: '<span class="option">{{ label }}</span>'
}
function mountSwitcher() {
return mount(OrganizationSwitcher, {
global: {
plugins: [ElementPlus],
stubs: { ElSelect: SelectStub, ElOption: OptionStub }
}
})
}
describe('OrganizationSwitcher 公司切换器', () => {
beforeEach(() => {
store.organizations = []
store.currentOrganizationId = null
store.currentWarehouse = null
store.switching = false
store.switchOrganization.mockReset()
})
test('展示授权公司选项并选中当前公司', () => {
store.organizations = [{ id: 1, name: '北京' }, { id: 2, name: '武汉' }]
store.currentOrganizationId = 1
const wrapper = mountSwitcher()
expect(wrapper.text()).toContain('北京')
expect(wrapper.text()).toContain('武汉')
expect(wrapper.findComponent(SelectStub).props('modelValue')).toBe(1)
})
test('选择公司时调用切换动作', async () => {
store.organizations = [{ id: 1, name: '北京' }, { id: 2, name: '武汉' }]
store.currentOrganizationId = 1
const wrapper = mountSwitcher()
wrapper.findComponent(SelectStub).vm.$emit('change', 2)
await flushPromises()
expect(store.switchOrganization).toHaveBeenCalledWith(2)
})
test('切换进行中下拉禁用', () => {
store.organizations = [{ id: 1, name: '北京' }]
store.switching = true
const wrapper = mountSwitcher()
expect(wrapper.findComponent(SelectStub).props('disabled')).toBe(true)
})
test('没有授权公司时下拉禁用并提示暂无公司', () => {
const wrapper = mountSwitcher()
expect(wrapper.findComponent(SelectStub).props('disabled')).toBe(true)
expect(wrapper.findComponent(SelectStub).props('placeholder')).toBe('暂无可用公司')
})
test('切换失败时吞掉错误、不向外抛异常', async () => {
store.organizations = [{ id: 1, name: '北京' }, { id: 2, name: '武汉' }]
store.currentOrganizationId = 1
store.switchOrganization.mockRejectedValueOnce(new Error('公司不可访问'))
const wrapper = mountSwitcher()
wrapper.findComponent(SelectStub).vm.$emit('change', 2)
await flushPromises()
// 未产生未捕获异常即视为通过;失败时保留旧公司由组件与 store 共同保证
expect(store.switchOrganization).toHaveBeenCalledWith(2)
})
test('展示当前公司的仓库与库位摘要', () => {
store.organizations = [{ id: 1, name: '北京' }]
store.currentOrganizationId = 1
store.currentWarehouse = {
id: 10, name: '北京仓', status: 'active', location: { id: 100, name: 'A库位', status: 'active' }
}
const wrapper = mountSwitcher()
expect(wrapper.text()).toContain('北京仓')
expect(wrapper.text()).toContain('A库位')
})
test('仓库停用时提示仓库已停用', () => {
store.organizations = [{ id: 1, name: '北京' }]
store.currentOrganizationId = 1
store.currentWarehouse = { id: 10, name: '北京仓', status: 'inactive', location: null }
const wrapper = mountSwitcher()
expect(wrapper.text()).toContain('本公司仓库已停用')
})
})

View File

@ -0,0 +1,72 @@
<template>
<div class="organization-switcher">
<!-- 只展示授权公司;切换失败时 company 不变,选择框会自然回退到旧公司 -->
<el-select
:model-value="organizationStore.currentOrganizationId"
:placeholder="placeholder"
:disabled="selectDisabled"
class="organization-select"
@change="handleChange"
>
<el-option
v-for="organization in organizationStore.organizations"
:key="organization.id"
:label="organization.name"
:value="organization.id"
/>
</el-select>
<span v-if="warehouseText" class="warehouse-summary">{{ warehouseText }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useOrganizationStore } from '../../stores/organization'
const organizationStore = useOrganizationStore()
const placeholder = computed(() =>
organizationStore.organizations.length === 0 ? '暂无可用公司' : '请选择公司'
)
// 没有授权公司或正在切换时禁止操作,避免空选择和无意义的并发切换。
const selectDisabled = computed(
() => organizationStore.switching || organizationStore.organizations.length === 0
)
// 仓库、库位摘要直接来自服务端返回,普通用户无需调用管理员仓库接口。
const warehouseText = computed(() => {
if (organizationStore.currentOrganizationId == null) return ''
const warehouse = organizationStore.currentWarehouse
if (!warehouse) return '请先配置本公司仓库'
if (warehouse.status !== 'active') return '本公司仓库已停用'
const location = warehouse.location
if (!location) return '请先配置本公司仓库的库位'
if (location.status !== 'active') return '本公司库位已停用'
return `${warehouse.name} · ${location.name}`
})
async function handleChange(organizationId) {
try {
await organizationStore.switchOrganization(organizationId)
} catch {
// 失败时保留旧公司;错误提示由 axios 拦截器统一负责,这里不再重复弹一次。
// 显式吞掉还避免切换的 rejection 冒到控制台变成 unhandled。
}
}
</script>
<style scoped>
.organization-switcher {
display: flex;
align-items: center;
gap: 10px;
}
.organization-select {
width: 160px;
}
.warehouse-summary {
color: #909399;
font-size: 12px;
}
</style>

View File

@ -1,6 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useOrganizationStore } from '../stores/organization'
// organizationScoped 标记“按公司隔离”的业务页面:进入前必须有当前公司,
// 且用公司 ID + 版本作为挂载 key。全局的用户/组织/角色管理页不重置公司数据。
export const routes = [ export const routes = [
{ {
path: '/login', path: '/login',
@ -16,13 +19,13 @@ export const routes = [
path: '/materials', path: '/materials',
name: 'Materials', name: 'Materials',
component: () => import('../views/Materials.vue'), component: () => import('../views/Materials.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/master-data/settings', path: '/master-data/settings',
name: 'MasterDataSettings', name: 'MasterDataSettings',
component: () => import('../views/master-data/MasterDataSettings.vue'), component: () => import('../views/master-data/MasterDataSettings.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/organizations', path: '/organizations',
@ -40,49 +43,49 @@ export const routes = [
path: '/partners/suppliers', path: '/partners/suppliers',
name: 'Suppliers', name: 'Suppliers',
component: () => import('../views/partners/Suppliers.vue'), component: () => import('../views/partners/Suppliers.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/partners/customers', path: '/partners/customers',
name: 'Customers', name: 'Customers',
component: () => import('../views/partners/Customers.vue'), component: () => import('../views/partners/Customers.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/inbound', path: '/inbound',
name: 'Inbound', name: 'Inbound',
component: () => import('../views/Inbound.vue'), component: () => import('../views/Inbound.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/outbound', path: '/outbound',
name: 'Outbound', name: 'Outbound',
component: () => import('../views/Outbound.vue'), component: () => import('../views/Outbound.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/stock', path: '/stock',
name: 'Stock', name: 'Stock',
component: () => import('../views/Stock.vue'), component: () => import('../views/Stock.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true, organizationScoped: true }
}, },
{ {
path: '/application', path: '/application',
name: 'Application', name: 'Application',
component: () => import('../views/Application.vue'), component: () => import('../views/Application.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true, organizationScoped: true }
}, },
{ {
path: '/approval', path: '/approval',
name: 'Approval', name: 'Approval',
component: () => import('../views/Approval.vue'), component: () => import('../views/Approval.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/logs', path: '/logs',
name: 'Logs', name: 'Logs',
component: () => import('../views/Logs.vue'), component: () => import('../views/Logs.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
}, },
{ {
path: '/users', path: '/users',
@ -97,8 +100,9 @@ const router = createRouter({
routes routes
}) })
router.beforeEach((to, from, next) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const organizationStore = useOrganizationStore()
if (to.meta.guestOnly && authStore.isLoggedIn) { if (to.meta.guestOnly && authStore.isLoggedIn) {
return next('/stock') return next('/stock')
@ -112,6 +116,16 @@ router.beforeEach((to, from, next) => {
return next('/stock') return next('/stock')
} }
// 组织初始化屏障:登录后先加载公司并确定当前公司,避免业务组件 onMounted
// 在缺少组织头的情况下发出请求。路由级只负责加载,页面不挂载交给 App 的屏障。
if (to.meta.requiresAuth && authStore.isLoggedIn && !organizationStore.initialized) {
try {
await organizationStore.initialize()
} catch {
// 加载失败不阻断导航:公司为空时由 App 的屏障提示用户选择公司。
}
}
next() next()
}) })

View File

@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { authApi } from '../api/auth' import { authApi } from '../api/auth'
import { resetOrganizationContext } from '../utils/organizationContext'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('wmms_token') || '') const token = ref(localStorage.getItem('wmms_token') || '')
@ -11,10 +12,13 @@ export const useAuthStore = defineStore('auth', () => {
const isAdmin = computed(() => hasRole('super_admin')) const isAdmin = computed(() => hasRole('super_admin'))
function saveAuth(newToken, newUser) { function saveAuth(newToken, newUser) {
// 换账号登录时清空公司上下文,避免把上一个账号选的公司带过去。
const accountChanged = (user.value?.id ?? null) !== (newUser?.id ?? null)
token.value = newToken token.value = newToken
user.value = newUser user.value = newUser
localStorage.setItem('wmms_token', newToken) localStorage.setItem('wmms_token', newToken)
localStorage.setItem('wmms_user', JSON.stringify(newUser)) localStorage.setItem('wmms_user', JSON.stringify(newUser))
if (accountChanged) resetOrganizationContext()
} }
function clearAuth() { function clearAuth() {
@ -22,6 +26,8 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null user.value = null
localStorage.removeItem('wmms_token') localStorage.removeItem('wmms_token')
localStorage.removeItem('wmms_user') localStorage.removeItem('wmms_user')
// 登出同样清空公司上下文,防止后续请求继续携带旧公司 ID。
resetOrganizationContext()
} }
async function login(username, password) { async function login(username, password) {

View File

@ -1,6 +1,11 @@
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test } from 'vitest' import { beforeEach, describe, expect, test } from 'vitest'
import { useAuthStore } from './auth' import { useAuthStore } from './auth'
import {
captureOrganizationContext,
commitOrganizationId,
resetOrganizationContext
} from '../utils/organizationContext'
describe('多角色管理员判断', () => { describe('多角色管理员判断', () => {
beforeEach(() => { beforeEach(() => {
@ -30,3 +35,42 @@ describe('多角色管理员判断', () => {
expect(store.isAdmin).toBe(false) expect(store.isAdmin).toBe(false)
}) })
}) })
describe('公司上下文清理', () => {
beforeEach(() => {
localStorage.clear()
resetOrganizationContext()
setActivePinia(createPinia())
})
test('登出清空公司上下文', () => {
const store = useAuthStore()
store.saveAuth('token', { id: 1, username: 'admin', roles: [] })
commitOrganizationId(3)
expect(captureOrganizationContext().organizationId).toBe(3)
store.logout()
expect(captureOrganizationContext().organizationId).toBeNull()
})
test('切换账号清空公司上下文', () => {
const store = useAuthStore()
store.saveAuth('token-a', { id: 1, username: 'a', roles: [] })
commitOrganizationId(3)
store.saveAuth('token-b', { id: 2, username: 'b', roles: [] })
expect(captureOrganizationContext().organizationId).toBeNull()
})
test('同一用户刷新用户信息不清空公司上下文', () => {
const store = useAuthStore()
store.saveAuth('token', { id: 1, username: 'a', roles: [] })
commitOrganizationId(3)
store.saveAuth('token', { id: 1, username: 'a', roles: [] })
expect(captureOrganizationContext().organizationId).toBe(3)
})
})

View File

@ -0,0 +1,166 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authApi } from '../api/auth'
import {
captureOrganizationContext,
commitOrganizationId,
getOrganizationId,
resetOrganizationContext
} from '../utils/organizationContext'
// 本标签页上次选择的公司按用户分开存:同一浏览器不同账号登录时互不继承选择。
function selectionStorageKey(userId) {
return `wmms_organization_id:${userId ?? 'anonymous'}`
}
// 直接读 localStorage 里的用户信息拿 id,不导入 auth store,避免 store 之间相互依赖成环。
function currentUserId() {
try {
const user = JSON.parse(localStorage.getItem('wmms_user') || 'null')
return user?.id ?? null
} catch {
return null
}
}
function readStoredOrganizationId() {
const raw = sessionStorage.getItem(selectionStorageKey(currentUserId()))
const id = Number(raw)
return Number.isInteger(id) && id > 0 ? id : null
}
function writeStoredOrganizationId(id) {
sessionStorage.setItem(selectionStorageKey(currentUserId()), String(id))
}
export const useOrganizationStore = defineStore('organization', () => {
const organizations = ref([])
const currentOrganizationId = ref(null)
const currentWarehouseId = ref(null)
const currentLocationId = ref(null)
// 当前公司的仓库摘要(含库位),供切换器与页面展示,普通用户无需调用管理员仓库接口。
const currentWarehouse = ref(null)
const contextVersion = ref(0)
const switching = ref(false)
// 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。
const initialized = ref(false)
// 把 store 内展示用的版本号同步成上下文工具里的真实版本,两者始终一致。
function syncVersion() {
contextVersion.value = captureOrganizationContext().version
}
// 仓库、库位的自动选择规则:只有唯一仓库启用时设置仓库 ID,其唯一库位也启用时才设置库位 ID。
// 缺配置、停用或失败时对应 ID 保持为空,绝不回退到旧公司的仓库或库位。
function applyWarehouseContext(warehouse) {
currentWarehouse.value = warehouse || null
// 先清空旧公司的仓库、库位,再按目标公司摘要设置。
currentWarehouseId.value = null
currentLocationId.value = null
if (warehouse && warehouse.status === 'active') {
currentWarehouseId.value = warehouse.id
const location = warehouse.location
if (location && location.status === 'active') {
currentLocationId.value = location.id
}
}
}
// 校验成功后才提交本地状态:先递增版本(同时取消旧读请求),再写公司 ID 和仓库、库位。
function applyContext(data) {
const organizationId = data?.organization_id ?? null
commitOrganizationId(organizationId)
currentOrganizationId.value = organizationId
applyWarehouseContext(data?.warehouse || null)
syncVersion()
if (organizationId) writeStoredOrganizationId(organizationId)
}
// 没有可用公司(或候选公司都失效)时进入空状态:公司 ID 保持为空,
// 拦截器不会给任何组织级请求写组织头,业务页面也不挂载。
function applyEmptyContext() {
commitOrganizationId(null)
currentOrganizationId.value = null
applyWarehouseContext(null)
syncVersion()
}
// 用切换校验接口验证候选公司:该接口会返回目标公司的仓库摘要,顺带完成授权校验。
async function validateCandidate(organizationId) {
try {
const res = await authApi.switchOrganizationContext(organizationId, getOrganizationId() ?? 0)
applyContext(res.data || {})
return true
} catch {
// 候选失效交给调用方继续尝试下一个;这里不弹提示,错误由拦截器统一处理。
return false
}
}
async function initialize() {
const res = await authApi.listOrganizations()
const data = res.data || {}
organizations.value = data.organizations || []
if (organizations.value.length === 0) {
// 无授权公司:空状态,不发起任何校验请求。
applyEmptyContext()
initialized.value = true
return
}
// 本标签页上次选择优先,其次默认公司;两者都必须重新校验授权。
const defaultId = data.default_organization_id ?? null
const candidates = [readStoredOrganizationId(), defaultId]
const seen = new Set()
for (const candidate of candidates) {
if (candidate == null || seen.has(candidate)) continue
seen.add(candidate)
if (!organizations.value.some(org => org.id === candidate)) continue
if (await validateCandidate(candidate)) {
initialized.value = true
return
}
}
// 候选都失效但仍有授权公司:保留列表,要求用户重新选择。
applyEmptyContext()
initialized.value = true
}
async function switchOrganization(organizationId) {
// 切换或保存进行中禁止再次切换:避免并发切换互相覆盖,以及写请求归属不明确。
if (switching.value) return
if (!organizationId || organizationId === currentOrganizationId.value) return
switching.value = true
try {
const res = await authApi.switchOrganizationContext(
organizationId,
currentOrganizationId.value ?? 0
)
// 校验成功后才提交;抛错时保持旧公司不变,由调用方决定是否提示。
applyContext(res.data || {})
} finally {
switching.value = false
}
}
// 登出或切换账号时清空:上下文、公司列表、仓库库位与初始化标记全部复位。
function reset() {
resetOrganizationContext()
organizations.value = []
currentOrganizationId.value = null
currentWarehouse.value = null
currentWarehouseId.value = null
currentLocationId.value = null
switching.value = false
initialized.value = false
syncVersion()
}
return {
organizations, currentOrganizationId, currentWarehouseId, currentLocationId,
currentWarehouse, contextVersion, switching, initialized,
initialize, switchOrganization, reset
}
})

View File

@ -0,0 +1,241 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { authApi } from '../api/auth'
import { useOrganizationStore } from './organization'
import { resetOrganizationContext } from '../utils/organizationContext'
vi.mock('../api/auth', () => ({
authApi: {
listOrganizations: vi.fn(),
switchOrganizationContext: vi.fn()
}
}))
// 模拟某个账号已登录:公司选择按用户 id 分开存储,这里写入对应账号。
function loginAs(id) {
localStorage.setItem('wmms_user', JSON.stringify({ id, username: `user${id}` }))
}
function orgList(organizations, defaultOrganizationId) {
return { data: { organizations, default_organization_id: defaultOrganizationId } }
}
function switchResult(organizationId, warehouse = null) {
return { data: { organization_id: organizationId, organization: { id: organizationId }, warehouse } }
}
const ACTIVE_WAREHOUSE = {
id: 10,
name: '武汉仓',
status: 'active',
location: { id: 100, name: 'A库位', status: 'active' }
}
describe('organization store', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
resetOrganizationContext()
setActivePinia(createPinia())
authApi.listOrganizations.mockReset()
authApi.switchOrganizationContext.mockReset()
})
test('默认公司进入并自动选中唯一启用的仓库与库位', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1, name: '北京' }, { id: 2, name: '武汉' }], 2))
authApi.switchOrganizationContext.mockResolvedValue(switchResult(2, ACTIVE_WAREHOUSE))
const store = useOrganizationStore()
await store.initialize()
expect(store.organizations.map(o => o.id)).toEqual([1, 2])
expect(store.currentOrganizationId).toBe(2)
expect(store.currentWarehouseId).toBe(10)
expect(store.currentLocationId).toBe(100)
expect(store.initialized).toBe(true)
expect(authApi.switchOrganizationContext).toHaveBeenCalledWith(2, 0)
})
test('只展示接口返回的授权公司', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 3, name: '上海' }], 3))
authApi.switchOrganizationContext.mockResolvedValue(switchResult(3))
const store = useOrganizationStore()
await store.initialize()
expect(store.organizations.map(o => o.id)).toEqual([3])
expect(store.currentOrganizationId).toBe(3)
})
test('默认公司失效时保留列表并要求重新选择', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1, name: '北京' }], 99))
authApi.switchOrganizationContext.mockRejectedValue(new Error('公司不可访问'))
const store = useOrganizationStore()
await store.initialize()
expect(store.organizations.map(o => o.id)).toEqual([1])
expect(store.currentOrganizationId).toBeNull()
expect(store.initialized).toBe(true)
})
test('没有授权公司时进入空状态且不发起校验', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([], null))
const store = useOrganizationStore()
await store.initialize()
expect(store.organizations).toEqual([])
expect(store.currentOrganizationId).toBeNull()
expect(store.currentWarehouseId).toBeNull()
expect(store.currentLocationId).toBeNull()
expect(authApi.switchOrganizationContext).not.toHaveBeenCalled()
})
test('切换失败时保留旧公司', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
authApi.switchOrganizationContext.mockResolvedValueOnce(
switchResult(1, { id: 9, name: '北京仓', status: 'active', location: { id: 90, name: '库位', status: 'active' } })
)
const store = useOrganizationStore()
await store.initialize()
expect(store.currentOrganizationId).toBe(1)
authApi.switchOrganizationContext.mockRejectedValueOnce(new Error('公司不可访问'))
await expect(store.switchOrganization(2)).rejects.toThrow()
expect(store.currentOrganizationId).toBe(1)
expect(store.currentWarehouseId).toBe(9)
expect(store.currentLocationId).toBe(90)
expect(store.switching).toBe(false)
})
test('切换成功后提交新公司并递增上下文版本', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
const store = useOrganizationStore()
await store.initialize()
const versionBefore = store.contextVersion
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2, ACTIVE_WAREHOUSE))
await store.switchOrganization(2)
expect(store.currentOrganizationId).toBe(2)
expect(store.contextVersion).toBeGreaterThan(versionBefore)
expect(store.currentWarehouseId).toBe(10)
expect(store.currentLocationId).toBe(100)
expect(authApi.switchOrganizationContext).toHaveBeenLastCalledWith(2, 1)
})
test('切换到当前公司不重复发起校验', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
const store = useOrganizationStore()
await store.initialize()
authApi.switchOrganizationContext.mockClear()
await store.switchOrganization(1)
expect(authApi.switchOrganizationContext).not.toHaveBeenCalled()
})
test('仓库停用时仓库与库位都为空', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
authApi.switchOrganizationContext.mockResolvedValue(switchResult(1, {
id: 5, name: '停用仓', status: 'inactive', location: { id: 50, name: '库位', status: 'active' }
}))
const store = useOrganizationStore()
await store.initialize()
expect(store.currentWarehouseId).toBeNull()
expect(store.currentLocationId).toBeNull()
})
test('库位缺失或停用时仅库位为空', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
authApi.switchOrganizationContext.mockResolvedValue(switchResult(1, {
id: 5, name: '启用仓', status: 'active', location: null
}))
const store = useOrganizationStore()
await store.initialize()
expect(store.currentWarehouseId).toBe(5)
expect(store.currentLocationId).toBeNull()
})
test('目标公司没有仓库时清空旧公司的仓库与库位', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1, ACTIVE_WAREHOUSE))
const store = useOrganizationStore()
await store.initialize()
expect(store.currentWarehouseId).toBe(10)
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2, null))
await store.switchOrganization(2)
expect(store.currentOrganizationId).toBe(2)
expect(store.currentWarehouseId).toBeNull()
expect(store.currentLocationId).toBeNull()
})
test('不同账号不继承上一账号的公司选择', async () => {
// 账号 1 上次在本标签页选择了公司 2
loginAs(1)
sessionStorage.setItem('wmms_organization_id:1', '2')
// 换成账号 2 登录:读到的应是账号 2 的存储(为空),回落到默认公司 1
loginAs(2)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
authApi.switchOrganizationContext.mockResolvedValue(switchResult(1))
const store = useOrganizationStore()
await store.initialize()
expect(store.currentOrganizationId).toBe(1)
expect(authApi.switchOrganizationContext).toHaveBeenCalledWith(1, 0)
})
test('切换进行中禁止再次切换', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
const store = useOrganizationStore()
await store.initialize()
authApi.switchOrganizationContext.mockClear()
store.switching = true
await store.switchOrganization(2)
expect(authApi.switchOrganizationContext).not.toHaveBeenCalled()
expect(store.currentOrganizationId).toBe(1)
})
test('reset 清空公司状态与上下文', async () => {
loginAs(1)
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
authApi.switchOrganizationContext.mockResolvedValue(switchResult(1, ACTIVE_WAREHOUSE))
const store = useOrganizationStore()
await store.initialize()
expect(store.currentOrganizationId).toBe(1)
store.reset()
expect(store.organizations).toEqual([])
expect(store.currentOrganizationId).toBeNull()
expect(store.currentWarehouseId).toBeNull()
expect(store.currentLocationId).toBeNull()
expect(store.initialized).toBe(false)
})
})

View File

@ -0,0 +1,60 @@
// 公司请求上下文:集中保存“当前公司 ID + 上下文版本”,供 axios 拦截器和 organization store 共用。
// 刻意不导入 Pinia / Axios:auth store -> API -> store 一旦形成环,import 顺序会决定谁先初始化,
// 容易出现“有 store 实例却拿不到 token”之类的隐性错误。这里只放纯状态,不依赖任何外部模块。
let currentOrganizationId = null
let currentVersion = 0
// 切换公司要取消旧公司尚未返回的读请求,所以所有读请求共用一个 AbortController。
let readController = new AbortController()
export function getOrganizationId() {
return currentOrganizationId
}
// 提交新的公司上下文:写入公司 ID 并递增版本。旧响应靠版本号即可识别并丢弃,
// 因为 A→B→A 时公司 ID 又变回相同值,仅比较 ID 无法区分“第一次 A”的响应。
export function commitOrganizationId(organizationId) {
currentOrganizationId = organizationId ?? null
currentVersion += 1
}
// 捕获发起请求那一刻的上下文快照,响应回来时用它判断是否已被切换作废。
export function captureOrganizationContext() {
return { organizationId: currentOrganizationId, version: currentVersion }
}
// 快照与当前上下文完全一致(公司 ID 和版本都相等)才算有效。
export function isCurrentOrganizationContext(snapshot) {
if (!snapshot) return false
return snapshot.organizationId === currentOrganizationId && snapshot.version === currentVersion
}
// 取消旧公司的读请求并立刻换一个新的控制器,后续请求挂在新控制器上。
export function abortReadRequests() {
readController.abort()
readController = new AbortController()
}
export function currentReadSignal() {
return readController.signal
}
// 主动丢弃旧响应时用的错误类型:页面据此区分“切换导致的作废”和真正的请求失败,
// 前者不应弹提示。用 name 判断,避免打包后多份模块副本导致 instanceof 失效。
export class OrganizationContextSupersededError extends Error {
constructor(message = '公司已切换,响应已作废') {
super(message)
this.name = 'OrganizationContextSupersededError'
}
}
export function isOrganizationContextSuperseded(error) {
return error?.name === 'OrganizationContextSupersededError'
}
// 登出或切换账号时清空上下文,避免下游把旧公司的 ID 继续发出去。
export function resetOrganizationContext() {
abortReadRequests()
currentOrganizationId = null
currentVersion += 1
}

View File

@ -0,0 +1,140 @@
import { ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import request from '../api/index'
import {
abortReadRequests,
captureOrganizationContext,
commitOrganizationId,
currentReadSignal,
isCurrentOrganizationContext,
isOrganizationContextSuperseded,
OrganizationContextSupersededError,
resetOrganizationContext
} from './organizationContext'
describe('公司上下文快照', () => {
beforeEach(() => {
resetOrganizationContext()
})
test('未选择公司时快照的公司 ID 为空', () => {
const snapshot = captureOrganizationContext()
expect(snapshot.organizationId).toBeNull()
expect(typeof snapshot.version).toBe('number')
})
test('提交公司后版本递增,旧快照失效', () => {
const before = captureOrganizationContext()
commitOrganizationId(1)
const after = captureOrganizationContext()
expect(after.organizationId).toBe(1)
expect(after.version).toBeGreaterThan(before.version)
expect(isCurrentOrganizationContext(after)).toBe(true)
expect(isCurrentOrganizationContext(before)).toBe(false)
})
test('A→B→A 时第一次 A 的快照因版本不同被丢弃', () => {
commitOrganizationId(1)
const firstA = captureOrganizationContext()
commitOrganizationId(2)
// 又切回公司 1:公司 ID 与第一次相同,但版本已经变了
commitOrganizationId(1)
expect(isCurrentOrganizationContext(firstA)).toBe(false)
expect(isCurrentOrganizationContext(captureOrganizationContext())).toBe(true)
})
test('取消读请求后换新控制器,旧信号已中止、新信号可用', () => {
const oldSignal = currentReadSignal()
expect(oldSignal.aborted).toBe(false)
abortReadRequests()
const newSignal = currentReadSignal()
expect(oldSignal.aborted).toBe(true)
expect(newSignal.aborted).toBe(false)
expect(newSignal).not.toBe(oldSignal)
})
test('作废错误可被识别,区别于普通失败', () => {
expect(isOrganizationContextSuperseded(new OrganizationContextSupersededError())).toBe(true)
expect(isOrganizationContextSuperseded(new Error('普通错误'))).toBe(false)
})
})
describe('axios 组织级请求拦截', () => {
const originalAdapter = request.defaults.adapter
beforeEach(() => {
resetOrganizationContext()
})
afterEach(() => {
request.defaults.adapter = originalAdapter
vi.restoreAllMocks()
})
// 用一个假适配器返回给定响应,顺便把请求 config 暴露给断言。
function respondsWith(data, status = 200, onCall) {
return async (config) => {
if (onCall) onCall(config)
return { data, status, statusText: 'OK', headers: {}, config }
}
}
test('组织级请求带上 X-Organization-ID', async () => {
commitOrganizationId(7)
let captured
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } }, 200, (cfg) => { captured = cfg })
await request.get('/materials')
expect(captured.headers['X-Organization-ID']).toBe('7')
})
test('认证与组织管理接口不写组织头', async () => {
commitOrganizationId(7)
let captured
request.defaults.adapter = respondsWith({ code: 0, data: {} }, 200, (cfg) => { captured = cfg })
await request.get('/auth/organizations')
expect(captured.headers['X-Organization-ID']).toBeUndefined()
request.defaults.adapter = respondsWith({ code: 0, data: {} }, 200, (cfg) => { captured = cfg })
await request.get('/organizations')
expect(captured.headers['X-Organization-ID']).toBeUndefined()
})
test('切换公司后旧成功响应被丢弃且不弹提示', async () => {
commitOrganizationId(1)
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } }, 200, () => {
// 响应返回前公司已经切换
commitOrganizationId(2)
})
await expect(request.get('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
expect(errorSpy).not.toHaveBeenCalled()
})
test('切换公司后旧错误被丢弃且不弹提示', async () => {
commitOrganizationId(1)
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
request.defaults.adapter = respondsWith({ message: '没有权限' }, 403, () => {
commitOrganizationId(2)
})
await expect(request.get('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
expect(errorSpy).not.toHaveBeenCalled()
})
test('公司未切换时组织级请求正常返回数据', async () => {
commitOrganizationId(1)
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } })
const res = await request.get('/materials')
expect(res.data).toEqual({ ok: true })
})
})