feat: 仪表盘(租户卡片墙/平台总览/时间切换)+ 顺手:409契约锁与死代码

- 仪表盘视图:超管=平台总览四指标卡(/api/platform/stats 合并视图)+
  租户卡片墙(逐租户 ?tenant= 下钻,含停用租户);租户管理员=自己
  指标卡(/tenants/{slug}/stats,slug 取登录态);口径文案照
  stats.py docstring(线索=留资三选一/成功率分母0显—等)
- 时间范围快捷切换(今天/近7天/近30天)→ from/to 本地日期串;
  utils/statsRange.ts 纯函数 + epoch 防快速切换过期响应竞写
- 空数据态:无租户 ElEmpty / 单租户零数据自然显 0 与 —
- api/stats.ts 补 fetchPlatformStats/fetchTenantStats;tenants.ts 补
  fetchTenantList(卡片墙枚举,其余仍留 Task 6)
- vitest +11(statsRange 6 + stats API 5,总 36)
- 顺手1:test_auth must_change 409 测试补 detail 断言,锁死前后端
  契约文案(client.ts MUST_CHANGE_DETAIL)
- 顺手2:删除 beforeEach.ts 的 isUnauthorizedError 死分支与 ApiStatus
  import(模板 isHttpError 判自建 ApiError 恒 false,分支不可达)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 16:59:12 +08:00
