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:
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user