|
@@ -0,0 +1,1777 @@
|
|
|
|
|
+#!/usr/bin/env python
|
|
|
|
|
+# coding=utf-8
|
|
|
|
|
+"""
|
|
|
|
|
+渠道效果分析可视化
|
|
|
|
|
+输出交互式 HTML 报告,包含多个矩阵视图
|
|
|
|
|
+"""
|
|
|
|
|
+import pandas as pd
|
|
|
|
|
+import json
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+task_dir = Path(__file__).parent
|
|
|
|
|
+output_dir = task_dir / "output"
|
|
|
|
|
+
|
|
|
|
|
+# 找到最新的原始数据文件
|
|
|
|
|
+csv_files = [f for f in output_dir.glob("*.csv") if f.stem.count('_') == 1]
|
|
|
|
|
+if not csv_files:
|
|
|
|
|
+ print("没有找到数据文件,请先运行 query.sql")
|
|
|
|
|
+ exit(1)
|
|
|
|
|
+
|
|
|
|
|
+latest_file = max(csv_files, key=lambda x: x.stat().st_mtime)
|
|
|
|
|
+df = pd.read_csv(latest_file)
|
|
|
|
|
+
|
|
|
|
|
+# 兼容旧版列名:自动映射到新名称
|
|
|
|
|
+column_mapping = {
|
|
|
|
|
+ '进入推荐率': '进入分发率',
|
|
|
|
|
+ '再分享回流率': '整体裂变率',
|
|
|
|
|
+ '原视频再分享回流率': '头部裂变率',
|
|
|
|
|
+ '推荐再分享回流率': '推荐裂变率',
|
|
|
|
|
+ '再分享回流uv': '裂变uv'
|
|
|
|
|
+}
|
|
|
|
|
+df = df.rename(columns={k: v for k, v in column_mapping.items() if k in df.columns})
|
|
|
|
|
+
|
|
|
|
|
+print(f"分析文件: {latest_file.name}")
|
|
|
|
|
+print(f"时间范围: {df['dt'].min()} ~ {df['dt'].max()}")
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 日期列表
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+all_dates = sorted([str(d) for d in df['dt'].unique()])
|
|
|
|
|
+date_options = ['all'] + all_dates # 'all' 表示汇总
|
|
|
|
|
+latest_date = all_dates[-1] if all_dates else 'all'
|
|
|
|
|
+
|
|
|
|
|
+print(f"日期数: {len(all_dates)}")
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 数据准备函数
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+def calc_channel_stats(data):
|
|
|
|
|
+ """计算渠道整体数据"""
|
|
|
|
|
+ stats = data.groupby('channel').agg({'点击uv': 'sum', '裂变uv': 'sum'}).reset_index()
|
|
|
|
|
+ for ch in stats['channel']:
|
|
|
|
|
+ ch_df = data[data['channel'] == ch]
|
|
|
|
|
+ uv = ch_df['点击uv'].sum()
|
|
|
|
|
+ if uv > 0:
|
|
|
|
|
+ stats.loc[stats['channel'] == ch, '进入分发率'] = (ch_df['进入分发率'] * ch_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[stats['channel'] == ch, '整体裂变率'] = (ch_df['整体裂变率'] * ch_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[stats['channel'] == ch, '头部裂变率'] = (ch_df['头部裂变率'] * ch_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[stats['channel'] == ch, '推荐裂变率'] = (ch_df['推荐裂变率'] * ch_df['点击uv']).sum() / uv
|
|
|
|
|
+ return stats.sort_values('点击uv', ascending=False)
|
|
|
|
|
+
|
|
|
|
|
+def calc_category_stats(data):
|
|
|
|
|
+ """计算一级品类整体数据"""
|
|
|
|
|
+ stats = data.groupby('merge一级品类').agg({'点击uv': 'sum', '裂变uv': 'sum'}).reset_index()
|
|
|
|
|
+ for cat in stats['merge一级品类']:
|
|
|
|
|
+ cat_df = data[data['merge一级品类'] == cat]
|
|
|
|
|
+ uv = cat_df['点击uv'].sum()
|
|
|
|
|
+ if uv > 0:
|
|
|
|
|
+ stats.loc[stats['merge一级品类'] == cat, '进入分发率'] = (cat_df['进入分发率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[stats['merge一级品类'] == cat, '整体裂变率'] = (cat_df['整体裂变率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[stats['merge一级品类'] == cat, '头部裂变率'] = (cat_df['头部裂变率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[stats['merge一级品类'] == cat, '推荐裂变率'] = (cat_df['推荐裂变率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ return stats.sort_values('点击uv', ascending=False)
|
|
|
|
|
+
|
|
|
|
|
+def calc_cat2_stats(data):
|
|
|
|
|
+ """计算二级品类整体数据"""
|
|
|
|
|
+ stats = data.groupby(['merge一级品类', 'merge二级品类']).agg({'点击uv': 'sum', '裂变uv': 'sum'}).reset_index()
|
|
|
|
|
+ stats['cat2_label'] = stats.apply(
|
|
|
|
|
+ lambda r: f"{str(r['merge一级品类'])}/{str(r['merge二级品类'])}"
|
|
|
|
|
+ if pd.notna(r['merge二级品类']) else str(r['merge一级品类']), axis=1
|
|
|
|
|
+ )
|
|
|
|
|
+ for idx in stats.index:
|
|
|
|
|
+ cat1, cat2 = stats.loc[idx, 'merge一级品类'], stats.loc[idx, 'merge二级品类']
|
|
|
|
|
+ mask = (data['merge一级品类'] == cat1) & (data['merge二级品类'] == cat2) if pd.notna(cat2) else (data['merge一级品类'] == cat1) & (data['merge二级品类'].isna())
|
|
|
|
|
+ cat_df = data[mask]
|
|
|
|
|
+ uv = cat_df['点击uv'].sum()
|
|
|
|
|
+ if uv > 0:
|
|
|
|
|
+ stats.loc[idx, '进入分发率'] = (cat_df['进入分发率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[idx, '整体裂变率'] = (cat_df['整体裂变率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[idx, '头部裂变率'] = (cat_df['头部裂变率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ stats.loc[idx, '推荐裂变率'] = (cat_df['推荐裂变率'] * cat_df['点击uv']).sum() / uv
|
|
|
|
|
+ return stats.sort_values('点击uv', ascending=False)
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 为每个日期计算数据
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+daily_data = {}
|
|
|
|
|
+for dt in date_options:
|
|
|
|
|
+ data = df if dt == 'all' else df[df['dt'].astype(str) == dt]
|
|
|
|
|
+ daily_data[dt] = {
|
|
|
|
|
+ 'channel_stats': calc_channel_stats(data),
|
|
|
|
|
+ 'category_stats': calc_category_stats(data),
|
|
|
|
|
+ 'cat2_stats': calc_cat2_stats(data),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# 使用汇总数据作为默认
|
|
|
|
|
+channel_stats = daily_data['all']['channel_stats']
|
|
|
|
|
+category_stats = daily_data['all']['category_stats']
|
|
|
|
|
+cat2_overall = daily_data['all']['cat2_stats']
|
|
|
|
|
+
|
|
|
|
|
+# 矩阵数据计算函数
|
|
|
|
|
+def calc_channel_category(data):
|
|
|
|
|
+ """计算渠道×一级品类矩阵"""
|
|
|
|
|
+ cc = data.groupby(['channel', 'merge一级品类']).apply(
|
|
|
|
|
+ lambda x: pd.Series({
|
|
|
|
|
+ '点击uv': x['点击uv'].sum(),
|
|
|
|
|
+ '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0
|
|
|
|
|
+ }), include_groups=False
|
|
|
|
|
+ ).reset_index()
|
|
|
|
|
+ return {
|
|
|
|
|
+ 'ror': cc.pivot(index='merge一级品类', columns='channel', values='整体裂变率'),
|
|
|
|
|
+ 'ror_orig': cc.pivot(index='merge一级品类', columns='channel', values='头部裂变率'),
|
|
|
|
|
+ 'ror_rec': cc.pivot(index='merge一级品类', columns='channel', values='推荐裂变率'),
|
|
|
|
|
+ 'uv': cc.pivot(index='merge一级品类', columns='channel', values='点击uv').fillna(0),
|
|
|
|
|
+ 'recommend': cc.pivot(index='merge一级品类', columns='channel', values='进入分发率'),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+def calc_channel_cat2(data):
|
|
|
|
|
+ """计算渠道×二级品类矩阵"""
|
|
|
|
|
+ cc2 = data.groupby(['channel', 'merge一级品类', 'merge二级品类']).apply(
|
|
|
|
|
+ lambda x: pd.Series({
|
|
|
|
|
+ '点击uv': x['点击uv'].sum(),
|
|
|
|
|
+ '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0
|
|
|
|
|
+ }), include_groups=False
|
|
|
|
|
+ ).reset_index()
|
|
|
|
|
+ cc2['cat2_label'] = cc2.apply(
|
|
|
|
|
+ lambda r: f"{str(r['merge一级品类'])}/{str(r['merge二级品类'])}"
|
|
|
|
|
+ if pd.notna(r['merge二级品类']) else str(r['merge一级品类']), axis=1
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ 'ror': cc2.pivot_table(index='cat2_label', columns='channel', values='整体裂变率'),
|
|
|
|
|
+ 'ror_orig': cc2.pivot_table(index='cat2_label', columns='channel', values='头部裂变率'),
|
|
|
|
|
+ 'ror_rec': cc2.pivot_table(index='cat2_label', columns='channel', values='推荐裂变率'),
|
|
|
|
|
+ 'uv': cc2.pivot_table(index='cat2_label', columns='channel', values='点击uv', fill_value=0),
|
|
|
|
|
+ 'recommend': cc2.pivot_table(index='cat2_label', columns='channel', values='进入分发率'),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# 为每个日期计算矩阵数据
|
|
|
|
|
+for dt in date_options:
|
|
|
|
|
+ data = df if dt == 'all' else df[df['dt'].astype(str) == dt]
|
|
|
|
|
+ daily_data[dt]['matrix'] = calc_channel_category(data)
|
|
|
|
|
+ daily_data[dt]['matrix2'] = calc_channel_cat2(data)
|
|
|
|
|
+
|
|
|
|
|
+# 使用汇总数据
|
|
|
|
|
+channel_category = df.groupby(['channel', 'merge一级品类']).apply(
|
|
|
|
|
+ lambda x: pd.Series({
|
|
|
|
|
+ '点击uv': x['点击uv'].sum(),
|
|
|
|
|
+ '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0
|
|
|
|
|
+ }), include_groups=False
|
|
|
|
|
+).reset_index()
|
|
|
|
|
+
|
|
|
|
|
+pivot_ror = channel_category.pivot(index='merge一级品类', columns='channel', values='整体裂变率')
|
|
|
|
|
+pivot_ror_orig = channel_category.pivot(index='merge一级品类', columns='channel', values='头部裂变率')
|
|
|
|
|
+pivot_ror_rec = channel_category.pivot(index='merge一级品类', columns='channel', values='推荐裂变率')
|
|
|
|
|
+pivot_uv = channel_category.pivot(index='merge一级品类', columns='channel', values='点击uv').fillna(0)
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 单渠道分析数据准备
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+# 获取主要渠道列表(按UV排序,点击UV >= MIN_CHANNEL_UV,在gen函数中定义)
|
|
|
|
|
+# MIN_CHANNEL_UV = 1000 定义在表格HTML生成函数部分
|
|
|
|
|
+main_channel_list = channel_stats[channel_stats['点击uv'] >= 1000]['channel'].tolist()
|
|
|
|
|
+
|
|
|
|
|
+# 计算每个渠道每天的数据(时间趋势)
|
|
|
|
|
+def calc_channel_daily_trend(channel):
|
|
|
|
|
+ """计算单渠道的每日趋势数据"""
|
|
|
|
|
+ ch_df = df[df['channel'] == channel]
|
|
|
|
|
+ trend = ch_df.groupby('dt').apply(
|
|
|
|
|
+ lambda x: pd.Series({
|
|
|
|
|
+ '点击uv': x['点击uv'].sum(),
|
|
|
|
|
+ '裂变uv': x['裂变uv'].sum(),
|
|
|
|
|
+ '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ }), include_groups=False
|
|
|
|
|
+ ).reset_index()
|
|
|
|
|
+ return trend
|
|
|
|
|
+
|
|
|
|
|
+# 计算每个渠道的品类分布
|
|
|
|
|
+def calc_channel_categories(channel, date=None):
|
|
|
|
|
+ """计算单渠道的品类分布,可按日期筛选"""
|
|
|
|
|
+ ch_df = df[df['channel'] == channel]
|
|
|
|
|
+ if date and date != 'all':
|
|
|
|
|
+ ch_df = ch_df[ch_df['dt'].astype(str) == str(date)]
|
|
|
|
|
+ cats = ch_df.groupby('merge一级品类').apply(
|
|
|
|
|
+ lambda x: pd.Series({
|
|
|
|
|
+ '点击uv': x['点击uv'].sum(),
|
|
|
|
|
+ '裂变uv': x['裂变uv'].sum(),
|
|
|
|
|
+ '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ }), include_groups=False
|
|
|
|
|
+ ).reset_index()
|
|
|
|
|
+ return cats.sort_values('点击uv', ascending=False)
|
|
|
|
|
+
|
|
|
|
|
+# 计算单渠道的品类×时间矩阵(支持一级/二级品类)
|
|
|
|
|
+def calc_channel_category_time(channel, level='cat1'):
|
|
|
|
|
+ """计算单渠道下各品类每天的数据"""
|
|
|
|
|
+ ch_df = df[df['channel'] == channel]
|
|
|
|
|
+
|
|
|
|
|
+ if level == 'cat1':
|
|
|
|
|
+ ct = ch_df.groupby(['merge一级品类', 'dt']).apply(
|
|
|
|
|
+ lambda x: pd.Series({
|
|
|
|
|
+ '点击uv': x['点击uv'].sum(),
|
|
|
|
|
+ '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ }), include_groups=False
|
|
|
|
|
+ ).reset_index()
|
|
|
|
|
+ cat_col = 'merge一级品类'
|
|
|
|
|
+ else:
|
|
|
|
|
+ ct = ch_df.groupby(['merge一级品类', 'merge二级品类', 'dt']).apply(
|
|
|
|
|
+ lambda x: pd.Series({
|
|
|
|
|
+ '点击uv': x['点击uv'].sum(),
|
|
|
|
|
+ '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
|
|
|
|
|
+ }), include_groups=False
|
|
|
|
|
+ ).reset_index()
|
|
|
|
|
+ ct['cat2_label'] = ct.apply(
|
|
|
|
|
+ lambda r: f"{str(r['merge一级品类'])}/{str(r['merge二级品类'])}"
|
|
|
|
|
+ if pd.notna(r['merge二级品类']) else str(r['merge一级品类']), axis=1
|
|
|
|
|
+ )
|
|
|
|
|
+ cat_col = 'cat2_label'
|
|
|
|
|
+
|
|
|
|
|
+ # 转为矩阵格式
|
|
|
|
|
+ uv_pivot = ct.pivot(index=cat_col, columns='dt', values='点击uv').fillna(0)
|
|
|
|
|
+ rec_pivot = ct.pivot(index=cat_col, columns='dt', values='进入分发率')
|
|
|
|
|
+ ror_pivot = ct.pivot(index=cat_col, columns='dt', values='整体裂变率')
|
|
|
|
|
+ orig_pivot = ct.pivot(index=cat_col, columns='dt', values='头部裂变率')
|
|
|
|
|
+ rec_ror_pivot = ct.pivot(index=cat_col, columns='dt', values='推荐裂变率')
|
|
|
|
|
+
|
|
|
|
|
+ # 按总UV排序
|
|
|
|
|
+ all_cats = uv_pivot.sum(axis=1).sort_values(ascending=False).index.tolist()
|
|
|
|
|
+ dates = sorted([int(d) for d in uv_pivot.columns])
|
|
|
|
|
+
|
|
|
|
|
+ def to_dict(pivot, is_int=False):
|
|
|
|
|
+ return {str(cat): {int(d): int(pivot.loc[cat, d]) if is_int else float(pivot.loc[cat, d]) if d in pivot.columns and pd.notna(pivot.loc[cat, d]) else 0 for d in dates} for cat in all_cats}
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ 'categories': all_cats,
|
|
|
|
|
+ 'dates': dates,
|
|
|
|
|
+ 'uv': to_dict(uv_pivot, is_int=True),
|
|
|
|
|
+ 'rec': to_dict(rec_pivot),
|
|
|
|
|
+ 'ror': to_dict(ror_pivot),
|
|
|
|
|
+ 'orig': to_dict(orig_pivot),
|
|
|
|
|
+ 'rec_ror': to_dict(rec_ror_pivot),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# 计算整体平均数据(用于对比)
|
|
|
|
|
+overall_avg = {
|
|
|
|
|
+ '点击uv': df['点击uv'].sum() / len(main_channel_list),
|
|
|
|
|
+ '进入分发率': (df['进入分发率'] * df['点击uv']).sum() / df['点击uv'].sum(),
|
|
|
|
|
+ '整体裂变率': (df['整体裂变率'] * df['点击uv']).sum() / df['点击uv'].sum(),
|
|
|
|
|
+ '头部裂变率': (df['头部裂变率'] * df['点击uv']).sum() / df['点击uv'].sum(),
|
|
|
|
|
+ '推荐裂变率': (df['推荐裂变率'] * df['点击uv']).sum() / df['点击uv'].sum(),
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# 预计算所有渠道的数据
|
|
|
|
|
+channel_analysis_data = {}
|
|
|
|
|
+for ch in main_channel_list:
|
|
|
|
|
+ trend = calc_channel_daily_trend(ch)
|
|
|
|
|
+ cats = calc_channel_categories(ch)
|
|
|
|
|
+ cat_time_cat1 = calc_channel_category_time(ch, 'cat1')
|
|
|
|
|
+ cat_time_cat2 = calc_channel_category_time(ch, 'cat2')
|
|
|
|
|
+ ch_stats = channel_stats[channel_stats['channel'] == ch].iloc[0]
|
|
|
|
|
+ # 计算每日品类数据
|
|
|
|
|
+ daily_cats = {'all': cats.to_dict('records')}
|
|
|
|
|
+ for dt in all_dates:
|
|
|
|
|
+ dt_cats = calc_channel_categories(ch, dt)
|
|
|
|
|
+ daily_cats[str(dt)] = dt_cats.to_dict('records')
|
|
|
|
|
+ channel_analysis_data[ch] = {
|
|
|
|
|
+ 'trend': trend.to_dict('records'),
|
|
|
|
|
+ 'categories': cats.to_dict('records'),
|
|
|
|
|
+ 'daily_categories': daily_cats,
|
|
|
|
|
+ 'category_time': {'cat1': cat_time_cat1, 'cat2': cat_time_cat2},
|
|
|
|
|
+ 'summary': {
|
|
|
|
|
+ '点击uv': int(ch_stats['点击uv']),
|
|
|
|
|
+ '进入分发率': float(ch_stats['进入分发率']),
|
|
|
|
|
+ '整体裂变率': float(ch_stats['整体裂变率']),
|
|
|
|
|
+ '头部裂变率': float(ch_stats['头部裂变率']),
|
|
|
|
|
+ '推荐裂变率': float(ch_stats['推荐裂变率']),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# 转为JSON供前端使用
|
|
|
|
|
+channel_analysis_json = json.dumps(channel_analysis_data, ensure_ascii=False)
|
|
|
|
|
+overall_avg_json = json.dumps(overall_avg, ensure_ascii=False)
|
|
|
|
|
+channel_list_json = json.dumps(main_channel_list, ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 统一渐变颜色函数
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+def get_gradient_color(val, max_val, min_val=0):
|
|
|
|
|
+ """统一的渐变颜色:白色(min) -> 绿色(max)"""
|
|
|
|
|
+ if val is None or pd.isna(val) or val <= min_val:
|
|
|
|
|
+ return "background: #f8f9fa"
|
|
|
|
|
+ ratio = min((val - min_val) / (max_val - min_val), 1.0)
|
|
|
|
|
+ r = int(255 - ratio * 215)
|
|
|
|
|
+ g = int(255 - ratio * 88)
|
|
|
|
|
+ b = int(255 - ratio * 186)
|
|
|
|
|
+ return f"background: rgb({r},{g},{b})"
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 表格HTML生成函数
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+MIN_CHANNEL_UV = 1000 # 渠道最小点击UV阈值
|
|
|
|
|
+
|
|
|
|
|
+def gen_channel_rows(stats):
|
|
|
|
|
+ """生成渠道表格行,只显示点击UV >= MIN_CHANNEL_UV的渠道"""
|
|
|
|
|
+ filtered = stats[stats['点击uv'] >= MIN_CHANNEL_UV].copy()
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ max_uv = filtered['点击uv'].max() if len(filtered) > 0 else 1
|
|
|
|
|
+ for _, row in filtered.iterrows():
|
|
|
|
|
+ uv_style = get_gradient_color(row['点击uv'], max_uv)
|
|
|
|
|
+ rec_style = get_gradient_color(row.get('进入分发率', 0), 1.0, 0.6)
|
|
|
|
|
+ ror_style = get_gradient_color(row.get('整体裂变率', 0), 0.8)
|
|
|
|
|
+ orig_style = get_gradient_color(row.get('头部裂变率', 0), 0.3)
|
|
|
|
|
+ rec_ror_style = get_gradient_color(row.get('推荐裂变率', 0), 0.5)
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f"<tr><td>{row['channel']}</td>"
|
|
|
|
|
+ f"<td style='{uv_style}; text-align:right'>{int(row['点击uv']):,}</td>"
|
|
|
|
|
+ f"<td style='{rec_style}; text-align:center'>{row.get('进入分发率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{ror_style}; text-align:center'>{row.get('整体裂变率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{orig_style}; text-align:center'>{row.get('头部裂变率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{rec_ror_style}; text-align:center'>{row.get('推荐裂变率', 0):.4f}</td></tr>"
|
|
|
|
|
+ )
|
|
|
|
|
+ return "".join(rows)
|
|
|
|
|
+
|
|
|
|
|
+def gen_category_rows(stats):
|
|
|
|
|
+ """生成品类表格行"""
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ max_uv = stats['点击uv'].max() if len(stats) > 0 else 1
|
|
|
|
|
+ for _, row in stats.iterrows():
|
|
|
|
|
+ cat_name = str(row['merge一级品类']) if pd.notna(row['merge一级品类']) else '-'
|
|
|
|
|
+ uv_style = get_gradient_color(row['点击uv'], max_uv)
|
|
|
|
|
+ rec_style = get_gradient_color(row.get('进入分发率', 0), 1.0, 0.6)
|
|
|
|
|
+ ror_style = get_gradient_color(row.get('整体裂变率', 0), 0.8)
|
|
|
|
|
+ orig_style = get_gradient_color(row.get('头部裂变率', 0), 0.3)
|
|
|
|
|
+ rec_ror_style = get_gradient_color(row.get('推荐裂变率', 0), 0.5)
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f"<tr><td>{cat_name}</td>"
|
|
|
|
|
+ f"<td style='{uv_style}; text-align:right'>{int(row['点击uv']):,}</td>"
|
|
|
|
|
+ f"<td style='{rec_style}; text-align:center'>{row.get('进入分发率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{ror_style}; text-align:center'>{row.get('整体裂变率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{orig_style}; text-align:center'>{row.get('头部裂变率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{rec_ror_style}; text-align:center'>{row.get('推荐裂变率', 0):.4f}</td></tr>"
|
|
|
|
|
+ )
|
|
|
|
|
+ return "".join(rows)
|
|
|
|
|
+
|
|
|
|
|
+def gen_cat2_rows(stats):
|
|
|
|
|
+ """生成二级品类表格行"""
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ max_uv = stats['点击uv'].max() if len(stats) > 0 else 1
|
|
|
|
|
+ for _, row in stats.iterrows():
|
|
|
|
|
+ uv_style = get_gradient_color(row['点击uv'], max_uv)
|
|
|
|
|
+ rec_style = get_gradient_color(row.get('进入分发率', 0), 1.0, 0.6)
|
|
|
|
|
+ ror_style = get_gradient_color(row.get('整体裂变率', 0), 0.8)
|
|
|
|
|
+ orig_style = get_gradient_color(row.get('头部裂变率', 0), 0.3)
|
|
|
|
|
+ rec_ror_style = get_gradient_color(row.get('推荐裂变率', 0), 0.5)
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f"<tr><td>{row['cat2_label']}</td>"
|
|
|
|
|
+ f"<td style='{uv_style}; text-align:right'>{int(row['点击uv']):,}</td>"
|
|
|
|
|
+ f"<td style='{rec_style}; text-align:center'>{row.get('进入分发率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{ror_style}; text-align:center'>{row.get('整体裂变率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{orig_style}; text-align:center'>{row.get('头部裂变率', 0):.4f}</td>"
|
|
|
|
|
+ f"<td style='{rec_ror_style}; text-align:center'>{row.get('推荐裂变率', 0):.4f}</td></tr>"
|
|
|
|
|
+ )
|
|
|
|
|
+ return "".join(rows)
|
|
|
|
|
+
|
|
|
|
|
+def gen_matrix_rows(pivot_df, valid_rows, cols, max_val, min_val=0, is_int=False):
|
|
|
|
|
+ """生成矩阵热力图行"""
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for cat in valid_rows:
|
|
|
|
|
+ if cat not in pivot_df.index:
|
|
|
|
|
+ continue
|
|
|
|
|
+ cells = [f"<td>{str(cat)}</td>"]
|
|
|
|
|
+ for ch in cols:
|
|
|
|
|
+ if ch in pivot_df.columns and pd.notna(pivot_df.loc[cat, ch]):
|
|
|
|
|
+ val = pivot_df.loc[cat, ch]
|
|
|
|
|
+ style = get_gradient_color(val, max_val, min_val)
|
|
|
|
|
+ if is_int:
|
|
|
|
|
+ cells.append(f'<td style="{style}">{int(val):,}</td>' if val > 0 else '<td>-</td>')
|
|
|
|
|
+ else:
|
|
|
|
|
+ cells.append(f'<td style="{style}">{val:.4f}</td>')
|
|
|
|
|
+ else:
|
|
|
|
|
+ cells.append("<td>-</td>")
|
|
|
|
|
+ rows.append("<tr>" + "".join(cells) + "</tr>")
|
|
|
|
|
+ return "".join(rows)
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 为每个日期生成表格HTML
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+# 先计算汇总数据确定有效品类和渠道(用于筛选,不用于排序)
|
|
|
|
|
+all_channel_stats = daily_data['all']['channel_stats']
|
|
|
|
|
+main_channels = all_channel_stats[all_channel_stats['点击uv'] >= 1000]['channel'].tolist() # 点击UV >= 1000
|
|
|
|
|
+all_matrix = daily_data['all']['matrix']
|
|
|
|
|
+valid_categories_set = set(all_matrix['uv'][all_matrix['uv'].sum(axis=1) >= 1000].index.tolist())
|
|
|
|
|
+
|
|
|
|
|
+# 二级品类有效列表
|
|
|
|
|
+all_matrix2 = daily_data['all']['matrix2']
|
|
|
|
|
+cat2_uv_sum = all_matrix2['uv'].sum(axis=1)
|
|
|
|
|
+valid_cat2_set = set(cat2_uv_sum[cat2_uv_sum >= 1000].sort_values(ascending=False).head(20).index.tolist())
|
|
|
|
|
+
|
|
|
|
|
+def get_sorted_cols(matrix_uv, valid_channels):
|
|
|
|
|
+ """按当天各渠道总UV降序排列列"""
|
|
|
|
|
+ col_uv = matrix_uv.sum(axis=0)
|
|
|
|
|
+ valid_cols = [c for c in col_uv.index if c in valid_channels]
|
|
|
|
|
+ return sorted(valid_cols, key=lambda c: col_uv.get(c, 0), reverse=True)
|
|
|
|
|
+
|
|
|
|
|
+def get_sorted_rows(matrix_uv, valid_rows_set):
|
|
|
|
|
+ """按当天各品类总UV降序排列行"""
|
|
|
|
|
+ row_uv = matrix_uv.sum(axis=1)
|
|
|
|
|
+ valid_rows = [r for r in row_uv.index if r in valid_rows_set]
|
|
|
|
|
+ return sorted(valid_rows, key=lambda r: row_uv.get(r, 0), reverse=True)
|
|
|
|
|
+
|
|
|
|
|
+def gen_header(label, cols):
|
|
|
|
|
+ """生成表头"""
|
|
|
|
|
+ return "<tr><th>" + label + "</th>" + "".join([f"<th>{c}</th>" for c in cols]) + "</tr>"
|
|
|
|
|
+
|
|
|
|
|
+daily_html = {}
|
|
|
|
|
+for dt in date_options:
|
|
|
|
|
+ data = daily_data[dt]
|
|
|
|
|
+ matrix = data['matrix']
|
|
|
|
|
+ matrix2 = data['matrix2']
|
|
|
|
|
+
|
|
|
|
|
+ # 当天按UV排序的列和行
|
|
|
|
|
+ dt_cols = get_sorted_cols(matrix['uv'], main_channels)
|
|
|
|
|
+ dt_rows = get_sorted_rows(matrix['uv'], valid_categories_set)
|
|
|
|
|
+ dt_cols2 = get_sorted_cols(matrix2['uv'], main_channels)
|
|
|
|
|
+ dt_rows2 = get_sorted_rows(matrix2['uv'], valid_cat2_set)
|
|
|
|
|
+
|
|
|
|
|
+ daily_html[dt] = {
|
|
|
|
|
+ 'channel_rows': gen_channel_rows(data['channel_stats']),
|
|
|
|
|
+ 'category_rows': gen_category_rows(data['category_stats']),
|
|
|
|
|
+ 'cat2_rows': gen_cat2_rows(data['cat2_stats']),
|
|
|
|
|
+ # 渠道×一级品类矩阵(按当天UV排序)
|
|
|
|
|
+ 'matrix_header': gen_header('品类', dt_cols),
|
|
|
|
|
+ 'matrix_ror': gen_matrix_rows(matrix['ror'], dt_rows, dt_cols, 0.8),
|
|
|
|
|
+ 'matrix_ror_orig': gen_matrix_rows(matrix['ror_orig'], dt_rows, dt_cols, 0.3),
|
|
|
|
|
+ 'matrix_ror_rec': gen_matrix_rows(matrix['ror_rec'], dt_rows, dt_cols, 0.5),
|
|
|
|
|
+ 'matrix_uv': gen_matrix_rows(matrix['uv'], dt_rows, dt_cols, 100000, is_int=True),
|
|
|
|
|
+ 'matrix_recommend': gen_matrix_rows(matrix['recommend'], dt_rows, dt_cols, 1.0, 0.6),
|
|
|
|
|
+ # 渠道×二级品类矩阵(按当天UV排序)
|
|
|
|
|
+ 'matrix2_header': gen_header('二级品类', dt_cols2),
|
|
|
|
|
+ 'matrix2_ror': gen_matrix_rows(matrix2['ror'], dt_rows2, dt_cols2, 0.8),
|
|
|
|
|
+ 'matrix2_ror_orig': gen_matrix_rows(matrix2['ror_orig'], dt_rows2, dt_cols2, 0.3),
|
|
|
|
|
+ 'matrix2_ror_rec': gen_matrix_rows(matrix2['ror_rec'], dt_rows2, dt_cols2, 0.5),
|
|
|
|
|
+ 'matrix2_recommend': gen_matrix_rows(matrix2['recommend'], dt_rows2, dt_cols2, 1.0, 0.6),
|
|
|
|
|
+ 'matrix2_uv': gen_matrix_rows(matrix2['uv'], dt_rows2, dt_cols2, 100000, is_int=True),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+# 生成 HTML
|
|
|
|
|
+# ============================================================
|
|
|
|
|
+
|
|
|
|
|
+total_uv = int(df['点击uv'].sum())
|
|
|
|
|
+avg_ror = (df['整体裂变率'] * df['点击uv']).sum() / df['点击uv'].sum()
|
|
|
|
|
+channel_count = df['channel'].nunique()
|
|
|
|
|
+category_count = df['merge一级品类'].nunique()
|
|
|
|
|
+date_range = f"{df['dt'].min()} ~ {df['dt'].max()}"
|
|
|
|
|
+
|
|
|
|
|
+# 日期选项HTML
|
|
|
|
|
+date_options_html = "".join([
|
|
|
|
|
+ f'<option value="{dt}" {"selected" if dt == latest_date else ""}>'
|
|
|
|
|
+ f'{"汇总" if dt == "all" else dt}</option>'
|
|
|
|
|
+ for dt in date_options
|
|
|
|
|
+])
|
|
|
|
|
+dates_json = json.dumps(date_options)
|
|
|
|
|
+
|
|
|
|
|
+# 渠道选项HTML
|
|
|
|
|
+channel_options = "".join([
|
|
|
|
|
+ f'<option value="{ch}">{ch}</option>'
|
|
|
|
|
+ for ch in main_channel_list
|
|
|
|
|
+])
|
|
|
|
|
+
|
|
|
|
|
+html_content = f"""<!DOCTYPE html>
|
|
|
|
|
+<html>
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="utf-8">
|
|
|
|
|
+ <title>渠道效果分析报告</title>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
+ margin: 0; background: #f5f5f5; }}
|
|
|
|
|
+ .sidebar {{ position: fixed; left: 0; top: 0; width: 220px; height: 100vh;
|
|
|
|
|
+ background: #2c3e50; color: white; overflow-y: auto; padding: 20px 0;
|
|
|
|
|
+ box-shadow: 2px 0 5px rgba(0,0,0,0.1); }}
|
|
|
|
|
+ .sidebar h2 {{ color: white; font-size: 16px; margin: 0 15px 15px; padding-bottom: 10px;
|
|
|
|
|
+ border-bottom: 1px solid #3d566e; }}
|
|
|
|
|
+ .sidebar ul {{ list-style: none; margin: 0; padding: 0; }}
|
|
|
|
|
+ .sidebar li {{ margin: 0; }}
|
|
|
|
|
+ .sidebar a {{ display: block; padding: 8px 15px; color: #bdc3c7; text-decoration: none;
|
|
|
|
|
+ font-size: 13px; transition: all 0.2s; }}
|
|
|
|
|
+ .sidebar a:hover {{ background: #34495e; color: white; }}
|
|
|
|
|
+ .sidebar .sub a {{ padding-left: 30px; font-size: 12px; }}
|
|
|
|
|
+ .sidebar .sub2 a {{ padding-left: 45px; font-size: 11px; color: #95a5a6; }}
|
|
|
|
|
+ .container {{ margin-left: 240px; padding: 20px; max-width: 1400px; }}
|
|
|
|
|
+ h1 {{ color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }}
|
|
|
|
|
+ h2 {{ color: #444; margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; }}
|
|
|
|
|
+ h3 {{ color: #555; margin-top: 20px; }}
|
|
|
|
|
+ h4 {{ color: #666; margin: 15px 0 5px; font-size: 14px; }}
|
|
|
|
|
+ .chart-container {{ background: white; padding: 20px; margin: 20px 0;
|
|
|
|
|
+ border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
|
|
|
|
+ .summary {{ background: white; padding: 20px; margin: 20px 0;
|
|
|
|
|
+ border-radius: 8px; display: flex; gap: 20px; flex-wrap: wrap; }}
|
|
|
|
|
+ .stat-card {{ flex: 1; min-width: 150px; padding: 15px;
|
|
|
|
|
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
+ border-radius: 8px; color: white; text-align: center; }}
|
|
|
|
|
+ .stat-card h4 {{ margin: 0; font-size: 24px; color: white; }}
|
|
|
|
|
+ .stat-card p {{ margin: 5px 0 0; opacity: 0.9; color: white; }}
|
|
|
|
|
+ table {{ width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; }}
|
|
|
|
|
+ th, td {{ padding: 6px 10px; text-align: left; border-bottom: 1px solid #ddd; }}
|
|
|
|
|
+ th {{ background: #f8f9fa; font-weight: 600; position: sticky; top: 0; }}
|
|
|
|
|
+ tr:hover {{ background: #f5f5f5; }}
|
|
|
|
|
+ .heatmap {{ overflow-x: auto; }}
|
|
|
|
|
+ .heatmap td {{ text-align: center; min-width: 70px; }}
|
|
|
|
|
+ canvas {{ max-height: 400px; }}
|
|
|
|
|
+ .matrix-section {{ margin-bottom: 40px; }}
|
|
|
|
|
+ .legend {{ font-size: 12px; margin: 10px 0; color: #666; }}
|
|
|
|
|
+ .sortable th {{ cursor: pointer; user-select: none; }}
|
|
|
|
|
+ .sortable th:hover {{ background: #e9ecef; }}
|
|
|
|
|
+ .sortable td:first-child {{ cursor: pointer; }}
|
|
|
|
|
+ .sortable td:first-child:hover {{ background: #e9ecef; }}
|
|
|
|
|
+ .sort-hint {{ font-size: 10px; color: #999; margin-left: 5px; }}
|
|
|
|
|
+ .reset-btn {{ font-size: 11px; padding: 2px 8px; margin-left: 10px; cursor: pointer;
|
|
|
|
|
+ background: #6c757d; color: white; border: none; border-radius: 3px; }}
|
|
|
|
|
+ .reset-btn:hover {{ background: #5a6268; }}
|
|
|
|
|
+ .date-switcher {{ display: inline-flex; align-items: center; gap: 5px; margin-left: 15px; }}
|
|
|
|
|
+ .date-switcher select {{ padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }}
|
|
|
|
|
+ .date-switcher button {{ padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px;
|
|
|
|
|
+ background: white; cursor: pointer; font-size: 13px; }}
|
|
|
|
|
+ .date-switcher button:hover:not(:disabled) {{ background: #e9ecef; }}
|
|
|
|
|
+ .date-switcher button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
|
|
|
|
|
+ .date-switcher .play-btn {{ background: #28a745; color: white; border-color: #28a745; }}
|
|
|
|
|
+ .date-switcher .play-btn:hover {{ background: #218838; }}
|
|
|
|
|
+ .date-switcher .play-btn.playing {{ background: #dc3545; border-color: #dc3545; }}
|
|
|
|
|
+ .global-date {{ background: #e3f2fd; padding: 10px 15px; border-radius: 8px; margin: 15px 0;
|
|
|
|
|
+ display: flex; align-items: center; }}
|
|
|
|
|
+ .global-date label {{ font-weight: 600; margin-right: 10px; }}
|
|
|
|
|
+ .table-date {{ float: right; }}
|
|
|
|
|
+ .date-content {{ display: none; opacity: 0; }}
|
|
|
|
|
+ .date-content.active {{ display: block; opacity: 1; }}
|
|
|
|
|
+ .date-content table tr {{ transition: transform 0.5s ease, opacity 0.3s ease; }}
|
|
|
|
|
+ .date-content.animating table tr {{ transform: translateY(0); }}
|
|
|
|
|
+ @keyframes slideUpColor {{
|
|
|
|
|
+ 0% {{ transform: translateY(20px); opacity: 0; color: #28a745; }}
|
|
|
|
|
+ 40% {{ transform: translateY(0); opacity: 1; color: #28a745; }}
|
|
|
|
|
+ 100% {{ transform: translateY(0); opacity: 1; color: inherit; }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ @keyframes slideDownColor {{
|
|
|
|
|
+ 0% {{ transform: translateY(-20px); opacity: 0; color: #dc3545; }}
|
|
|
|
|
+ 40% {{ transform: translateY(0); opacity: 1; color: #dc3545; }}
|
|
|
|
|
+ 100% {{ transform: translateY(0); opacity: 1; color: inherit; }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ .row-up {{ animation: slideUpColor 1s ease forwards; }}
|
|
|
|
|
+ .row-down {{ animation: slideDownColor 1s ease forwards; }}
|
|
|
|
|
+ .row-same {{ animation: none; }}
|
|
|
|
|
+ .channel-selector {{ font-size: 14px; }}
|
|
|
|
|
+ .channel-selector select {{ padding: 8px 15px; font-size: 14px; border: 1px solid #ccc;
|
|
|
|
|
+ border-radius: 4px; min-width: 200px; }}
|
|
|
|
|
+ .metric-selector {{ margin: 10px 0; display: flex; gap: 20px; flex-wrap: wrap; }}
|
|
|
|
|
+ .metric-selector label {{ cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px; }}
|
|
|
|
|
+ .comparison-table {{ width: 100%; }}
|
|
|
|
|
+ .comparison-table th, .comparison-table td {{ text-align: center; padding: 10px; }}
|
|
|
|
|
+ .comparison-table .positive {{ color: #28a745; font-weight: 600; }}
|
|
|
|
|
+ .comparison-table .negative {{ color: #dc3545; font-weight: 600; }}
|
|
|
|
|
+ #channel-summary .stat-card {{ min-width: 120px; }}
|
|
|
|
|
+ .channel-selector-inline {{ margin-right: 15px; }}
|
|
|
|
|
+ .channel-selector-inline select {{ padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }}
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <nav class="sidebar">
|
|
|
|
|
+ <h2>目录</h2>
|
|
|
|
|
+ <ul>
|
|
|
|
|
+ <li><a href="#sec1">一、整体表现</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec1-1">1.1 渠道整体</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec1-2">1.2 一级品类整体</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec1-3">1.3 二级品类整体</a></li>
|
|
|
|
|
+ <li><a href="#sec2">二、渠道×一级品类</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec2-1">2.1 裂变率</a></li>
|
|
|
|
|
+ <li class="sub2"><a href="#sec2-1-1">整体裂变率</a></li>
|
|
|
|
|
+ <li class="sub2"><a href="#sec2-1-2">头部裂变率</a></li>
|
|
|
|
|
+ <li class="sub2"><a href="#sec2-1-3">推荐裂变率</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec2-2">2.2 进入分发率</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec2-3">2.3 点击UV</a></li>
|
|
|
|
|
+ <li><a href="#sec3">三、渠道×二级品类</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec3-1">3.1 裂变率</a></li>
|
|
|
|
|
+ <li class="sub2"><a href="#sec3-1-1">整体裂变率</a></li>
|
|
|
|
|
+ <li class="sub2"><a href="#sec3-1-2">头部裂变率</a></li>
|
|
|
|
|
+ <li class="sub2"><a href="#sec3-1-3">推荐裂变率</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec3-2">3.2 进入分发率</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec3-3">3.3 点击UV</a></li>
|
|
|
|
|
+ <li><a href="#sec4">四、单渠道分析</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec4-1">4.1 渠道选择</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec4-2">4.2 时间趋势</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec4-3">4.3 品类分布</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec4-4">4.4 品类×时间</a></li>
|
|
|
|
|
+ <li class="sub"><a href="#sec4-5">4.5 指标对比</a></li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </nav>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="container">
|
|
|
|
|
+ <h1>渠道效果分析报告</h1>
|
|
|
|
|
+ <p>数据范围: {date_range}</p>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="summary">
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>{total_uv:,}</h4>
|
|
|
|
|
+ <p>总点击UV</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>{avg_ror:.4f}</h4>
|
|
|
|
|
+ <p>平均裂变率</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>{channel_count}</h4>
|
|
|
|
|
+ <p>渠道数</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>{category_count}</h4>
|
|
|
|
|
+ <p>品类数</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="global-date">
|
|
|
|
|
+ <label>全局日期切换:</label>
|
|
|
|
|
+ <div class="date-switcher" id="global-switcher">
|
|
|
|
|
+ <button class="prev-btn" title="前一天">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn" title="后一天">▶</button>
|
|
|
|
|
+ <button class="play-btn" title="播放">▶ 播放</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h2 id="sec1">一、整体表现</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec1-1">1.1 渠道整体</h3>
|
|
|
|
|
+ <div class="chart-container" data-section="channel">
|
|
|
|
|
+ <div class="legend">
|
|
|
|
|
+ 颜色越深=数值越高 | 点击表头排序 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="channel">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable"><tr><th>渠道</th><th>点击UV</th><th>进入分发率</th><th>整体裂变率</th><th>头部裂变率</th><th>推荐裂变率</th></tr>
|
|
|
|
|
+ {daily_html[dt]['channel_rows']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec1-2">1.2 一级品类整体</h3>
|
|
|
|
|
+ <div class="chart-container" data-section="category">
|
|
|
|
|
+ <div class="legend">
|
|
|
|
|
+ 颜色越深=数值越高 | 点击表头排序 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="category">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable"><tr><th>一级品类</th><th>点击UV</th><th>进入分发率</th><th>整体裂变率</th><th>头部裂变率</th><th>推荐裂变率</th></tr>
|
|
|
|
|
+ {daily_html[dt]['category_rows']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec1-3">1.3 二级品类整体</h3>
|
|
|
|
|
+ <div class="chart-container" data-section="cat2">
|
|
|
|
|
+ <div class="legend">
|
|
|
|
|
+ 颜色越深=数值越高 | 点击表头排序 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="cat2">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable"><tr><th>二级品类</th><th>点击UV</th><th>进入分发率</th><th>整体裂变率</th><th>头部裂变率</th><th>推荐裂变率</th></tr>
|
|
|
|
|
+ {daily_html[dt]['cat2_rows']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h2 id="sec2">二、渠道×一级品类</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec2-1">2.1 裂变率</h3>
|
|
|
|
|
+ <h4 id="sec2-1-1">整体裂变率</h4>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m1_ror">
|
|
|
|
|
+ <div class="legend">max=0.80 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m1_ror">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix_header']}{daily_html[dt]['matrix_ror']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h4 id="sec2-1-2">头部裂变率</h4>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m1_orig">
|
|
|
|
|
+ <div class="legend">max=0.30 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m1_orig">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix_header']}{daily_html[dt]['matrix_ror_orig']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h4 id="sec2-1-3">推荐裂变率</h4>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m1_rec">
|
|
|
|
|
+ <div class="legend">max=0.50 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m1_rec">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix_header']}{daily_html[dt]['matrix_ror_rec']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec2-2">2.2 进入分发率</h3>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m1_recommend">
|
|
|
|
|
+ <div class="legend">min=0.60, max=1.00 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m1_recommend">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix_header']}{daily_html[dt]['matrix_recommend']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec2-3">2.3 点击UV</h3>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m1_uv">
|
|
|
|
|
+ <div class="legend">max=10万 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m1_uv">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix_header']}{daily_html[dt]['matrix_uv']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h2 id="sec3">三、渠道×二级品类</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec3-1">3.1 裂变率</h3>
|
|
|
|
|
+ <h4 id="sec3-1-1">整体裂变率</h4>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m2_ror">
|
|
|
|
|
+ <div class="legend">max=0.80 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m2_ror">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix2_header']}{daily_html[dt]['matrix2_ror']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h4 id="sec3-1-2">头部裂变率</h4>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m2_orig">
|
|
|
|
|
+ <div class="legend">max=0.30 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m2_orig">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix2_header']}{daily_html[dt]['matrix2_ror_orig']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h4 id="sec3-1-3">推荐裂变率</h4>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m2_rec">
|
|
|
|
|
+ <div class="legend">max=0.50 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m2_rec">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix2_header']}{daily_html[dt]['matrix2_ror_rec']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec3-2">3.2 进入分发率</h3>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m2_recommend">
|
|
|
|
|
+ <div class="legend">min=0.60, max=1.00 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m2_recommend">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix2_header']}{daily_html[dt]['matrix2_recommend']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec3-3">3.3 点击UV</h3>
|
|
|
|
|
+ <div class="chart-container heatmap matrix-section" data-section="m2_uv">
|
|
|
|
|
+ <div class="legend">max=10万 <button class="reset-btn" onclick="resetSection(this.closest('.chart-container').dataset.section)">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="m2_uv">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {"".join([f'''<div class="date-content{' active' if dt == latest_date else ''}" data-date="{dt}">
|
|
|
|
|
+ <table class="sortable">{daily_html[dt]['matrix2_header']}{daily_html[dt]['matrix2_uv']}</table></div>''' for dt in date_options])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h2 id="sec4">四、单渠道分析</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec4-1">4.1 渠道选择</h3>
|
|
|
|
|
+ <div class="chart-container">
|
|
|
|
|
+ <div class="channel-selector">
|
|
|
|
|
+ <label>选择渠道:</label>
|
|
|
|
|
+ <select id="channel-select" onchange="syncChannelSelect(this.value)">
|
|
|
|
|
+ {"".join([f'<option value="{ch}">{ch}</option>' for ch in main_channel_list])}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="channel-summary" class="summary" style="margin-top: 15px;">
|
|
|
|
|
+ <!-- 渠道汇总数据由JS动态填充 -->
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec4-2">4.2 时间趋势</h3>
|
|
|
|
|
+ <div class="chart-container">
|
|
|
|
|
+ <div class="legend">
|
|
|
|
|
+ <span class="channel-selector-inline">
|
|
|
|
|
+ 渠道:<select class="channel-select-sync" onchange="syncChannelSelect(this.value)">{channel_options}</select>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ 选择指标查看趋势变化
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="metric-selector">
|
|
|
|
|
+ <label><input type="checkbox" id="trend-uv" checked onchange="updateTrendChart()"> 点击UV</label>
|
|
|
|
|
+ <label><input type="checkbox" id="trend-ror" checked onchange="updateTrendChart()"> 整体裂变率</label>
|
|
|
|
|
+ <label><input type="checkbox" id="trend-rec" onchange="updateTrendChart()"> 进入分发率</label>
|
|
|
|
|
+ <label><input type="checkbox" id="trend-orig" onchange="updateTrendChart()"> 头部裂变率</label>
|
|
|
|
|
+ <label><input type="checkbox" id="trend-rec-ror" onchange="updateTrendChart()"> 推荐裂变率</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <canvas id="trend-chart" height="100"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec4-3">4.3 品类分布</h3>
|
|
|
|
|
+ <div class="chart-container" data-section="channel-cat">
|
|
|
|
|
+ <div class="legend">
|
|
|
|
|
+ <span class="channel-selector-inline">
|
|
|
|
|
+ 渠道:<select class="channel-select-sync" onchange="syncChannelSelect(this.value)">{channel_options}</select>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ 按点击UV排序
|
|
|
|
|
+ <button class="reset-btn" onclick="resetCategoryTable()">重置</button>
|
|
|
|
|
+ <div class="date-switcher table-date" data-section="channel-cat">
|
|
|
|
|
+ <button class="prev-btn">◀</button>
|
|
|
|
|
+ <select id="cat-date-select" class="date-select">{date_options_html}</select>
|
|
|
|
|
+ <button class="next-btn">▶</button>
|
|
|
|
|
+ <button class="play-btn">▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <table id="channel-category-table" class="sortable">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th onclick="sortCategoryTable(0)">品类</th>
|
|
|
|
|
+ <th onclick="sortCategoryTable(1)">点击UV</th>
|
|
|
|
|
+ <th onclick="sortCategoryTable(2)">进入分发率</th>
|
|
|
|
|
+ <th onclick="sortCategoryTable(3)">整体裂变率</th>
|
|
|
|
|
+ <th onclick="sortCategoryTable(4)">头部裂变率</th>
|
|
|
|
|
+ <th onclick="sortCategoryTable(5)">推荐裂变率</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody id="channel-category-body">
|
|
|
|
|
+ <!-- 由JS动态填充 -->
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec4-4">4.4 品类×时间</h3>
|
|
|
|
|
+ <div class="chart-container">
|
|
|
|
|
+ <div class="legend">
|
|
|
|
|
+ <span class="channel-selector-inline">
|
|
|
|
|
+ 渠道:<select class="channel-select-sync" onchange="syncChannelSelect(this.value)">{channel_options}</select>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ 品类:<select id="cat-time-level" onchange="updateCategoryTimeMatrix()" style="padding: 4px;">
|
|
|
|
|
+ <option value="cat1">一级品类</option>
|
|
|
|
|
+ <option value="cat2">二级品类</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ 指标:<select id="cat-time-metric" onchange="updateCategoryTimeMatrix()" style="padding: 4px;">
|
|
|
|
|
+ <option value="uv">点击UV</option>
|
|
|
|
|
+ <option value="rec">进入分发率</option>
|
|
|
|
|
+ <option value="ror">整体裂变率</option>
|
|
|
|
|
+ <option value="orig">头部裂变率</option>
|
|
|
|
|
+ <option value="rec_ror">推荐裂变率</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <button class="reset-btn" onclick="updateCategoryTimeMatrix()">重置</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="heatmap">
|
|
|
|
|
+ <table id="category-time-table">
|
|
|
|
|
+ <thead id="category-time-header">
|
|
|
|
|
+ <!-- 由JS动态填充 -->
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody id="category-time-body">
|
|
|
|
|
+ <!-- 由JS动态填充 -->
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <h3 id="sec4-5">4.5 指标对比</h3>
|
|
|
|
|
+ <div class="chart-container">
|
|
|
|
|
+ <div class="legend">
|
|
|
|
|
+ <span class="channel-selector-inline">
|
|
|
|
|
+ 渠道:<select class="channel-select-sync" onchange="syncChannelSelect(this.value)">{channel_options}</select>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ vs 全渠道平均
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="comparison-container">
|
|
|
|
|
+ <table class="comparison-table" id="comparison-table">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th>指标</th>
|
|
|
|
|
+ <th>当前渠道</th>
|
|
|
|
|
+ <th>全渠道平均</th>
|
|
|
|
|
+ <th>差异</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody id="comparison-body">
|
|
|
|
|
+ <!-- 由JS动态填充 -->
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
|
|
+ <script>
|
|
|
|
|
+ // 排序功能
|
|
|
|
|
+ document.querySelectorAll('.sortable').forEach((table, tableIndex) => {{
|
|
|
|
|
+ try {{
|
|
|
|
|
+ const firstRow = table.querySelector('tr');
|
|
|
|
|
+ if (!firstRow || !firstRow.cells) return; // 跳过空表格
|
|
|
|
|
+
|
|
|
|
|
+ const rows = Array.from(table.querySelectorAll('tbody tr, tr')).filter(tr => tr.querySelector('td'));
|
|
|
|
|
+ const headerCells = table.querySelectorAll('th');
|
|
|
|
|
+ if (rows.length === 0 || headerCells.length === 0) return; // 无数据行或无表头
|
|
|
|
|
+
|
|
|
|
|
+ // 保存原始顺序
|
|
|
|
|
+ const originalRowOrder = rows.map(r => r.cloneNode(true));
|
|
|
|
|
+ const originalColOrder = Array.from(firstRow.cells).map(c => c.cloneNode(true));
|
|
|
|
|
+
|
|
|
|
|
+ // 点击表头:按列排序
|
|
|
|
|
+ headerCells.forEach((th, colIndex) => {{
|
|
|
|
|
+ if (!th) return;
|
|
|
|
|
+ th.addEventListener('click', () => {{
|
|
|
|
|
+ const isAsc = th.dataset.sort !== 'asc';
|
|
|
|
|
+ headerCells.forEach(h => h.dataset.sort = '');
|
|
|
|
|
+ th.dataset.sort = isAsc ? 'asc' : 'desc';
|
|
|
|
|
+
|
|
|
|
|
+ rows.sort((a, b) => {{
|
|
|
|
|
+ const aCell = a.cells[colIndex];
|
|
|
|
|
+ const bCell = b.cells[colIndex];
|
|
|
|
|
+ if (!aCell || !bCell) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ let aVal = aCell.textContent.replace(/,/g, '').replace(/-/g, '');
|
|
|
|
|
+ let bVal = bCell.textContent.replace(/,/g, '').replace(/-/g, '');
|
|
|
|
|
+
|
|
|
|
|
+ const aNum = parseFloat(aVal);
|
|
|
|
|
+ const bNum = parseFloat(bVal);
|
|
|
|
|
+
|
|
|
|
|
+ if (!isNaN(aNum) && !isNaN(bNum)) {{
|
|
|
|
|
+ return isAsc ? aNum - bNum : bNum - aNum;
|
|
|
|
|
+ }}
|
|
|
|
|
+ return isAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ rows.forEach(row => table.appendChild(row));
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 点击行首:按该行值重排列
|
|
|
|
|
+ rows.forEach(row => {{
|
|
|
|
|
+ const firstCell = row.cells[0];
|
|
|
|
|
+ if (firstCell) {{
|
|
|
|
|
+ firstCell.addEventListener('click', (e) => {{
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ const values = Array.from(row.cells);
|
|
|
|
|
+
|
|
|
|
|
+ const colData = [];
|
|
|
|
|
+ for (let i = 1; i < values.length; i++) {{
|
|
|
|
|
+ let val = values[i]?.textContent.replace(/,/g, '').replace(/-/g, '') || '0';
|
|
|
|
|
+ colData.push({{ index: i, value: parseFloat(val) || 0 }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ colData.sort((a, b) => b.value - a.value);
|
|
|
|
|
+
|
|
|
|
|
+ table.querySelectorAll('tr').forEach(tr => {{
|
|
|
|
|
+ const cells = Array.from(tr.cells);
|
|
|
|
|
+ const first = cells[0];
|
|
|
|
|
+ const newRow = [first];
|
|
|
|
|
+ colData.forEach(col => newRow.push(cells[col.index]));
|
|
|
|
|
+ newRow.forEach(cell => tr.appendChild(cell));
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 重置按钮
|
|
|
|
|
+ const resetBtn = document.getElementById('reset-' + tableIndex);
|
|
|
|
|
+ if (resetBtn) {{
|
|
|
|
|
+ resetBtn.addEventListener('click', () => {{
|
|
|
|
|
+ // 重置行顺序
|
|
|
|
|
+ const headerRow = table.querySelector('tr');
|
|
|
|
|
+ originalRowOrder.forEach((origRow, i) => {{
|
|
|
|
|
+ const currentRow = rows[i];
|
|
|
|
|
+ Array.from(origRow.cells).forEach((cell, j) => {{
|
|
|
|
|
+ currentRow.cells[j].innerHTML = cell.innerHTML;
|
|
|
|
|
+ currentRow.cells[j].style.cssText = cell.style.cssText;
|
|
|
|
|
+ }});
|
|
|
|
|
+ table.appendChild(currentRow);
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 重置列顺序
|
|
|
|
|
+ table.querySelectorAll('tr').forEach((tr, rowIdx) => {{
|
|
|
|
|
+ if (rowIdx === 0) {{
|
|
|
|
|
+ originalColOrder.forEach((cell, j) => {{
|
|
|
|
|
+ tr.cells[j].innerHTML = cell.innerHTML;
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ headerCells.forEach(h => h.dataset.sort = '');
|
|
|
|
|
+ location.reload(); // 简单方案:刷新页面
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ }} catch(e) {{ console.error('排序初始化错误', e); }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // ============================================================
|
|
|
|
|
+ // 日期切换功能
|
|
|
|
|
+ // ============================================================
|
|
|
|
|
+ const dates = {dates_json};
|
|
|
|
|
+ let playIntervals = {{}};
|
|
|
|
|
+
|
|
|
|
|
+ // 存储每个section当前的行顺序
|
|
|
|
|
+ const sectionRowOrder = {{}};
|
|
|
|
|
+
|
|
|
|
|
+ function getRowKeys(container) {{
|
|
|
|
|
+ const active = container.querySelector('.date-content.active');
|
|
|
|
|
+ if (!active) return [];
|
|
|
|
|
+ return Array.from(active.querySelectorAll('table tr')).slice(1).map(tr => tr.cells[0]?.textContent || '');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function switchDate(section, date) {{
|
|
|
|
|
+ const container = document.querySelector(`.chart-container[data-section="${{section}}"]`);
|
|
|
|
|
+ if (!container) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 记录切换前的行顺序
|
|
|
|
|
+ const oldOrder = getRowKeys(container);
|
|
|
|
|
+ const oldOrderMap = {{}};
|
|
|
|
|
+ oldOrder.forEach((key, idx) => oldOrderMap[key] = idx);
|
|
|
|
|
+
|
|
|
|
|
+ // 切换内容显示
|
|
|
|
|
+ container.querySelectorAll('.date-content').forEach(el => {{
|
|
|
|
|
+ el.classList.toggle('active', el.dataset.date === String(date));
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 获取新的行顺序并应用动画
|
|
|
|
|
+ const newContent = container.querySelector('.date-content.active');
|
|
|
|
|
+ if (newContent && oldOrder.length > 0) {{
|
|
|
|
|
+ const rows = Array.from(newContent.querySelectorAll('table tr')).slice(1);
|
|
|
|
|
+ rows.forEach((tr, newIdx) => {{
|
|
|
|
|
+ const key = tr.cells[0]?.textContent || '';
|
|
|
|
|
+ const oldIdx = oldOrderMap[key];
|
|
|
|
|
+ tr.classList.remove('row-up', 'row-down', 'row-same');
|
|
|
|
|
+ if (oldIdx === undefined) {{
|
|
|
|
|
+ tr.classList.add('row-up'); // 新出现的行
|
|
|
|
|
+ }} else if (newIdx < oldIdx) {{
|
|
|
|
|
+ tr.classList.add('row-up'); // 排名上升
|
|
|
|
|
+ }} else if (newIdx > oldIdx) {{
|
|
|
|
|
+ tr.classList.add('row-down'); // 排名下降
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ tr.classList.add('row-same');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 更新下拉框
|
|
|
|
|
+ const switcher = document.querySelector(`.date-switcher[data-section="${{section}}"]`);
|
|
|
|
|
+ if (switcher) {{
|
|
|
|
|
+ switcher.querySelector('.date-select').value = date;
|
|
|
|
|
+ updateNavButtons(switcher);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function updateNavButtons(switcher) {{
|
|
|
|
|
+ const select = switcher.querySelector('.date-select');
|
|
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
|
|
+ switcher.querySelector('.prev-btn').disabled = (idx <= 0);
|
|
|
|
|
+ switcher.querySelector('.next-btn').disabled = (idx >= dates.length - 1);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function switchAll(date) {{
|
|
|
|
|
+ // 所有可切换的 section
|
|
|
|
|
+ const allSections = [
|
|
|
|
|
+ 'channel', 'category', 'cat2',
|
|
|
|
|
+ 'm1_ror', 'm1_orig', 'm1_rec', 'm1_recommend', 'm1_uv',
|
|
|
|
|
+ 'm2_ror', 'm2_orig', 'm2_rec', 'm2_recommend', 'm2_uv'
|
|
|
|
|
+ ];
|
|
|
|
|
+ allSections.forEach(section => switchDate(section, date));
|
|
|
|
|
+ document.querySelector('#global-switcher .date-select').value = date;
|
|
|
|
|
+ updateNavButtons(document.querySelector('#global-switcher'));
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function playSection(switcher, section) {{
|
|
|
|
|
+ const playBtn = switcher.querySelector('.play-btn');
|
|
|
|
|
+ const isGlobal = switcher.id === 'global-switcher';
|
|
|
|
|
+ const key = isGlobal ? 'global' : section;
|
|
|
|
|
+
|
|
|
|
|
+ if (playIntervals[key]) {{
|
|
|
|
|
+ clearInterval(playIntervals[key]);
|
|
|
|
|
+ playIntervals[key] = null;
|
|
|
|
|
+ playBtn.classList.remove('playing');
|
|
|
|
|
+ playBtn.textContent = isGlobal ? '▶ 播放' : '▶';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ playBtn.classList.add('playing');
|
|
|
|
|
+ playBtn.textContent = isGlobal ? '⏸ 暂停' : '⏸';
|
|
|
|
|
+
|
|
|
|
|
+ let idx = 0;
|
|
|
|
|
+ const play = () => {{
|
|
|
|
|
+ if (idx >= dates.length) {{
|
|
|
|
|
+ clearInterval(playIntervals[key]);
|
|
|
|
|
+ playIntervals[key] = null;
|
|
|
|
|
+ playBtn.classList.remove('playing');
|
|
|
|
|
+ playBtn.textContent = isGlobal ? '▶ 播放' : '▶';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (isGlobal) {{
|
|
|
|
|
+ switchAll(dates[idx]);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ switchDate(section, dates[idx]);
|
|
|
|
|
+ }}
|
|
|
|
|
+ idx++;
|
|
|
|
|
+ }};
|
|
|
|
|
+ play();
|
|
|
|
|
+ playIntervals[key] = setInterval(play, 1500);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 保存每个表格的原始HTML(用于重置)
|
|
|
|
|
+ const originalContent = {{}};
|
|
|
|
|
+ document.querySelectorAll('.chart-container[data-section]').forEach(container => {{
|
|
|
|
|
+ const section = container.dataset.section;
|
|
|
|
|
+ originalContent[section] = {{}};
|
|
|
|
|
+ container.querySelectorAll('.date-content').forEach(content => {{
|
|
|
|
|
+ const date = content.dataset.date;
|
|
|
|
|
+ originalContent[section][date] = content.innerHTML;
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 重置单个表格排序(恢复原始HTML)
|
|
|
|
|
+ function resetSection(section) {{
|
|
|
|
|
+ const container = document.querySelector(`.chart-container[data-section="${{section}}"]`);
|
|
|
|
|
+ if (!container || !originalContent[section]) return;
|
|
|
|
|
+ container.querySelectorAll('.date-content').forEach(content => {{
|
|
|
|
|
+ const date = content.dataset.date;
|
|
|
|
|
+ if (originalContent[section][date]) {{
|
|
|
|
|
+ content.innerHTML = originalContent[section][date];
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 绑定全局切换器
|
|
|
|
|
+ const globalSwitcher = document.querySelector('#global-switcher');
|
|
|
|
|
+ globalSwitcher.querySelector('.date-select').addEventListener('change', e => switchAll(e.target.value));
|
|
|
|
|
+ globalSwitcher.querySelector('.prev-btn').addEventListener('click', () => {{
|
|
|
|
|
+ const select = globalSwitcher.querySelector('.date-select');
|
|
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
|
|
+ if (idx > 0) switchAll(dates[idx - 1]);
|
|
|
|
|
+ }});
|
|
|
|
|
+ globalSwitcher.querySelector('.next-btn').addEventListener('click', () => {{
|
|
|
|
|
+ const select = globalSwitcher.querySelector('.date-select');
|
|
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
|
|
+ if (idx < dates.length - 1) switchAll(dates[idx + 1]);
|
|
|
|
|
+ }});
|
|
|
|
|
+ globalSwitcher.querySelector('.play-btn').addEventListener('click', () => playSection(globalSwitcher, null));
|
|
|
|
|
+ updateNavButtons(globalSwitcher);
|
|
|
|
|
+
|
|
|
|
|
+ // 绑定各表格切换器
|
|
|
|
|
+ document.querySelectorAll('.table-date').forEach(switcher => {{
|
|
|
|
|
+ const section = switcher.dataset.section;
|
|
|
|
|
+ const isChannelCat = section === 'channel-cat';
|
|
|
|
|
+
|
|
|
|
|
+ // 日期切换:根据 section 类型调用不同函数
|
|
|
|
|
+ switcher.querySelector('.date-select').addEventListener('change', e => {{
|
|
|
|
|
+ if (isChannelCat) {{
|
|
|
|
|
+ updateCategoryTableWithAnimation();
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ switchDate(section, e.target.value);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ switcher.querySelector('.prev-btn').addEventListener('click', () => {{
|
|
|
|
|
+ const select = switcher.querySelector('.date-select');
|
|
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
|
|
+ if (idx > 0) {{
|
|
|
|
|
+ select.value = dates[idx - 1];
|
|
|
|
|
+ if (isChannelCat) {{
|
|
|
|
|
+ updateCategoryTableWithAnimation();
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ switchDate(section, dates[idx - 1]);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ updateNavButtons(switcher);
|
|
|
|
|
+ }});
|
|
|
|
|
+ switcher.querySelector('.next-btn').addEventListener('click', () => {{
|
|
|
|
|
+ const select = switcher.querySelector('.date-select');
|
|
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
|
|
+ if (idx < dates.length - 1) {{
|
|
|
|
|
+ select.value = dates[idx + 1];
|
|
|
|
|
+ if (isChannelCat) {{
|
|
|
|
|
+ updateCategoryTableWithAnimation();
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ switchDate(section, dates[idx + 1]);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ updateNavButtons(switcher);
|
|
|
|
|
+ }});
|
|
|
|
|
+ switcher.querySelector('.play-btn').addEventListener('click', () => {{
|
|
|
|
|
+ if (isChannelCat) {{
|
|
|
|
|
+ playCategorySection(switcher);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ playSection(switcher, section);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ updateNavButtons(switcher);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // ============================================================
|
|
|
|
|
+ // 单渠道分析功能
|
|
|
|
|
+ // ============================================================
|
|
|
|
|
+ const channelData = {channel_analysis_json};
|
|
|
|
|
+ const overallAvg = {overall_avg_json};
|
|
|
|
|
+ const channelList = {channel_list_json};
|
|
|
|
|
+ let trendChart = null;
|
|
|
|
|
+
|
|
|
|
|
+ // 同步所有渠道选择器并更新分析
|
|
|
|
|
+ function syncChannelSelect(channel) {{
|
|
|
|
|
+ // 更新所有渠道选择器
|
|
|
|
|
+ document.getElementById('channel-select').value = channel;
|
|
|
|
|
+ document.querySelectorAll('.channel-select-sync').forEach(sel => {{
|
|
|
|
|
+ sel.value = channel;
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 更新分析
|
|
|
|
|
+ updateChannelAnalysis(channel);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function updateChannelAnalysis(channel) {{
|
|
|
|
|
+ if (!channelData[channel]) return;
|
|
|
|
|
+ const data = channelData[channel];
|
|
|
|
|
+
|
|
|
|
|
+ // 更新汇总卡片
|
|
|
|
|
+ const summary = data.summary;
|
|
|
|
|
+ document.getElementById('channel-summary').innerHTML = `
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>${{summary['点击uv'].toLocaleString()}}</h4>
|
|
|
|
|
+ <p>点击UV</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>${{summary['进入分发率'].toFixed(4)}}</h4>
|
|
|
|
|
+ <p>进入分发率</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>${{summary['整体裂变率'].toFixed(4)}}</h4>
|
|
|
|
|
+ <p>整体裂变率</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>${{summary['头部裂变率'].toFixed(4)}}</h4>
|
|
|
|
|
+ <p>头部裂变率</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h4>${{summary['推荐裂变率'].toFixed(4)}}</h4>
|
|
|
|
|
+ <p>推荐裂变率</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+
|
|
|
|
|
+ // 更新品类表格(调用独立函数)
|
|
|
|
|
+ updateCategoryTable();
|
|
|
|
|
+
|
|
|
|
|
+ // 更新对比表格
|
|
|
|
|
+ const compBody = document.getElementById('comparison-body');
|
|
|
|
|
+ const metrics = [
|
|
|
|
|
+ {{ name: '点击UV', key: '点击uv', format: v => parseInt(v).toLocaleString() }},
|
|
|
|
|
+ {{ name: '进入分发率', key: '进入分发率', format: v => v.toFixed(4) }},
|
|
|
|
|
+ {{ name: '整体裂变率', key: '整体裂变率', format: v => v.toFixed(4) }},
|
|
|
|
|
+ {{ name: '头部裂变率', key: '头部裂变率', format: v => v.toFixed(4) }},
|
|
|
|
|
+ {{ name: '推荐裂变率', key: '推荐裂变率', format: v => v.toFixed(4) }},
|
|
|
|
|
+ ];
|
|
|
|
|
+ compBody.innerHTML = metrics.map(m => {{
|
|
|
|
|
+ const chVal = summary[m.key];
|
|
|
|
|
+ const avgVal = overallAvg[m.key];
|
|
|
|
|
+ const diff = chVal - avgVal;
|
|
|
|
|
+ const diffPct = avgVal !== 0 ? (diff / avgVal * 100).toFixed(1) : 0;
|
|
|
|
|
+ const diffClass = diff > 0 ? 'positive' : (diff < 0 ? 'negative' : '');
|
|
|
|
|
+ const diffSign = diff > 0 ? '+' : '';
|
|
|
|
|
+ return `
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td>${{m.name}}</td>
|
|
|
|
|
+ <td>${{m.format(chVal)}}</td>
|
|
|
|
|
+ <td>${{m.format(avgVal)}}</td>
|
|
|
|
|
+ <td class="${{diffClass}}">${{diffSign}}${{diffPct}}%</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }}).join('');
|
|
|
|
|
+
|
|
|
|
|
+ // 更新趋势图和品类×时间矩阵
|
|
|
|
|
+ updateTrendChart();
|
|
|
|
|
+ updateCategoryTimeMatrix();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 渐变颜色函数
|
|
|
|
|
+ function getGradient(val, maxVal, minVal = 0) {{
|
|
|
|
|
+ if (val <= minVal || maxVal <= minVal) return '#f8f9fa';
|
|
|
|
|
+ const ratio = Math.min((val - minVal) / (maxVal - minVal), 1);
|
|
|
|
|
+ const r = Math.round(255 - ratio * 215);
|
|
|
|
|
+ const g = Math.round(255 - ratio * 88);
|
|
|
|
|
+ const b = Math.round(255 - ratio * 186);
|
|
|
|
|
+ return `rgb(${{r}},${{g}},${{b}})`;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 品类分布表格 - 记录当前行顺序用于动画
|
|
|
|
|
+ let catTableOrder = {{}};
|
|
|
|
|
+
|
|
|
|
|
+ function getCatTableOrder() {{
|
|
|
|
|
+ const tbody = document.getElementById('channel-category-body');
|
|
|
|
|
+ const rows = tbody.querySelectorAll('tr');
|
|
|
|
|
+ const order = {{}};
|
|
|
|
|
+ rows.forEach((tr, idx) => {{
|
|
|
|
|
+ const key = tr.cells[0]?.textContent || '';
|
|
|
|
|
+ order[key] = idx;
|
|
|
|
|
+ }});
|
|
|
|
|
+ return order;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function updateCategoryTable() {{
|
|
|
|
|
+ const channel = document.getElementById('channel-select').value;
|
|
|
|
|
+ const date = document.getElementById('cat-date-select').value;
|
|
|
|
|
+ if (!channelData[channel] || !channelData[channel].daily_categories) return;
|
|
|
|
|
+
|
|
|
|
|
+ const cats = channelData[channel].daily_categories[date] || channelData[channel].categories;
|
|
|
|
|
+ if (!cats || cats.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 计算最大值用于渐变
|
|
|
|
|
+ const maxUv = Math.max(...cats.map(c => c['点击uv'] || 0));
|
|
|
|
|
+
|
|
|
|
|
+ const tbody = document.getElementById('channel-category-body');
|
|
|
|
|
+ tbody.innerHTML = cats.map(cat => {{
|
|
|
|
|
+ const uv = cat['点击uv'] || 0;
|
|
|
|
|
+ const uvBg = getGradient(uv, maxUv);
|
|
|
|
|
+ const recBg = getGradient(cat['进入分发率'] || 0, 1.0, 0.6);
|
|
|
|
|
+ const rorBg = getGradient(cat['整体裂变率'] || 0, 0.8);
|
|
|
|
|
+ const origBg = getGradient(cat['头部裂变率'] || 0, 0.3);
|
|
|
|
|
+ const recRorBg = getGradient(cat['推荐裂变率'] || 0, 0.5);
|
|
|
|
|
+ return `
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td>${{cat['merge一级品类'] || '-'}}</td>
|
|
|
|
|
+ <td style="text-align:right;background:${{uvBg}}">${{parseInt(uv).toLocaleString()}}</td>
|
|
|
|
|
+ <td style="text-align:center;background:${{recBg}}">${{(cat['进入分发率'] || 0).toFixed(4)}}</td>
|
|
|
|
|
+ <td style="text-align:center;background:${{rorBg}}">${{(cat['整体裂变率'] || 0).toFixed(4)}}</td>
|
|
|
|
|
+ <td style="text-align:center;background:${{origBg}}">${{(cat['头部裂变率'] || 0).toFixed(4)}}</td>
|
|
|
|
|
+ <td style="text-align:center;background:${{recRorBg}}">${{(cat['推荐裂变率'] || 0).toFixed(4)}}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }}).join('');
|
|
|
|
|
+
|
|
|
|
|
+ // 更新行顺序记录
|
|
|
|
|
+ catTableOrder = getCatTableOrder();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 带动画的品类表格更新
|
|
|
|
|
+ function updateCategoryTableWithAnimation() {{
|
|
|
|
|
+ const oldOrder = catTableOrder;
|
|
|
|
|
+ updateCategoryTable();
|
|
|
|
|
+ const newOrder = getCatTableOrder();
|
|
|
|
|
+
|
|
|
|
|
+ // 应用动画
|
|
|
|
|
+ const tbody = document.getElementById('channel-category-body');
|
|
|
|
|
+ const rows = tbody.querySelectorAll('tr');
|
|
|
|
|
+ rows.forEach((tr, newIdx) => {{
|
|
|
|
|
+ const key = tr.cells[0]?.textContent || '';
|
|
|
|
|
+ const oldIdx = oldOrder[key];
|
|
|
|
|
+ tr.classList.remove('row-up', 'row-down', 'row-same');
|
|
|
|
|
+ if (oldIdx === undefined) {{
|
|
|
|
|
+ tr.classList.add('row-up');
|
|
|
|
|
+ }} else if (newIdx < oldIdx) {{
|
|
|
|
|
+ tr.classList.add('row-up');
|
|
|
|
|
+ }} else if (newIdx > oldIdx) {{
|
|
|
|
|
+ tr.classList.add('row-down');
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ tr.classList.add('row-same');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 品类分布自动播放
|
|
|
|
|
+ function playCategorySection(switcher) {{
|
|
|
|
|
+ const playBtn = switcher.querySelector('.play-btn');
|
|
|
|
|
+ const key = 'channel-cat';
|
|
|
|
|
+
|
|
|
|
|
+ if (playIntervals[key]) {{
|
|
|
|
|
+ clearInterval(playIntervals[key]);
|
|
|
|
|
+ playIntervals[key] = null;
|
|
|
|
|
+ playBtn.classList.remove('playing');
|
|
|
|
|
+ playBtn.textContent = '▶';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ playBtn.classList.add('playing');
|
|
|
|
|
+ playBtn.textContent = '⏸';
|
|
|
|
|
+
|
|
|
|
|
+ const select = document.getElementById('cat-date-select');
|
|
|
|
|
+ let idx = 0;
|
|
|
|
|
+
|
|
|
|
|
+ const play = () => {{
|
|
|
|
|
+ if (idx >= dates.length) {{
|
|
|
|
|
+ clearInterval(playIntervals[key]);
|
|
|
|
|
+ playIntervals[key] = null;
|
|
|
|
|
+ playBtn.classList.remove('playing');
|
|
|
|
|
+ playBtn.textContent = '▶';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+ select.value = dates[idx];
|
|
|
|
|
+ updateCategoryTableWithAnimation();
|
|
|
|
|
+ updateNavButtons(switcher);
|
|
|
|
|
+ idx++;
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ play();
|
|
|
|
|
+ playIntervals[key] = setInterval(play, 1500);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function switchCatDate(delta) {{
|
|
|
|
|
+ const select = document.getElementById('cat-date-select');
|
|
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
|
|
+ const newIdx = idx + delta;
|
|
|
|
|
+ if (newIdx >= 0 && newIdx < dates.length) {{
|
|
|
|
|
+ select.value = dates[newIdx];
|
|
|
|
|
+ updateCategoryTableWithAnimation();
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function resetCategoryTable() {{
|
|
|
|
|
+ document.getElementById('cat-date-select').value = '{latest_date}';
|
|
|
|
|
+ updateCategoryTable();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 品类分布表格排序
|
|
|
|
|
+ let catSortCol = -1;
|
|
|
|
|
+ let catSortAsc = true;
|
|
|
|
|
+
|
|
|
|
|
+ function sortCategoryTable(colIndex) {{
|
|
|
|
|
+ const tbody = document.getElementById('channel-category-body');
|
|
|
|
|
+ const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
|
+ if (rows.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 记录旧顺序
|
|
|
|
|
+ const oldOrder = getCatTableOrder();
|
|
|
|
|
+
|
|
|
|
|
+ // 切换排序方向
|
|
|
|
|
+ if (catSortCol === colIndex) {{
|
|
|
|
|
+ catSortAsc = !catSortAsc;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ catSortCol = colIndex;
|
|
|
|
|
+ catSortAsc = colIndex === 0; // 品类名默认升序,其他默认降序
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ rows.sort((a, b) => {{
|
|
|
|
|
+ const aCell = a.cells[colIndex];
|
|
|
|
|
+ const bCell = b.cells[colIndex];
|
|
|
|
|
+ if (!aCell || !bCell) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ let aVal = aCell.textContent.replace(/,/g, '');
|
|
|
|
|
+ let bVal = bCell.textContent.replace(/,/g, '');
|
|
|
|
|
+ const aNum = parseFloat(aVal);
|
|
|
|
|
+ const bNum = parseFloat(bVal);
|
|
|
|
|
+
|
|
|
|
|
+ if (!isNaN(aNum) && !isNaN(bNum)) {{
|
|
|
|
|
+ return catSortAsc ? aNum - bNum : bNum - aNum;
|
|
|
|
|
+ }}
|
|
|
|
|
+ return catSortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ rows.forEach(row => tbody.appendChild(row));
|
|
|
|
|
+
|
|
|
|
|
+ // 应用动画
|
|
|
|
|
+ rows.forEach((tr, newIdx) => {{
|
|
|
|
|
+ const key = tr.cells[0]?.textContent || '';
|
|
|
|
|
+ const oldIdx = oldOrder[key];
|
|
|
|
|
+ tr.classList.remove('row-up', 'row-down', 'row-same');
|
|
|
|
|
+ if (oldIdx === undefined) {{
|
|
|
|
|
+ tr.classList.add('row-up');
|
|
|
|
|
+ }} else if (newIdx < oldIdx) {{
|
|
|
|
|
+ tr.classList.add('row-up');
|
|
|
|
|
+ }} else if (newIdx > oldIdx) {{
|
|
|
|
|
+ tr.classList.add('row-down');
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ tr.classList.add('row-same');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 更新顺序记录
|
|
|
|
|
+ catTableOrder = getCatTableOrder();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function updateTrendChart() {{
|
|
|
|
|
+ const channel = document.getElementById('channel-select').value;
|
|
|
|
|
+ if (!channelData[channel]) return;
|
|
|
|
|
+ const trend = channelData[channel].trend;
|
|
|
|
|
+
|
|
|
|
|
+ const labels = trend.map(d => String(d.dt).slice(-4)); // 取日期后4位
|
|
|
|
|
+ const datasets = [];
|
|
|
|
|
+
|
|
|
|
|
+ if (document.getElementById('trend-uv').checked) {{
|
|
|
|
|
+ datasets.push({{
|
|
|
|
|
+ label: '点击UV',
|
|
|
|
|
+ data: trend.map(d => d['点击uv']),
|
|
|
|
|
+ borderColor: '#007bff',
|
|
|
|
|
+ backgroundColor: 'rgba(0,123,255,0.1)',
|
|
|
|
|
+ yAxisID: 'y',
|
|
|
|
|
+ tension: 0.3,
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (document.getElementById('trend-ror').checked) {{
|
|
|
|
|
+ datasets.push({{
|
|
|
|
|
+ label: '整体裂变率',
|
|
|
|
|
+ data: trend.map(d => d['整体裂变率']),
|
|
|
|
|
+ borderColor: '#28a745',
|
|
|
|
|
+ yAxisID: 'y1',
|
|
|
|
|
+ tension: 0.3,
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (document.getElementById('trend-rec').checked) {{
|
|
|
|
|
+ datasets.push({{
|
|
|
|
|
+ label: '进入分发率',
|
|
|
|
|
+ data: trend.map(d => d['进入分发率']),
|
|
|
|
|
+ borderColor: '#17a2b8',
|
|
|
|
|
+ yAxisID: 'y1',
|
|
|
|
|
+ tension: 0.3,
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (document.getElementById('trend-orig').checked) {{
|
|
|
|
|
+ datasets.push({{
|
|
|
|
|
+ label: '头部裂变率',
|
|
|
|
|
+ data: trend.map(d => d['头部裂变率']),
|
|
|
|
|
+ borderColor: '#ffc107',
|
|
|
|
|
+ yAxisID: 'y1',
|
|
|
|
|
+ tension: 0.3,
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (document.getElementById('trend-rec-ror').checked) {{
|
|
|
|
|
+ datasets.push({{
|
|
|
|
|
+ label: '推荐裂变率',
|
|
|
|
|
+ data: trend.map(d => d['推荐裂变率']),
|
|
|
|
|
+ borderColor: '#dc3545',
|
|
|
|
|
+ yAxisID: 'y1',
|
|
|
|
|
+ tension: 0.3,
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (trendChart) {{
|
|
|
|
|
+ trendChart.destroy();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ const ctx = document.getElementById('trend-chart').getContext('2d');
|
|
|
|
|
+ trendChart = new Chart(ctx, {{
|
|
|
|
|
+ type: 'line',
|
|
|
|
|
+ data: {{ labels, datasets }},
|
|
|
|
|
+ options: {{
|
|
|
|
|
+ responsive: true,
|
|
|
|
|
+ interaction: {{ mode: 'index', intersect: false }},
|
|
|
|
|
+ scales: {{
|
|
|
|
|
+ y: {{
|
|
|
|
|
+ type: 'linear',
|
|
|
|
|
+ display: datasets.some(d => d.yAxisID === 'y'),
|
|
|
|
|
+ position: 'left',
|
|
|
|
|
+ title: {{ display: true, text: '点击UV' }},
|
|
|
|
|
+ }},
|
|
|
|
|
+ y1: {{
|
|
|
|
|
+ type: 'linear',
|
|
|
|
|
+ display: datasets.some(d => d.yAxisID === 'y1'),
|
|
|
|
|
+ position: 'right',
|
|
|
|
|
+ title: {{ display: true, text: '比率' }},
|
|
|
|
|
+ grid: {{ drawOnChartArea: false }},
|
|
|
|
|
+ }},
|
|
|
|
|
+ }},
|
|
|
|
|
+ plugins: {{
|
|
|
|
|
+ legend: {{ position: 'top' }},
|
|
|
|
|
+ }},
|
|
|
|
|
+ }},
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function updateCategoryTimeMatrix() {{
|
|
|
|
|
+ const channel = document.getElementById('channel-select').value;
|
|
|
|
|
+ if (!channelData[channel] || !channelData[channel].category_time) return;
|
|
|
|
|
+
|
|
|
|
|
+ const level = document.getElementById('cat-time-level').value;
|
|
|
|
|
+ const metric = document.getElementById('cat-time-metric').value;
|
|
|
|
|
+ const catTime = channelData[channel].category_time[level];
|
|
|
|
|
+ if (!catTime) return;
|
|
|
|
|
+
|
|
|
|
|
+ const data = catTime[metric];
|
|
|
|
|
+ const categories = catTime.categories;
|
|
|
|
|
+ const dates = catTime.dates;
|
|
|
|
|
+
|
|
|
|
|
+ // 生成表头(可点击排序)
|
|
|
|
|
+ const dateLabels = dates.map(d => String(d).slice(-4));
|
|
|
|
|
+ const levelLabel = level === 'cat1' ? '一级品类' : '二级品类';
|
|
|
|
|
+ document.getElementById('category-time-header').innerHTML = `
|
|
|
|
|
+ <tr><th style="cursor:pointer" onclick="sortCatTimeTable(0)">${{levelLabel}}</th>${{dateLabels.map((d, i) =>
|
|
|
|
|
+ `<th style="cursor:pointer" onclick="sortCatTimeTable(${{i+1}})">${{d}}</th>`).join('')}}</tr>
|
|
|
|
|
+ `;
|
|
|
|
|
+
|
|
|
|
|
+ // 根据指标设置渐变范围
|
|
|
|
|
+ let maxVal = 0, minVal = 0;
|
|
|
|
|
+ categories.forEach(cat => {{
|
|
|
|
|
+ dates.forEach(dt => {{
|
|
|
|
|
+ const val = data[cat]?.[String(dt)] || 0;
|
|
|
|
|
+ if (val > maxVal) maxVal = val;
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 设置最小阈值
|
|
|
|
|
+ const thresholds = {{ uv: 10000, rec: 1.0, ror: 0.5, orig: 0.2, rec_ror: 0.3 }};
|
|
|
|
|
+ if (metric === 'rec') minVal = 0.6;
|
|
|
|
|
+ maxVal = Math.max(maxVal, thresholds[metric] || 0.5);
|
|
|
|
|
+
|
|
|
|
|
+ // 生成数据行(行头可点击排序)
|
|
|
|
|
+ document.getElementById('category-time-body').innerHTML = categories.map((cat, rowIdx) => {{
|
|
|
|
|
+ const cells = dates.map(dt => {{
|
|
|
|
|
+ const val = data[cat]?.[String(dt)] || 0;
|
|
|
|
|
+ const ratio = maxVal > minVal ? Math.min((val - minVal) / (maxVal - minVal), 1) : 0;
|
|
|
|
|
+ const r = Math.round(255 - ratio * 215);
|
|
|
|
|
+ const g = Math.round(255 - ratio * 88);
|
|
|
|
|
+ const b = Math.round(255 - ratio * 186);
|
|
|
|
|
+ const bg = ratio > 0 ? `rgb(${{r}},${{g}},${{b}})` : '#f8f9fa';
|
|
|
|
|
+ const display = metric === 'uv' ? parseInt(val).toLocaleString() : val.toFixed(4);
|
|
|
|
|
+ return `<td style="background:${{bg}};text-align:center">${{display}}</td>`;
|
|
|
|
|
+ }}).join('');
|
|
|
|
|
+ return `<tr><td style="cursor:pointer" onclick="sortCatTimeTableByRow(${{rowIdx}})">${{cat}}</td>${{cells}}</tr>`;
|
|
|
|
|
+ }}).join('');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 品类×时间表排序状态
|
|
|
|
|
+ let catTimeSortCol = -1;
|
|
|
|
|
+ let catTimeSortAsc = true;
|
|
|
|
|
+
|
|
|
|
|
+ function sortCatTimeTable(colIndex) {{
|
|
|
|
|
+ const table = document.getElementById('category-time-table');
|
|
|
|
|
+ const tbody = document.getElementById('category-time-body');
|
|
|
|
|
+ const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
|
+
|
|
|
|
|
+ // 切换排序方向
|
|
|
|
|
+ if (catTimeSortCol === colIndex) {{
|
|
|
|
|
+ catTimeSortAsc = !catTimeSortAsc;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ catTimeSortCol = colIndex;
|
|
|
|
|
+ catTimeSortAsc = false; // 默认降序
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ rows.sort((a, b) => {{
|
|
|
|
|
+ const aCell = a.cells[colIndex];
|
|
|
|
|
+ const bCell = b.cells[colIndex];
|
|
|
|
|
+ let aVal = aCell.textContent.replace(/,/g, '');
|
|
|
|
|
+ let bVal = bCell.textContent.replace(/,/g, '');
|
|
|
|
|
+ const aNum = parseFloat(aVal);
|
|
|
|
|
+ const bNum = parseFloat(bVal);
|
|
|
|
|
+ if (!isNaN(aNum) && !isNaN(bNum)) {{
|
|
|
|
|
+ return catTimeSortAsc ? aNum - bNum : bNum - aNum;
|
|
|
|
|
+ }}
|
|
|
|
|
+ return catTimeSortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ rows.forEach(row => tbody.appendChild(row));
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function sortCatTimeTableByRow(rowIndex) {{
|
|
|
|
|
+ const table = document.getElementById('category-time-table');
|
|
|
|
|
+ const thead = document.getElementById('category-time-header');
|
|
|
|
|
+ const tbody = document.getElementById('category-time-body');
|
|
|
|
|
+ const headerRow = thead.querySelector('tr');
|
|
|
|
|
+ const dataRows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
|
+ const targetRow = dataRows[rowIndex];
|
|
|
|
|
+ if (!targetRow) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 获取该行各列的值和索引
|
|
|
|
|
+ const colData = [];
|
|
|
|
|
+ for (let i = 1; i < targetRow.cells.length; i++) {{
|
|
|
|
|
+ let val = targetRow.cells[i].textContent.replace(/,/g, '');
|
|
|
|
|
+ colData.push({{ index: i, value: parseFloat(val) || 0 }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ colData.sort((a, b) => b.value - a.value);
|
|
|
|
|
+
|
|
|
|
|
+ // 重排所有行的列
|
|
|
|
|
+ [headerRow, ...dataRows].forEach(tr => {{
|
|
|
|
|
+ const cells = Array.from(tr.cells);
|
|
|
|
|
+ const first = cells[0];
|
|
|
|
|
+ const newOrder = [first];
|
|
|
|
|
+ colData.forEach(col => newOrder.push(cells[col.index]));
|
|
|
|
|
+ newOrder.forEach(cell => tr.appendChild(cell));
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化:加载第一个渠道
|
|
|
|
|
+ if (channelList.length > 0) {{
|
|
|
|
|
+ try {{
|
|
|
|
|
+ syncChannelSelect(channelList[0]);
|
|
|
|
|
+ }} catch (e) {{
|
|
|
|
|
+ console.error('初始化错误:', e);
|
|
|
|
|
+ alert('初始化错误: ' + e.message);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ </script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+html_file = output_dir / f"{latest_file.stem}_报告.html"
|
|
|
|
|
+with open(html_file, 'w', encoding='utf-8') as f:
|
|
|
|
|
+ f.write(html_content)
|
|
|
|
|
+
|
|
|
|
|
+print(f"\nHTML 报告已生成: {html_file}")
|
|
|
|
|
+print(f"包含矩阵: 回流率、点击UV、进入分发率")
|