parent 3973abea8b
commit f640889f22
9 changed files with 664 additions and 32 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* 统计 API 单元测试(Phase 3 Task 4)
*
* 钉住端点 URL 与查询参数组装(?from/&to/&tenant= 事实源
* app/platform/routes/stats_routes.py);HTTP 层行为(拦截/token/错误
* 提示)已在 client.test.ts 覆盖,此处 mock http 只断言 API 层契约:
* - fetchPlatformStats:/api/platform/stats 合并视图;tenant= 下钻;
* showErrorMessage: false 透传(卡片墙逐卡内联错误防 N 连弹)
* - fetchTenantStats:/api/platform/tenants/{slug}/stats 单租户
* - fetchTenantList:/api/platform/tenants(卡片墙枚举租户)
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/client', () => ({
http: { get: vi.fn() }
}))
import { http } from '@/api/client'
import { fetchPlatformStats, fetchTenantStats } from '@/api/stats'
import { fetchTenantList } from '@/api/tenants'
/** 零数据统计响应形态(与后端 stats._empty_stats 同构) */
const EMPTY_STATS = {
cards: { conversations: 0, leads: 0, high_intent: 0, push_success_rate: null },
funnel: { conversations: 0, leads: 0, evaluated: 0, push_succeeded: 0 },
trend: [],
distributions: { intent: {}, source: {}, site: {}, country: [] },
push_health: { pending: 0, success: 0, failed: 0, skipped: 0, duplicated: 0 }
}
describe('统计 API(src/api/stats.ts + tenants.ts 列表函数)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(http.get).mockResolvedValue(EMPTY_STATS)
})
it('fetchPlatformStats 无参数:GET /api/platform/stats,无查询参数', async () => {
const res = await fetchPlatformStats()
expect(res).toEqual(EMPTY_STATS)
expect(http.get).toHaveBeenCalledWith('/api/platform/stats', { params: undefined })
})
it('fetchPlatformStats 带 from/to/tenant:全部进查询参数(卡片墙下钻形态)', async () => {
await fetchPlatformStats({ tenant: 'jiancai', from: '2026-10-13', to: '2026-10-13' })
expect(http.get).toHaveBeenCalledWith('/api/platform/stats', {
params: { tenant: 'jiancai', from: '2026-10-13', to: '2026-10-13' }
})
})
it('fetchPlatformStats 透传 showErrorMessage: false(卡片墙防 N 连弹)', async () => {
await fetchPlatformStats({ from: '2026-10-13' }, { showErrorMessage: false })
expect(http.get).toHaveBeenCalledWith('/api/platform/stats', {
params: { from: '2026-10-13' },
showErrorMessage: false
})
})
it('fetchTenantStats:GET /api/platform/tenants/{slug}/stats 带 from/to', async () => {
await fetchTenantStats('jiancai', { from: '2026-10-07', to: '2026-10-13' })
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/stats', {
params: { from: '2026-10-07', to: '2026-10-13' }
})
})
it('fetchTenantList:GET /api/platform/tenants(卡片墙枚举)', async () => {
const body = {
items: [
{
slug: 'jiancai',
name: '建材租户',
status: 'active',
domain_count: 1,
current_version: 1,
admins: [{ username: 'jc-admin', status: 'active' }]
}
]
}
vi.mocked(http.get).mockResolvedValue(body)
const res = await fetchTenantList()
expect(res).toEqual(body)
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants')
})
})
+34 -3
View File
@@ -1,10 +1,15 @@
/**
* 统计分析 API 骨架(Phase 3 Task 3)
* 统计分析 API(Phase 3 Task 4 实装调用函数;类型与端点 Task 3 建立)
*
* 端点事实源:app/platform/routes/stats_routes.py + app/platform/stats.py
* (平台合并统计 / 单租户统计,?from/&to= 闭区间)。本文件只建类型与
* 端点常量——调用函数在 Task 4(仪表盘)与 Task 8(统计分析)实装。
* (平台合并统计 / 单租户统计,?from/&to= 闭区间)。
*
* 指标口径事实源:app/platform/stats.py 模块 docstring 的口径表——
* 会话数不要求有消息 / 线索=留资三选一(phone/whatsapp/email)任一非空 /
* 高意向=评估 interested / 推送成功率 success/(success+failed)(分母 0 →
* null,前端展示"—")。Task 8(统计分析页)复用本组函数。
*/
import { http } from './client'
/** 指标卡(None 项为 null——如无推送时成功率无定义) */
export interface StatsCards {
@@ -66,3 +71,29 @@ export const STATS_ENDPOINTS = {
platform: '/api/platform/stats',
tenant: (slug: string) => `/api/platform/tenants/${slug}/stats`
} as const
/** 平台合并统计查询参数(?tenant= 单租户下钻,未知 slug 404) */
export interface PlatformStatsParams extends StatsParams {
tenant?: string
}
/**
* 平台统计(仅超管):缺省全部租户合并(逐库查询应用层合并);
* tenant=slug 单租户下钻——仪表盘租户卡片墙逐租户调用即此形态。
* options.showErrorMessage: false 关全局错误提示(卡片墙 N 连发的失败
* toast 改由调用方逐卡内联展示)
*/
export function fetchPlatformStats(
params?: PlatformStatsParams,
options?: { showErrorMessage?: boolean }
): Promise<StatsResponse> {
return http.get<StatsResponse>(STATS_ENDPOINTS.platform, { params, ...options })
}
/**
* 单租户统计(本租户管理员或超管):租户管理员的"自己指标卡"走这里,
* slug 取登录态 tenant_slug(auth store)
*/
export function fetchTenantStats(slug: string, params?: StatsParams): Promise<StatsResponse> {
return http.get<StatsResponse>(STATS_ENDPOINTS.tenant(slug), { params })
}
+11 -2
View File
@@ -1,10 +1,11 @@
/**
* 租户与站点管理 API 骨架(Phase 3 Task 3)
* 租户与站点管理 API(Phase 3 Task 3 骨架;Task 4 增列表函数)
*
* 端点事实源:app/platform/routes/tenant_routes.py
* (/api/platform/tenants,require_super / require_tenant(slug) 鉴权)。
* 本文件只建类型与端点常量——调用函数在 Task 6(租户管理与站点管理)实装。
* 其余调用函数在 Task 6(租户管理与站点管理)实装。
*/
import { http } from './client'
/** 租户状态(Tenant.status) */
export type TenantStatus = 'active' | 'disabled'
@@ -93,3 +94,11 @@ export const TENANT_ENDPOINTS = {
deleteDomain: (slug: string, siteSlug: string, domain: string) =>
`/api/platform/tenants/${slug}/sites/${siteSlug}/domains/${domain}`
} as const
/**
* 租户列表(仅超管)——Task 4 仪表盘的租户卡片墙需要枚举租户;
* 列表行的 slug/name/status 供卡片展示与逐租户统计下钻
*/
export function fetchTenantList(): Promise<TenantListResponse> {
return http.get<TenantListResponse>(TENANT_ENDPOINTS.list)
}