feat: 新增公司切换与请求上下文
- 新增 company 请求上下文工具、organization store 与公司切换器 - axios 拦截器为组织级请求写入 X-Organization-ID 并校验上下文版本 - 切换公司用 AbortController 取消旧读请求,丢弃旧响应且不弹提示 - 路由与页面加公司初始化屏障,组织级页面按公司 ID + 版本挂载
This commit is contained in:
parent
8f4460b84a
commit
6cc3cd5dda
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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="header-left">
|
||||||
<div class="app-title">仓库物料管理系统</div>
|
<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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
frontend/src/components/layout/OrganizationSwitcher.test.js
Normal file
126
frontend/src/components/layout/OrganizationSwitcher.test.js
Normal 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('本公司仓库已停用')
|
||||||
|
})
|
||||||
|
})
|
||||||
72
frontend/src/components/layout/OrganizationSwitcher.vue
Normal file
72
frontend/src/components/layout/OrganizationSwitcher.vue
Normal 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>
|
||||||
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
166
frontend/src/stores/organization.js
Normal file
166
frontend/src/stores/organization.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
241
frontend/src/stores/organization.test.js
Normal file
241
frontend/src/stores/organization.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
frontend/src/utils/organizationContext.js
Normal file
60
frontend/src/utils/organizationContext.js
Normal 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
|
||||||
|
}
|
||||||
140
frontend/src/utils/organizationContext.test.js
Normal file
140
frontend/src/utils/organizationContext.test.js
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user