f640889f22
- 仪表盘视图:超管=平台总览四指标卡(/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>
439 lines
13 KiB
Vue
439 lines
13 KiB
Vue
<!--
|
||
仪表盘(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="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>
|
||
<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>
|
||
|
||
<!-- 登录态异常(租户管理员无租户标识——理论不可达,防御展示) -->
|
||
<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">
|
||
.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>
|