Просмотр исходного кода

feat(渠道效果分析): 添加单渠道分析模块,支持多维度深入分析

新增第四章单渠道分析功能:
- 渠道选择器同步:所有子章节渠道选择联动
- 时间趋势图:Chart.js折线图展示多指标趋势
- 品类分布表:支持日期切换、排序动画、自动播放
- 品类×时间矩阵:热力图展示各品类时间变化
- 指标对比:当前渠道 vs 全渠道平均

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
yangxiaohui 2 месяцев назад
Родитель
Сommit
4d7d884afd
1 измененных файлов с 762 добавлено и 6 удалено
  1. 762 6
      tasks/渠道效果分析/visualize.py

+ 762 - 6
tasks/渠道效果分析/visualize.py

@@ -174,6 +174,110 @@ pivot_ror_orig = channel_category.pivot(index='merge一级品类', columns='chan
 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排序)
+main_channel_list = channel_stats['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):
+    """计算单渠道下各品类每天的数据"""
+    ch_df = df[df['channel'] == channel]
+    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,
+        }), include_groups=False
+    ).reset_index()
+    # 转为矩阵格式:{品类: {日期: 值}}
+    uv_pivot = ct.pivot(index='merge一级品类', columns='dt', values='点击uv').fillna(0)
+    ror_pivot = ct.pivot(index='merge一级品类', 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])
+    return {
+        'categories': all_cats,
+        'dates': dates,
+        'uv': {str(cat): {int(d): int(uv_pivot.loc[cat, d]) if d in uv_pivot.columns else 0 for d in dates} for cat in all_cats},
+        'ror': {str(cat): {int(d): float(ror_pivot.loc[cat, d]) if d in ror_pivot.columns and pd.notna(ror_pivot.loc[cat, d]) else 0 for d in dates} for cat in all_cats},
+    }
+
+# 计算整体平均数据(用于对比)
+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 = calc_channel_category_time(ch)
+    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': cat_time,
+        '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)
+
 # ============================================================
 # 统一渐变颜色函数
 # ============================================================
@@ -354,6 +458,12 @@ date_options_html = "".join([
 ])
 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>
@@ -435,6 +545,18 @@ html_content = f"""<!DOCTYPE html>
         .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>
@@ -459,6 +581,12 @@ html_content = f"""<!DOCTYPE html>
             <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>
 
@@ -691,20 +819,142 @@ html_content = f"""<!DOCTYPE html>
                 <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-metric" onchange="updateCategoryTimeMatrix()" style="margin-left: 10px; padding: 4px;">
+                    <option value="ror">整体裂变率</option>
+                    <option value="uv">点击UV</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) => {{
-        const rows = Array.from(table.querySelectorAll('tr')).slice(1);
+        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(table.querySelector('tr').cells).map(c => c.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 = '');
@@ -784,6 +1034,7 @@ html_content = f"""<!DOCTYPE html>
                 location.reload(); // 简单方案:刷新页面
             }});
         }}
+        }} catch(e) {{ console.error('排序初始化错误', e); }}
     }});
 
     // ============================================================
@@ -940,24 +1191,529 @@ html_content = f"""<!DOCTYPE html>
     // 绑定各表格切换器
     document.querySelectorAll('.table-date').forEach(switcher => {{
         const section = switcher.dataset.section;
-        switcher.querySelector('.date-select').addEventListener('change', e => switchDate(section, e.target.value));
+        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) {{
-                switchDate(section, dates[idx - 1]);
+                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) {{
-                switchDate(section, dates[idx + 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);
             }}
         }});
-        switcher.querySelector('.play-btn').addEventListener('click', () => 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 catTime = channelData[channel].category_time;
+        const metric = document.getElementById('cat-time-metric').value;
+        const data = catTime[metric];
+        const categories = catTime.categories;
+        const dates = catTime.dates;
+
+        // 生成表头(可点击排序)
+        const dateLabels = dates.map(d => String(d).slice(-4));
+        document.getElementById('category-time-header').innerHTML = `
+            <tr><th style="cursor:pointer" onclick="sortCatTimeTable(0)">品类</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;
+            }});
+        }});
+        if (metric === 'ror') maxVal = Math.max(maxVal, 0.5);
+        if (metric === 'uv') maxVal = Math.max(maxVal, 10000);
+
+        // 生成数据行(行头可点击排序)
+        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>