material_manage_system/frontend/src/stores/organization.js
文睿 张 1a4b48cf9a fix: 切换公司时重置组织级页面状态
- 组织级 store 统一提供 resetForOrganization,清空缓存、查询参数、分页与 loading
- 加载方法提交状态前校验捕获的公司 ID 与上下文版本,旧公司延迟响应不再覆盖新公司
- 页面重挂载清理局部状态(物料选择、单据 ID、申请详情、审批弹窗)
- 新增切换守卫,脏表单时取消切换不清空数据、保存期间禁止切换
- 接上库存事件消费与写请求的保存互斥标记
- 仓库配置页在已有仓库/库位时隐藏新增入口,仅保留编辑与启停
2026-10-09 14:19:17 +08:00

262 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { authApi } from '../api/auth'
import { createCompanySocket } from '../utils/companySocket'
import {
captureOrganizationContext,
commitOrganizationId,
getOrganizationId,
resetOrganizationContext
} from '../utils/organizationContext'
// 库存等实时事件处理器:默认空实现,页面通过 setStockUpdateHandler 注册。
// 放在模块级而非 store 内,保证整个应用只有一条公司级实时连接。
let stockUpdateHandler = () => {}
export function setStockUpdateHandler(handler) {
stockUpdateHandler = typeof handler === 'function' ? handler : () => {}
}
// 公司级实时连接:切换公司时 connect 会先关旧连、再按当前公司重新取票重连;
// 登出与空状态只 close,不重连。connect 内部对网络/取票失败静默降级,不会阻断切换。
const organizationSocket = createCompanySocket({
fetchTicket: () => authApi.createWebSocketTicket(),
onMessage: (payload, snapshot) => stockUpdateHandler(payload, snapshot)
})
// 本标签页上次选择的公司按用户分开存:同一浏览器不同账号登录时互不继承选择。
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)
// 写请求(保存)进行中标记:业务 store 在发起保存前 beginSaving()、结束后 endSaving()。
// 保存飞行中若切换公司,保存的成功/失败响应会因版本变化被视为作废并静默丢弃,
// 用户既看不到成功也看不到失败,所以保存期间必须禁止切换。
const saving = ref(false)
// 任何会导致“公司上下文变更或归属不明”的操作进行中都算忙:切换中或保存中。
const busy = computed(() => switching.value || saving.value)
// 组织范围变更(切换公司、进入空状态、登出)时,各业务 store 注册的清理回调统一在此调用。
// 业务 store 在自己的 setup 里 registerOrganizationReset(resetForOrganization),
// 由组织 store 充当协调者,避免“页面各自记得手动清理”造成遗漏,也避免业务 store 反向导入组织 store 成环。
const organizationResetters = new Set()
function registerOrganizationReset(resetter) {
if (typeof resetter !== 'function') return () => {}
organizationResetters.add(resetter)
return () => organizationResetters.delete(resetter)
}
// 复制一份再遍历:回调内部若再注册/注销也不会破坏本次遍历。
function resetOrganizationScopedStores() {
for (const resetter of [...organizationResetters]) {
resetter()
}
}
// 切换公司前的“未保存内容”确认钩子:由当前页面注册,返回 false 表示用户取消切换。
// 未注册时行为与原来完全一致(直接切换),不影响已有语义。
let switchGuard = null
function setOrganizationSwitchGuard(guard) {
switchGuard = typeof guard === 'function' ? guard : null
}
// 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。
const initialized = ref(false)
// 最近一次 initialize 是否因临时故障失败:用于把“加载失败”与“确实没有可用公司”区分开,
// 避免把网络异常误导成“请先选择要操作的公司”。
const loadError = 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)
// 公司已确定:先清空各业务 store 的共享缓存(列表/详情/筛选/分页),页面随后重新加载。
resetOrganizationScopedStores()
// 当前公司已确定:关闭旧公司连接并为新公司取得票据重连。connect 不阻塞、不抛错。
organizationSocket.connect()
}
// 没有可用公司(或候选公司都失效)时进入空状态:公司 ID 保持为空,
// 拦截器不会给任何组织级请求写组织头,业务页面也不挂载。
function applyEmptyContext() {
commitOrganizationId(null)
currentOrganizationId.value = null
applyWarehouseContext(null)
syncVersion()
// 空状态同样清空业务缓存,避免没有可用公司时页面还留着上一个公司(或上一标签页)的数据。
resetOrganizationScopedStores()
// 无可用公司:不建立实时连接。
organizationSocket.close()
}
// 用切换校验接口验证候选公司:该接口会返回目标公司的仓库摘要,顺带完成授权校验。
async function validateCandidate(organizationId) {
try {
const res = await authApi.switchOrganizationContext(organizationId, getOrganizationId() ?? 0)
applyContext(res.data || {})
return true
} catch (error) {
// 只有 403(该用户对该候选公司确实无授权)才算“候选失效”,返回 false 交给调用方尝试下一个。
if (error?.response?.status === 403) return false
// 网络异常、500 等临时故障必须向上抛出:若被当成候选失效,initialize 会落入空状态并
// 提示“请先选择要操作的公司”,把一次性故障误导成需要重新选公司。
throw error
}
}
async function initialize() {
loadError.value = false
try {
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
} catch (error) {
// 临时故障(列表拉取失败或候选校验非 403)不降级成“无公司”空状态:
// 标记加载失败供 App 显示“加载失败”而非“请先选择公司”,并向上抛出交由调用方重试。
loadError.value = true
throw error
}
}
// 业务 store 复用的“写请求进行中”开关:发起保存前置位、结束后复位(务必放在 finally)。
function beginSaving() {
saving.value = true
}
function endSaving() {
saving.value = false
}
async function switchOrganization(organizationId) {
// 切换或保存进行中禁止再次切换:避免并发切换互相覆盖,以及写请求归属不明确。
// 保存飞行中切换会让保存的响应被版本判定作废并被静默吞掉,用户既看不到成功也看不到失败。
if (busy.value) return
if (!organizationId || organizationId === currentOrganizationId.value) return
// 切换前先进入“切换中”,既覆盖确认框停留期间的重入,也让切换器在确认期间保持不可操作。
switching.value = true
try {
// 未保存内容确认:页面注册的守卫返回 false 表示用户取消,保持当前公司不变。
if (switchGuard) {
const allowed = await switchGuard(organizationId)
if (!allowed) return
}
const res = await authApi.switchOrganizationContext(
organizationId,
currentOrganizationId.value ?? 0
)
// 校验成功后才提交;抛错时保持旧公司不变,由调用方决定是否提示。
applyContext(res.data || {})
} finally {
switching.value = false
}
}
// 登出或切换账号时清空:上下文、公司列表、仓库库位与初始化标记全部复位。
function reset() {
resetOrganizationContext()
// 登出/账号变化:关闭实时连接且不重连。
organizationSocket.close()
organizations.value = []
currentOrganizationId.value = null
currentWarehouse.value = null
currentWarehouseId.value = null
currentLocationId.value = null
switching.value = false
saving.value = false
initialized.value = false
loadError.value = false
// 账号变化也不能把上一个账号(或上一个公司)的业务缓存留给下个会话。
resetOrganizationScopedStores()
switchGuard = null
syncVersion()
}
return {
organizations, currentOrganizationId, currentWarehouseId, currentLocationId,
currentWarehouse, contextVersion, switching, saving, busy, initialized, loadError,
beginSaving, endSaving,
registerOrganizationReset, setOrganizationSwitchGuard,
initialize, switchOrganization, reset
}
})