- 组织级 store 统一提供 resetForOrganization,清空缓存、查询参数、分页与 loading - 加载方法提交状态前校验捕获的公司 ID 与上下文版本,旧公司延迟响应不再覆盖新公司 - 页面重挂载清理局部状态(物料选择、单据 ID、申请详情、审批弹窗) - 新增切换守卫,脏表单时取消切换不清空数据、保存期间禁止切换 - 接上库存事件消费与写请求的保存互斥标记 - 仓库配置页在已有仓库/库位时隐藏新增入口,仅保留编辑与启停
262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
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
|
||
}
|
||
})
|