dodo hold 3 månader sedan
förälder
incheckning
8ad7996d5c

+ 13 - 0
src/api/app.ts

@@ -42,6 +42,18 @@ export interface DailyLogRecord {
     jd_parse_abandon_percentage: string;
     jd_parse_success_count: number;
     jd_parse_success_percentage: string;
+
+    pdd_parse_total_count: number;
+    pdd_parse_abandon_count: number;
+    pdd_parse_abandon_percentage: string;
+    pdd_parse_success_count: number;
+    pdd_parse_success_percentage: string;
+
+    ks_parse_total_count: number;
+    ks_parse_abandon_count: number;
+    ks_parse_abandon_percentage: string;
+    ks_parse_success_count: number;
+    ks_parse_success_percentage: string;
 }
 
 export interface DailyLogParams extends Partial<DailyLogRecord> {
@@ -52,6 +64,7 @@ export interface DailyLogParams extends Partial<DailyLogRecord> {
     pageSize: number;
     sort?: string;
     order?: string;
+    getTotal?: boolean;
 }
 export interface DailyLogRes extends ListRes {
     list: DailyLogRecord[];

+ 5 - 0
src/api/jd.ts

@@ -55,6 +55,11 @@ export interface JdPoolRecord {
     cookies: string;
     union_cookies: string;
     time_range: number;
+    is_hide?: boolean;
+    parse_type?: string;
+    riskStrategy?: string;
+    launchScene?: number;
+    riskCookie?: string;
 }
 
 export interface JdPoolParams extends Partial<JdPoolRecord> {

+ 5 - 0
src/api/pdd.ts

@@ -76,6 +76,11 @@ export interface PddPoolRecord {
     user_agent: string;
     cookies: string;
     time_range: number;
+    is_hide?: boolean;
+    parse_type?: string;
+    riskStrategy?: string;
+    launchScene?: number;
+    riskCookie?: string;
 }
 
 export interface PddPoolParams extends Partial<PddPoolRecord> {

+ 9 - 0
src/api/taobao.ts

@@ -46,6 +46,10 @@ export interface UpdateConfigModel {
     pdd_limit_per_oaid_24h: number;
     pdd_limit_per_ip_24h: number;
     pdd_blacklist_regular: string;
+    pdd_rrecheck_enabled: boolean;
+    pdd_precheck_client_id: string;
+    pdd_precheck_client_secret: string;
+    pdd_precheck_pid: string;
 
     ks_limit_per_ip_24h: number;
     ks_limit_per_oaid_24h: number;
@@ -140,6 +144,11 @@ export interface TkPoolRecord {
     current_daily_calls: number;
     time_range: number;
     is_banned: boolean;
+    is_hide?: boolean;
+    parse_type?: string;
+    riskStrategy?: string;
+    launchScene?: number;
+    riskCookie?: string;
 }
 
 export interface FormRes {

+ 37 - 1
src/api/tracks.ts

@@ -23,12 +23,27 @@ export interface TrackReportRecord {
     scene: string;
     unique_id: string;
     description: string;
+    account_id: number;
     report_date: string;
     event_count: number;
     create_time: Date;
     update_time: Date;
 }
 
+export interface TrackHourlyReportRecord {
+    track_link_id: number;
+    event_type: string;
+    platform: string;
+    typename: string;
+    scene: string;
+    unique_id: string;
+    account_id: number;
+    report_date: string;
+    hour: number;
+    hour_label: string;
+    event_count: number;
+}
+
 export interface TrackListParams {
     current: number;
     pageSize: number;
@@ -47,11 +62,21 @@ export interface TrackReportParams {
     typename?: string;
     scene?: string;
     unique_id?: string;
+    account_id?: number;
+    include_account_breakdown?: boolean;
+    account_breakdown_only?: boolean;
     start?: string;
     end?: string;
     getTotal: boolean;
 }
 
+export interface TrackHourlyReportParams {
+    track_link_id: number;
+    date: string;
+    scene?: string;
+    account_id?: number;
+}
+
 export interface TrackListRes extends ListRes {
     list: TrackLinkRecord[];
 }
@@ -60,12 +85,19 @@ export interface TrackReportRes extends ListRes {
     list: TrackReportRecord[];
 }
 
+export interface TrackHourlyReportRes {
+    list: TrackHourlyReportRecord[];
+    count: number;
+}
+
 export function queryTrackList(data: TrackListParams) {
     return axios.post<TrackListRes>('/api/TracksAdmin/list', data);
 }
 
 export function getTrackInfo(id: number) {
-    return axios.get<TrackLinkRecord>('/api/TracksAdmin/info', { params: { id } });
+    return axios.get<TrackLinkRecord>('/api/TracksAdmin/info', {
+        params: { id },
+    });
 }
 
 export function createTrack(data: Partial<TrackLinkRecord>) {
@@ -79,3 +111,7 @@ export function deleteTrack(id: number) {
 export function queryTrackReport(data: TrackReportParams) {
     return axios.post<TrackReportRes>('/api/TracksAdmin/report', data);
 }
+
+export function queryTrackHourlyReport(data: TrackHourlyReportParams) {
+    return axios.post<TrackHourlyReportRes>('/api/TracksAdmin/hourly', data);
+}

+ 100 - 41
src/components/chart/index.vue

@@ -1,47 +1,106 @@
 <template>
-  <VCharts
-    v-if="renderChart"
-    :option="options"
-    :autoresize="autoResize"
-    :style="{ width, height }"
-  />
+    <div ref="chartRef" class="chart-root" :style="{ width, height }"></div>
 </template>
 
 <script lang="ts" setup>
-  import { ref, nextTick } from 'vue';
-  import VCharts from 'vue-echarts';
-  // import { useAppStore } from '@/store';
-
-  defineProps({
-    options: {
-      type: Object,
-      default() {
-        return {};
-      },
-    },
-    autoResize: {
-      type: Boolean,
-      default: true,
-    },
-    width: {
-      type: String,
-      default: '100%',
-    },
-    height: {
-      type: String,
-      default: '100%',
-    },
-  });
-  // const appStore = useAppStore();
-  // const theme = computed(() => {
-  //   if (appStore.theme === 'dark') return 'dark';
-  //   return '';
-  // });
-  const renderChart = ref(false);
-  // wait container expand
-  nextTick(() => {
-    renderChart.value = true;
-  });
+    import {
+        computed,
+        nextTick,
+        onBeforeUnmount,
+        onMounted,
+        ref,
+        shallowRef,
+        watch,
+    } from 'vue';
+    import { init, type ECharts, type EChartsCoreOption } from 'echarts/core';
+
+    const props = defineProps({
+        option: {
+            type: Object,
+            default() {
+                return null;
+            },
+        },
+        options: {
+            type: Object,
+            default() {
+                return {};
+            },
+        },
+        autoResize: {
+            type: Boolean,
+            default: true,
+        },
+        width: {
+            type: String,
+            default: '100%',
+        },
+        height: {
+            type: String,
+            default: '100%',
+        },
+    });
+
+    const chartRef = ref<HTMLDivElement>();
+    const chartInstance = shallowRef<ECharts>();
+    let resizeObserver: ResizeObserver | null = null;
+    let resizeTimer = 0;
+
+    const chartOptions = computed(
+        () => (props.option || props.options || {}) as EChartsCoreOption
+    );
+
+    const resize = () => {
+        if (!props.autoResize || !chartInstance.value) return;
+        window.clearTimeout(resizeTimer);
+        resizeTimer = window.setTimeout(() => {
+            chartInstance.value?.resize();
+        }, 40);
+    };
+
+    const render = () => {
+        if (!chartInstance.value) return;
+        chartInstance.value.setOption(chartOptions.value, true);
+        resize();
+    };
+
+    onMounted(async () => {
+        await nextTick();
+        if (!chartRef.value) return;
+
+        chartInstance.value = init(chartRef.value);
+        render();
+        window.setTimeout(resize, 120);
+
+        if (props.autoResize) {
+            resizeObserver = new ResizeObserver(() => {
+                resize();
+            });
+            resizeObserver.observe(chartRef.value);
+            window.addEventListener('resize', resize);
+        }
+    });
+
+    watch(
+        chartOptions,
+        () => {
+            render();
+        },
+        { deep: true }
+    );
+
+    onBeforeUnmount(() => {
+        window.clearTimeout(resizeTimer);
+        window.removeEventListener('resize', resize);
+        resizeObserver?.disconnect();
+        chartInstance.value?.dispose();
+        chartInstance.value = undefined;
+    });
 </script>
 
-<style scoped lang="less"></style>
+<style scoped lang="less">
+    .chart-root {
+        display: block;
+        min-width: 0;
+    }
+</style>

+ 172 - 1
src/views/dashboard/dailyLog/components/all.vue

@@ -93,7 +93,20 @@
         </template>
         <template #xAxis="{ record }">
             <template v-if="record.isLeaf">
-                {{ record.accountName }}
+                <div class="account-cell">
+                    <span class="account-name">{{ record.accountName }}</span>
+                    <span class="account-tags">
+                        <a-tag
+                            v-for="tag in getAccountTags(record)"
+                            :key="tag.key"
+                            size="small"
+                            bordered
+                            :color="tag.color"
+                        >
+                            {{ tag.text }}
+                        </a-tag>
+                    </span>
+                </div>
             </template>
             <template v-else>
                 {{ dayjs(record.log_date).format('YYYY-MM-DD') }}
@@ -205,6 +218,17 @@ import { useI18n } from 'vue-i18n';
 import dayjs from 'dayjs';
 import useLoading from '@/hooks/loading';
 import { queryDailyLog, DailyLogRecord, DailyLogParams } from '@/api/app';
+import {
+    queryTkPool,
+    type TkPoolParams,
+    type TkPoolRecord,
+} from '@/api/taobao';
+import { queryJdPool, type JdPoolParams, type JdPoolRecord } from '@/api/jd';
+import {
+    queryPddPool,
+    type PddPoolParams,
+    type PddPoolRecord,
+} from '@/api/pdd';
 import { Pagination } from '@/types/global';
 import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface';
 import type { TableData, TableColumnData } from '@arco-design/web-vue/es/table/interface';
@@ -214,6 +238,26 @@ import CategoriesPercent from './categories-percent.vue';
 
 type SizeProps = 'mini' | 'small' | 'medium' | 'large';
 type Column = TableColumnData & { checked?: true };
+type AccountMeta = {
+    id: number;
+    channel: number;
+    name?: string;
+    company?: string;
+    parse_type?: string;
+    riskStrategy?: string;
+    launchScene?: number;
+    riskCookie?: string;
+};
+type AccountTag = {
+    key: string;
+    text: string;
+    color: string;
+};
+
+const CHANNEL_TB = 0;
+const CHANNEL_JD = 1;
+const CHANNEL_PDD = 3;
+const ACCOUNT_PAGE_SIZE = 5000;
 
 const generateFormModel = () => {
     return {
@@ -233,6 +277,8 @@ const renderData = ref<DailyLogRecord[]>([]);
 const formModel = ref(generateFormModel());
 const cloneColumns = ref<Column[]>([]);
 const showColumns = ref<Column[]>([]);
+const accountMetaMap = ref<Record<string, AccountMeta>>({});
+const accountNameMetaMap = ref<Record<string, AccountMeta>>({});
 
 const size = ref<SizeProps>('medium');
 
@@ -285,6 +331,113 @@ const toAmount = (val: any): any => {
     return val;
 }
 
+const buildAccountKey = (channel: number, accountId: number) => `${channel}:${accountId}`;
+const buildAccountNameKey = (channel: number, accountName: string) =>
+    `${channel}:${String(accountName || '').trim()}`;
+
+const toAccountMeta = (
+    channel: number,
+    account: TkPoolRecord | JdPoolRecord | PddPoolRecord
+): AccountMeta => ({
+    id: account.id,
+    channel,
+    name: account.name,
+    company: account.company,
+    parse_type: account.parse_type,
+    riskStrategy: account.riskStrategy,
+    launchScene: account.launchScene,
+    riskCookie: account.riskCookie,
+});
+
+const appendAccountMeta = (
+    idMap: Record<string, AccountMeta>,
+    nameMap: Record<string, AccountMeta>,
+    channel: number,
+    accounts: Array<TkPoolRecord | JdPoolRecord | PddPoolRecord>
+) => {
+    accounts.forEach((account) => {
+        const meta = toAccountMeta(channel, account);
+        idMap[buildAccountKey(channel, account.id)] = meta;
+        [account.name, account.company].forEach((accountName) => {
+            const nameKey = buildAccountNameKey(channel, accountName || '');
+            if (nameKey !== `${channel}:`) {
+                nameMap[nameKey] = meta;
+            }
+        });
+    });
+};
+
+const readAccountList = async <T,>(request: Promise<{ data: { list: T[] } }>) => {
+    try {
+        const { data } = await request;
+        return data?.list || [];
+    } catch (err) {
+        console.log(err);
+        return [];
+    }
+};
+
+const loadAccountMeta = async () => {
+    const commonParams = {
+        current: 1,
+        pageSize: ACCOUNT_PAGE_SIZE,
+        show_hide: false,
+        sort: 'id',
+        order: 'descending',
+        getTotal: false,
+    };
+    const [tkAccounts, jdAccounts, pddAccounts] = await Promise.all([
+        readAccountList<TkPoolRecord>(queryTkPool(commonParams as TkPoolParams)),
+        readAccountList<JdPoolRecord>(queryJdPool(commonParams as JdPoolParams)),
+        readAccountList<PddPoolRecord>(queryPddPool(commonParams as PddPoolParams)),
+    ]);
+    const idMap: Record<string, AccountMeta> = {};
+    const nameMap: Record<string, AccountMeta> = {};
+    appendAccountMeta(idMap, nameMap, CHANNEL_TB, tkAccounts);
+    appendAccountMeta(idMap, nameMap, CHANNEL_JD, jdAccounts);
+    appendAccountMeta(idMap, nameMap, CHANNEL_PDD, pddAccounts);
+    accountMetaMap.value = idMap;
+    accountNameMetaMap.value = nameMap;
+};
+
+const getAccountMeta = (record: DailyLogRecord) => {
+    const channel = Number(record.channel);
+    const accountId = Number(record.accountId);
+    return (
+        accountMetaMap.value[buildAccountKey(channel, accountId)] ||
+        accountNameMetaMap.value[buildAccountNameKey(channel, record.accountName)]
+    );
+};
+
+const getAccountTags = (record: DailyLogRecord): AccountTag[] => {
+    const meta = getAccountMeta(record);
+    if (!meta) return [];
+
+    const tags: AccountTag[] = [];
+    if (meta.parse_type) {
+        tags.push({
+            key: 'parse_type',
+            text: meta.parse_type,
+            color: 'blue',
+        });
+    }
+    if (meta.riskStrategy) {
+        tags.push({
+            key: 'riskStrategy',
+            text: `${meta.riskStrategy}-${meta.launchScene}`,
+            color: 'orange',
+        });
+    }
+    if (meta.riskCookie) {
+        tags.push({
+            key: 'riskCookie',
+            text: meta.riskCookie,
+            color: 'red',
+        });
+    }
+    return tags;
+};
+
 const columns = computed<TableColumnData[]>(() => [
     {
         title: '日期',
@@ -637,6 +790,7 @@ const onPageChange = (current: number) => {
     });
 };
 
+loadAccountMeta();
 fetchData();
 const reset = () => {
     formModel.value = generateFormModel();
@@ -748,4 +902,21 @@ export default {
         cursor: pointer;
     }
 }
+
+.account-cell {
+    display: inline-flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 4px 6px;
+}
+
+.account-name {
+    white-space: nowrap;
+}
+
+.account-tags {
+    display: inline-flex;
+    flex-wrap: wrap;
+    gap: 4px;
+}
 </style>

+ 36 - 3
src/views/settings/config.vue

@@ -242,6 +242,30 @@
                                         </template>
                                     </a-form-item>
 
+                                    <a-divider />
+
+                                    <a-form-item field="pdd_rrecheck_enabled" label="Rrecheck">
+                                        <a-switch v-model="formData.pdd_rrecheck_enabled">
+                                            <template #checked>启用</template>
+                                            <template #unchecked>关闭</template>
+                                        </a-switch>
+                                    </a-form-item>
+
+                                    <a-form-item field="pdd_precheck_client_id" label="Precheck ClientId">
+                                        <a-input v-model="formData.pdd_precheck_client_id"
+                                            :disabled="!formData.pdd_rrecheck_enabled" />
+                                    </a-form-item>
+
+                                    <a-form-item field="pdd_precheck_client_secret" label="Precheck Secret">
+                                        <a-input-password v-model="formData.pdd_precheck_client_secret"
+                                            :disabled="!formData.pdd_rrecheck_enabled" />
+                                    </a-form-item>
+
+                                    <a-form-item field="pdd_precheck_pid" label="Precheck PID">
+                                        <a-input v-model="formData.pdd_precheck_pid"
+                                            :disabled="!formData.pdd_rrecheck_enabled" />
+                                    </a-form-item>
+
                                 </a-tab-pane>
                                 <a-tab-pane key="ks" title="快手">
                                     <a-form-item field="ksIgnorePercentage" label="放弃流量" :rules="[{ required: true }]">
@@ -364,6 +388,10 @@ const formData = ref<UpdateConfigModel>({
     pddIgnorePercentageCity: '',
     pddIgnorePercentage: 0,
     pdd_blacklist_regular: '',
+    pdd_rrecheck_enabled: true,
+    pdd_precheck_client_id: 'c8823690b47842649e4fee054317e0c1',
+    pdd_precheck_client_secret: '0b5eeab761a6b61df59f11296387b95523a26b09',
+    pdd_precheck_pid: '13585632_187155840',
 
     ks_limit_per_ip_24h: 0,
     ksIgnorePercentageCity: '',
@@ -437,9 +465,14 @@ const reset = async () => {
         config.tk_blacklist_regular = restoreJson(config.tk_blacklist_regular)
         config.multi_token_regular = restoreJson(config.multi_token_regular)
         config.account_warning_ignore_groups = config.account_warning_ignore_groups || ''
-        config.webook_forward_zlong = buildForwardChannelValue(
-            config.webook_forward_zlong || (config as any).webook_xhs_zlong || zlongForwardChannelDefaultValue
-        )
+        config.pdd_rrecheck_enabled = config.pdd_rrecheck_enabled ?? true
+        config.pdd_precheck_client_id = config.pdd_precheck_client_id || 'c8823690b47842649e4fee054317e0c1'
+        config.pdd_precheck_client_secret = config.pdd_precheck_client_secret || '0b5eeab761a6b61df59f11296387b95523a26b09'
+        config.pdd_precheck_pid = config.pdd_precheck_pid || '13585632_187155840'
+        const forwardChannelValue = Object.prototype.hasOwnProperty.call(config, 'webook_forward_zlong')
+            ? config.webook_forward_zlong
+            : (config as any).webook_xhs_zlong ?? zlongForwardChannelDefaultValue
+        config.webook_forward_zlong = buildForwardChannelValue(forwardChannelValue)
         formData.value = config
     }
     setLoading(false);

+ 577 - 364
src/views/site/tracks.vue

@@ -4,13 +4,21 @@
         <a-card class="general-card" title="监测链接管理">
             <a-row>
                 <a-col :flex="1">
-                    <a-form :model="formModel" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
-                        label-align="left">
+                    <a-form
+                        :model="formModel"
+                        :label-col-props="{ span: 6 }"
+                        :wrapper-col-props="{ span: 18 }"
+                        label-align="left"
+                    >
                         <a-row :gutter="16">
                             <a-col :span="8">
                                 <a-form-item field="keyword" label="关键词">
-                                    <a-input v-model="formModel.keyword" placeholder="搜索 platform/scene/uniqueId"
-                                        allow-clear @press-enter="search" />
+                                    <a-input
+                                        v-model="formModel.keyword"
+                                        placeholder="搜索 platform/scene/uniqueId"
+                                        allow-clear
+                                        @press-enter="search"
+                                    />
                                 </a-form-item>
                             </a-col>
                         </a-row>
@@ -40,17 +48,38 @@
                         </a-button>
                     </a-space>
                 </a-col>
-                <a-col :span="12" style="display: flex; align-items: center; justify-content: end;">
+                <a-col
+                    :span="12"
+                    style="
+                        display: flex;
+                        align-items: center;
+                        justify-content: end;
+                    "
+                >
                     <a-tooltip content="刷新">
-                        <div class="action-icon" @click="search"><icon-refresh size="18" /></div>
+                        <div class="action-icon" @click="search"
+                            ><icon-refresh size="18"
+                        /></div>
                     </a-tooltip>
                 </a-col>
             </a-row>
 
-            <a-table row-key="id" :loading="loading" :pagination="pagination" :columns="columns" :data="renderData"
-                :bordered="{ headerCell: true }" size="small" @page-change="onPageChange">
+            <a-table
+                row-key="id"
+                :loading="loading"
+                :pagination="pagination"
+                :columns="columns"
+                :data="renderData"
+                :bordered="{ headerCell: true }"
+                size="small"
+                @page-change="onPageChange"
+            >
                 <template #event_type="{ record }">
-                    <a-tag :color="record.event_type === 'expose' ? 'blue' : 'green'">
+                    <a-tag
+                        :color="
+                            record.event_type === 'expose' ? 'blue' : 'green'
+                        "
+                    >
                         {{ record.event_type === 'expose' ? '曝光' : '点击' }}
                     </a-tag>
                 </template>
@@ -64,21 +93,39 @@
                     {{ record.scene || '-' }}
                 </template>
                 <template #path="{ record }">
-                    <a-typography-paragraph copyable :ellipsis="{ rows: 1 }" style="margin-bottom: 0;">
+                    <a-typography-paragraph
+                        copyable
+                        :ellipsis="{ rows: 1 }"
+                        style="margin-bottom: 0"
+                    >
                         {{ record.path }}
                     </a-typography-paragraph>
                 </template>
                 <template #time="{ record }">
-                    <div style="color:gray;">
-                        <div>创建:{{ dayjs(record.create_time).format('YYYY-MM-DD HH:mm') }}</div>
+                    <div style="color: gray">
+                        <div
+                            >创建:{{
+                                dayjs(record.create_time).format(
+                                    'YYYY-MM-DD HH:mm'
+                                )
+                            }}</div
+                        >
                     </div>
                 </template>
                 <template #operations="{ record }">
                     <a-space>
-                        <a-button type="primary" size="small" @click="onViewReport(record)">
+                        <a-button
+                            type="primary"
+                            size="small"
+                            @click="onViewReport(record)"
+                        >
                             查看报表
                         </a-button>
-                        <a-button status="danger" size="small" @click="onDelete(record)">
+                        <a-button
+                            status="danger"
+                            size="small"
+                            @click="onDelete(record)"
+                        >
                             删除
                         </a-button>
                     </a-space>
@@ -87,56 +134,128 @@
         </a-card>
 
         <!-- 新增链接弹窗 -->
-        <a-modal v-model:visible="addModalVisible" title="新增监测链接" @ok="handleAddSubmit" @cancel="handleAddCancel"
-            width="500px">
+        <a-modal
+            v-model:visible="addModalVisible"
+            title="新增监测链接"
+            width="500px"
+            @ok="handleAddSubmit"
+            @cancel="handleAddCancel"
+        >
             <a-form :model="addForm" layout="vertical">
                 <a-form-item label="事件类型" required>
-                    <a-select v-model="addForm.event_type" placeholder="请选择事件类型">
+                    <a-select
+                        v-model="addForm.event_type"
+                        placeholder="请选择事件类型"
+                    >
                         <a-option value="expose">曝光 (expose)</a-option>
                         <a-option value="click">点击 (click)</a-option>
                     </a-select>
                 </a-form-item>
                 <a-form-item label="平台 (platform)">
-                    <a-input v-model="addForm.platform" placeholder="可选,输入平台ID,如:1(淘宝)、2(抖音)、13(京东)" allow-clear />
+                    <a-input
+                        v-model="addForm.platform"
+                        placeholder="可选,输入平台ID,如:1(淘宝)、2(抖音)、13(京东)"
+                        allow-clear
+                    />
                 </a-form-item>
                 <a-form-item label="类型 (typename)">
-                    <a-input v-model="addForm.typename" placeholder="可选,输入类型,如:口令、SDK优惠券" allow-clear />
+                    <a-input
+                        v-model="addForm.typename"
+                        placeholder="可选,输入类型,如:口令、SDK优惠券"
+                        allow-clear
+                    />
                 </a-form-item>
                 <a-form-item label="场景 (scene)">
-                    <a-input v-model="addForm.scene" placeholder="可选,输入场景,如:100000、应用建议卡" allow-clear />
+                    <a-input
+                        v-model="addForm.scene"
+                        placeholder="可选,输入场景,如:100000、应用建议卡"
+                        allow-clear
+                    />
                 </a-form-item>
                 <a-form-item label="唯一标识 (unique_id)">
-                    <a-input v-model="addForm.unique_id" placeholder="可选,用于区分同一场景下的不同链接" allow-clear />
+                    <a-input
+                        v-model="addForm.unique_id"
+                        placeholder="可选,用于区分同一场景下的不同链接"
+                        allow-clear
+                    />
                 </a-form-item>
                 <a-form-item label="描述 (description)">
-                    <a-input v-model="addForm.description" placeholder="可选,链接描述" allow-clear />
+                    <a-input
+                        v-model="addForm.description"
+                        placeholder="可选,链接描述"
+                        allow-clear
+                    />
                 </a-form-item>
             </a-form>
         </a-modal>
 
         <!-- 报表弹窗 -->
-        <a-modal v-model:visible="reportModalVisible" title="链接报表" width="900px" :footer="false">
-            <a-row :gutter="16" style="margin-bottom: 16px;">
+        <a-modal
+            v-model:visible="reportModalVisible"
+            title="链接报表"
+            width="900px"
+            :footer="false"
+        >
+            <a-row :gutter="16" style="margin-bottom: 16px">
                 <a-col :span="8">
-                    <a-range-picker v-model="reportDateRange" style="width: 100%;" @change="onReportDateChange" />
+                    <a-range-picker
+                        v-model="reportDateRange"
+                        style="width: 100%"
+                        @change="onReportDateChange"
+                    />
                 </a-col>
                 <a-col :span="4">
-                    <a-button type="primary" @click="fetchReport">查询</a-button>
+                    <a-button type="primary" @click="searchReport"
+                        >查询</a-button
+                    >
                 </a-col>
             </a-row>
-            <a-descriptions :data="currentTrackInfo" :column="2" bordered size="small" style="margin-bottom: 16px;" />
+            <a-descriptions
+                :data="currentTrackInfo"
+                :column="2"
+                bordered
+                size="small"
+                style="margin-bottom: 16px"
+            />
             <!-- 图表区域 -->
-            <a-spin :loading="chartLoading" style="width: 100%; margin-bottom: 16px;">
-                <div class="chart-title">事件趋势</div>
+            <a-spin
+                :loading="chartLoading"
+                style="width: 100%; margin-bottom: 16px"
+            >
+                <div class="chart-header">
+                    <div class="chart-title">事件趋势</div>
+                    <a-tag
+                        v-if="hourlyChartDate"
+                        color="arcoblue"
+                        closable
+                        @close="clearHourlyChart"
+                    >
+                        {{ hourlyChartDate }}
+                    </a-tag>
+                </div>
                 <Chart height="250px" :option="chartOption" />
             </a-spin>
-            <a-table row-key="id" :loading="reportLoading" :pagination="reportPagination" :columns="reportColumns"
-                :data="reportData" :bordered="{ headerCell: true }" size="small" @page-change="onReportPageChange">
+            <a-table
+                row-key="id"
+                :loading="reportLoading"
+                :pagination="reportPagination"
+                :columns="reportColumns"
+                :data="reportData"
+                :bordered="{ headerCell: true }"
+                size="small"
+                class="report-table"
+                @page-change="onReportPageChange"
+                @row-click="onReportRowClick"
+            >
                 <template #report_date="{ record }">
                     {{ dayjs(record.report_date).format('YYYY-MM-DD') }}
                 </template>
                 <template #event_type="{ record }">
-                    <a-tag :color="record.event_type === 'expose' ? 'blue' : 'green'">
+                    <a-tag
+                        :color="
+                            record.event_type === 'expose' ? 'blue' : 'green'
+                        "
+                    >
                         {{ record.event_type === 'expose' ? '曝光' : '点击' }}
                     </a-tag>
                 </template>
@@ -155,371 +274,465 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, ref, reactive, onMounted } from 'vue';
-import dayjs from 'dayjs';
-import { graphic } from 'echarts';
-import useLoading from '@/hooks/loading';
-import { Pagination } from '@/types/global';
-import { Modal, Message } from '@arco-design/web-vue';
-import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
-import {
-    TrackLinkRecord, TrackReportRecord, TrackListParams, TrackReportParams,
-    queryTrackList, createTrack, deleteTrack, queryTrackReport
-} from '@/api/tracks';
-
-const { loading, setLoading } = useLoading(false);
-const renderData = ref<TrackLinkRecord[]>([]);
-
-const formModel = ref({
-    keyword: '',
-});
-
-const basePagination: Pagination = {
-    current: 1,
-    pageSize: 20,
-};
-const pagination = reactive({
-    ...basePagination,
-});
-
-const columns = computed<TableColumnData[]>(() => [
-    { title: 'ID', dataIndex: 'id', width: 60 },
-    { title: '事件类型', slotName: 'event_type', width: 100 },
-    { title: '平台', slotName: 'platform', width: 100 },
-    { title: '类型', slotName: 'typename', width: 120 },
-    { title: '场景', slotName: 'scene', width: 150 },
-    { title: 'UniqueId', dataIndex: 'unique_id', width: 100, ellipsis: true },
-    { title: '描述', dataIndex: 'description', width: 150, ellipsis: true },
-    { title: '路径', slotName: 'path', width: 250 },
-    { title: '时间', slotName: 'time', width: 160 },
-    { title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
-]);
-
-const fetchData = async (params: TrackListParams) => {
-    setLoading(true);
-    try {
-        const res: any = await queryTrackList(params);
-        if (res && res.data) {
-            renderData.value = res.data.list || [];
-            pagination.current = res.data.page || 1;
-            pagination.total = res.data.count || 0;
-        }
-    } catch (err) {
-        console.error(err);
-    } finally {
-        setLoading(false);
-    }
-};
-
-const search = () => {
-    fetchData({
-        ...basePagination,
-        keyword: formModel.value.keyword || undefined,
-        getTotal: true,
+    import { computed, ref, reactive, onMounted } from 'vue';
+    import dayjs from 'dayjs';
+    import { graphic } from 'echarts';
+    import useLoading from '@/hooks/loading';
+    import { Pagination } from '@/types/global';
+    import { Modal, Message } from '@arco-design/web-vue';
+    import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
+    import {
+        TrackLinkRecord,
+        TrackReportRecord,
+        TrackHourlyReportRecord,
+        TrackListParams,
+        TrackReportParams,
+        queryTrackList,
+        createTrack,
+        deleteTrack,
+        queryTrackReport,
+        queryTrackHourlyReport,
+    } from '@/api/tracks';
+
+    const { loading, setLoading } = useLoading(false);
+    const renderData = ref<TrackLinkRecord[]>([]);
+
+    const formModel = ref({
+        keyword: '',
     });
-};
 
-const onPageChange = (current: number) => {
-    fetchData({
+    const basePagination: Pagination = {
+        current: 1,
+        pageSize: 20,
+        showPageSize: true,
+        showTotal: true,
+        pageSizeOptions: [10, 20, 50, 100],
+    };
+    const pagination = reactive({
         ...basePagination,
-        current,
-        keyword: formModel.value.keyword || undefined,
-        getTotal: true,
     });
-};
-
-// 新增链接
-const addModalVisible = ref(false);
-const addForm = ref<{
-    event_type: string;
-    platform: string;
-    typename: string;
-    scene: string;
-    unique_id: string;
-    description: string;
-}>({
-    event_type: 'expose',
-    platform: '',
-    typename: '',
-    scene: '',
-    unique_id: '',
-    description: '',
-});
-
-const onAdd = () => {
-    addForm.value = {
+
+    const columns = computed<TableColumnData[]>(() => [
+        { title: 'ID', dataIndex: 'id', width: 60 },
+        { title: '事件类型', slotName: 'event_type', width: 100 },
+        { title: '平台', slotName: 'platform', width: 100 },
+        { title: '类型', slotName: 'typename', width: 120 },
+        { title: '场景', slotName: 'scene', width: 150 },
+        {
+            title: 'UniqueId',
+            dataIndex: 'unique_id',
+            width: 100,
+            ellipsis: true,
+        },
+        { title: '描述', dataIndex: 'description', width: 150, ellipsis: true },
+        { title: '路径', slotName: 'path', width: 250 },
+        { title: '时间', slotName: 'time', width: 160 },
+        { title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
+    ]);
+
+    const fetchData = async (params: TrackListParams) => {
+        setLoading(true);
+        try {
+            const res: any = await queryTrackList(params);
+            if (res && res.data) {
+                renderData.value = res.data.list || [];
+                pagination.current = res.data.page || 1;
+                pagination.total = res.data.count || 0;
+            }
+        } catch {
+            Message.error('监测链接加载失败');
+        } finally {
+            setLoading(false);
+        }
+    };
+
+    const search = () => {
+        fetchData({
+            ...basePagination,
+            keyword: formModel.value.keyword || undefined,
+            getTotal: true,
+        });
+    };
+
+    const onPageChange = (current: number) => {
+        fetchData({
+            ...basePagination,
+            current,
+            keyword: formModel.value.keyword || undefined,
+            getTotal: true,
+        });
+    };
+
+    // 新增链接
+    const addModalVisible = ref(false);
+    const addForm = ref<{
+        event_type: string;
+        platform: string;
+        typename: string;
+        scene: string;
+        unique_id: string;
+        description: string;
+    }>({
         event_type: 'expose',
         platform: '',
         typename: '',
         scene: '',
         unique_id: '',
         description: '',
+    });
+
+    const onAdd = () => {
+        addForm.value = {
+            event_type: 'expose',
+            platform: '',
+            typename: '',
+            scene: '',
+            unique_id: '',
+            description: '',
+        };
+        addModalVisible.value = true;
     };
-    addModalVisible.value = true;
-};
 
-const handleAddSubmit = async () => {
-    if (!addForm.value.event_type) {
-        Message.warning('请选择事件类型');
-        return;
-    }
-    try {
-        const res: any = await createTrack(addForm.value);
-        if (res.success) {
-            Message.success(res.msg || '创建成功');
-            addModalVisible.value = false;
-            search();
-        } else {
-            Message.error(res.msg || '创建失败');
+    const handleAddSubmit = async () => {
+        if (!addForm.value.event_type) {
+            Message.warning('请选择事件类型');
+            return;
         }
-    } catch (error) {
-        Message.error('请求失败');
-    }
-};
-
-const handleAddCancel = () => {
-    addModalVisible.value = false;
-};
-
-// 删除
-const onDelete = (record: TrackLinkRecord) => {
-    Modal.confirm({
-        title: '确认删除',
-        content: `确定要删除该监测链接吗?删除后将清理缓存数据。`,
-        okText: '删除',
-        okButtonProps: { status: 'danger' },
-        cancelText: '取消',
-        onOk: async () => {
-            try {
-                const res: any = await deleteTrack(record.id);
-                if (res.success || res.data?.success) {
-                    Message.success(res.msg || res.data?.msg || '删除成功');
-                    search();
-                } else {
-                    Message.error(res.msg || res.data?.msg || '删除失败');
-                }
-            } catch (error) {
-                Message.error('删除请求失败');
+        try {
+            const res: any = await createTrack(addForm.value);
+            if (res.success) {
+                Message.success(res.msg || '创建成功');
+                addModalVisible.value = false;
+                search();
+            } else {
+                Message.error(res.msg || '创建失败');
             }
+        } catch (error) {
+            Message.error('请求失败');
         }
-    });
-};
-
-// 报表查看
-const reportModalVisible = ref(false);
-const reportLoading = ref(false);
-const chartLoading = ref(false);
-const reportData = ref<TrackReportRecord[]>([]);
-const currentTrack = ref<TrackLinkRecord | null>(null);
-const reportDateRange = ref<string[]>([
-    dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
-    dayjs().format('YYYY-MM-DD')
-]);
-
-// 图表数据
-const chartXAxis = ref<string[]>([]);
-const chartData = ref<number[]>([]);
-
-const chartOption = computed(() => {
-    return {
-        tooltip: {
-            trigger: 'axis',
-            axisPointer: { type: 'line' },
-        },
-        grid: {
-            left: '3%',
-            right: '4%',
-            bottom: '3%',
-            top: '10%',
-            containLabel: true,
-        },
-        xAxis: {
-            type: 'category',
-            boundaryGap: false,
-            data: chartXAxis.value,
-            axisLabel: { color: '#4E5969' },
-        },
-        yAxis: {
-            type: 'value',
-            axisLabel: {
-                formatter: (value: number) => {
-                    if (value >= 10000) return `${(value / 10000).toFixed(1)}w`;
-                    if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
-                    return value;
+    };
+
+    const handleAddCancel = () => {
+        addModalVisible.value = false;
+    };
+
+    // 删除
+    const onDelete = (record: TrackLinkRecord) => {
+        Modal.confirm({
+            title: '确认删除',
+            content: `确定要删除该监测链接吗?删除后将清理缓存数据。`,
+            okText: '删除',
+            okButtonProps: { status: 'danger' },
+            cancelText: '取消',
+            onOk: async () => {
+                try {
+                    const res: any = await deleteTrack(record.id);
+                    if (res.success || res.data?.success) {
+                        Message.success(res.msg || res.data?.msg || '删除成功');
+                        search();
+                    } else {
+                        Message.error(res.msg || res.data?.msg || '删除失败');
+                    }
+                } catch (error) {
+                    Message.error('删除请求失败');
+                }
+            },
+        });
+    };
+
+    // 报表查看
+    const reportModalVisible = ref(false);
+    const reportLoading = ref(false);
+    const chartLoading = ref(false);
+    const reportData = ref<TrackReportRecord[]>([]);
+    const currentTrack = ref<TrackLinkRecord | null>(null);
+    const reportDateRange = ref<string[]>([
+        dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
+        dayjs().format('YYYY-MM-DD'),
+    ]);
+
+    // 图表数据
+    const chartXAxis = ref<string[]>([]);
+    const chartData = ref<number[]>([]);
+    const hourlyChartDate = ref('');
+
+    const chartOption = computed(() => {
+        return {
+            tooltip: {
+                trigger: 'axis',
+                axisPointer: { type: 'line' },
+            },
+            grid: {
+                left: '3%',
+                right: '4%',
+                bottom: '3%',
+                top: '10%',
+                containLabel: true,
+            },
+            xAxis: {
+                type: 'category',
+                boundaryGap: false,
+                data: chartXAxis.value,
+                axisLabel: {
+                    color: '#4E5969',
+                    interval: hourlyChartDate.value ? 2 : 'auto',
                 },
             },
-            splitLine: {
-                lineStyle: { type: 'dashed', color: '#E5E8EF' },
+            yAxis: {
+                type: 'value',
+                axisLabel: {
+                    formatter: (value: number) => {
+                        if (value >= 10000)
+                            return `${(value / 10000).toFixed(1)}w`;
+                        if (value >= 1000)
+                            return `${(value / 1000).toFixed(1)}k`;
+                        return value;
+                    },
+                },
+                splitLine: {
+                    lineStyle: { type: 'dashed', color: '#E5E8EF' },
+                },
             },
-        },
-        series: [
-            {
-                name: '事件数',
-                type: 'line',
-                smooth: true,
-                data: chartData.value,
-                lineStyle: { width: 2, color: '#165DFF' },
-                itemStyle: { color: '#165DFF' },
-                areaStyle: {
-                    color: new graphic.LinearGradient(0, 0, 0, 1, [
-                        { offset: 0, color: 'rgba(22, 93, 255, 0.2)' },
-                        { offset: 1, color: 'rgba(22, 93, 255, 0)' },
-                    ]),
+            series: [
+                {
+                    name: '事件数',
+                    type: 'line',
+                    smooth: true,
+                    data: chartData.value,
+                    lineStyle: { width: 2, color: '#165DFF' },
+                    itemStyle: { color: '#165DFF' },
+                    areaStyle: {
+                        color: new graphic.LinearGradient(0, 0, 0, 1, [
+                            { offset: 0, color: 'rgba(22, 93, 255, 0.2)' },
+                            { offset: 1, color: 'rgba(22, 93, 255, 0)' },
+                        ]),
+                    },
                 },
+            ],
+        };
+    });
+
+    const reportPagination = reactive({
+        current: 1,
+        pageSize: 10,
+        total: 0,
+    });
+
+    const reportColumns = computed<TableColumnData[]>(() => [
+        { title: '日期', slotName: 'report_date', width: 120 },
+        { title: '事件类型', slotName: 'event_type', width: 100 },
+        { title: '平台', slotName: 'report_platform', width: 100 },
+        { title: '场景', slotName: 'report_scene', width: 120 },
+        { title: 'UniqueId', dataIndex: 'unique_id', width: 120 },
+        { title: '事件数', slotName: 'event_count', width: 100 },
+    ]);
+
+    const currentTrackInfo = computed(() => {
+        if (!currentTrack.value) return [];
+        return [
+            {
+                label: '事件类型',
+                value:
+                    currentTrack.value.event_type === 'expose'
+                        ? '曝光'
+                        : '点击',
             },
-        ],
+            { label: '平台', value: currentTrack.value.platform || '-' },
+            { label: '类型', value: currentTrack.value.typename || '-' },
+            { label: '场景', value: currentTrack.value.scene || '-' },
+            { label: 'UniqueId', value: currentTrack.value.unique_id || '-' },
+            { label: '描述', value: currentTrack.value.description || '-' },
+        ];
+    });
+
+    const onViewReport = (record: TrackLinkRecord) => {
+        currentTrack.value = record;
+        reportDateRange.value = [
+            dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
+            dayjs().format('YYYY-MM-DD'),
+        ];
+        reportPagination.current = 1;
+        hourlyChartDate.value = '';
+        reportModalVisible.value = true;
+        fetchReport();
+        fetchChartData();
     };
-});
-
-const reportPagination = reactive({
-    current: 1,
-    pageSize: 10,
-    total: 0,
-});
-
-const reportColumns = computed<TableColumnData[]>(() => [
-    { title: '日期', slotName: 'report_date', width: 120 },
-    { title: '事件类型', slotName: 'event_type', width: 100 },
-    { title: '平台', slotName: 'report_platform', width: 100 },
-    { title: '场景', slotName: 'report_scene', width: 120 },
-    { title: 'UniqueId', dataIndex: 'unique_id', width: 120 },
-    { title: '事件数', slotName: 'event_count', width: 100 },
-]);
-
-const currentTrackInfo = computed(() => {
-    if (!currentTrack.value) return [];
-    return [
-        { label: '事件类型', value: currentTrack.value.event_type === 'expose' ? '曝光' : '点击' },
-        { label: '平台', value: currentTrack.value.platform || '-' },
-        { label: '类型', value: currentTrack.value.typename || '-' },
-        { label: '场景', value: currentTrack.value.scene || '-' },
-        { label: 'UniqueId', value: currentTrack.value.unique_id || '-' },
-        { label: '描述', value: currentTrack.value.description || '-' },
-    ];
-});
-
-const onViewReport = (record: TrackLinkRecord) => {
-    currentTrack.value = record;
-    reportDateRange.value = [
-        dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
-        dayjs().format('YYYY-MM-DD')
-    ];
-    reportPagination.current = 1;
-    reportModalVisible.value = true;
-    fetchReport();
-    fetchChartData();
-};
-
-const fetchReport = async () => {
-    if (!currentTrack.value) return;
-    reportLoading.value = true;
-    try {
-        const params: TrackReportParams = {
-            current: reportPagination.current,
-            pageSize: reportPagination.pageSize,
-            track_link_id: currentTrack.value.id,
-            start: reportDateRange.value[0],
-            end: reportDateRange.value[1],
-            getTotal: true,
-        };
-        const res: any = await queryTrackReport(params);
-        if (res && res.data) {
-            reportData.value = res.data.list || [];
-            reportPagination.total = res.data.count || 0;
-        }
-    } catch (err) {
-        console.error(err);
-    } finally {
-        reportLoading.value = false;
-    }
-};
-
-const fetchChartData = async () => {
-    if (!currentTrack.value) return;
-    chartLoading.value = true;
-    try {
-        const params: TrackReportParams = {
-            current: 1,
-            pageSize: 1000,
-            track_link_id: currentTrack.value.id,
-            start: reportDateRange.value[0],
-            end: reportDateRange.value[1],
-            getTotal: false,
-        };
-        const res: any = await queryTrackReport(params);
-        if (res && res.data && res.data.list) {
-            // 按日期聚合
-            const dateMap = new Map<string, number>();
-            const start = dayjs(reportDateRange.value[0]);
-            const end = dayjs(reportDateRange.value[1]);
-            let current = start;
-            while (current.isBefore(end) || current.isSame(end, 'day')) {
-                dateMap.set(current.format('YYYY-MM-DD'), 0);
-                current = current.add(1, 'day');
+
+    const searchReport = () => {
+        reportPagination.current = 1;
+        hourlyChartDate.value = '';
+        fetchReport();
+        fetchChartData();
+    };
+
+    const fetchReport = async () => {
+        if (!currentTrack.value) return;
+        reportLoading.value = true;
+        try {
+            const params: TrackReportParams = {
+                current: reportPagination.current,
+                pageSize: reportPagination.pageSize,
+                track_link_id: currentTrack.value.id,
+                start: reportDateRange.value[0],
+                end: reportDateRange.value[1],
+                getTotal: true,
+            };
+            const res: any = await queryTrackReport(params);
+            if (res && res.data) {
+                reportData.value = res.data.list || [];
+                reportPagination.total = res.data.count || 0;
             }
+        } catch {
+            Message.error('报表数据加载失败');
+        } finally {
+            reportLoading.value = false;
+        }
+    };
 
-            res.data.list.forEach((item: TrackReportRecord) => {
-                const date = dayjs(item.report_date).format('YYYY-MM-DD');
-                if (dateMap.has(date)) {
-                    dateMap.set(date, (dateMap.get(date) || 0) + item.event_count);
+    const fetchChartData = async () => {
+        if (!currentTrack.value) return;
+        chartLoading.value = true;
+        try {
+            const params: TrackReportParams = {
+                current: 1,
+                pageSize: 1000,
+                track_link_id: currentTrack.value.id,
+                start: reportDateRange.value[0],
+                end: reportDateRange.value[1],
+                getTotal: false,
+            };
+            const res: any = await queryTrackReport(params);
+            if (res && res.data && res.data.list) {
+                // 按日期聚合
+                const dateMap = new Map<string, number>();
+                const start = dayjs(reportDateRange.value[0]);
+                const end = dayjs(reportDateRange.value[1]);
+                let current = start;
+                while (current.isBefore(end) || current.isSame(end, 'day')) {
+                    dateMap.set(current.format('YYYY-MM-DD'), 0);
+                    current = current.add(1, 'day');
                 }
+
+                res.data.list.forEach((item: TrackReportRecord) => {
+                    const date = dayjs(item.report_date).format('YYYY-MM-DD');
+                    if (dateMap.has(date)) {
+                        dateMap.set(
+                            date,
+                            (dateMap.get(date) || 0) + item.event_count
+                        );
+                    }
+                });
+
+                chartXAxis.value = [];
+                chartData.value = [];
+                const sortedDates = Array.from(dateMap.keys()).sort();
+                sortedDates.forEach((date) => {
+                    chartXAxis.value.push(dayjs(date).format('MM-DD'));
+                    chartData.value.push(dateMap.get(date) || 0);
+                });
+            }
+        } catch {
+            Message.error('趋势数据加载失败');
+        } finally {
+            chartLoading.value = false;
+        }
+    };
+
+    const fetchHourlyChartData = async (date: string, scene = '') => {
+        if (!currentTrack.value) return;
+        chartLoading.value = true;
+        try {
+            const res: any = await queryTrackHourlyReport({
+                track_link_id: currentTrack.value.id,
+                date,
+                scene,
+            });
+            const list: TrackHourlyReportRecord[] = res?.data?.list || [];
+            const hourMap = new Map<number, number>();
+            for (let hour = 0; hour < 24; hour += 1) {
+                hourMap.set(hour, 0);
+            }
+
+            list.forEach((item) => {
+                hourMap.set(item.hour, item.event_count || 0);
             });
 
             chartXAxis.value = [];
             chartData.value = [];
-            const sortedDates = Array.from(dateMap.keys()).sort();
-            sortedDates.forEach(date => {
-                chartXAxis.value.push(dayjs(date).format('MM-DD'));
-                chartData.value.push(dateMap.get(date) || 0);
-            });
+            for (let hour = 0; hour < 24; hour += 1) {
+                chartXAxis.value.push(`${hour.toString().padStart(2, '0')}:00`);
+                chartData.value.push(hourMap.get(hour) || 0);
+            }
+        } catch {
+            Message.error('小时数据加载失败');
+        } finally {
+            chartLoading.value = false;
         }
-    } catch (err) {
-        console.error(err);
-    } finally {
-        chartLoading.value = false;
-    }
-};
-
-const onReportDateChange = () => {
-    reportPagination.current = 1;
-    fetchReport();
-    fetchChartData();
-};
-
-const onReportPageChange = (current: number) => {
-    reportPagination.current = current;
-    fetchReport();
-};
-
-onMounted(() => {
-    search();
-});
+    };
+
+    const clearHourlyChart = () => {
+        hourlyChartDate.value = '';
+        fetchChartData();
+    };
+
+    const onReportRowClick = (record: any) => {
+        const date = dayjs(record.report_date).format('YYYY-MM-DD');
+        hourlyChartDate.value = date;
+        fetchHourlyChartData(date, record.scene || '');
+    };
+
+    const onReportDateChange = () => {
+        reportPagination.current = 1;
+        hourlyChartDate.value = '';
+        fetchReport();
+        fetchChartData();
+    };
+
+    const onReportPageChange = (current: number) => {
+        reportPagination.current = current;
+        fetchReport();
+    };
+
+    onMounted(() => {
+        search();
+    });
 </script>
 
 <script lang="ts">
-export default {
-    name: 'Tracks',
-};
+    export default {
+        name: 'Tracks',
+    };
 </script>
 
 <style scoped lang="less">
-.container {
-    padding: 0 20px 20px 20px;
-}
-
-:deep(.general-card) {
-    height: 100%;
-}
-
-.action-icon {
-    margin-left: 12px;
-    cursor: pointer;
-}
-
-.chart-title {
-    font-size: 14px;
-    font-weight: 500;
-    color: var(--color-text-1);
-    margin-bottom: 12px;
-}
+    .container {
+        padding: 0 20px 20px 20px;
+    }
+
+    :deep(.general-card) {
+        height: 100%;
+    }
+
+    .action-icon {
+        margin-left: 12px;
+        cursor: pointer;
+    }
+
+    .chart-header {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        min-height: 24px;
+        margin-bottom: 12px;
+    }
+
+    .chart-title {
+        font-size: 14px;
+        font-weight: 500;
+        color: var(--color-text-1);
+    }
+
+    .report-table :deep(.arco-table-tr) {
+        cursor: pointer;
+    }
 </style>

+ 1913 - 168
src/views/site/tracksReport.vue

@@ -1,187 +1,1932 @@
 <template>
     <div class="container">
         <Breadcrumb :items="['menu.settings', 'menu.tracksReport']" />
-        <a-card class="general-card" title="监测链接报表">
-            <a-row :gutter="16" style="margin-bottom: 16px;">
-                <a-col :span="5">
-                    <a-form-item label="时间范围">
-                        <a-range-picker v-model="dateRange" style="width: 100%;" @change="onDateChange" />
-                    </a-form-item>
-                </a-col>
-                <a-col :span="3">
-                    <a-form-item label="事件类型">
-                        <a-select v-model="formModel.event_type" placeholder="全部" allow-clear @change="search">
-                            <a-option value="expose">曝光</a-option>
-                            <a-option value="click">点击</a-option>
-                        </a-select>
-                    </a-form-item>
-                </a-col>
-                <a-col :span="3">
-                    <a-form-item label="平台">
-                        <a-input v-model="formModel.platform" placeholder="平台ID" allow-clear @press-enter="search" />
-                    </a-form-item>
-                </a-col>
-                <a-col :span="3">
-                    <a-form-item label="类型">
-                        <a-input v-model="formModel.typename" placeholder="类型" allow-clear @press-enter="search" />
-                    </a-form-item>
-                </a-col>
-                <a-col :span="3">
-                    <a-form-item label="场景">
-                        <a-input v-model="formModel.scene" placeholder="场景" allow-clear @press-enter="search" />
-                    </a-form-item>
-                </a-col>
-                <a-col :span="3">
-                    <a-button type="primary" @click="search" style="margin-top: 20px;">
+        <a-card class="general-card" :bordered="false">
+            <div class="report-header">
+                <div>
+                    <div class="page-title">链接报表</div>
+                    <div class="page-subtitle">
+                        {{ dateRangeText }} ·
+                        {{ formatNumber(summary.total) }} 次事件
+                    </div>
+                </div>
+            </div>
+
+            <div class="filter-bar">
+                <a-range-picker
+                    v-model="dateRange"
+                    class="filter-date"
+                    @change="onDateChange"
+                />
+                <a-select
+                    v-model="formModel.event_type"
+                    class="filter-control"
+                    placeholder="全部事件"
+                    allow-clear
+                    @change="search"
+                >
+                    <a-option value="expose">曝光</a-option>
+                    <a-option value="click">点击</a-option>
+                </a-select>
+                <a-select
+                    v-model="formModel.platform"
+                    class="filter-control"
+                    placeholder="平台"
+                    allow-clear
+                    @change="onPlatformChange"
+                >
+                    <a-option
+                        v-for="item in platformOptions"
+                        :key="item.value"
+                        :value="item.value"
+                    >
+                        {{ item.label }}
+                    </a-option>
+                </a-select>
+                <a-select
+                    v-model="formModel.typename"
+                    class="filter-control"
+                    :disabled="!formModel.platform"
+                    :placeholder="formModel.platform ? '类型' : '先选平台'"
+                    allow-clear
+                    @change="onLocalFilterChange"
+                >
+                    <a-option
+                        v-for="item in typenameOptions"
+                        :key="item.value"
+                        :value="item.value"
+                    >
+                        {{ item.label }}
+                    </a-option>
+                </a-select>
+                <a-input
+                    v-model="formModel.scene"
+                    class="filter-control"
+                    placeholder="场景"
+                    allow-clear
+                    @press-enter="search"
+                />
+                <a-input
+                    v-model="formModel.unique_id"
+                    class="filter-control filter-unique"
+                    placeholder="唯一标识"
+                    allow-clear
+                    @press-enter="search"
+                />
+                <div class="filter-actions">
+                    <a-button @click="resetFilters">重置</a-button>
+                    <a-button type="primary" :loading="loading" @click="search">
                         <template #icon><icon-search /></template>
                         查询
                     </a-button>
-                </a-col>
-            </a-row>
-
-            <!-- 表格区域 -->
-            <a-table row-key="id" :loading="loading" :pagination="pagination" :columns="columns" :data="renderData"
-                :bordered="{ headerCell: true }" size="small" @page-change="onPageChange">
-                <template #report_date="{ record }">
-                    {{ dayjs(record.report_date).format('YYYY-MM-DD') }}
-                </template>
-                <template #event_type="{ record }">
-                    <a-tag :color="record.event_type === 'expose' ? 'blue' : 'green'">
-                        {{ record.event_type === 'expose' ? '曝光' : '点击' }}
-                    </a-tag>
-                </template>
-                <template #platform="{ record }">
-                    {{ record.platform || '-' }}
-                </template>
-                <template #typename="{ record }">
-                    {{ record.typename || '-' }}
-                </template>
-                <template #scene="{ record }">
-                    {{ record.scene || '-' }}
-                </template>
-                <template #event_count="{ record }">
-                    <a-tag color="arcoblue">{{ record.event_count.toLocaleString() }}</a-tag>
-                </template>
-            </a-table>
+                </div>
+            </div>
+
+            <a-alert
+                v-if="dataLimitExceeded"
+                type="warning"
+                class="limit-alert"
+            >
+                当前条件命中
+                {{ formatNumber(serverTotal) }} 条日报记录,页面已分析前
+                {{ formatNumber(rawRecords.length) }}
+                条。建议缩短时间范围后再看分布。
+            </a-alert>
+            <a-alert
+                v-if="accountDataLimitExceeded"
+                type="warning"
+                class="limit-alert"
+            >
+                当前条件命中
+                {{ formatNumber(accountServerTotal) }} 条账号明细,页面已分析前
+                {{ formatNumber(accountRecords.length) }}
+                条。建议缩短时间范围后再看账号展开。
+            </a-alert>
+
+            <div class="metric-grid">
+                <div class="metric-item">
+                    <div class="metric-label">总曝光</div>
+                    <div class="metric-value">{{
+                        formatNumber(summary.expose)
+                    }}</div>
+                    <div class="metric-meta"
+                        >{{ summary.activeDays }} 个有数据日期</div
+                    >
+                </div>
+                <div class="metric-item">
+                    <div class="metric-label">总点击</div>
+                    <div class="metric-value">{{
+                        formatNumber(summary.click)
+                    }}</div>
+                    <div class="metric-meta">
+                        {{ summary.clickShareText }} 的事件来自点击
+                    </div>
+                </div>
+                <div class="metric-item">
+                    <div class="metric-label">点击率</div>
+                    <div class="metric-value">{{
+                        formatRate(summary.ctr)
+                    }}</div>
+                    <div class="metric-meta">点击 / 曝光</div>
+                </div>
+                <div class="metric-item">
+                    <div class="metric-label">活跃链路</div>
+                    <div class="metric-value">{{
+                        formatNumber(summary.activeLinks)
+                    }}</div>
+                    <div class="metric-meta">按平台、类型、场景、标识聚合</div>
+                </div>
+            </div>
+
+            <div class="report-section">
+                <div class="section-head">
+                    <div>
+                        <div class="section-title">趋势总览</div>
+                        <div class="section-subtitle">
+                            峰值 {{ peakDayText }}
+                        </div>
+                    </div>
+                    <a-tag color="arcoblue">{{ rangeDays }} 天</a-tag>
+                </div>
+                <a-spin class="chart-spin" :loading="loading">
+                    <Chart height="300px" :option="trendChartOption" />
+                </a-spin>
+            </div>
+
+            <div class="insight-grid">
+                <div class="insight-panel">
+                    <div class="section-head compact">
+                        <div class="section-title">平台表现</div>
+                    </div>
+                    <div v-if="sourceRanking.length" class="rank-list">
+                        <div
+                            v-for="item in sourceRanking"
+                            :key="item.name"
+                            class="rank-row"
+                        >
+                            <div class="rank-main">
+                                <span class="rank-name">{{ item.name }}</span>
+                                <span class="rank-value">{{
+                                    formatNumber(item.value)
+                                }}</span>
+                            </div>
+                            <div class="rank-track">
+                                <div
+                                    class="rank-bar"
+                                    :style="{ width: `${item.percent}%` }"
+                                />
+                            </div>
+                        </div>
+                    </div>
+                    <a-empty v-else />
+                </div>
+
+                <div class="insight-panel">
+                    <div class="section-head compact">
+                        <div class="section-title">场景表现</div>
+                    </div>
+                    <div v-if="sceneRanking.length" class="rank-list">
+                        <div
+                            v-for="item in sceneRanking"
+                            :key="item.name"
+                            class="rank-row"
+                        >
+                            <div class="rank-main">
+                                <span class="rank-name">{{ item.name }}</span>
+                                <span class="rank-value">{{
+                                    formatNumber(item.value)
+                                }}</span>
+                            </div>
+                            <div class="rank-track">
+                                <div
+                                    class="rank-bar scene"
+                                    :style="{ width: `${item.percent}%` }"
+                                />
+                            </div>
+                        </div>
+                    </div>
+                    <a-empty v-else />
+                </div>
+            </div>
+
+            <div class="report-section">
+                <div class="section-head">
+                    <div>
+                        <div class="section-title">链路表现</div>
+                        <div class="section-subtitle">日报维度</div>
+                    </div>
+                    <a-tag>{{ formatNumber(dimensionRows.length) }} 条</a-tag>
+                </div>
+                <a-table
+                    row-key="key"
+                    :loading="loading"
+                    :pagination="tablePagination"
+                    :columns="columns"
+                    :data="pagedTableRows"
+                    :bordered="{ headerCell: true }"
+                    size="small"
+                    class="detail-table"
+                    :expanded-keys="expandedAccountRows"
+                    :expandable="tableExpandable"
+                    @page-change="onPageChange"
+                    @page-size-change="onPageSizeChange"
+                    @expanded-change="onExpandedAccountRowsChange"
+                >
+                    <template #expandIcon="{ record }">
+                        <a-button
+                            v-if="
+                                !record.isAccountRow &&
+                                getAccountRows(record).length
+                            "
+                            type="text"
+                            size="mini"
+                            @click.stop="toggleAccountExpand(record.key)"
+                        >
+                            <icon-plus
+                                v-if="!expandedAccountRows.includes(record.key)"
+                            />
+                            <icon-minus v-else />
+                        </a-button>
+                        <a-tooltip
+                            v-else-if="!record.isAccountRow"
+                            content="暂无账号明细"
+                        >
+                            <span class="empty-expand-icon">-</span>
+                        </a-tooltip>
+                    </template>
+                    <template #date="{ record }">
+                        <span v-if="record.isAccountRow" class="account-id">
+                            {{ record.accountId }}
+                        </span>
+                        <span v-else>{{ record.date }}</span>
+                    </template>
+                    <template #dimension="{ record }">
+                        <div v-if="record.isAccountRow" class="account-cell">
+                            <div class="account-line">
+                                <span class="account-name">
+                                    {{ record.name }}
+                                </span>
+                                <a-tag color="green" size="small">
+                                    调用 {{ formatNumber(record.calls) }}
+                                </a-tag>
+                                <a-tag
+                                    v-for="tag in record.tags"
+                                    :key="tag.key"
+                                    :color="tag.color"
+                                    size="small"
+                                >
+                                    {{ tag.text }}
+                                </a-tag>
+                            </div>
+                            <div v-if="record.company" class="account-company">
+                                {{ record.company }}
+                            </div>
+                        </div>
+                        <div v-else class="dimension-cell">
+                            <div class="dimension-line">
+                                <a-tag :color="getSourceColor(record.source)">
+                                    {{ record.source }}
+                                </a-tag>
+                                <span class="dimension-type">{{
+                                    record.typename
+                                }}</span>
+                                <span class="dimension-scene">{{
+                                    record.scene
+                                }}</span>
+                            </div>
+                            <div class="dimension-sub">
+                                {{ record.unique_id }}
+                            </div>
+                        </div>
+                    </template>
+                    <template #expose="{ record }">
+                        <span class="number-cell">{{
+                            formatNumber(record.expose)
+                        }}</span>
+                    </template>
+                    <template #click="{ record }">
+                        <span class="number-cell">{{
+                            formatNumber(record.click)
+                        }}</span>
+                    </template>
+                    <template #ctr="{ record }">
+                        <span class="number-cell">{{
+                            formatRate(record.ctr)
+                        }}</span>
+                    </template>
+                    <template #total="{ record }">
+                        <a-tag color="arcoblue">
+                            {{ formatNumber(record.total) }}
+                        </a-tag>
+                    </template>
+                    <template #hour_action="{ record }">
+                        <a-button
+                            v-if="!record.isAccountRow"
+                            type="text"
+                            size="mini"
+                            @click.stop="openHourlyReport(record)"
+                        >
+                            小时分布
+                        </a-button>
+                    </template>
+                </a-table>
+            </div>
         </a-card>
+
+        <a-modal
+            v-model:visible="hourlyVisible"
+            width="820px"
+            :footer="false"
+            :title="hourlyTitle"
+        >
+            <div v-if="selectedRow" class="hourly-context">
+                <a-tag :color="getSourceColor(selectedRow.source)">
+                    {{ selectedRow.source }}
+                </a-tag>
+                <span>{{ selectedRow.scene }}</span>
+                <span>{{ selectedRow.typename }}</span>
+                <span class="hourly-unique">{{ selectedRow.unique_id }}</span>
+            </div>
+            <div class="hourly-metrics">
+                <div class="hourly-metric">
+                    <span>曝光</span>
+                    <strong>{{ formatNumber(hourlySummary.expose) }}</strong>
+                </div>
+                <div class="hourly-metric">
+                    <span>点击</span>
+                    <strong>{{ formatNumber(hourlySummary.click) }}</strong>
+                </div>
+                <div class="hourly-metric">
+                    <span>点击率</span>
+                    <strong>{{ formatRate(hourlySummary.ctr) }}</strong>
+                </div>
+            </div>
+            <a-spin class="chart-spin" :loading="hourlyLoading">
+                <Chart
+                    :key="hourlyChartKey"
+                    height="300px"
+                    :option="hourlyChartOption"
+                />
+            </a-spin>
+        </a-modal>
     </div>
 </template>
 
 <script lang="ts" setup>
-import { ref, reactive, computed, onMounted } from 'vue';
-import dayjs from 'dayjs';
-import useLoading from '@/hooks/loading';
-import { Pagination } from '@/types/global';
-import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
-import { TrackReportRecord, TrackReportParams, queryTrackReport } from '@/api/tracks';
-
-const { loading, setLoading } = useLoading(false);
-const renderData = ref<TrackReportRecord[]>([]);
-
-const dateRange = ref<string[]>([
-    dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
-    dayjs().format('YYYY-MM-DD')
-]);
-
-const formModel = ref<{
-    event_type: string;
-    platform: string;
-    typename: string;
-    scene: string;
-    unique_id: string;
-}>({
-    event_type: '',
-    platform: '',
-    typename: '',
-    scene: '',
-    unique_id: '',
-});
-
-const basePagination: Pagination = {
-    current: 1,
-    pageSize: 20,
-};
-const pagination = reactive({
-    ...basePagination,
-});
-
-const columns = computed<TableColumnData[]>(() => [
-    { title: '日期', slotName: 'report_date', width: 120 },
-    { title: '事件类型', slotName: 'event_type', width: 100 },
-    { title: '平台', slotName: 'platform', width: 100 },
-    { title: '类型', slotName: 'typename', width: 120 },
-    { title: '场景', slotName: 'scene', width: 150 },
-    { title: 'UniqueId', dataIndex: 'unique_id', width: 100, ellipsis: true },
-    { title: '描述', dataIndex: 'description', width: 150, ellipsis: true },
-    { title: '事件数', slotName: 'event_count', width: 120 },
-]);
-
-const fetchData = async (params: TrackReportParams) => {
-    setLoading(true);
-    try {
-        const res: any = await queryTrackReport(params);
-        if (res && res.data) {
-            renderData.value = res.data.list || [];
-            pagination.current = res.data.page || 1;
-            pagination.total = res.data.count || 0;
-        }
-    } catch (err) {
-        console.error(err);
-    } finally {
-        setLoading(false);
-    }
-};
-
-const search = () => {
-    const params: TrackReportParams = {
-        ...basePagination,
-        event_type: formModel.value.event_type || undefined,
-        platform: formModel.value.platform || undefined,
-        typename: formModel.value.typename || undefined,
-        scene: formModel.value.scene || undefined,
-        start: dateRange.value[0],
-        end: dateRange.value[1],
-        getTotal: true,
-    };
-    fetchData(params);
-};
-
-const onDateChange = () => {
-    search();
-};
-
-const onPageChange = (current: number) => {
-    const params: TrackReportParams = {
-        ...basePagination,
-        current,
-        event_type: formModel.value.event_type || undefined,
-        platform: formModel.value.platform || undefined,
-        typename: formModel.value.typename || undefined,
-        scene: formModel.value.scene || undefined,
-        start: dateRange.value[0],
-        end: dateRange.value[1],
-        getTotal: true,
-    };
-    fetchData(params);
-};
-
-onMounted(() => {
-    search();
-});
+    import { computed, onMounted, reactive, ref, watch } from 'vue';
+    import dayjs from 'dayjs';
+    import { graphic } from 'echarts';
+    import useLoading from '@/hooks/loading';
+    import { Pagination } from '@/types/global';
+    import { Message } from '@arco-design/web-vue';
+    import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
+    import {
+        TrackHourlyReportRecord,
+        TrackLinkRecord,
+        TrackReportParams,
+        TrackReportRecord,
+        queryTrackList,
+        queryTrackHourlyReport,
+        queryTrackReport,
+    } from '@/api/tracks';
+    import {
+        DailyLogParams,
+        DailyLogRecord,
+        queryDailyLog,
+    } from '@/api/app';
+    import {
+        TkPoolParams,
+        TkPoolRecord,
+        queryTkPool,
+    } from '@/api/taobao';
+    import {
+        JdPoolParams,
+        JdPoolRecord,
+        queryJdPool,
+    } from '@/api/jd';
+    import {
+        PddPoolParams,
+        PddPoolRecord,
+        queryPddPool,
+    } from '@/api/pdd';
+
+    type EventType = 'expose' | 'click';
+
+    interface DimensionRow {
+        key: string;
+        date: string;
+        source: string;
+        typename: string;
+        scene: string;
+        unique_id: string;
+        expose: number;
+        click: number;
+        total: number;
+        ctr: number | null;
+        exposeTrackIds: number[];
+        clickTrackIds: number[];
+    }
+
+    interface RankingItem {
+        name: string;
+        value: number;
+        percent: number;
+    }
+
+    interface AccountMeta {
+        id: number;
+        channel: number;
+        name?: string;
+        company?: string;
+        parse_type?: string;
+        riskStrategy?: string;
+        launchScene?: number;
+        riskCookie?: string;
+    }
+
+    interface AccountTag {
+        key: string;
+        text: string;
+        color: string;
+    }
+
+    interface AccountReportRow {
+        key: string;
+        accountId: number;
+        channel: number;
+        name: string;
+        company: string;
+        tags: AccountTag[];
+        calls: number;
+        expose: number;
+        click: number;
+        total: number;
+        ctr: number | null;
+    }
+
+    interface AccountTableRow extends AccountReportRow {
+        isAccountRow: true;
+        parentKey: string;
+        date: string;
+        source: string;
+        typename: string;
+        scene: string;
+        unique_id: string;
+        exposeTrackIds: number[];
+        clickTrackIds: number[];
+    }
+
+    type DimensionTableRow = DimensionRow & {
+        isAccountRow?: false;
+        children?: AccountTableRow[];
+    };
+
+    const reportFetchLimit = 5000;
+    const accountReportFetchLimit = 10000;
+    const linkFetchLimit = 5000;
+    const dailyLogFetchLimit = 5000;
+    const accountFetchLimit = 5000;
+    const CHANNEL_TB = 0;
+    const CHANNEL_JD = 1;
+    const CHANNEL_PDD = 3;
+    const sourceByTrackId: Record<number, string> = {
+        1: '淘宝',
+        2: '淘宝',
+        19: '京东',
+        20: '京东',
+        21: '拼多多',
+        22: '拼多多',
+        23: '淘宝',
+        24: '淘宝',
+    };
+    const channelBySource: Record<string, number> = {
+        淘宝: CHANNEL_TB,
+        京东: CHANNEL_JD,
+        拼多多: CHANNEL_PDD,
+    };
+    const sourceByCode: Record<string, string> = {
+        '1': '淘宝',
+        '9': '拼多多',
+        '13': '京东',
+        'jd': '京东',
+        'pdd': '拼多多',
+        'taobao': '淘宝',
+        'tb': '淘宝',
+    };
+
+    function normalizeEventType(eventType: string) {
+        return eventType?.toLowerCase() as EventType;
+    }
+
+    function normalizeDimensionValue(value: string, fallback: string) {
+        const text = `${value || ''}`.trim();
+        return text || fallback;
+    }
+
+    function getLinkPlatformLabel(link: TrackLinkRecord) {
+        return normalizeDimensionValue(link.platform, '未设置平台');
+    }
+
+    function getLinkTypeLabel(link: TrackLinkRecord) {
+        return normalizeDimensionValue(link.typename, '未设置类型');
+    }
+
+    function getSourceLabel(record: TrackReportRecord) {
+        const link = trackLinkMap.value.get(record.track_link_id);
+        if (link) return getLinkPlatformLabel(link);
+
+        const rawPlatform = `${record.platform || ''}`.trim();
+        const lowerPlatform = rawPlatform.toLowerCase();
+        if (rawPlatform && sourceByCode[lowerPlatform]) {
+            return sourceByCode[lowerPlatform];
+        }
+        if (rawPlatform) return rawPlatform;
+
+        const uniquePrefix = `${record.unique_id || ''}`
+            .split('|')[0]
+            .trim()
+            .toLowerCase();
+        if (uniquePrefix && sourceByCode[uniquePrefix]) {
+            return sourceByCode[uniquePrefix];
+        }
+
+        return sourceByTrackId[record.track_link_id] || '未设置平台';
+    }
+
+    function getTypeLabel(record: TrackReportRecord) {
+        const link = trackLinkMap.value.get(record.track_link_id);
+        if (link) return getLinkTypeLabel(link);
+        return normalizeDimensionValue(record.typename, '未设置类型');
+    }
+
+    function getSceneLabel(record: TrackReportRecord) {
+        const scene = `${record.scene || ''}`.trim();
+        return scene || '默认场景';
+    }
+
+    function getUniqueLabel(record: TrackReportRecord) {
+        const uniqueId = `${record.unique_id || ''}`.trim();
+        if (uniqueId) return uniqueId;
+        return '未设置标识';
+    }
+
+    function addTrackId(list: number[], trackId: number) {
+        if (trackId > 0 && !list.includes(trackId)) list.push(trackId);
+    }
+
+    const buildAccountKey = (channel: number, accountId: number) =>
+        `${channel}:${accountId}`;
+
+    const toAccountMeta = (
+        channel: number,
+        account: TkPoolRecord | JdPoolRecord | PddPoolRecord
+    ): AccountMeta => ({
+        id: account.id,
+        channel,
+        name: account.name,
+        company: account.company,
+        parse_type: account.parse_type,
+        riskStrategy: account.riskStrategy,
+        launchScene: account.launchScene,
+        riskCookie: account.riskCookie,
+    });
+
+    const appendAccountMeta = (
+        map: Record<string, AccountMeta>,
+        channel: number,
+        accounts: Array<TkPoolRecord | JdPoolRecord | PddPoolRecord>
+    ) => {
+        accounts.forEach((account) => {
+            map[buildAccountKey(channel, account.id)] = toAccountMeta(
+                channel,
+                account
+            );
+        });
+    };
+
+    const readAccountList = async <T,>(request: Promise<any>) => {
+        try {
+            const { data } = await request;
+            return (data?.list || []) as T[];
+        } catch {
+            return [];
+        }
+    };
+
+    const getChannelBySource = (source: string) => channelBySource[source] ?? -1;
+
+    const getCallCount = (record: DailyLogRecord) => {
+        const channel = Number(record.channel);
+        if (channel === CHANNEL_JD) {
+            return Number(record.jd_parse_total_count || 0);
+        }
+        if (channel === CHANNEL_PDD) {
+            return Number(record.pdd_parse_total_count || 0);
+        }
+        return Number(record.parse_total_count || 0);
+    };
+
+    const { loading, setLoading } = useLoading(false);
+    const rawRecords = ref<TrackReportRecord[]>([]);
+    const accountRecords = ref<TrackReportRecord[]>([]);
+    const dailyLogs = ref<DailyLogRecord[]>([]);
+    const trackLinks = ref<TrackLinkRecord[]>([]);
+    const accountMetaMap = ref<Record<string, AccountMeta>>({});
+    const expandedAccountRows = ref<string[]>([]);
+    const serverTotal = ref(0);
+    const accountServerTotal = ref(0);
+    const hourlyVisible = ref(false);
+    const hourlyLoading = ref(false);
+    const selectedRow = ref<DimensionRow | null>(null);
+    const hourlyXAxis = ref<string[]>([]);
+    const hourlyExpose = ref<number[]>([]);
+    const hourlyClick = ref<number[]>([]);
+    const hourlyChartKey = ref(0);
+
+    const defaultDateRange = () => [
+        dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
+        dayjs().format('YYYY-MM-DD'),
+    ];
+
+    const dateRange = ref<string[]>(defaultDateRange());
+    const formModel = reactive({
+        event_type: '',
+        platform: '',
+        typename: '',
+        scene: '',
+        unique_id: '',
+    });
+
+    const pagination = reactive<Pagination>({
+        current: 1,
+        pageSize: 20,
+        total: 0,
+        showPageSize: true,
+        showTotal: true,
+        pageSizeOptions: [10, 20, 50, 100],
+    });
+
+    const columns = computed<TableColumnData[]>(() => [
+        { title: '日期', dataIndex: 'date', slotName: 'date', width: 120 },
+        { title: '业务维度', slotName: 'dimension', width: 360 },
+        { title: '曝光', slotName: 'expose', width: 120, align: 'right' },
+        { title: '点击', slotName: 'click', width: 120, align: 'right' },
+        { title: '点击率', slotName: 'ctr', width: 120, align: 'right' },
+        { title: '合计', slotName: 'total', width: 120, align: 'right' },
+        { title: '操作', slotName: 'hour_action', width: 120, align: 'center' },
+    ]);
+
+    const trackLinkMap = computed(() => {
+        const map = new Map<number, TrackLinkRecord>();
+        trackLinks.value.forEach((item) => {
+            if (item.id > 0) map.set(item.id, item);
+        });
+        return map;
+    });
+
+    const dailyCallMap = computed(() => {
+        const map = new Map<string, number>();
+        dailyLogs.value.forEach((record) => {
+            const date = dayjs(record.log_date).format('YYYY-MM-DD');
+            const channel = Number(record.channel);
+            const accountId = Number(record.accountId);
+            if (channel < 0 || accountId <= 0) return;
+            const key = [date, channel, accountId].join('|');
+            map.set(key, (map.get(key) || 0) + getCallCount(record));
+        });
+        return map;
+    });
+
+    const getAccountMeta = (channel: number, accountId: number) =>
+        accountMetaMap.value[buildAccountKey(channel, accountId)];
+
+    const getAccountTags = (meta?: AccountMeta): AccountTag[] => {
+        if (!meta) return [];
+
+        const tags: AccountTag[] = [];
+        if (meta.parse_type) {
+            tags.push({
+                key: 'parse_type',
+                text: meta.parse_type,
+                color: 'blue',
+            });
+        }
+        if (meta.riskStrategy) {
+            tags.push({
+                key: 'riskStrategy',
+                text:
+                    meta.launchScene !== undefined && meta.launchScene !== null
+                        ? `${meta.riskStrategy}-${meta.launchScene}`
+                        : meta.riskStrategy,
+                color: 'orange',
+            });
+        }
+        if (meta.riskCookie) {
+            tags.push({
+                key: 'riskCookie',
+                text: meta.riskCookie,
+                color: 'red',
+            });
+        }
+        return tags;
+    };
+
+    const platformTypeMap = computed(() => {
+        const map = new Map<string, Set<string>>();
+        trackLinks.value.forEach((item) => {
+            const platform = getLinkPlatformLabel(item);
+            const typename = getLinkTypeLabel(item);
+            if (!map.has(platform)) map.set(platform, new Set<string>());
+            map.get(platform)?.add(typename);
+        });
+        return map;
+    });
+
+    const platformOptions = computed(() =>
+        Array.from(platformTypeMap.value.keys())
+            .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN'))
+            .map((value) => ({ label: value, value }))
+    );
+
+    const typenameOptions = computed(() => {
+        if (!formModel.platform) return [];
+        return Array.from(platformTypeMap.value.get(formModel.platform) || [])
+            .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN'))
+            .map((value) => ({ label: value, value }));
+    });
+
+    const normalizedRecords = computed(() => {
+        const sceneKeyword = formModel.scene.trim().toLowerCase();
+        const uniqueKeyword = formModel.unique_id.trim().toLowerCase();
+
+        return rawRecords.value.filter((record) => {
+            const source = getSourceLabel(record);
+            const typename = getTypeLabel(record);
+            const scene = getSceneLabel(record);
+            const uniqueId = getUniqueLabel(record);
+
+            if (formModel.platform && source !== formModel.platform) {
+                return false;
+            }
+            if (formModel.typename && typename !== formModel.typename) {
+                return false;
+            }
+            if (sceneKeyword && !scene.toLowerCase().includes(sceneKeyword)) {
+                return false;
+            }
+            if (
+                uniqueKeyword &&
+                !uniqueId.toLowerCase().includes(uniqueKeyword)
+            ) {
+                return false;
+            }
+            return true;
+        });
+    });
+
+    const normalizedAccountRecords = computed(() => {
+        const sceneKeyword = formModel.scene.trim().toLowerCase();
+        const uniqueKeyword = formModel.unique_id.trim().toLowerCase();
+
+        return accountRecords.value.filter((record) => {
+            if (Number(record.account_id || 0) <= 0) return false;
+
+            const source = getSourceLabel(record);
+            const typename = getTypeLabel(record);
+            const scene = getSceneLabel(record);
+            const uniqueId = getUniqueLabel(record);
+
+            if (formModel.platform && source !== formModel.platform) {
+                return false;
+            }
+            if (formModel.typename && typename !== formModel.typename) {
+                return false;
+            }
+            if (sceneKeyword && !scene.toLowerCase().includes(sceneKeyword)) {
+                return false;
+            }
+            if (
+                uniqueKeyword &&
+                !uniqueId.toLowerCase().includes(uniqueKeyword)
+            ) {
+                return false;
+            }
+            return true;
+        });
+    });
+
+    const dimensionRows = computed<DimensionRow[]>(() => {
+        const map = new Map<string, DimensionRow>();
+        normalizedRecords.value.forEach((record) => {
+            const date = dayjs(record.report_date).format('YYYY-MM-DD');
+            const source = getSourceLabel(record);
+            const typename = getTypeLabel(record);
+            const scene = getSceneLabel(record);
+            const uniqueId = getUniqueLabel(record);
+            const key = [date, source, typename, scene, uniqueId].join('|');
+            const eventType = normalizeEventType(record.event_type);
+            const count = Number(record.event_count || 0);
+
+            if (!map.has(key)) {
+                map.set(key, {
+                    key,
+                    date,
+                    source,
+                    typename,
+                    scene,
+                    unique_id: uniqueId,
+                    expose: 0,
+                    click: 0,
+                    total: 0,
+                    ctr: null,
+                    exposeTrackIds: [],
+                    clickTrackIds: [],
+                });
+            }
+
+            const item = map.get(key);
+            if (!item) return;
+            if (eventType === 'expose') {
+                item.expose += count;
+                addTrackId(item.exposeTrackIds, record.track_link_id);
+            } else if (eventType === 'click') {
+                item.click += count;
+                addTrackId(item.clickTrackIds, record.track_link_id);
+            }
+            item.total += count;
+        });
+
+        return Array.from(map.values())
+            .map((item) => ({
+                ...item,
+                ctr: item.expose > 0 ? (item.click / item.expose) * 100 : null,
+            }))
+            .sort((a, b) => {
+                if (a.date !== b.date) return b.date.localeCompare(a.date);
+                return b.total - a.total;
+            });
+    });
+
+    const accountRowsByDimension = computed(() => {
+        const map = new Map<string, AccountReportRow[]>();
+        const rowMap = new Map<string, AccountReportRow>();
+
+        normalizedAccountRecords.value.forEach((record) => {
+            const date = dayjs(record.report_date).format('YYYY-MM-DD');
+            const source = getSourceLabel(record);
+            const channel = getChannelBySource(source);
+            const accountId = Number(record.account_id || 0);
+            if (channel < 0 || accountId <= 0) return;
+
+            const typename = getTypeLabel(record);
+            const scene = getSceneLabel(record);
+            const uniqueId = getUniqueLabel(record);
+            const dimensionKey = [
+                date,
+                source,
+                typename,
+                scene,
+                uniqueId,
+            ].join('|');
+            const accountKey = [
+                dimensionKey,
+                channel,
+                accountId,
+            ].join('|');
+            const meta = getAccountMeta(channel, accountId);
+            const callKey = [date, channel, accountId].join('|');
+
+            if (!rowMap.has(accountKey)) {
+                rowMap.set(accountKey, {
+                    key: accountKey,
+                    accountId,
+                    channel,
+                    name:
+                        meta?.name ||
+                        meta?.company ||
+                        `账号 ${accountId}`,
+                    company: meta?.company || '',
+                    tags: getAccountTags(meta),
+                    calls: dailyCallMap.value.get(callKey) || 0,
+                    expose: 0,
+                    click: 0,
+                    total: 0,
+                    ctr: null,
+                });
+            }
+
+            const item = rowMap.get(accountKey);
+            if (!item) return;
+            const count = Number(record.event_count || 0);
+            if (normalizeEventType(record.event_type) === 'expose') {
+                item.expose += count;
+            } else if (normalizeEventType(record.event_type) === 'click') {
+                item.click += count;
+            }
+            item.total += count;
+
+            if (!map.has(dimensionKey)) map.set(dimensionKey, []);
+            const rows = map.get(dimensionKey);
+            if (rows && !rows.includes(item)) rows.push(item);
+        });
+
+        map.forEach((rows) => {
+            rows.forEach((row) => {
+                row.ctr =
+                    row.expose > 0 ? (row.click / row.expose) * 100 : null;
+            });
+            rows.sort((left, right) => {
+                if (right.calls !== left.calls) return right.calls - left.calls;
+                return right.total - left.total;
+            });
+        });
+
+        return map;
+    });
+
+    const getAccountRows = (row: DimensionRow) =>
+        accountRowsByDimension.value.get(row.key) || [];
+
+    const tableExpandable = computed(() => ({
+        expandRowByClick: false,
+        title: '',
+        width: 32,
+        rowExpandable: (record: DimensionRow) => getAccountRows(record).length > 0,
+    }));
+
+    const pagedDimensionRows = computed(() => {
+        const start = (pagination.current - 1) * pagination.pageSize;
+        return dimensionRows.value.slice(start, start + pagination.pageSize);
+    });
+
+    const pagedTableRows = computed<DimensionTableRow[]>(() =>
+        pagedDimensionRows.value.map((row) => {
+            const children = getAccountRows(row).map<AccountTableRow>(
+                (account) => ({
+                    ...account,
+                    isAccountRow: true,
+                    parentKey: row.key,
+                    date: row.date,
+                    source: row.source,
+                    typename: row.typename,
+                    scene: row.scene,
+                    unique_id: row.unique_id,
+                    exposeTrackIds: [],
+                    clickTrackIds: [],
+                })
+            );
+            return {
+                ...row,
+                isAccountRow: false,
+                children: children.length ? children : undefined,
+            };
+        })
+    );
+
+    const tablePagination = computed(() => ({
+        ...pagination,
+        total: dimensionRows.value.length,
+    }));
+
+    const dateRangeText = computed(
+        () => `${dateRange.value[0]} 至 ${dateRange.value[1]}`
+    );
+    const rangeDays = computed(() => {
+        const start = dayjs(dateRange.value[0]);
+        const end = dayjs(dateRange.value[1]);
+        return Math.max(end.diff(start, 'day') + 1, 1);
+    });
+
+    const dailyTrend = computed(() => {
+        const map = new Map<string, { expose: number; click: number }>();
+        let current = dayjs(dateRange.value[0]);
+        const end = dayjs(dateRange.value[1]);
+        while (current.isBefore(end) || current.isSame(end, 'day')) {
+            map.set(current.format('YYYY-MM-DD'), { expose: 0, click: 0 });
+            current = current.add(1, 'day');
+        }
+
+        normalizedRecords.value.forEach((record) => {
+            const date = dayjs(record.report_date).format('YYYY-MM-DD');
+            if (!map.has(date)) return;
+            const item = map.get(date);
+            if (!item) return;
+            const count = Number(record.event_count || 0);
+            if (normalizeEventType(record.event_type) === 'expose') {
+                item.expose += count;
+            } else if (normalizeEventType(record.event_type) === 'click') {
+                item.click += count;
+            }
+        });
+
+        return Array.from(map.entries()).map(([date, value]) => ({
+            date,
+            expose: value.expose,
+            click: value.click,
+            total: value.expose + value.click,
+        }));
+    });
+
+    const summary = computed(() => {
+        const expose = normalizedRecords.value
+            .filter((item) => normalizeEventType(item.event_type) === 'expose')
+            .reduce((sum, item) => sum + Number(item.event_count || 0), 0);
+        const click = normalizedRecords.value
+            .filter((item) => normalizeEventType(item.event_type) === 'click')
+            .reduce((sum, item) => sum + Number(item.event_count || 0), 0);
+        const total = expose + click;
+        const activeDays = dailyTrend.value.filter(
+            (item) => item.total > 0
+        ).length;
+        const activeLinks = new Set(
+            dimensionRows.value.map((item) =>
+                [item.source, item.typename, item.scene, item.unique_id].join(
+                    '|'
+                )
+            )
+        ).size;
+
+        return {
+            expose,
+            click,
+            total,
+            activeDays,
+            activeLinks,
+            ctr: expose > 0 ? (click / expose) * 100 : null,
+            clickShareText:
+                total > 0 ? formatRate((click / total) * 100) : '--',
+        };
+    });
+
+    const peakDayText = computed(() => {
+        const peak = dailyTrend.value.reduce(
+            (current, item) => (item.total > current.total ? item : current),
+            { date: '', expose: 0, click: 0, total: 0 }
+        );
+        if (!peak.date || peak.total <= 0) return '暂无峰值';
+        return `${peak.date} · ${formatNumber(peak.total)}`;
+    });
+
+    const sourceRanking = computed(() =>
+        buildRanking(dimensionRows.value, 'source')
+    );
+    const sceneRanking = computed(() =>
+        buildRanking(dimensionRows.value, 'scene')
+    );
+    const dataLimitExceeded = computed(
+        () =>
+            serverTotal.value > 0 && serverTotal.value > rawRecords.value.length
+    );
+    const accountDataLimitExceeded = computed(
+        () =>
+            accountServerTotal.value > 0 &&
+            accountServerTotal.value > accountRecords.value.length
+    );
+
+    const trendChartOption = computed(() => {
+        const showExpose =
+            !formModel.event_type || formModel.event_type === 'expose';
+        const showClick =
+            !formModel.event_type || formModel.event_type === 'click';
+        const series = [];
+
+        if (showExpose) {
+            series.push({
+                name: '曝光',
+                type: 'line',
+                smooth: true,
+                data: dailyTrend.value.map((item) => item.expose),
+                lineStyle: { width: 2, color: '#165dff' },
+                itemStyle: { color: '#165dff' },
+                areaStyle: {
+                    color: new graphic.LinearGradient(0, 0, 0, 1, [
+                        { offset: 0, color: 'rgba(22, 93, 255, 0.18)' },
+                        { offset: 1, color: 'rgba(22, 93, 255, 0)' },
+                    ]),
+                },
+            });
+        }
+
+        if (showClick) {
+            series.push({
+                name: '点击',
+                type: 'line',
+                smooth: true,
+                data: dailyTrend.value.map((item) => item.click),
+                lineStyle: { width: 2, color: '#00b42a' },
+                itemStyle: { color: '#00b42a' },
+            });
+        }
+
+        return {
+            color: ['#165dff', '#00b42a'],
+            tooltip: {
+                trigger: 'axis',
+                axisPointer: { type: 'line' },
+            },
+            legend: {
+                right: 0,
+                top: 0,
+            },
+            grid: {
+                left: 16,
+                right: 24,
+                top: 42,
+                bottom: 8,
+                containLabel: true,
+            },
+            xAxis: {
+                type: 'category',
+                boundaryGap: false,
+                data: dailyTrend.value.map((item) =>
+                    dayjs(item.date).format('MM-DD')
+                ),
+                axisLabel: { color: '#4e5969' },
+                axisTick: { show: false },
+            },
+            yAxis: {
+                type: 'value',
+                axisLabel: { formatter: formatCompactNumber },
+                splitLine: {
+                    lineStyle: { type: 'dashed', color: '#e5e8ef' },
+                },
+            },
+            series,
+        };
+    });
+
+    const hourlyTitle = computed(() => {
+        if (!selectedRow.value) return '小时分布';
+        return `${selectedRow.value.date} 小时分布`;
+    });
+
+    const hourlySummary = computed(() => {
+        const expose = hourlyExpose.value.reduce(
+            (sum, value) => sum + value,
+            0
+        );
+        const click = hourlyClick.value.reduce((sum, value) => sum + value, 0);
+        return {
+            expose,
+            click,
+            ctr: expose > 0 ? (click / expose) * 100 : null,
+        };
+    });
+
+    const hourlyChartOption = computed(() => {
+        const series = [];
+        if (!selectedRow.value || selectedRow.value.exposeTrackIds.length > 0) {
+            series.push({
+                name: '曝光',
+                type: 'bar',
+                data: hourlyExpose.value,
+                barMaxWidth: 18,
+                itemStyle: { color: '#165dff' },
+            });
+        }
+        if (!selectedRow.value || selectedRow.value.clickTrackIds.length > 0) {
+            series.push({
+                name: '点击',
+                type: 'line',
+                smooth: true,
+                data: hourlyClick.value,
+                lineStyle: { width: 2, color: '#00b42a' },
+                itemStyle: { color: '#00b42a' },
+            });
+        }
+
+        return {
+            color: ['#165dff', '#00b42a'],
+            tooltip: { trigger: 'axis' },
+            legend: { right: 0, top: 0 },
+            grid: {
+                left: 16,
+                right: 24,
+                top: 42,
+                bottom: 8,
+                containLabel: true,
+            },
+            xAxis: {
+                type: 'category',
+                data: hourlyXAxis.value,
+                axisLabel: { color: '#4e5969', interval: 2 },
+                axisTick: { show: false },
+            },
+            yAxis: {
+                type: 'value',
+                axisLabel: { formatter: formatCompactNumber },
+                splitLine: {
+                    lineStyle: { type: 'dashed', color: '#e5e8ef' },
+                },
+            },
+            series,
+        };
+    });
+
+    const fetchTrackLinks = async () => {
+        const firstRes: any = await queryTrackList({
+            current: 1,
+            pageSize: linkFetchLimit,
+            getTotal: true,
+        });
+        const firstList: TrackLinkRecord[] = firstRes?.data?.list || [];
+        const total = firstRes?.data?.count || firstList.length;
+        const pageCount = Math.ceil(total / linkFetchLimit);
+        if (pageCount <= 1) return firstList;
+
+        const restResults = await Promise.all(
+            Array.from({ length: pageCount - 1 }, (_, index) =>
+                queryTrackList({
+                    current: index + 2,
+                    pageSize: linkFetchLimit,
+                    getTotal: false,
+                })
+            )
+        );
+
+        return restResults.reduce<TrackLinkRecord[]>((list, res: any) => {
+            list.push(...(res?.data?.list || []));
+            return list;
+        }, firstList);
+    };
+
+    const fetchTrackReportData = async (params: TrackReportParams) => {
+        const firstRes: any = await queryTrackReport(params);
+        const firstList: TrackReportRecord[] = firstRes?.data?.list || [];
+        const total = firstRes?.data?.count || firstList.length;
+        const pageSize = params.pageSize || reportFetchLimit;
+        const pageCount = Math.ceil(total / pageSize);
+        if (pageCount <= 1) return { list: firstList, total };
+
+        const restResults = await Promise.all(
+            Array.from({ length: pageCount - 1 }, (_, index) =>
+                queryTrackReport({
+                    ...params,
+                    current: index + 2,
+                    getTotal: false,
+                })
+            )
+        );
+
+        const list = restResults.reduce<TrackReportRecord[]>((items, res: any) => {
+            items.push(...(res?.data?.list || []));
+            return items;
+        }, firstList);
+        return { list, total };
+    };
+
+    const fetchDailyLogs = async () => {
+        const params: DailyLogParams = {
+            current: 1,
+            pageSize: dailyLogFetchLimit,
+            query_date: [dateRange.value[0], dateRange.value[1]],
+            sort: 'log_date',
+            order: 'descending',
+            getTotal: true,
+        };
+        const firstRes: any = await queryDailyLog(params);
+        const firstList: DailyLogRecord[] = firstRes?.data?.list || [];
+        const total = firstRes?.data?.count || firstList.length;
+        const pageCount = Math.ceil(total / dailyLogFetchLimit);
+        if (pageCount <= 1) return firstList;
+
+        const restResults = await Promise.all(
+            Array.from({ length: pageCount - 1 }, (_, index) =>
+                queryDailyLog({
+                    ...params,
+                    current: index + 2,
+                    getTotal: false,
+                })
+            )
+        );
+
+        return restResults.reduce<DailyLogRecord[]>((items, res: any) => {
+            items.push(...(res?.data?.list || []));
+            return items;
+        }, firstList);
+    };
+
+    const loadAccountMeta = async () => {
+        const commonParams = {
+            current: 1,
+            pageSize: accountFetchLimit,
+            show_hide: false,
+            sort: 'id',
+            order: 'descending',
+            getTotal: false,
+        };
+        const [tkAccounts, jdAccounts, pddAccounts] = await Promise.all([
+            readAccountList<TkPoolRecord>(
+                queryTkPool(commonParams as TkPoolParams)
+            ),
+            readAccountList<JdPoolRecord>(
+                queryJdPool(commonParams as JdPoolParams)
+            ),
+            readAccountList<PddPoolRecord>(
+                queryPddPool(commonParams as PddPoolParams)
+            ),
+        ]);
+        const map: Record<string, AccountMeta> = {};
+        appendAccountMeta(map, CHANNEL_TB, tkAccounts);
+        appendAccountMeta(map, CHANNEL_JD, jdAccounts);
+        appendAccountMeta(map, CHANNEL_PDD, pddAccounts);
+        accountMetaMap.value = map;
+    };
+
+    const fetchData = async () => {
+        setLoading(true);
+        try {
+            const params: TrackReportParams = {
+                current: 1,
+                pageSize: reportFetchLimit,
+                event_type: formModel.event_type || undefined,
+                account_id: 0,
+                start: dateRange.value[0],
+                end: dateRange.value[1],
+                getTotal: true,
+            };
+            const accountParams: TrackReportParams = {
+                current: 1,
+                pageSize: accountReportFetchLimit,
+                event_type: formModel.event_type || undefined,
+                account_breakdown_only: true,
+                start: dateRange.value[0],
+                end: dateRange.value[1],
+                getTotal: true,
+            };
+            const [reportRes, accountRes, links, logs] =
+                await Promise.all([
+                    fetchTrackReportData(params),
+                    fetchTrackReportData(accountParams),
+                    fetchTrackLinks(),
+                    fetchDailyLogs(),
+                    loadAccountMeta(),
+                ]);
+            rawRecords.value = reportRes.list;
+            accountRecords.value = accountRes.list;
+            dailyLogs.value = logs;
+            trackLinks.value = links;
+            serverTotal.value = reportRes.total;
+            accountServerTotal.value = accountRes.total;
+            expandedAccountRows.value = expandedAccountRows.value.filter(
+                (key) => accountRowsByDimension.value.has(key)
+            );
+            pagination.current = 1;
+        } catch {
+            Message.error('报表数据加载失败');
+        } finally {
+            setLoading(false);
+        }
+    };
+
+    const search = () => {
+        pagination.current = 1;
+        fetchData();
+    };
+
+    watch(
+        () => [
+            formModel.platform,
+            formModel.typename,
+            formModel.scene,
+            formModel.unique_id,
+        ],
+        () => {
+            pagination.current = 1;
+            expandedAccountRows.value = expandedAccountRows.value.filter(
+                (key) => accountRowsByDimension.value.has(key)
+            );
+        }
+    );
+
+    const onPlatformChange = () => {
+        formModel.typename = '';
+        pagination.current = 1;
+    };
+
+    const onLocalFilterChange = () => {
+        pagination.current = 1;
+    };
+
+    const resetFilters = () => {
+        dateRange.value = defaultDateRange();
+        formModel.event_type = '';
+        formModel.platform = '';
+        formModel.typename = '';
+        formModel.scene = '';
+        formModel.unique_id = '';
+        search();
+    };
+
+    const onDateChange = () => {
+        search();
+    };
+
+    const onPageChange = (current: number) => {
+        pagination.current = current;
+    };
+
+    const onPageSizeChange = (pageSize: number) => {
+        pagination.pageSize = pageSize;
+        pagination.current = 1;
+    };
+
+    const toggleAccountExpand = (key: string) => {
+        expandedAccountRows.value = expandedAccountRows.value.includes(key)
+            ? expandedAccountRows.value.filter((item) => item !== key)
+            : [...expandedAccountRows.value, key];
+    };
+
+    const onExpandedAccountRowsChange = (keys: Array<string | number>) => {
+        expandedAccountRows.value = keys.map((key) => String(key));
+    };
+
+    const openHourlyReport = async (row: any) => {
+        if (row?.isAccountRow) return;
+        const reportRow = row as DimensionRow;
+        const trackIds = [
+            ...reportRow.exposeTrackIds,
+            ...reportRow.clickTrackIds,
+        ];
+        if (!trackIds.length) {
+            Message.warning('这条记录没有可用的监测链接ID');
+            return;
+        }
+
+        selectedRow.value = reportRow;
+        hourlyVisible.value = true;
+        hourlyLoading.value = true;
+        hourlyChartKey.value += 1;
+        hourlyXAxis.value = Array.from(
+            { length: 24 },
+            (_, hour) => `${hour.toString().padStart(2, '0')}:00`
+        );
+        hourlyExpose.value = Array(24).fill(0);
+        hourlyClick.value = Array(24).fill(0);
+
+        try {
+            const [exposeList, clickList] = await Promise.all([
+                fetchHourlyCounts(
+                    reportRow.exposeTrackIds,
+                    reportRow.date,
+                    reportRow.scene
+                ),
+                fetchHourlyCounts(
+                    reportRow.clickTrackIds,
+                    reportRow.date,
+                    reportRow.scene
+                ),
+            ]);
+            hourlyExpose.value = exposeList;
+            hourlyClick.value = clickList;
+            hourlyChartKey.value += 1;
+        } catch {
+            Message.error('小时数据加载失败');
+        } finally {
+            hourlyLoading.value = false;
+        }
+    };
+
+    const fetchHourlyCounts = async (
+        trackIds: number[],
+        date: string,
+        scene: string
+    ) => {
+        const totals = Array(24).fill(0) as number[];
+        if (!trackIds.length) return totals;
+
+        const results = await Promise.all(
+            trackIds.map((trackId) =>
+                queryTrackHourlyReport({
+                    track_link_id: trackId,
+                    date,
+                    scene,
+                })
+            )
+        );
+
+        results.forEach((res: any) => {
+            const list: TrackHourlyReportRecord[] = res?.data?.list || [];
+            list.forEach((item) => {
+                if (item.hour >= 0 && item.hour < 24) {
+                    totals[item.hour] += Number(item.event_count || 0);
+                }
+            });
+        });
+
+        return totals;
+    };
+
+    const buildRanking = (rows: DimensionRow[], field: 'source' | 'scene') => {
+        const map = new Map<string, number>();
+        rows.forEach((row) => {
+            const name = row[field] || '未设置';
+            map.set(name, (map.get(name) || 0) + row.total);
+        });
+
+        const items = Array.from(map.entries())
+            .map(([name, value]) => ({ name, value }))
+            .sort((a, b) => b.value - a.value)
+            .slice(0, 5);
+        const maxValue = Math.max(...items.map((item) => item.value), 1);
+
+        return items.map<RankingItem>((item) => ({
+            ...item,
+            percent: Math.max((item.value / maxValue) * 100, 4),
+        }));
+    };
+
+    const getSourceColor = (source: string) => {
+        if (source === '淘宝') return 'orange';
+        if (source === '京东') return 'red';
+        if (source === '拼多多') return 'magenta';
+        return 'arcoblue';
+    };
+
+    const formatNumber = (value: number) => Number(value || 0).toLocaleString();
+
+    const formatRate = (value: number | null) => {
+        if (value === null || Number.isNaN(value)) return '--';
+        return `${value.toFixed(2)}%`;
+    };
+
+    const formatCompactNumber = (value: number) => {
+        if (value >= 10000) return `${(value / 10000).toFixed(1)}w`;
+        if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
+        return `${value}`;
+    };
+
+    onMounted(() => {
+        fetchData();
+    });
 </script>
 
 <script lang="ts">
-export default {
-    name: 'TracksReport',
-};
+    export default {
+        name: 'TracksReport',
+    };
 </script>
 
 <style scoped lang="less">
-.container {
-    padding: 0 20px 20px 20px;
-}
+    .container {
+        padding: 0 20px 20px;
+    }
 
-:deep(.general-card) {
-    height: 100%;
-}
+    :deep(.general-card) {
+        min-height: calc(100vh - 140px);
+    }
+
+    .report-header {
+        margin-bottom: 12px;
+    }
+
+    .page-title {
+        color: var(--color-text-1);
+        font-weight: 600;
+        font-size: 18px;
+        line-height: 26px;
+    }
+
+    .page-subtitle,
+    .section-subtitle,
+    .metric-meta,
+    .dimension-sub,
+    .hourly-unique {
+        color: var(--color-text-3);
+    }
+
+    .page-subtitle {
+        margin-top: 4px;
+        font-size: 13px;
+    }
+
+    .filter-bar {
+        display: grid;
+        grid-template-columns:
+            270px 140px 140px 140px 140px minmax(180px, 1fr)
+            auto;
+        gap: 10px;
+        align-items: center;
+        margin-bottom: 16px;
+        padding: 10px 12px;
+        background: var(--color-fill-1);
+        border: 1px solid var(--color-border-2);
+        border-radius: 6px;
+    }
+
+    .filter-date {
+        width: 100%;
+    }
+
+    .filter-control {
+        width: 100%;
+    }
+
+    .filter-unique {
+        width: 100%;
+    }
+
+    .filter-actions {
+        display: flex;
+        gap: 8px;
+        justify-content: flex-end;
+        white-space: nowrap;
+    }
+
+    .limit-alert {
+        margin-bottom: 16px;
+    }
+
+    .metric-grid {
+        display: grid;
+        grid-template-columns: repeat(4, minmax(0, 1fr));
+        gap: 12px;
+        margin-bottom: 18px;
+    }
+
+    .metric-item {
+        min-height: 96px;
+        padding: 16px;
+        background: var(--color-bg-2);
+        border: 1px solid var(--color-border-2);
+        border-radius: 6px;
+    }
+
+    .metric-label {
+        color: var(--color-text-2);
+        font-size: 13px;
+    }
+
+    .metric-value {
+        margin-top: 10px;
+        color: var(--color-text-1);
+        font-weight: 600;
+        font-size: 26px;
+        line-height: 32px;
+    }
+
+    .metric-meta {
+        margin-top: 8px;
+        font-size: 12px;
+    }
+
+    .report-section,
+    .insight-panel {
+        margin-bottom: 16px;
+        padding: 16px;
+        background: var(--color-bg-2);
+        border: 1px solid var(--color-border-2);
+        border-radius: 6px;
+    }
+
+    .chart-spin {
+        display: block;
+        width: 100%;
+    }
+
+    .chart-spin :deep(.arco-spin-children) {
+        width: 100%;
+    }
+
+    .section-head {
+        display: flex;
+        gap: 12px;
+        align-items: flex-start;
+        justify-content: space-between;
+        margin-bottom: 12px;
+    }
+
+    .section-head.compact {
+        margin-bottom: 14px;
+    }
+
+    .section-title {
+        color: var(--color-text-1);
+        font-weight: 600;
+        font-size: 15px;
+    }
+
+    .section-subtitle {
+        margin-top: 4px;
+        font-size: 12px;
+    }
+
+    .insight-grid {
+        display: grid;
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+        gap: 16px;
+    }
+
+    .rank-list {
+        display: grid;
+        gap: 14px;
+    }
+
+    .rank-main {
+        display: flex;
+        gap: 12px;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 6px;
+    }
+
+    .rank-name {
+        color: var(--color-text-2);
+    }
+
+    .rank-value {
+        color: var(--color-text-1);
+        font-weight: 600;
+    }
+
+    .rank-track {
+        height: 6px;
+        overflow: hidden;
+        background: var(--color-fill-2);
+        border-radius: 99px;
+    }
+
+    .rank-bar {
+        height: 100%;
+        background: #165dff;
+        border-radius: 99px;
+    }
+
+    .rank-bar.scene {
+        background: #00b42a;
+    }
+
+    .detail-table :deep(.arco-table-expanded-row-cell) {
+        padding: 12px 16px;
+        background: var(--color-fill-1);
+    }
+
+    .detail-table :deep(.arco-table-expand-col) {
+        width: 32px;
+        min-width: 32px;
+        max-width: 32px;
+    }
+
+    .detail-table :deep(.arco-table-cell-expand-icon) {
+        justify-content: center;
+    }
+
+    .detail-table
+        :deep(.arco-table-cell-expand-icon .arco-table-cell-inline-icon) {
+        width: 24px;
+        min-width: 24px;
+        max-width: 24px;
+        justify-content: center;
+    }
+
+    .empty-expand-icon {
+        display: inline-flex;
+        width: 14px;
+        height: 24px;
+        align-items: center;
+        justify-content: center;
+        color: var(--color-text-4);
+        cursor: default;
+    }
+
+    .account-detail-panel {
+        width: 100%;
+    }
+
+    .account-detail-table {
+        cursor: default;
+    }
+
+    .account-detail-table :deep(.arco-table-tr) {
+        cursor: default;
+    }
+
+    .account-cell {
+        display: grid;
+        gap: 4px;
+        min-width: 0;
+    }
+
+    .account-line,
+    .account-tags {
+        display: flex;
+        gap: 6px;
+        align-items: center;
+        min-width: 0;
+        flex-wrap: wrap;
+    }
+
+    .account-name {
+        overflow: hidden;
+        color: var(--color-text-1);
+        font-weight: 500;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+
+    .account-company {
+        overflow: hidden;
+        color: var(--color-text-3);
+        font-size: 12px;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+
+    .dimension-cell {
+        min-width: 0;
+    }
+
+    .dimension-line {
+        display: flex;
+        gap: 8px;
+        align-items: center;
+        min-width: 0;
+    }
+
+    .dimension-scene {
+        overflow: hidden;
+        color: var(--color-text-1);
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+
+    .dimension-type {
+        color: var(--color-text-1);
+        font-weight: 500;
+        white-space: nowrap;
+    }
+
+    .dimension-sub {
+        margin-top: 4px;
+        overflow: hidden;
+        font-size: 12px;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+
+    .number-cell {
+        font-variant-numeric: tabular-nums;
+    }
+
+    .hourly-context {
+        display: flex;
+        gap: 8px;
+        align-items: center;
+        margin-bottom: 12px;
+    }
+
+    .hourly-metrics {
+        display: grid;
+        grid-template-columns: repeat(3, minmax(0, 1fr));
+        gap: 10px;
+        margin-bottom: 14px;
+    }
+
+    .hourly-metric {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 10px 12px;
+        background: var(--color-fill-1);
+        border: 1px solid var(--color-border-2);
+        border-radius: 6px;
+    }
+
+    .hourly-metric span {
+        color: var(--color-text-2);
+    }
+
+    .hourly-metric strong {
+        color: var(--color-text-1);
+        font-size: 16px;
+    }
+
+    @media (max-width: 1200px) {
+        .filter-bar {
+            grid-template-columns: repeat(3, minmax(0, 1fr));
+        }
+
+        .filter-actions {
+            justify-content: flex-start;
+        }
+
+        .metric-grid {
+            grid-template-columns: repeat(2, minmax(0, 1fr));
+        }
+
+        .insight-grid {
+            grid-template-columns: 1fr;
+        }
+    }
+
+    @media (max-width: 768px) {
+        .filter-bar {
+            grid-template-columns: 1fr;
+        }
+
+        .metric-grid,
+        .hourly-metrics {
+            grid-template-columns: 1fr;
+        }
+    }
 </style>