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:
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+6
-3
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user