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 @@