From b02326036f450631bbbd2cf11e94ccc9fe93b88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 14 Oct 2026 10:36:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=A7=9F=E6=88=B7=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=99=A8useTenantOptions=E6=94=B6=E6=8B=A2=E4=B8=89?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 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 --- .../__tests__/useTenantOptions.test.ts | 124 ++++++++++++++++++ admin/src/composables/useTenantOptions.ts | 85 ++++++++++++ admin/src/views/config/center/index.vue | 42 ++---- admin/src/views/conversations/list/index.vue | 42 ++---- admin/src/views/stats/overview/index.vue | 29 ++-- 5 files changed, 245 insertions(+), 77 deletions(-) create mode 100644 admin/src/composables/__tests__/useTenantOptions.test.ts create mode 100644 admin/src/composables/useTenantOptions.ts diff --git a/admin/src/composables/__tests__/useTenantOptions.test.ts b/admin/src/composables/__tests__/useTenantOptions.test.ts new file mode 100644 index 0000000..2f696c4 --- /dev/null +++ b/admin/src/composables/__tests__/useTenantOptions.test.ts @@ -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) + }) +}) diff --git a/admin/src/composables/useTenantOptions.ts b/admin/src/composables/useTenantOptions.ts new file mode 100644 index 0000000..6b809e2 --- /dev/null +++ b/admin/src/composables/useTenantOptions.ts @@ -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 + tenantsLoading: Ref + tenantsError: Ref + /** 拉取租户列表(错误告警的「重试」按钮同用) */ + loadTenants: () => Promise + /** 超管角色判定(下拉 v-if) */ + isSuper: ComputedRef + /** 当前选中租户 slug;'' = 未选(stats 页即全部租户合并视图) */ + activeSlug: Ref + /** 会话/配置页 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([]) + const tenantsLoading = ref(false) + const tenantsError = ref('') + const activeSlug = ref('') + + async function loadTenants(): Promise { + 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 + } +} diff --git a/admin/src/views/config/center/index.vue b/admin/src/views/config/center/index.vue index 3faacc6..a673e56 100644 --- a/admin/src/views/config/center/index.vue +++ b/admin/src/views/config/center/index.vue @@ -488,7 +488,8 @@ diff --git a/admin/src/views/conversations/list/index.vue b/admin/src/views/conversations/list/index.vue index fcbeec4..fb08b65 100644 --- a/admin/src/views/conversations/list/index.vue +++ b/admin/src/views/conversations/list/index.vue @@ -370,7 +370,7 @@ diff --git a/admin/src/views/stats/overview/index.vue b/admin/src/views/stats/overview/index.vue index a629f44..324cc9b 100644 --- a/admin/src/views/stats/overview/index.vue +++ b/admin/src/views/stats/overview/index.vue @@ -198,7 +198,7 @@ import type { EChartsOption } from '@/plugins/echarts' import { useAuthStore } from '@/store/modules/auth' 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 { STATS_RANGE_OPTIONS, resolveStatsRange, type StatsRangeFilter } from '@/utils/statsRange' import { @@ -215,7 +215,6 @@ defineOptions({ name: 'StatsOverview' }) const authStore = useAuthStore() - const isSuper = computed(() => authStore.role === 'super') // ---- 筛选:时间档 + 自定义区间 + 租户(超管) -------------------------------------- @@ -224,22 +223,16 @@ /** 当前筛选 → 查询参数;null = 自定义区间不完整(不发请求,保留上一份数据) */ const params = computed(() => resolveStatsRange(rangeFilter.value, customRange.value)) - const tenantFilter = ref('') // '' = 全部租户合并视图 - const tenants = ref([]) - const tenantsLoading = ref(false) - const tenantsError = ref('') - - async function loadTenants(): Promise { - tenantsLoading.value = true - tenantsError.value = '' - try { - tenants.value = (await fetchTenantList()).items - } catch (error) { - tenantsError.value = error instanceof Error ? error.message : '租户列表加载失败' - } finally { - tenantsLoading.value = false - } - } + // 纯下拉数据源(selectFirst: false):tenantFilter = activeSlug 改名, + // '' = 全部租户合并视图,切换只进 reload 的查询参数——useTenantOptions 收拢 + const { + tenants, + tenantsLoading, + tenantsError, + loadTenants, + isSuper, + activeSlug: tenantFilter + } = useTenantOptions({ selectFirst: false }) // ---- 统计取数(epoch 防筛选快速切换的过期响应竞写) --------------------------------