- 保存请求执行期间禁止切换:organization store 新增 saving/busy 与 beginSaving/endSaving,切换守卫与切换器禁用条件均由 switching 改为 busy - 切换提交时 abort 旧读请求:commitOrganizationId 内调用 abortReadRequests, 不再只在 resetOrganizationContext 里取消 - validateCandidate 仅将 403 视为候选失效,网络异常/500 等向上抛出; initialize 置 loadError,App 据此区分“加载失败”与“请先选择公司”并支持重试 - 补竞态与中止测试;需要写请求时旧响应仍被版本丢弃的用例改用 POST 覆盖
200 lines
8.0 KiB
JavaScript
200 lines
8.0 KiB
JavaScript
import { defineStore } from 'pinia'
|
||
import { computed, 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)
|
||
// 写请求(保存)进行中标记:业务 store 在发起保存前 beginSaving()、结束后 endSaving()。
|
||
// 保存飞行中若切换公司,保存的成功/失败响应会因版本变化被视为作废并静默丢弃,
|
||
// 用户既看不到成功也看不到失败,所以保存期间必须禁止切换。
|
||
const saving = ref(false)
|
||
// 任何会导致“公司上下文变更或归属不明”的操作进行中都算忙:切换中或保存中。
|
||
const busy = computed(() => switching.value || saving.value)
|
||
// 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。
|
||
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)
|
||
}
|
||
|
||
// 没有可用公司(或候选公司都失效)时进入空状态:公司 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 (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 {
|
||
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
|
||
saving.value = false
|
||
initialized.value = false
|
||
loadError.value = false
|
||
syncVersion()
|
||
}
|
||
|
||
return {
|
||
organizations, currentOrganizationId, currentWarehouseId, currentLocationId,
|
||
currentWarehouse, contextVersion, switching, saving, busy, initialized, loadError,
|
||
beginSaving, endSaving,
|
||
initialize, switchOrganization, reset
|
||
}
|
||
})
|