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