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
@@ -0,0 +1,57 @@
/**
* 统计时间快捷范围单测(Phase 3 Task 4)
*
* 钉住口径:from/to 为本地日期串 YYYY-MM-DD;"近 N 天"含今天往回共 N 天
* (今天→from==to;近7天→from=今天-6;近30天→今天-29)——后端把日期-only
* 的 to 视为含当日全天(stats_routes 的 _parse_dt),前端不传时间部分。
*/
import { describe, expect, it } from 'vitest'
import { STATS_QUICK_RANGES, rangeToParams } from '@/utils/statsRange'
/** 固定"现在":2026-10-13 09:30(本地时间)——跨月/跨年边界另测 */
const NOW = new Date(2026, 9, 13, 9, 30)
describe('统计时间快捷范围(src/utils/statsRange.ts)', () => {
it('今天:from 与 to 同为今天', () => {
expect(rangeToParams('today', NOW)).toEqual({
from: '2026-10-13',
to: '2026-10-13'
})
})
it('近7天:from = 今天往回 6 天(窗口含今天共 7 天)', () => {
expect(rangeToParams('last7', NOW)).toEqual({
from: '2026-10-07',
to: '2026-10-13'
})
})
it('近30天:from = 今天往回 29 天', () => {
expect(rangeToParams('last30', NOW)).toEqual({
from: '2026-09-14',
to: '2026-10-13'
})
})
it('月份/日期补零:单数月日出两位日期串', () => {
expect(rangeToParams('today', new Date(2026, 0, 5, 23, 59))).toEqual({
from: '2026-01-05',
to: '2026-01-05'
})
})
it('跨月回退由 Date 构造正确结算(10-01 的近7天跨回 9 月)', () => {
expect(rangeToParams('last7', new Date(2026, 9, 1, 12))).toEqual({
from: '2026-09-25',
to: '2026-10-01'
})
})
it('快捷范围清单与类型对齐:三个挡位、标签非空', () => {
expect(STATS_QUICK_RANGES.map((item) => item.value)).toEqual(['today', 'last7', 'last30'])
for (const item of STATS_QUICK_RANGES) {
expect(item.label).not.toBe('')
expect(item.days).toBeGreaterThan(0)
}
})
})
+41
View File
@@ -0,0 +1,41 @@
/**
* 统计时间快捷范围(Phase 3 Task 4 仪表盘;Task 8 统计分析复用)
*
* 口径事实源:app/platform/routes/stats_routes.py 的 from/to 解析
* (data_routes._parse_dt)——前端只传本地日期串 YYYY-MM-DD:
* - 日期-only 的 to 后端视为含当日全天(23:59:59.999999,边界含);
* - 边界是 naive UTC(与库内 created_at 同一对比空间),前端不做时区换算;
* - "近 N 天" = 含今天往回共 N 天(今天 / 近7天 / 近30天)。
*/
export type StatsQuickRange = 'today' | 'last7' | 'last30'
/** 快捷范围清单(顺序即切换器展示序) */
export const STATS_QUICK_RANGES: Array<{
value: StatsQuickRange
label: string
/** 窗口天数(含今天) */
days: number
}> = [
{ value: 'today', label: '今天', days: 1 },
{ value: 'last7', label: '近7天', days: 7 },
{ value: 'last30', label: '近30天', days: 30 }
]
/** Date → 本地日期串 YYYY-MM-DD(月/日两位补零) */
function toDateKey(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
/** 快捷范围 → {from, to} 查询参数(本地日期;now 可注入便于测试) */
export function rangeToParams(
range: StatsQuickRange,
now: Date = new Date()
): { from: string; to: string } {
const days = STATS_QUICK_RANGES.find((item) => item.value === range)?.days ?? 1
return {
from: toDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1))),
to: toDateKey(now)
}
}