feat: 统计分析(卡片/漏斗/趋势/分布/推送健康,ECharts)

- admin 统计分析页(views/stats/overview): 指标卡四张(口径文案沿用
  仪表盘)/ 漏斗横向条形(有序单色明度阶梯,标注 数量·占上一级%)/
  按日趋势双线(窗口内无数据日期前端补零)/ 意向环形(分类三槽固定
  指派,色随实体不随筛选)/ 来源·站点·国家 Top10 横向条形(名义类目
  统一单色)/ 推送健康表(四状态计数 + duplicated 子集行)
- 筛选一行作用全部图表: 时间档(今天/近7天/近30天/自定义区间,自定义
  不完整不发请求并提示)+ 租户切换(超管含"全部"合并选项,停用租户
  标注;租户管理员固定自己)
- charts.ts 纯函数层(选项构建 + 双模式主题档),配色经
  validate_palette.js 明暗双模式校验(分类/漏斗 ordinal 全过;
  意向饼语义红绿组 CVD 硬伤弃用改分类槽)
- statsRange.ts 扩展自定义区间解析(既有仪表盘导出不动);
  新增 charts/resolveStatsRange 单测 19 例(vitest 120→139)
- 修复 merge_stats distributions 尾随逗号 1 元组 bug(合并端点曾把
  distributions 序列化成 JSON 数组,单租户路径不受累)+ 回归钉子;
  后端 595 测试全过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 22:33:55 +08:00
