refactor: 租户切换器useTenantOptions收拢三页
新建 composables/useTenantOptions.ts:会话/配置/统计三页各自的 tenants/tenantsLoading/tenantsError/loadTenants 拷贝收拢为单一 composable。两种形态: - 默认(会话/配置页):loadTenants 成功选中首个租户 + bootstrapTenantContext()(原两页 onMounted 公共体:超管拉列表/ 租户管理员固定自己/无 slug 防御不动),选中后的 watch 仍留各页 (重置逻辑页面各异); - selectFirst: false(统计页):纯下拉数据源,activeSlug 以 tenantFilter 别名解构('' = 全部租户合并视图),模板零改动。 dashboard 控制台是租户卡片墙(非下拉切换),不在此列。 composable 显式 import vue API——vitest 不跑 unplugin-auto-import。 新增 8 个 composable 测试(mock api/auth store,不装 pinia)。 vitest 164 passed(156+8);build 通过;dts 行尾漂移已还原。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* useTenantOptions 单元测试(Phase 4 Task 5E)
|
||||||
|
*
|
||||||
|
* 覆盖三页收拢前的行为契约(会话/配置页的选中引导 + stats 页的纯数据源):
|
||||||
|
* - loadTenants:成功填列表并(默认)选首个租户;失败置错误文案保上一份
|
||||||
|
* - selectFirst: false(stats 页形态):只填列表,activeSlug 保持 ''(合并视图)
|
||||||
|
* - bootstrapTenantContext:超管拉列表 / 租户管理员固定自己 / 无 slug 不动
|
||||||
|
* - isSuper 随角色
|
||||||
|
*
|
||||||
|
* 测试纪律:mock @/api/tenants 与 @/store/modules/auth(不装 pinia——
|
||||||
|
* composable 只读 role/tenantSlug 两个字段)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
fetchTenantList: vi.fn(),
|
||||||
|
auth: { role: 'super', tenantSlug: null as string | null }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/tenants', () => ({
|
||||||
|
fetchTenantList: mocks.fetchTenantList
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/store/modules/auth', () => ({
|
||||||
|
useAuthStore: () => mocks.auth
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useTenantOptions } from '@/composables/useTenantOptions'
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ slug: 'jiancai', name: '建材租户' },
|
||||||
|
{ slug: 'auto', name: '汽车租户' }
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('useTenantOptions(src/composables/useTenantOptions.ts)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.auth.role = 'super'
|
||||||
|
mocks.auth.tenantSlug = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadTenants 默认形态:填列表 + 选中首个租户(会话/配置页行为)', async () => {
|
||||||
|
mocks.fetchTenantList.mockResolvedValue({ items: ITEMS })
|
||||||
|
const t = useTenantOptions()
|
||||||
|
|
||||||
|
expect(t.activeSlug.value).toBe('')
|
||||||
|
await t.loadTenants()
|
||||||
|
|
||||||
|
expect(mocks.fetchTenantList).toHaveBeenCalledTimes(1)
|
||||||
|
expect(t.tenants.value).toEqual(ITEMS)
|
||||||
|
expect(t.activeSlug.value).toBe('jiancai')
|
||||||
|
expect(t.tenantsError.value).toBe('')
|
||||||
|
expect(t.tenantsLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectFirst: false(stats 页形态):只填列表,activeSlug 保持合并视图', async () => {
|
||||||
|
mocks.fetchTenantList.mockResolvedValue({ items: ITEMS })
|
||||||
|
const t = useTenantOptions({ selectFirst: false })
|
||||||
|
|
||||||
|
await t.loadTenants()
|
||||||
|
|
||||||
|
expect(t.tenants.value).toEqual(ITEMS)
|
||||||
|
expect(t.activeSlug.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadTenants 失败:错误文案进 tenantsError,列表保持上一份', async () => {
|
||||||
|
mocks.fetchTenantList.mockResolvedValueOnce({ items: ITEMS })
|
||||||
|
mocks.fetchTenantList.mockRejectedValueOnce(new Error('网络错误'))
|
||||||
|
const t = useTenantOptions()
|
||||||
|
|
||||||
|
await t.loadTenants()
|
||||||
|
await t.loadTenants()
|
||||||
|
|
||||||
|
expect(t.tenantsError.value).toBe('网络错误')
|
||||||
|
expect(t.tenants.value).toEqual(ITEMS) // 失败不清旧列表
|
||||||
|
expect(t.tenantsLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadTenants 失败(非 Error 抛出物):兜底文案', async () => {
|
||||||
|
mocks.fetchTenantList.mockRejectedValueOnce('boom')
|
||||||
|
const t = useTenantOptions()
|
||||||
|
|
||||||
|
await t.loadTenants()
|
||||||
|
|
||||||
|
expect(t.tenantsError.value).toBe('租户列表加载失败')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bootstrapTenantContext 超管:拉租户列表(成功后选中首个)', async () => {
|
||||||
|
mocks.fetchTenantList.mockResolvedValue({ items: ITEMS })
|
||||||
|
const t = useTenantOptions()
|
||||||
|
|
||||||
|
t.bootstrapTenantContext()
|
||||||
|
await vi.waitFor(() => expect(t.activeSlug.value).toBe('jiancai'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bootstrapTenantContext 租户管理员:固定自己的 tenantSlug,不发列表请求', () => {
|
||||||
|
mocks.auth.role = 'tenant'
|
||||||
|
mocks.auth.tenantSlug = 'jiancai'
|
||||||
|
const t = useTenantOptions()
|
||||||
|
|
||||||
|
t.bootstrapTenantContext()
|
||||||
|
|
||||||
|
expect(t.activeSlug.value).toBe('jiancai')
|
||||||
|
expect(mocks.fetchTenantList).not.toHaveBeenCalled()
|
||||||
|
expect(t.isSuper.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bootstrapTenantContext 租户管理员无 slug:防御不动(模板出告警)', () => {
|
||||||
|
mocks.auth.role = 'tenant'
|
||||||
|
mocks.auth.tenantSlug = null
|
||||||
|
const t = useTenantOptions()
|
||||||
|
|
||||||
|
t.bootstrapTenantContext()
|
||||||
|
|
||||||
|
expect(t.activeSlug.value).toBe('')
|
||||||
|
expect(mocks.fetchTenantList).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isSuper 随角色判定', () => {
|
||||||
|
expect(useTenantOptions().isSuper.value).toBe(true)
|
||||||
|
mocks.auth.role = 'tenant'
|
||||||
|
expect(useTenantOptions().isSuper.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 租户切换器 composable(Phase 4 Task 5E)
|
||||||
|
*
|
||||||
|
* 收拢三页重复的租户下拉装配(原会话/配置中心/统计总览各自的
|
||||||
|
* tenants/tenantsLoading/tenantsError/loadTenants 拷贝):
|
||||||
|
* - conversations 与 config 两页:超管进页拉列表并默认选中第一个租户,
|
||||||
|
* 租户管理员固定自己的 tenantSlug(bootstrapTenantContext 即原两页
|
||||||
|
* onMounted 的公共体,选中后由各页自己的 watch(activeSlug) 触发首载);
|
||||||
|
* - stats 页:纯下拉数据源(selectFirst: false),'' = 全部租户合并视图,
|
||||||
|
* 切换只进 reload 的查询参数,无选中引导。
|
||||||
|
*
|
||||||
|
* dashboard 控制台是租户卡片墙(非下拉切换),不在此列。
|
||||||
|
*
|
||||||
|
* 显式 import vue API:视图层靠 unplugin-auto-import,vitest 不跑该
|
||||||
|
* 插件——composable 直接进测试必须自带 import。
|
||||||
|
*/
|
||||||
|
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||||
|
|
||||||
|
import { fetchTenantList, type TenantListItem } from '@/api/tenants'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
|
export interface TenantOptions {
|
||||||
|
/** 租户列表(下拉数据源;加载失败保持上一份) */
|
||||||
|
tenants: Ref<TenantListItem[]>
|
||||||
|
tenantsLoading: Ref<boolean>
|
||||||
|
tenantsError: Ref<string>
|
||||||
|
/** 拉取租户列表(错误告警的「重试」按钮同用) */
|
||||||
|
loadTenants: () => Promise<void>
|
||||||
|
/** 超管角色判定(下拉 v-if) */
|
||||||
|
isSuper: ComputedRef<boolean>
|
||||||
|
/** 当前选中租户 slug;'' = 未选(stats 页即全部租户合并视图) */
|
||||||
|
activeSlug: Ref<string>
|
||||||
|
/** 会话/配置页 onMounted 引导:超管拉列表(成功选首个),租户管理员固定自己 */
|
||||||
|
bootstrapTenantContext: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantOptions(
|
||||||
|
options: { selectFirst?: boolean } = {}
|
||||||
|
): TenantOptions {
|
||||||
|
const { selectFirst = true } = options
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isSuper = computed(() => authStore.role === 'super')
|
||||||
|
const tenants = ref<TenantListItem[]>([])
|
||||||
|
const tenantsLoading = ref(false)
|
||||||
|
const tenantsError = ref('')
|
||||||
|
const activeSlug = ref('')
|
||||||
|
|
||||||
|
async function loadTenants(): Promise<void> {
|
||||||
|
tenantsLoading.value = true
|
||||||
|
tenantsError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetchTenantList()
|
||||||
|
tenants.value = res.items
|
||||||
|
// 默认选中第一个租户(选中后由页面 watch(activeSlug) 触发首载);
|
||||||
|
// selectFirst: false(stats 页)不选——'' 即全部租户合并视图
|
||||||
|
if (selectFirst && res.items.length > 0) {
|
||||||
|
activeSlug.value = res.items[0].slug
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
tenantsError.value = error instanceof Error ? error.message : '租户列表加载失败'
|
||||||
|
} finally {
|
||||||
|
tenantsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrapTenantContext(): void {
|
||||||
|
if (isSuper.value) {
|
||||||
|
loadTenants() // 成功后置 activeSlug → watch 触发首载
|
||||||
|
} else if (authStore.tenantSlug) {
|
||||||
|
activeSlug.value = authStore.tenantSlug // watch 触发首载
|
||||||
|
}
|
||||||
|
// 租户管理员无 tenantSlug:模板已出防御告警,不加载
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenants,
|
||||||
|
tenantsLoading,
|
||||||
|
tenantsError,
|
||||||
|
loadTenants,
|
||||||
|
isSuper,
|
||||||
|
activeSlug,
|
||||||
|
bootstrapTenantContext
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -488,7 +488,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '@/store/modules/auth'
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { fetchTenantDetail, fetchTenantList, type TenantDetailResponse, type TenantListItem } from '@/api/tenants'
|
import { fetchTenantDetail, type TenantDetailResponse } from '@/api/tenants'
|
||||||
|
import { useTenantOptions } from '@/composables/useTenantOptions'
|
||||||
import {
|
import {
|
||||||
fetchTenantConfig,
|
fetchTenantConfig,
|
||||||
normalizeBrandGroup,
|
normalizeBrandGroup,
|
||||||
@@ -560,31 +561,19 @@
|
|||||||
() => GROUP_NAV.find((g) => g.key === activeGroup.value)?.label ?? ''
|
() => GROUP_NAV.find((g) => g.key === activeGroup.value)?.label ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- 租户上下文(超管可切换;租户管理员固定自己——会话页同款) ------------------------
|
// ---- 租户上下文(超管可切换;租户管理员固定自己——useTenantOptions 收拢) ----------------
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isSuper = computed(() => authStore.role === 'super')
|
|
||||||
|
|
||||||
const tenants = ref<TenantListItem[]>([])
|
const {
|
||||||
const tenantsLoading = ref(false)
|
tenants,
|
||||||
const tenantsError = ref('')
|
tenantsLoading,
|
||||||
const activeSlug = ref('')
|
tenantsError,
|
||||||
|
activeSlug,
|
||||||
async function loadTenants(): Promise<void> {
|
loadTenants,
|
||||||
tenantsLoading.value = true
|
isSuper,
|
||||||
tenantsError.value = ''
|
bootstrapTenantContext
|
||||||
try {
|
} = useTenantOptions()
|
||||||
const res = await fetchTenantList()
|
|
||||||
tenants.value = res.items
|
|
||||||
if (res.items.length > 0) {
|
|
||||||
activeSlug.value = res.items[0].slug
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
tenantsError.value = error instanceof Error ? error.message : '租户列表加载失败'
|
|
||||||
} finally {
|
|
||||||
tenantsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 配置状态(品牌表单 + 整份快照——Task 7b 起提示词/词表 pane 消费快照组) ----------
|
// ---- 配置状态(品牌表单 + 整份快照——Task 7b 起提示词/词表 pane 消费快照组) ----------
|
||||||
|
|
||||||
@@ -902,12 +891,7 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isSuper.value) {
|
bootstrapTenantContext()
|
||||||
loadTenants() // 成功后置 activeSlug → watch 触发首载
|
|
||||||
} else if (authStore.tenantSlug) {
|
|
||||||
activeSlug.value = authStore.tenantSlug // watch 触发首载
|
|
||||||
}
|
|
||||||
// 租户管理员无 tenantSlug:模板已出防御告警,不加载
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -370,7 +370,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { useAuthStore } from '@/store/modules/auth'
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { fetchTenantList, type TenantListItem } from '@/api/tenants'
|
import { useTenantOptions } from '@/composables/useTenantOptions'
|
||||||
import {
|
import {
|
||||||
INTENT_OPTIONS,
|
INTENT_OPTIONS,
|
||||||
PUSH_STATUS_OPTIONS,
|
PUSH_STATUS_OPTIONS,
|
||||||
@@ -394,30 +394,17 @@
|
|||||||
defineOptions({ name: 'ConversationList' })
|
defineOptions({ name: 'ConversationList' })
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isSuper = computed(() => authStore.role === 'super')
|
|
||||||
|
|
||||||
// ---- 租户上下文(超管可切换;租户管理员固定自己) --------------------------------
|
// ---- 租户上下文(超管可切换;租户管理员固定自己——useTenantOptions 收拢) --------------
|
||||||
const tenants = ref<TenantListItem[]>([])
|
const {
|
||||||
const tenantsLoading = ref(false)
|
tenants,
|
||||||
const tenantsError = ref('')
|
tenantsLoading,
|
||||||
const activeSlug = ref('')
|
tenantsError,
|
||||||
|
activeSlug,
|
||||||
async function loadTenants(): Promise<void> {
|
loadTenants,
|
||||||
tenantsLoading.value = true
|
isSuper,
|
||||||
tenantsError.value = ''
|
bootstrapTenantContext
|
||||||
try {
|
} = useTenantOptions()
|
||||||
const res = await fetchTenantList()
|
|
||||||
tenants.value = res.items
|
|
||||||
// 默认选中第一个租户(选中后由 watch(activeSlug) 触发首载)
|
|
||||||
if (res.items.length > 0) {
|
|
||||||
activeSlug.value = res.items[0].slug
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
tenantsError.value = error instanceof Error ? error.message : '租户列表加载失败'
|
|
||||||
} finally {
|
|
||||||
tenantsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 页签 -----------------------------------------------------------------------
|
// ---- 页签 -----------------------------------------------------------------------
|
||||||
const activeTab = ref<'list' | 'pushLogs'>('list')
|
const activeTab = ref<'list' | 'pushLogs'>('list')
|
||||||
@@ -663,12 +650,7 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isSuper.value) {
|
bootstrapTenantContext()
|
||||||
loadTenants() // 成功后置 activeSlug → watch 触发首载
|
|
||||||
} else if (authStore.tenantSlug) {
|
|
||||||
activeSlug.value = authStore.tenantSlug // watch 触发首载
|
|
||||||
}
|
|
||||||
// 租户管理员无 tenantSlug:模板已出防御告警,不加载
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
import type { EChartsOption } from '@/plugins/echarts'
|
import type { EChartsOption } from '@/plugins/echarts'
|
||||||
import { useAuthStore } from '@/store/modules/auth'
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { fetchPlatformStats, fetchTenantStats, type StatsResponse } from '@/api/stats'
|
import { fetchPlatformStats, fetchTenantStats, type StatsResponse } from '@/api/stats'
|
||||||
import { fetchTenantList, type TenantListItem } from '@/api/tenants'
|
import { useTenantOptions } from '@/composables/useTenantOptions'
|
||||||
import { conversationSourceLabel } from '@/api/conversations'
|
import { conversationSourceLabel } from '@/api/conversations'
|
||||||
import { STATS_RANGE_OPTIONS, resolveStatsRange, type StatsRangeFilter } from '@/utils/statsRange'
|
import { STATS_RANGE_OPTIONS, resolveStatsRange, type StatsRangeFilter } from '@/utils/statsRange'
|
||||||
import {
|
import {
|
||||||
@@ -215,7 +215,6 @@
|
|||||||
defineOptions({ name: 'StatsOverview' })
|
defineOptions({ name: 'StatsOverview' })
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isSuper = computed(() => authStore.role === 'super')
|
|
||||||
|
|
||||||
// ---- 筛选:时间档 + 自定义区间 + 租户(超管) --------------------------------------
|
// ---- 筛选:时间档 + 自定义区间 + 租户(超管) --------------------------------------
|
||||||
|
|
||||||
@@ -224,22 +223,16 @@
|
|||||||
/** 当前筛选 → 查询参数;null = 自定义区间不完整(不发请求,保留上一份数据) */
|
/** 当前筛选 → 查询参数;null = 自定义区间不完整(不发请求,保留上一份数据) */
|
||||||
const params = computed(() => resolveStatsRange(rangeFilter.value, customRange.value))
|
const params = computed(() => resolveStatsRange(rangeFilter.value, customRange.value))
|
||||||
|
|
||||||
const tenantFilter = ref('') // '' = 全部租户合并视图
|
// 纯下拉数据源(selectFirst: false):tenantFilter = activeSlug 改名,
|
||||||
const tenants = ref<TenantListItem[]>([])
|
// '' = 全部租户合并视图,切换只进 reload 的查询参数——useTenantOptions 收拢
|
||||||
const tenantsLoading = ref(false)
|
const {
|
||||||
const tenantsError = ref('')
|
tenants,
|
||||||
|
tenantsLoading,
|
||||||
async function loadTenants(): Promise<void> {
|
tenantsError,
|
||||||
tenantsLoading.value = true
|
loadTenants,
|
||||||
tenantsError.value = ''
|
isSuper,
|
||||||
try {
|
activeSlug: tenantFilter
|
||||||
tenants.value = (await fetchTenantList()).items
|
} = useTenantOptions({ selectFirst: false })
|
||||||
} catch (error) {
|
|
||||||
tenantsError.value = error instanceof Error ? error.message : '租户列表加载失败'
|
|
||||||
} finally {
|
|
||||||
tenantsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 统计取数(epoch 防筛选快速切换的过期响应竞写) --------------------------------
|
// ---- 统计取数(epoch 防筛选快速切换的过期响应竞写) --------------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user