|
|
@@ -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>
|