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)
}
-16
View File
@@ -49,7 +49,6 @@ import { staticRoutes } from '../routes/staticRoutes'
import { loadingService } from '@/utils/ui'
import { useCommon } from '@/hooks/core/useCommon'
import { useWorktabStore } from '@/store/modules/worktab'
import { ApiStatus } from '@/utils/http/status'
import { isHttpError } from '@/utils/http/error'
import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core'
@@ -389,14 +388,6 @@ async function handleDynamicRoutes(
// 关闭 loading
closeLoading()
// 401 错误:axios 拦截器已处理退出登录,取消当前导航
if (isUnauthorizedError(error)) {
// 重置状态,允许重新登录后再次初始化
routeInitInProgress = false
next(false)
return
}
// 标记初始化失败,防止死循环
routeInitFailed = true
routeInitInProgress = false
@@ -459,10 +450,3 @@ function handleRootPathRedirect(to: RouteLocationNormalized, next: NavigationGua
return false
}
/**
* 判断是否为未授权错误(401)
*/
function isUnauthorizedError(error: unknown): boolean {
return isHttpError(error) && error.code === ApiStatus.unauthorized
}
+5
View File
@@ -65,6 +65,7 @@ declare module 'vue' {
BoxStyleSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
ColorSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default']
ContainerSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
@@ -85,10 +86,14 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
HorizontalSubmenu: typeof import('./../../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
@@ -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)
}
}
+419 -6
View File
@@ -1,25 +1,438 @@
<!-- 仪表盘(骨架占位页) -->
<!--
仪表盘(Phase 3 Task 4)
- 超管:平台总览四指标卡(GET /api/platform/stats 合并视图)+ 租户卡片墙
(逐租户 GET /api/platform/stats?tenant=<slug> 下钻)
- 租户管理员:自己租户的指标卡(GET /api/platform/tenants/{slug}/stats,
slug 取登录态 tenant_slug)
- 时间范围快捷切换(今天 / 近7天 / 近30天 → from/to 参数,闭区间)
- 指标口径事实源:app/platform/stats.py 模块 docstring 的口径表——
会话数不要求有消息 / 线索=留资三选一任一非空 / 推送成功率分母 0 → null
(展示"—");卡片文案照口径表写
-->
<template>
<div class="page-placeholder">
<ElCard shadow="never">
<div class="dashboard-page">
<!-- 头部:标题 + 时间范围快捷切换 -->
<div class="dashboard-header">
<div>
<h2 class="page-title">仪表盘</h2>
<p class="page-sub">平台运行状况与租户指标一览</p>
</div>
<ElRadioGroup v-model="quickRange">
<ElRadioButton
v-for="item in STATS_QUICK_RANGES"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</ElRadioButton>
</ElRadioGroup>
</div>
<!-- 指标卡:超管=平台总览(合并视图);租户管理员=自己租户 -->
<ElCard shadow="never" class="section-card">
<template #header>
<span class="card-title">仪表盘</span>
<div class="card-header">
<span class="card-title">
{{ isSuper ? `平台总览(${rangeLabel})` : `我的指标(${rangeLabel})` }}
</span>
<span v-if="!isSuper && authStore.tenantSlug" class="card-sub">
租户:{{ authStore.tenantSlug }}
</span>
</div>
</template>
<ElEmpty description="平台概览与租户运行状况(后续任务实现)" />
<!-- 登录态异常(租户管理员无租户标识——理论不可达,防御展示) -->
<ElAlert
v-if="!isSuper && !authStore.tenantSlug"
type="warning"
:closable="false"
title="登录态缺少租户标识,请重新登录"
/>
<template v-else>
<ElAlert
v-if="overviewError"
type="error"
:closable="false"
:title="overviewError"
class="load-error"
>
<ElButton size="small" :loading="overviewLoading" @click="reload">重试</ElButton>
</ElAlert>
<!-- 加载骨架 -->
<div v-if="overviewLoading" class="stat-grid">
<div v-for="i in 4" :key="i" class="stat-tile">
<ElSkeleton :rows="1" animated class="stat-skeleton" />
</div>
</div>
<div v-else class="stat-grid">
<div v-for="tile in overviewTiles" :key="tile.label" class="stat-tile">
<p class="stat-label">{{ tile.label }}</p>
<p class="stat-value">{{ tile.value }}</p>
<p class="stat-caption">{{ tile.caption }}</p>
</div>
</div>
</template>
</ElCard>
<!-- 租户卡片墙(仅超管) -->
<ElCard v-if="isSuper" shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">租户卡片墙({{ rangeLabel }})</span>
<span class="card-sub">每租户独立下钻统计 · 含停用租户(历史数据仍可见)</span>
</div>
</template>
<!-- 空数据态:尚无任何租户 -->
<ElEmpty
v-if="wallError === '' && !tenantsLoading && tenants.length === 0"
description="暂无租户——到「租户管理」创建第一个租户后,这里将展示各租户的运行指标"
/>
<ElAlert
v-else-if="wallError"
type="error"
:closable="false"
:title="wallError"
class="load-error"
>
<ElButton size="small" :loading="tenantsLoading" @click="reload">重试</ElButton>
</ElAlert>
<div v-else v-loading="tenantsLoading" class="tenant-grid">
<div v-for="tenant in tenants" :key="tenant.slug" class="tenant-card art-card">
<div class="tenant-head">
<span class="tenant-name" :title="tenant.name">{{ tenant.name }}</span>
<ElTag :type="tenant.status === 'active' ? 'success' : 'info'" size="small">
{{ tenant.status === 'active' ? '启用' : '停用' }}
</ElTag>
</div>
<p class="tenant-slug">@{{ tenant.slug }}</p>
<div v-if="wallCards[tenant.slug]?.loading" class="tenant-state">加载中…</div>
<div v-else-if="wallCards[tenant.slug]?.error" class="tenant-state is-error">
{{ wallCards[tenant.slug]?.error }}
</div>
<div v-else class="tenant-metrics">
<div class="metric">
<span class="metric-value">{{ wallCards[tenant.slug]?.stats?.cards.conversations ?? 0 }}</span>
<span class="metric-label">会话</span>
</div>
<div class="metric">
<span class="metric-value">{{ wallCards[tenant.slug]?.stats?.cards.leads ?? 0 }}</span>
<span class="metric-label">线索</span>
</div>
<div class="metric">
<span class="metric-value">{{
formatRate(wallCards[tenant.slug]?.stats?.cards.push_success_rate)
}}</span>
<span class="metric-label">推送成功率</span>
</div>
</div>
</div>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/store/modules/auth'
import { fetchTenantStats, fetchPlatformStats, type StatsResponse } from '@/api/stats'
import { fetchTenantList, type TenantListItem } from '@/api/tenants'
import {
STATS_QUICK_RANGES,
rangeToParams,
type StatsQuickRange
} from '@/utils/statsRange'
defineOptions({ name: 'DashboardConsole' })
const authStore = useAuthStore()
const isSuper = computed(() => authStore.role === 'super')
// ---- 时间范围 -----------------------------------------------------------------
const quickRange = ref<StatsQuickRange>('today')
const rangeLabel = computed(
() => STATS_QUICK_RANGES.find((item) => item.value === quickRange.value)?.label ?? ''
)
const rangeParams = computed(() => rangeToParams(quickRange.value))
// ---- 指标卡(平台总览 / 自己指标) ----------------------------------------------
const overview = ref<StatsResponse | null>(null)
const overviewLoading = ref(false)
const overviewError = ref('')
/** 推送成功率展示:null(分母 0)→ “—”;其余 → 百分比(整 % 去小数位) */
function formatRate(rate: number | null | undefined): string {
if (rate === null || rate === undefined) return '—'
return `${(rate * 100).toFixed(1).replace(/\.0$/, '')}%`
}
/** 四指标卡数据(口径文案照 stats.py docstring:线索=留资三选一任一非空等) */
const overviewTiles = computed(() => {
const cards = overview.value?.cards
return [
{
label: '会话',
value: cards ? String(cards.conversations) : '—',
caption: '发生的会话,不要求有消息'
},
{
label: '线索',
value: cards ? String(cards.leads) : '—',
caption: '留资三选一:电话 / WhatsApp / 邮箱任一非空'
},
{
label: '高意向',
value: cards ? String(cards.high_intent) : '—',
caption: '评估为「有意向」的会话'
},
{
label: '推送成功率',
value: cards ? formatRate(cards.push_success_rate) : '—',
caption: '成功 / (成功 + 失败);无终态推送显示 —'
}
]
})
async function loadOverview(epoch: number): Promise<void> {
overviewLoading.value = true
overviewError.value = ''
try {
const res = isSuper.value
? await fetchPlatformStats(rangeParams.value)
: await fetchTenantStats(authStore.tenantSlug as string, rangeParams.value)
if (epoch !== reloadEpoch.value) return // 已切换范围,丢弃过期响应
overview.value = res
} catch (error) {
if (epoch !== reloadEpoch.value) return
overviewError.value = error instanceof Error ? error.message : '指标加载失败'
} finally {
if (epoch === reloadEpoch.value) overviewLoading.value = false
}
}
// ---- 租户卡片墙(仅超管) --------------------------------------------------------
interface WallCard {
loading: boolean
stats: StatsResponse | null
error: string
}
const tenants = ref<TenantListItem[]>([])
const tenantsLoading = ref(false)
const wallError = ref('')
const wallCards = ref<Record<string, WallCard>>({})
async function loadWall(epoch: number): Promise<void> {
tenantsLoading.value = true
wallError.value = ''
try {
const list = await fetchTenantList()
if (epoch !== reloadEpoch.value) return
tenants.value = list.items
wallCards.value = Object.fromEntries(
list.items.map((tenant) => [tenant.slug, { loading: true, stats: null, error: '' }])
)
// 逐租户下钻并行拉取;失败逐卡内联展示(关闭全局 toast,防 N 连弹)
await Promise.allSettled(
list.items.map(async (tenant) => {
try {
const stats = await fetchPlatformStats(
{ tenant: tenant.slug, ...rangeParams.value },
{ showErrorMessage: false }
)
if (epoch !== reloadEpoch.value) return
wallCards.value[tenant.slug] = { loading: false, stats, error: '' }
} catch (error) {
if (epoch !== reloadEpoch.value) return
wallCards.value[tenant.slug] = {
loading: false,
stats: null,
error: error instanceof Error ? error.message : '加载失败'
}
}
})
)
} catch (error) {
if (epoch === reloadEpoch.value) {
wallError.value = error instanceof Error ? error.message : '租户列表加载失败'
}
} finally {
if (epoch === reloadEpoch.value) tenantsLoading.value = false
}
}
// ---- 加载编排(epoch 防范围快速切换的过期响应竞写) --------------------------------
const reloadEpoch = ref(0)
function reload(): void {
const epoch = ++reloadEpoch.value
loadOverview(epoch)
if (isSuper.value) loadWall(epoch)
}
onMounted(reload)
watch(quickRange, reload)
</script>
<style scoped lang="scss">
.page-placeholder {
.dashboard-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.dashboard-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.page-sub {
margin: 4px 0 0;
font-size: 13px;
color: var(--art-gray-500);
}
}
.section-card {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-weight: 600;
}
.card-sub {
font-size: 12px;
color: var(--art-gray-500);
}
}
.load-error {
margin-bottom: 12px;
}
// ---- 指标卡行 ---------------------------------------------------------------
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.stat-tile {
padding: 16px;
border: 1px solid var(--art-card-border);
border-radius: 8px;
.stat-label {
margin: 0;
font-size: 13px;
color: var(--art-gray-500);
}
.stat-value {
margin: 8px 0;
font-size: 28px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-caption {
margin: 0;
font-size: 12px;
color: var(--art-gray-400);
}
}
.stat-skeleton {
:deep(.el-skeleton__item) {
height: 28px;
}
}
// ---- 租户卡片墙 ---------------------------------------------------------------
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
min-height: 60px;
}
.tenant-card {
display: flex;
flex-direction: column;
padding: 14px 16px;
border-radius: 8px;
background: var(--default-box-color);
.tenant-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.tenant-name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.tenant-slug {
margin: 2px 0 12px;
font-size: 12px;
color: var(--art-gray-400);
}
.tenant-state {
padding: 12px 0;
font-size: 13px;
color: var(--art-gray-500);
&.is-error {
color: var(--el-color-danger);
}
}
.tenant-metrics {
display: flex;
gap: 8px;
.metric {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
gap: 2px;
.metric-value {
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.metric-label {
font-size: 12px;
color: var(--art-gray-500);
}
}
}
}
</style>
+6 -3
View File
@@ -126,10 +126,13 @@ def test_change_password_flow(app_client):
def test_must_change_password_409_semantics(app_client):
"""引导密码强制语义: must_change=True 时受保护端点(logout)409;
change-password 本身豁免(200);改完 409 消除。"""
"""引导密码强制语义: must_change=True 时受保护端点(logout)409
(detail 为固定文案——前端 client.ts 的 MUST_CHANGE_DETAIL 依赖此
契约,改动文案即红);change-password 本身豁免(200);改完 409 消除。"""
token = _login(app_client).json()["token"]
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 409
r = app_client.post(LOGOUT, headers=_auth(token))
assert r.status_code == 409
assert r.json()["detail"] == "必须先修改初始密码"
r = app_client.post(
CHANGE_PW, headers=_auth(token),
json={"old_password": "admin123", "new_password": "n3w-pass-3"})