diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 0392f5f0..7d62dfb3 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -5,7 +5,11 @@
@@ -19,15 +23,29 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
+import { useOrganizationStore } from './stores/organization'
import AppHeader from './components/layout/AppHeader.vue'
import AppSidebar from './components/layout/AppSidebar.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
+const organizationStore = useOrganizationStore()
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) {
router.push(path)
}
@@ -53,4 +71,10 @@ function handleMenuSelect(path) {
padding: 20px;
overflow-y: auto;
}
+.organization-barrier {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
index f6311d9a..db74ddad 100644
--- a/frontend/src/api/auth.js
+++ b/frontend/src/api/auth.js
@@ -6,5 +6,16 @@ export const authApi = {
},
getCurrentUser() {
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
+ })
}
}
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 53aa0e36..dee3b066 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -2,6 +2,13 @@ import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
import { hasChinese, responseErrorText } from '../utils/errorText'
+import {
+ captureOrganizationContext,
+ currentReadSignal,
+ getOrganizationId,
+ isCurrentOrganizationContext,
+ OrganizationContextSupersededError
+} from '../utils/organizationContext'
const request = axios.create({
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.interceptors.request.use(
(config) => {
@@ -18,6 +44,19 @@ request.interceptors.request.use(
if (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)
return config
},
@@ -27,6 +66,11 @@ request.interceptors.request.use(
// Response interceptor - handle errors and 401
request.interceptors.response.use(
(response) => {
+ // 响应属于已被切换掉的旧公司:丢弃且不提示,页面只应关心新公司的数据。
+ const context = response.config?.__organizationContext
+ if (context && !isCurrentOrganizationContext(context)) {
+ return Promise.reject(new OrganizationContextSupersededError())
+ }
const res = response.data
if (res.code === 0) {
return res
@@ -35,6 +79,15 @@ request.interceptors.response.use(
return Promise.reject(new Error(res.message))
},
(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) {
localStorage.removeItem('wmms_token')
localStorage.removeItem('wmms_user')
diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue
index e5ff058b..c3a2a6f3 100644
--- a/frontend/src/components/layout/AppHeader.vue
+++ b/frontend/src/components/layout/AppHeader.vue
@@ -1,6 +1,10 @@