diff --git a/admin/src/utils/__tests__/statsRange.test.ts b/admin/src/utils/__tests__/statsRange.test.ts index 3bb0cfe..2d574cd 100644 --- a/admin/src/utils/__tests__/statsRange.test.ts +++ b/admin/src/utils/__tests__/statsRange.test.ts @@ -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' + ]) + }) +}) diff --git a/admin/src/utils/statsRange.ts b/admin/src/utils/statsRange.ts index ae93a99..72baecc 100644 --- a/admin/src/utils/statsRange.ts +++ b/admin/src/utils/statsRange.ts @@ -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 } +} diff --git a/admin/src/views/stats/overview/__tests__/charts.test.ts b/admin/src/views/stats/overview/__tests__/charts.test.ts new file mode 100644 index 0000000..fc37643 --- /dev/null +++ b/admin/src/views/stats/overview/__tests__/charts.test.ts @@ -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([]) + }) +}) diff --git a/admin/src/views/stats/overview/charts.ts b/admin/src/views/stats/overview/charts.ts new file mode 100644 index 0000000..d570674 --- /dev/null +++ b/admin/src/views/stats/overview/charts.ts @@ -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): 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): 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 })) +} diff --git a/admin/src/views/stats/overview/index.vue b/admin/src/views/stats/overview/index.vue index 01ede20..a629f44 100644 --- a/admin/src/views/stats/overview/index.vue +++ b/admin/src/views/stats/overview/index.vue @@ -1,25 +1,592 @@ - + diff --git a/app/platform/stats.py b/app/platform/stats.py index 254e927..47690f8 100644 --- a/app/platform/stats.py +++ b/app/platform/stats.py @@ -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 diff --git a/tests/test_stats.py b/tests/test_stats.py index 110ee75..7c858ce 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -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() # 未为统计建空库