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
+421 -8
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;
}
.card-title {
font-weight: 600;
.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>