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:
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() # 未为统计建空库
|
||||
|
||||
|
||||
Reference in New Issue
Block a user