parent 481243b958
commit 230eb2d86c
7 changed files with 1193 additions and 11 deletions
+48 -2
View File
@@ -1,12 +1,18 @@
/**
* 统计时间快捷范围单测(Phase 3 Task 4)
* 统计时间快捷范围单测(Phase 3 Task 4;Task 8 补自定义区间 resolveStatsRange)
*
* 钉住口径:from/to 为本地日期串 YYYY-MM-DD;"近 N 天"含今天往回共 N 天
* (今天→from==to;近7天→from=今天-6;近30天→今天-29)——后端把日期-only
* 的 to 视为含当日全天(stats_routes 的 _parse_dt),前端不传时间部分。
* 自定义档:完整且起≤止 → 原样透传;缺半/起>止 → null(调用方不发请求)。
*/
import { describe, expect, it } from 'vitest'
import { STATS_QUICK_RANGES, rangeToParams } from '@/utils/statsRange'
import {
STATS_QUICK_RANGES,
STATS_RANGE_OPTIONS,
rangeToParams,
resolveStatsRange
} from '@/utils/statsRange'
/** 固定"现在":2026-10-13 09:30(本地时间)——跨月/跨年边界另测 */
const NOW = new Date(2026, 9, 13, 9, 30)
@@ -55,3 +61,43 @@ describe('统计时间快捷范围(src/utils/statsRange.ts)', () => {
}
})
})
describe('时间筛选档位解析(resolveStatsRange——Task 8 自定义区间)', () => {
it('快捷档等价 rangeToParams(custom 入参被忽略)', () => {
expect(resolveStatsRange('last7', null, NOW)).toEqual({ from: '2026-10-07', to: '2026-10-13' })
})
it('自定义完整区间:原样透传(不做时区/时间部分加工)', () => {
expect(resolveStatsRange('custom', ['2026-09-01', '2026-10-13'], NOW)).toEqual({
from: '2026-09-01',
to: '2026-10-13'
})
})
it('自定义起=止:单日闭区间合法', () => {
expect(resolveStatsRange('custom', ['2026-10-13', '2026-10-13'], NOW)).toEqual({
from: '2026-10-13',
to: '2026-10-13'
})
})
it('自定义缺半(null/空串)→ null(不发请求)', () => {
expect(resolveStatsRange('custom', null, NOW)).toBeNull()
expect(resolveStatsRange('custom', undefined, NOW)).toBeNull()
expect(resolveStatsRange('custom', ['', '2026-10-13'], NOW)).toBeNull()
expect(resolveStatsRange('custom', ['2026-10-13', ''], NOW)).toBeNull()
})
it('自定义起>止 → null(ISO 日期串字典序即时间序)', () => {
expect(resolveStatsRange('custom', ['2026-10-14', '2026-10-13'], NOW)).toBeNull()
})
it('筛选档位清单:三快捷档 + 自定义殿后(切换器展示序)', () => {
expect(STATS_RANGE_OPTIONS.map((item) => item.value)).toEqual([
'today',
'last7',
'last30',
'custom'
])
})
})
+29
View File
@@ -39,3 +39,32 @@ export function rangeToParams(
to: toDateKey(now)
}
}
// ---- 自定义区间扩展(Phase 3 Task 8 统计分析页) ----------------------------------
/** 时间筛选状态:快捷档 + 自定义(统计分析页;仪表盘只用快捷档) */
export type StatsRangeFilter = StatsQuickRange | 'custom'
/** 筛选档位清单(顺序即切换器展示序;自定义档配合日期区间选择器) */
export const STATS_RANGE_OPTIONS: Array<{ value: StatsRangeFilter; label: string }> = [
...STATS_QUICK_RANGES.map(({ value, label }) => ({ value, label })),
{ value: 'custom', label: '自定义' }
]
/**
* 筛选状态 → {from, to} 查询参数(custom 需完整且起≤止的区间)
*
* 返回 null = 区间不完整/非法(自定义只选了一半、起>止)——调用方不发请求,
* 等 ElDatePicker 补全;快速档永不为 null。
*/
export function resolveStatsRange(
filter: StatsRangeFilter,
custom: [string, string] | null | undefined,
now: Date = new Date()
): { from: string; to: string } | null {
if (filter !== 'custom') return rangeToParams(filter, now)
if (!custom || !custom[0] || !custom[1]) return null
const [from, to] = custom
if (from > to) return null // ISO 日期串字典序即时间序
return { from, to }
}
@@ -0,0 +1,157 @@
/**
* 统计图表构建单测(Phase 3 Task 8)
*
* 钉住 charts.ts 纯函数层的口径与配色纪律:
* - fillTrendDates:窗口补零(含缺口日/边界日)、无界回退数据 min/max、空数据原样;
* - buildFunnelOption:y 类目倒序(会话在顶)、逐级转化标注、x 轴 max=会话数归一;
* - 意向环形:色随实体(零值过滤后幸存系列保留原槽位色,不因删行换色);
* - recordEntries:降序 + 同数按 key 字典序(与后端 _sorted_counts 同规)、滤零。
*/
import { describe, expect, it } from 'vitest'
import type { StatsFunnel, StatsTrendPoint } from '@/api/stats'
import {
CHART_THEME_DARK,
CHART_THEME_LIGHT,
buildFunnelOption,
buildIntentPieOption,
fillTrendDates,
intentEntries,
recordEntries
} from '../charts'
describe('fillTrendDates(趋势按窗口补零)', () => {
it('窗口内缺口日补 {0,0},输出按日升序', () => {
const trend: StatsTrendPoint[] = [
{ date: '2026-10-13', conversations: 3, leads: 1 },
{ date: '2026-10-10', conversations: 2, leads: 0 }
]
const out = fillTrendDates(trend, '2026-10-10', '2026-10-13')
expect(out.map((p) => p.date)).toEqual([
'2026-10-10',
'2026-10-11',
'2026-10-12',
'2026-10-13'
])
expect(out[1]).toEqual({ date: '2026-10-11', conversations: 0, leads: 0 })
expect(out[3]).toEqual({ date: '2026-10-13', conversations: 3, leads: 1 })
})
it('窗口恰好覆盖数据(from==to 单日):原样单点', () => {
const trend = [{ date: '2026-10-13', conversations: 5, leads: 2 }]
expect(fillTrendDates(trend, '2026-10-13', '2026-10-13')).toEqual(trend)
})
it('跨月窗口补零(09-30 → 10-01 的日界推进)', () => {
const out = fillTrendDates(
[{ date: '2026-09-30', conversations: 1, leads: 0 }],
'2026-09-30',
'2026-10-01'
)
expect(out.map((p) => p.date)).toEqual(['2026-09-30', '2026-10-01'])
})
it('from/to 缺失或非法:回退数据自身 min/max', () => {
const trend: StatsTrendPoint[] = [
{ date: '2026-10-12', conversations: 1, leads: 1 },
{ date: '2026-10-10', conversations: 4, leads: 2 }
]
const out = fillTrendDates(trend)
expect(out.map((p) => p.date)).toEqual(['2026-10-10', '2026-10-11', '2026-10-12'])
expect(fillTrendDates(trend, '2026-10-20', '2026-10-10')).toHaveLength(3) // 起>止回退
})
it('空趋势原样返回(空数据态由图表层处理)', () => {
expect(fillTrendDates([], '2026-10-01', '2026-10-13')).toEqual([])
})
})
describe('buildFunnelOption(漏斗横向条形)', () => {
const funnel: StatsFunnel = {
conversations: 100,
leads: 40,
evaluated: 30,
push_succeeded: 12
}
it('y 类目倒序录入使「会话」在顶;x 轴按会话数归一', () => {
const option = buildFunnelOption(funnel, CHART_THEME_LIGHT)
const yAxis = option.yAxis as { data: string[] }
expect(yAxis.data).toEqual(['推送成功', '评估完成', '留资', '会话'])
const xAxis = option.xAxis as { max: number }
expect(xAxis.max).toBe(100)
})
it('逐级标注:首级纯数量,其余 数量 · 占上一级%(40%→75%→40%)', () => {
const option = buildFunnelOption(funnel, CHART_THEME_LIGHT)
const series = option.series as Array<{
data: Array<{ value: number }>
label: { formatter: (params: { dataIndex: number }) => string }
}>
const labelAt = (index: number) => series[0].label.formatter({ dataIndex: index })
// dataIndex 0 是数组首位 = 倒序后的「推送成功」
expect(labelAt(0)).toBe('12 · 40%')
expect(labelAt(1)).toBe('30 · 75%')
expect(labelAt(2)).toBe('40 · 40%')
expect(labelAt(3)).toBe('100') // 首级无转化率
})
it('上级为 0 时只标数量(除零防护);四级色取主题漏斗阶梯', () => {
const option = buildFunnelOption(
{ conversations: 10, leads: 0, evaluated: 3, push_succeeded: 0 },
CHART_THEME_DARK
)
const series = option.series as Array<{
data: Array<{ value: number; itemStyle: { color: string } }>
label: { formatter: (params: { dataIndex: number }) => string }
}>
expect(series[0].label.formatter({ dataIndex: 1 })).toBe('3') // 上级 leads=0(除零防护)
expect(series[0].data.map((item) => item.itemStyle.color)).toEqual([...CHART_THEME_DARK.funnel].reverse())
})
})
describe('意向环形(色随实体,零值过滤不重排槽位)', () => {
it('intentEntries:滤零、保 INTENT_PIE_ORDER 固定序与槽位', () => {
const entries = intentEntries({ interested: 3, not_interested: 0, pending: 7 })
expect(entries).toEqual([
{ name: '有意向', value: 3, slot: 0 },
{ name: '待定', value: 7, slot: 2 }
])
// 全零 → 空数组(图表走空态)
expect(intentEntries({ interested: 0, not_interested: 0, pending: 0 })).toEqual([])
})
it('buildIntentPieOption:系列色按固定槽位——首系列为零时幸存者不换色', () => {
const entries = intentEntries({ interested: 0, not_interested: 5, pending: 9 })
const option = buildIntentPieOption(entries, CHART_THEME_LIGHT)
const series = option.series as Array<{
data: Array<{ name: string; itemStyle: { color: string } }>
}>
// not_interested 恒为 slot 2 橙,不因 interested 缺席升位变蓝
expect(series[0].data[0].itemStyle.color).toBe(CHART_THEME_LIGHT.series[1])
expect(series[0].data[1].itemStyle.color).toBe(CHART_THEME_LIGHT.series[2])
})
it('中心合计 = 各片之和(总评估数)', () => {
const option = buildIntentPieOption(
intentEntries({ interested: 2, not_interested: 3, pending: 5 }),
CHART_THEME_LIGHT
)
expect((option.title as { text: string }).text).toBe('10')
})
})
describe('recordEntries(名义类目降序条目)', () => {
it('按计数降序、同数按 key 字典序、滤零(与后端 _sorted_counts 同规)', () => {
const entries = recordEntries({ widget: 5, embedded: 9, standalone: 5, app: 0 })
expect(entries).toEqual([
{ name: 'embedded', value: 9 },
{ name: 'standalone', value: 5 },
{ name: 'widget', value: 5 }
])
})
it('空对象/全零 → 空数组(图表走空态)', () => {
expect(recordEntries({})).toEqual([])
expect(recordEntries({ a: 0 })).toEqual([])
})
})
+379
View File
@@ -0,0 +1,379 @@
/**
* 统计分析图表配置(Phase 3 Task 8)
*
* 纯函数层:StatsResponse(api/stats.ts 类型,形态事实源 app/platform/stats.py)
* + 主题档 → EChartsOption;Vue 层只管取数与生命周期(useChart)。
* 图表形态与配色方法(dataviz 规范):
* - 漏斗 = 有序量尺(stage 有序)→ 单色相明度阶梯(ordinal ramp),不用多色分类;
* - 来源/站点/国家 = 名义类目 → 全部同色(slot 1 单系列),禁止按数值上色;
* - 趋势双线 / 意向三片 = 系列即主体 → 分类色固定槽位(色随实体,筛选不重排);
* - 文字一律文字色(标签/数值/图例不用系列色);网格线实线细线;条形 ≤24px、
* 数据端 4px 圆角;饼片间 2px 表面缝隙(borderWidth/surface 色)。
* 配色经 validate_palette.js 双模式校验(明度带/色度/CVD 邻对 ΔE/对比度全过):
* - 分类三槽 light #2a78d6/#eb6834/#1baf7a · dark #3987e5/#d95926/#199e70
* (意向饼语义红绿组 green↔red CVD ΔE 4.1 硬伤,弃用改分类槽);
* - 漏斗 ordinal light #1c5cab→#86b6ef · dark #b7d3f6→#184f95(首深末浅/镜像)。
*/
import type { EChartsOption } from '@/plugins/echarts'
import type { StatsFunnel, StatsTrendPoint } from '@/api/stats'
// ---- 主题档(两模式各一套;chrome 色同 dataviz 参考面板) --------------------------
export interface ChartTheme {
/** 分类系列槽位(固定序:随实体不随筛选) */
series: [string, string, string]
/** 名义类目条形统一色(slot 1) */
barHue: string
/** 漏斗四级明度阶梯(stage 1 → 4) */
funnel: [string, string, string, string]
/** 图表表面色(饼片缝隙/标记环) */
surface: string
/** 主文字色(直接标注数值) */
ink: string
/** 次级文字色(轴标/图例/条端数值) */
muted: string
/** 网格细实线 */
grid: string
/** 基线(x 轴线) */
axis: string
/** tooltip 底色(半透明表面) */
tooltipBg: string
/** tooltip 文字 */
tooltipInk: string
}
export const CHART_THEME_LIGHT: ChartTheme = {
series: ['#2a78d6', '#eb6834', '#1baf7a'],
barHue: '#2a78d6',
funnel: ['#1c5cab', '#2a78d6', '#5598e7', '#86b6ef'],
surface: '#fcfcfb',
ink: '#0b0b0b',
muted: '#52514e',
grid: '#e1e0d9',
axis: '#c3c2b7',
tooltipBg: 'rgba(255, 255, 255, 0.94)',
tooltipInk: '#0b0b0b'
}
export const CHART_THEME_DARK: ChartTheme = {
series: ['#3987e5', '#d95926', '#199e70'],
barHue: '#3987e5',
funnel: ['#b7d3f6', '#6da7ec', '#2a78d6', '#184f95'],
surface: '#1a1a19',
ink: '#f0efec',
muted: '#c3c2b7',
grid: '#2c2c2a',
axis: '#383835',
tooltipBg: 'rgba(0, 0, 0, 0.8)',
tooltipInk: '#f0efec'
}
export function chartTheme(dark: boolean): ChartTheme {
return dark ? CHART_THEME_DARK : CHART_THEME_LIGHT
}
/** tooltip 公共样式(item/axis 触发) */
function tooltipStyle(theme: ChartTheme, trigger: 'item' | 'axis') {
return {
trigger,
backgroundColor: theme.tooltipBg,
borderColor: theme.axis,
borderWidth: 1,
textStyle: { color: theme.tooltipInk, fontSize: 12 },
...(trigger === 'item' ? {} : { axisPointer: { type: 'line' as const } })
}
}
// ---- 趋势:按日补零 ----------------------------------------------------------------
/** Date → UTC 日期串(date-only 串的加减日用 UTC,避免本地时区漂移) */
function utcKey(date: Date): string {
return date.toISOString().slice(0, 10)
}
/**
* 趋势补零(stats.py 口径:无数据的日期不产出行,前端补零)
*
* from/to 缺失或非法时回退数据自身 min/max 日期;趋势原始为空 → 原样返回
* (空数据态由图表层处理)。输出按日升序,缺口日 {0,0}。
*/
export function fillTrendDates(
trend: StatsTrendPoint[],
from?: string,
to?: string
): StatsTrendPoint[] {
if (trend.length === 0) return trend
const dates = trend.map((p) => p.date).sort()
const hasBounds = !!from && !!to && /^\d{4}-\d{2}-\d{2}$/.test(from) && /^\d{4}-\d{2}-\d{2}$/.test(to) && from! <= to!
const start = hasBounds ? from! : dates[0]
const end = hasBounds ? to! : dates[dates.length - 1]
const byDate = new Map(trend.map((p) => [p.date, p]))
const out: StatsTrendPoint[] = []
for (let cur = new Date(`${start}T00:00:00Z`); utcKey(cur) <= end; cur.setUTCDate(cur.getUTCDate() + 1)) {
const key = utcKey(cur)
const point = byDate.get(key)
out.push({ date: key, conversations: point?.conversations ?? 0, leads: point?.leads ?? 0 })
}
return out
}
/** 趋势双线(会话 slot 1 / 线索 slot 2;x 轴显 MM-DD,tooltip 显全日期) */
export function buildTrendOption(points: StatsTrendPoint[], theme: ChartTheme): EChartsOption {
return {
legend: {
bottom: 0,
icon: 'roundRect',
itemWidth: 12,
itemHeight: 12,
itemGap: 20,
textStyle: { color: theme.muted, fontSize: 12 }
},
grid: { top: 16, right: 16, bottom: 40, left: 8, containLabel: true },
tooltip: tooltipStyle(theme, 'axis'),
xAxis: {
type: 'category',
boundaryGap: false,
data: points.map((p) => p.date),
axisLine: { lineStyle: { color: theme.axis, width: 1 } },
axisTick: { show: false },
axisLabel: {
color: theme.muted,
fontSize: 12,
hideOverlap: true,
formatter: (value: string) => value.slice(5) // MM-DD(年份进 tooltip)
}
},
yAxis: {
type: 'value',
minInterval: 1, // 计数轴取整
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: theme.muted, fontSize: 12 },
splitLine: { show: true, lineStyle: { color: theme.grid, width: 1, type: 'solid' } }
},
series: [
{
name: '会话',
type: 'line',
data: points.map((p) => p.conversations),
color: theme.series[0],
lineStyle: { width: 2 },
symbol: 'circle',
symbolSize: 8,
showSymbol: false
},
{
name: '线索',
type: 'line',
data: points.map((p) => p.leads),
color: theme.series[1],
lineStyle: { width: 2 },
symbol: 'circle',
symbolSize: 8,
showSymbol: false
}
]
}
}
// ---- 漏斗:横向条形模拟(有序色阶) --------------------------------------------------
const FUNNEL_STAGES: Array<{ key: keyof StatsFunnel; label: string }> = [
{ key: 'conversations', label: '会话' },
{ key: 'leads', label: '留资' },
{ key: 'evaluated', label: '评估完成' },
{ key: 'push_succeeded', label: '推送成功' }
]
/**
* 漏斗横向条形:宽度按会话数归一(x 轴 max=会话数),逐级标注
* 「数量 · 占上一级 %」(首级无转化率;上级 0 时只标数量)。
*/
export function buildFunnelOption(funnel: StatsFunnel, theme: ChartTheme): EChartsOption {
const counts = FUNNEL_STAGES.map((stage) => funnel[stage.key])
// y 类目轴 index 0 在底部 → 倒序录入使「会话」在顶
const labels = FUNNEL_STAGES.map((stage, index) => {
const count = counts[index]
if (index === 0) return String(count)
const prev = counts[index - 1]
if (prev <= 0) return String(count)
return `${count} · ${Math.round((count / prev) * 100)}%`
}).reverse()
return {
grid: { top: 8, right: 16, bottom: 8, left: 8, containLabel: true },
tooltip: {
...tooltipStyle(theme, 'item'),
formatter: (params) => {
const point = Array.isArray(params) ? params[0] : params
const stageIndex = FUNNEL_STAGES.length - 1 - (point?.dataIndex ?? 0) // 还原正序
const stage = FUNNEL_STAGES[stageIndex]
const count = counts[stageIndex]
const base = counts[0]
const overall = base > 0 ? ` · 整体转化 ${Math.round((count / base) * 100)}%` : ''
return `${stage.label}:${count}${overall}`
}
},
xAxis: {
type: 'value',
max: counts[0], // 各级宽度同比于会话数(漏斗比例语义)
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false }
},
yAxis: {
type: 'category',
data: FUNNEL_STAGES.map((stage) => stage.label).reverse(),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: theme.muted, fontSize: 12 }
},
series: [
{
type: 'bar',
barWidth: 22,
data: counts
.map((value, index) => ({
value,
itemStyle: { color: theme.funnel[index], borderRadius: [0, 4, 4, 0] }
}))
.reverse(),
label: {
show: true,
position: 'right',
color: theme.muted,
fontSize: 12,
formatter: (params: { dataIndex: number }) => labels[params.dataIndex]
}
}
]
}
}
// ---- 意向分布:环形图(分类三槽固定指派) ----------------------------------------------
/** 意向键固定序(色随实体:interested=slot1 / not_interested=slot2 / pending=slot3) */
export const INTENT_PIE_ORDER: Array<{ key: string; label: string }> = [
{ key: 'interested', label: '有意向' },
{ key: 'not_interested', label: '无意向' },
{ key: 'pending', label: '待定' }
]
/** 意向分布条目(过滤零值,保固定序;后端枚举三键,未知键不展示) */
export interface IntentEntry {
name: string
value: number
/** 固定色槽位(interested=0 / not_interested=1 / pending=2——色随实体不随筛选) */
slot: number
}
export function intentEntries(intent: Record<string, number>): IntentEntry[] {
return INTENT_PIE_ORDER.map((item, slot) => ({
name: item.label,
value: intent[item.key] ?? 0,
slot
})).filter((entry) => entry.value > 0)
}
/** 意向环形图:外标 name 数值(占比),图例兜底识别;片间 2px 表面缝隙 */
export function buildIntentPieOption(entries: IntentEntry[], theme: ChartTheme): EChartsOption {
const total = entries.reduce((sum, item) => sum + item.value, 0)
return {
title: {
text: String(total),
subtext: '总评估',
left: 'center',
top: '38%',
textStyle: { color: theme.ink, fontSize: 22, fontWeight: 600 },
subtextStyle: { color: theme.muted, fontSize: 12 }
},
legend: {
bottom: 0,
icon: 'roundRect',
itemWidth: 12,
itemHeight: 12,
itemGap: 20,
textStyle: { color: theme.muted, fontSize: 12 }
},
tooltip: tooltipStyle(theme, 'item'),
series: [
{
type: 'pie',
radius: ['50%', '72%'],
center: ['50%', '44%'],
data: entries.map((entry) => ({
name: entry.name,
value: entry.value,
itemStyle: { color: theme.series[entry.slot] }
})),
itemStyle: { borderWidth: 2, borderColor: theme.surface },
label: {
color: theme.muted,
fontSize: 12,
formatter: '{b} {c}({d}%)'
},
labelLine: { length: 10, length2: 12, lineStyle: { color: theme.axis } }
}
]
}
}
// ---- 名义类目横向条形(来源/站点/国家) ----------------------------------------------
export interface BarEntry {
name: string
value: number
}
/**
* 名义类目横向条形(统一 slot 1 单色——类目无序,不按数值上色)
*
* entries 需预排序(多→少)与截断;调用方负责 name 展示文案(如来源枚举中文化)。
*/
export function buildBarOption(entries: BarEntry[], theme: ChartTheme): EChartsOption {
return {
grid: { top: 8, right: 48, bottom: 8, left: 8, containLabel: true },
tooltip: tooltipStyle(theme, 'item'),
xAxis: {
type: 'value',
minInterval: 1,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false }
},
yAxis: {
type: 'category',
data: entries.map((entry) => entry.name).reverse(),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: theme.muted,
fontSize: 12,
width: 130,
overflow: 'truncate'
}
},
series: [
{
type: 'bar',
barWidth: 16,
data: entries.map((entry) => ({
...entry,
itemStyle: { color: theme.barHue, borderRadius: [0, 4, 4, 0] }
})),
label: { show: true, position: 'right', color: theme.muted, fontSize: 12 }
}
]
}
}
// ---- 计数表降序工具(source/site 的 Record 形态 → BarEntry[]) -----------------------
/** 计数 Record → 降序条目(value>0;同数按 key 字典序,与后端 _sorted_counts 同规) */
export function recordEntries(record: Record<string, number>): BarEntry[] {
return Object.entries(record)
.filter(([, value]) => value > 0)
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
.map(([name, value]) => ({ name, value }))
}
+575 -8
View File
@@ -1,25 +1,592 @@
<!-- 统计分析(骨架占位页) -->
<!--
统计分析(Phase 3 Task 8)
- 指标卡四张(口径文案沿用仪表盘 caption——事实源 app/platform/stats.py 口径表)
- 漏斗:会话 → 留资 → 评估完成 → 推送成功(横向条形模拟,有序单色明度阶梯)
- 趋势:按日会话/线索双线(后端无数据日期不产出行,前端按窗口补零)
- 分布:意向(环形)/ 来源(条形)/ 站点(条形)/ 国家 Top10(横向条形)
- 推送健康表:四状态计数 + duplicated(success 的 outcome 子集,非错误)
- 筛选:时间档(今天/近7天/近30天/自定义区间)+ 租户切换(超管,含"全部"
合并选项;租户管理员固定自己)——一行筛选作用于全部图表
- 图表配色/形态见 ./charts.ts 头注(validate_palette.js 双模式校验通过)
-->
<template>
<div class="page-placeholder">
<ElCard shadow="never">
<div class="stats-page">
<!-- 头部:标题 + 筛选行(一行筛选作用域覆盖下方全部图表与卡片) -->
<div class="page-header">
<div>
<h2 class="page-title">统计分析</h2>
<p class="page-sub">转化漏斗、按日趋势与分布(指标口径与仪表盘一致)</p>
</div>
<div class="filter-bar">
<ElRadioGroup v-model="rangeFilter">
<ElRadioButton
v-for="item in STATS_RANGE_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</ElRadioButton>
</ElRadioGroup>
<ElDatePicker
v-model="customRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:clearable="false"
:disabled="rangeFilter !== 'custom'"
class="filter-date"
/>
<ElSelect
v-if="isSuper"
v-model="tenantFilter"
:loading="tenantsLoading"
placeholder="租户"
class="tenant-switcher"
>
<ElOption value="" label="全部租户(合并)" />
<ElOption
v-for="tenant in tenants"
:key="tenant.slug"
:value="tenant.slug"
:label="`${tenant.name}(@${tenant.slug})${tenant.status === 'disabled' ? ' · 停用' : ''}`"
/>
</ElSelect>
<ElTag v-else-if="authStore.tenantSlug" type="info" size="large">
租户:{{ authStore.tenantSlug }}
</ElTag>
</div>
</div>
<!-- 自定义区间不完整提示(未选全不发请求,保留上一份数据) -->
<ElAlert
v-if="params === null"
type="info"
:closable="false"
title="自定义区间未选完整——补全起止日期后自动查询"
class="section-alert"
/>
<!-- 超管租户枚举失败(合并统计不受影响,仅切换器不可用) -->
<ElAlert
v-if="isSuper && tenantsError"
type="warning"
:closable="false"
:title="`租户列表加载失败(不影响合并统计):${tenantsError}`"
class="section-alert"
>
<ElButton size="small" :loading="tenantsLoading" @click="loadTenants">重试</ElButton>
</ElAlert>
<!-- 指标卡行 -->
<ElCard shadow="never" class="section-card">
<ElAlert v-if="statsError" type="error" :closable="false" :title="statsError">
<ElButton size="small" :loading="statsLoading" @click="reload">重试</ElButton>
</ElAlert>
<div v-else v-loading="statsLoading" 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>
</ElCard>
<!-- 漏斗 + 趋势 -->
<ElCard shadow="never" class="section-card">
<template #header>
<span class="card-title">统计分析</span>
<div class="card-header">
<span class="card-title">转化漏斗</span>
<span class="card-sub">会话 → 留资 → 评估完成 → 推送成功 · 数值标注「数量 · 占上一级 %」</span>
</div>
</template>
<ElEmpty description="卡片 / 漏斗 / 趋势 / 分布与推送健康(后续任务实现)" />
<div ref="funnelRef" v-loading="statsLoading" class="chart chart-funnel" />
</ElCard>
<ElCard shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">按日趋势</span>
<span class="card-sub">会话 / 线索双线 · 北京日界分组 · 窗口内无数据日期补零</span>
</div>
</template>
<div ref="trendRef" v-loading="statsLoading" class="chart chart-trend" />
</ElCard>
<!-- 分布四联 -->
<div class="dist-grid">
<ElCard shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">意向分布</span>
<span class="card-sub">评估意向三级</span>
</div>
</template>
<div ref="intentRef" v-loading="statsLoading" class="chart chart-pie" />
</ElCard>
<ElCard shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">来源分布</span>
<span class="card-sub">{{ distCaption(sourceAll.length, '来源') }}</span>
</div>
</template>
<div ref="sourceRef" v-loading="statsLoading" class="chart chart-bar" />
</ElCard>
<ElCard shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">站点分布</span>
<span class="card-sub">{{ distCaption(siteAll.length, '站点') }}</span>
</div>
</template>
<div ref="siteRef" v-loading="statsLoading" class="chart chart-bar" />
</ElCard>
<ElCard shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">国家 Top10</span>
<span class="card-sub">会话 IP 归国(iptocc)</span>
</div>
</template>
<div ref="countryRef" v-loading="statsLoading" class="chart chart-country" />
</ElCard>
</div>
<!-- 推送健康 -->
<ElCard shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">推送健康</span>
<span class="card-sub">
按推送日志时间窗计数 · skipped(预检不满足必拒)与 pending 不进成功率分母 ·
duplicated 为 success 的 outcome 子集(200 但对方判重,非错误)
</span>
</div>
</template>
<ElTable :data="healthRows" v-loading="statsLoading">
<ElTableColumn label="状态" width="140">
<template #default="{ row }">
<ElTag :type="row.tag" size="small">{{ row.label }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="计数" width="100" align="right">
<template #default="{ row }">
<span class="mono">{{ row.count }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="占比(四状态)" width="130" align="right">
<template #default="{ row }">
<span class="mono">{{ row.pct }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="说明" min-width="280">
<template #default="{ row }">
<span class="note">{{ row.note }}</span>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useChart } from '@/hooks'
import type { EChartsOption } from '@/plugins/echarts'
import { useAuthStore } from '@/store/modules/auth'
import { fetchPlatformStats, fetchTenantStats, type StatsResponse } from '@/api/stats'
import { fetchTenantList, type TenantListItem } from '@/api/tenants'
import { conversationSourceLabel } from '@/api/conversations'
import { STATS_RANGE_OPTIONS, resolveStatsRange, type StatsRangeFilter } from '@/utils/statsRange'
import {
buildBarOption,
buildFunnelOption,
buildIntentPieOption,
buildTrendOption,
chartTheme,
fillTrendDates,
intentEntries,
recordEntries
} from './charts'
defineOptions({ name: 'StatsOverview' })
const authStore = useAuthStore()
const isSuper = computed(() => authStore.role === 'super')
// ---- 筛选:时间档 + 自定义区间 + 租户(超管) --------------------------------------
const rangeFilter = ref<StatsRangeFilter>('last30')
const customRange = ref<[string, string] | null>(null)
/** 当前筛选 → 查询参数;null = 自定义区间不完整(不发请求,保留上一份数据) */
const params = computed(() => resolveStatsRange(rangeFilter.value, customRange.value))
const tenantFilter = ref('') // '' = 全部租户合并视图
const tenants = ref<TenantListItem[]>([])
const tenantsLoading = ref(false)
const tenantsError = ref('')
async function loadTenants(): Promise<void> {
tenantsLoading.value = true
tenantsError.value = ''
try {
tenants.value = (await fetchTenantList()).items
} catch (error) {
tenantsError.value = error instanceof Error ? error.message : '租户列表加载失败'
} finally {
tenantsLoading.value = false
}
}
// ---- 统计取数(epoch 防筛选快速切换的过期响应竞写) --------------------------------
const stats = ref<StatsResponse | null>(null)
const statsLoading = ref(false)
const statsError = ref('')
const statsEpoch = ref(0)
async function reload(): Promise<void> {
const bounds = params.value
if (!bounds) return // 自定义区间不完整:不发请求
const epoch = ++statsEpoch.value
statsLoading.value = true
statsError.value = ''
try {
const res = isSuper.value
? await fetchPlatformStats(
tenantFilter.value ? { tenant: tenantFilter.value, ...bounds } : bounds
)
: await fetchTenantStats(authStore.tenantSlug as string, bounds)
if (epoch !== statsEpoch.value) return
stats.value = res
} catch (error) {
if (epoch !== statsEpoch.value) return
stats.value = null
statsError.value = error instanceof Error ? error.message : '统计加载失败'
} finally {
if (epoch === statsEpoch.value) statsLoading.value = false
}
}
// ---- 图表(useChart 实例 × 6;数据/主题变化统一重绘) ------------------------------
const funnelChart = useChart()
const trendChart = useChart()
const intentChart = useChart()
const sourceChart = useChart()
const siteChart = useChart()
const countryChart = useChart()
const { chartRef: funnelRef } = funnelChart
const { chartRef: trendRef } = trendChart
const { chartRef: intentRef } = intentChart
const { chartRef: sourceRef } = sourceChart
const { chartRef: siteRef } = siteChart
const { chartRef: countryRef } = countryChart
const { isDark } = trendChart
/** 重绘单图:有数据先 clear 再 setOption(类目数变化不残留旧轴),空数据走空态 */
function render(
chart: ReturnType<typeof useChart>,
option: EChartsOption,
empty: boolean
): void {
if (!empty) chart.getChartInstance()?.clear()
chart.initChart(option, empty)
}
/** 名义类目条目截断上限(Top10;超出在卡片 sub 标注总数) */
const BAR_TOP_N = 10
const trendPoints = computed(() =>
stats.value
? fillTrendDates(stats.value.trend, params.value?.from, params.value?.to)
: []
)
const intentData = computed(() => intentEntries(stats.value?.distributions.intent ?? {}))
const sourceAll = computed(() =>
recordEntries(stats.value?.distributions.source ?? {}).map((entry) => ({
name: conversationSourceLabel(entry.name),
value: entry.value
}))
)
const siteAll = computed(() => recordEntries(stats.value?.distributions.site ?? {}))
const countryData = computed(() =>
(stats.value?.distributions.country ?? []).map((item) => ({
name: item.country,
value: item.count
}))
)
function distCaption(total: number, noun: string): string {
return total > BAR_TOP_N ? `共 ${total} ${noun},显示前 ${BAR_TOP_N}` : `按会话数排序`
}
function renderCharts(): void {
const theme = chartTheme(isDark.value)
const s = stats.value
render(
funnelChart,
s ? buildFunnelOption(s.funnel, theme) : {},
!s || s.funnel.conversations === 0
)
render(trendChart, buildTrendOption(trendPoints.value, theme), trendPoints.value.length === 0)
render(intentChart, buildIntentPieOption(intentData.value, theme), intentData.value.length === 0)
render(
sourceChart,
buildBarOption(sourceAll.value.slice(0, BAR_TOP_N), theme),
sourceAll.value.length === 0
)
render(
siteChart,
buildBarOption(siteAll.value.slice(0, BAR_TOP_N), theme),
siteAll.value.length === 0
)
render(countryChart, buildBarOption(countryData.value, theme), countryData.value.length === 0)
}
// ---- 指标卡(口径文案沿用仪表盘 caption) ------------------------------------------
/** 推送成功率展示:null(分母 0)→ "—";其余 → 百分比(整 % 去小数位) */
function formatRate(rate: number | null | undefined): string {
if (rate === null || rate === undefined) return '—'
return `${(rate * 100).toFixed(1).replace(/\.0$/, '')}%`
}
const overviewTiles = computed(() => {
const cards = stats.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: '成功 / (成功 + 失败);无终态推送显示 —'
}
]
})
// ---- 推送健康表(四状态 + duplicated 子集行) --------------------------------------
const healthRows = computed(() => {
const h = stats.value?.push_health
const total = h ? h.pending + h.success + h.failed + h.skipped : 0
const pct = (count: number) =>
total > 0 ? `${Math.round((count / total) * 100)}%` : '—'
const value = (key: 'pending' | 'success' | 'failed' | 'skipped' | 'duplicated') =>
h ? h[key] : 0
return [
{
key: 'pending',
label: '待推送',
tag: 'warning' as const,
count: value('pending'),
pct: pct(value('pending')),
note: '已入队尚未到终态(重试中等)'
},
{
key: 'success',
label: '成功',
tag: 'success' as const,
count: value('success'),
pct: pct(value('success')),
note: '推送 200 且对方接受(含判重)'
},
{
key: 'failed',
label: '失败',
tag: 'danger' as const,
count: value('failed'),
pct: pct(value('failed')),
note: '重试耗尽或不可达'
},
{
key: 'skipped',
label: '跳过',
tag: 'info' as const,
count: value('skipped'),
pct: pct(value('skipped')),
note: '推送预检不满足(无联系方式/未启用推送),必拒不入成功率分母'
},
{
key: 'duplicated',
label: '重复(判重)',
tag: 'info' as const,
count: value('duplicated'),
pct: '—',
note: 'success 的 outcome 子集:200 但对方判为重复,不是错误'
}
]
})
// ---- 装配 -------------------------------------------------------------------------
watch([stats, isDark], () => nextTick(renderCharts))
watch([rangeFilter, customRange, tenantFilter], reload)
onMounted(() => {
renderCharts() // 先出空态骨架,取数完成后 watch 触发重绘
if (isSuper.value) loadTenants()
if (!isSuper.value && !authStore.tenantSlug) return // 防御:无租户标识不发请求
reload()
})
</script>
<style scoped lang="scss">
.page-placeholder {
.stats-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.card-title {
font-weight: 600;
.page-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.page-sub {
margin: 4px 0 0;
font-size: 13px;
color: var(--art-gray-500);
}
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
.filter-date {
width: 260px;
}
.tenant-switcher {
width: 240px;
}
}
.section-alert {
:deep(.el-alert__content) {
display: flex;
align-items: center;
}
}
.section-card {
.card-header {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: baseline;
justify-content: space-between;
}
.card-title {
font-weight: 600;
}
.card-sub {
font-size: 12px;
color: var(--art-gray-500);
}
}
// ---- 指标卡行 ---------------------------------------------------------------
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
min-height: 90px;
}
.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;
}
.stat-caption {
margin: 0;
font-size: 12px;
color: var(--art-gray-400);
}
}
// ---- 图表(固定高度含轴标带——ECharts 容器常见坑) ------------------------------
.chart {
width: 100%;
}
.chart-funnel {
height: 280px;
}
.chart-trend {
height: 320px;
}
.chart-pie {
height: 300px;
}
.chart-bar {
height: 300px;
}
.chart-country {
height: 360px;
}
.dist-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
}
.mono {
font-variant-numeric: tabular-nums;
}
.note {
font-size: 13px;
color: var(--art-gray-500);
}
</style>
+1 -1
View File
@@ -271,5 +271,5 @@ def merge_stats(parts: list[dict]) -> dict:
"site": _sorted_counts(site),
"country": [
{"country": name, "count": count}
for name, count in country.most_common(COUNTRY_TOP_N)]},
for name, count in country.most_common(COUNTRY_TOP_N)]}
return out
+4
View File
@@ -374,6 +374,10 @@ def test_cross_tenant_super_view(super_client, tmp_path):
{"date": "2026-01-05", "conversations": 1, "leads": 1},
{"date": "2026-01-06", "conversations": 2, "leads": 1},
]
# 回归钉子: merge_stats 的 distributions 必须是对象(曾因赋值行尾随逗号
# 变 1 元组序列化成 JSON 数组,单租户路径不受累——Phase 3 Task 8 冒烟发现)
assert body["distributions"]["intent"] == {
"interested": 1, "not_interested": 1, "pending": 0}
assert body["push_health"]["success"] == 1
assert not (tmp_path / "tenants" / "jcc.db").exists() # 未为统计建空库