Files
ai-kefu-admin/admin/src/views/dashboard/console/index.vue
T
浅念 f640889f22 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>
2026-10-13 16:59:12 +08:00

439 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
仪表盘(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>