123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563 |
- <template>
- <div class="">
- <!-- 新增趋势 -->
- <div class="px-14 py-9">
-
- <div class="flex items-center justify-between mb-2 mt-3 ">
- <div class="flex items-center">
- <el-select v-model="industryCompare" class="!w-[120px]" clearable @change="handleCompareChange">
- <el-option label="版本对比" value="version" />
- <el-option label="渠道对比" value="channel" />
- <el-option label="时段对比" value="time" />
- </el-select>
- <!-- 版本对比和渠道对比使用popover -->
- <el-popover v-if="industryCompare !== 'time' && industryCompare" placement="bottom" trigger="click"
- width="400">
- <template #reference>
- <el-button class="ml-2">{{ t('addUser.average') }}</el-button>
- </template>
- <template #default>
- <div class="p-3">
- <div class="mb-3">
- <label class="text-sm font-medium mb-2 block">{{ getCompareTitle() }}</label>
- <el-input v-model="searchKeyword" :placeholder="`请搜索${getCompareTypeText()}`"
- clearable @input="filterCompareOptions" size="small" />
- </div>
- <div class="max-h-60 overflow-y-auto">
- <el-checkbox-group v-model="selectedCompareItems"
- @change="handleCompareItemsChange">
- <div v-for="item in filteredCompareOptions" :key="item" class="mb-2">
- <el-checkbox :label="item" size="small">{{ item }}</el-checkbox>
- </div>
- </el-checkbox-group>
- </div>
- </div>
- </template>
- </el-popover>
- <!-- 时段对比使用日期选择 -->
- <el-popover v-if="industryCompare === 'time'" placement="bottom" trigger="click" width="300"
- :visible="timeCompareVisible" :hide-after="0" :persistent="true">
- <template #reference>
- <el-button class="ml-2"
- @click="timeCompareVisible = !timeCompareVisible">{{ t('addUser.average') }}</el-button>
- </template>
- <template #default>
- <div class="p-3">
- <div class="mb-3">
- <label class="text-sm font-medium mb-2 block">选择对比时段</label>
- <el-date-picker v-model="timeCompareRange" type="date" format="YYYY-MM-DD"
- value-format="YYYY-MM-DD" :disabled-date="disableAfterToday"
- @change="handleTimeCompareChange" style="width: 100%" :clearable="false" />
- </div>
- </div>
- <!-- <div class="flex justify-end">
- <el-button size="small" @click="clearTimeCompare">取消</el-button>
- <el-button size="small" type="primary" @click="confirmTimeCompare">确定</el-button>
- </div> -->
- </template>
- </el-popover>
- <el-button link class="ml-2" @click="clearCompare(), getData('clearAll')">清除</el-button>
- </div>
- <div class="flex items-center">
- <el-radio-group v-model="formData.timeUnit" @change="getData('clearAll'),getBottomDetail()">
- <el-radio-button label="hour" :disabled="isHourDisabled">小时</el-radio-button>
- <el-radio-button label="day" :disabled="isDayDisabled">天</el-radio-button>
- <el-radio-button label="week" :disabled="isWeekDisabled">周</el-radio-button>
- <el-radio-button label="month" :disabled="isMonthDisabled">月</el-radio-button>
- </el-radio-group>
- </div>
- </div>
- <div class="relative ">
- <div ref="lineChartRef" style="width: 100%; height: 320px"></div>
- </div>
- </div>
- <!-- 明细表格 -->
- <div class="mt-3">
- <div class="flex items-center justify-between mb-2">
- <div class="text-base font-medium cursor-pointer select-none ml-3 items-center flex text-[#167AF0]"
- @click="showDetail = !showDetail">
- {{ showDetail ? '收起明细数据' : '展开明细数据' }} <el-icon class="ml-2">
- <ArrowDown v-if="showDetail" />
- <ArrowUp v-else />
- </el-icon>
- </div>
- <div>
- <el-button type="primary" text>导出</el-button>
- </div>
- </div>
- <el-table v-if="showDetail" :data="tableData" border :cell-style="tableStyle.cellStyle"
- :header-cell-style="tableStyle.headerCellStyle">
- <el-table-column prop="date" label="日期" min-width="140" />
- <el-table-column label="新增用户(占比)" align="right" min-width="220">
- <template #default="scope">
- <div class="flex items-center justify-center w-full">
- <span>{{ scope.row.newUser }}</span>
- <span class="text-gray-500 text-xs ml-2">({{ (scope.row.newUserRate * 100).toFixed(2)
- }}%)</span>
- </div>
- </template>
- </el-table-column>
- </el-table>
- <div v-if="showDetail" class="flex justify-end mt-3">
- <el-pagination v-model:current-page="pagination.current" v-model:page-size="pagination.size"
- @change="getBottomDetail" background layout="total, prev, pager, next, sizes"
- :total="pagination.total" :page-sizes="[5, 10, 20]" />
- </div>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
- import * as echarts from 'echarts';
- import { useI18n } from 'vue-i18n';
- import dayjs from 'dayjs';
- import { getDaysBetweenDates } from '/@/utils/formatTime';
- import { getAppVersion, getAppChannel } from '/@/api/common/common';
- import { getTrend, getTrendDetail } from '/@/api/count/addUser';
- const { t } = useI18n();
- const tableStyle = {
- cellStyle: { textAlign: 'center' },
- headerCellStyle: {
- textAlign: 'center',
- background: 'var(--el-table-row-hover-bg-color)',
- color: 'var(--el-text-color-primary)',
- },
- rowStyle: { textAlign: 'center' },
- };
- const industryCompare = ref('');
- const selectedCompareItems = ref<string[]>([]);
- const searchKeyword = ref('');
- const compareOptions = ref<string[]>([]);
- const filteredCompareOptions = ref<string[]>([]);
- const timeCompareRange = ref<string>('');
- const timeCompareVisible = ref(false);
- const masterData = ref(<any>{
- //图表主数据
- dates: [],
- items: [],
- });
- const emit = defineEmits(['query']);
- const props = defineProps({
- formData: {
- type: Object,
- default: () => ({}),
- },
- });
- interface FormDataType {
- timeUnit: string;
- version: string[];
- channel: string[];
- fromDate: string;
- toDate: string;
- }
- const formData = ref<FormDataType>({
- timeUnit: 'day',
- version: [],
- channel: [],
- fromDate: props.formData?.time[0],
- toDate: props.formData?.time[1],
- });
- const colorSchemes = [
- { color: '#409EFF' }, // 蓝色
- { color: '#67C23A' }, // 绿色
- { color: '#E6A23C' }, // 黄色
- { color: '#F56C6C' }, // 红色
- ];
- const tableData = ref([]); //表格数据
- const pagination = ref({
- current: 1, //当前页数
- total: 0, // 数据总数
- size: 5, // 每页显示条数
- });
- const previousCompareItems = ref<string[]>([]);
- const chartTimes = ref<string[]>([]);
- const getData = async (type: string) => {
- //上方图表
- if (type === 'clearAll') {
- lineChartData.value = { dates: [], items: [] };
- industryCompare.value = '';
- }
- // Guard: 小时维度跨度>=7天时不发起查询,不做自动纠正
- if (formData.value.timeUnit === 'hour') {
- const span = getDaysBetweenDates(formData.value.fromDate, formData.value.toDate);
- if (span >= 7) {
- return;
- }
- }
- const res = await getTrend({ ...formData.value });
- const data = res?.data || [];
- if (!industryCompare.value) {
- masterData.value = data;
- lineChartData.value.items = data.items.map((item: any, index: number) => {
- const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
- return {
- name: getName(item, index, data),
- type: 'line',
- smooth: true,
- data: item.data,
- itemStyle: {
- color: colorSchemes[randomColorIndex].color,
- },
- lineStyle: {
- color: colorSchemes[randomColorIndex].color,
- },
- };
- });
- chartTimes.value = [];
- } else {
- lineChartData.value.items.push(
- data.items.map((item: any, index: number) => {
- console.log(item);
- const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
- return {
- name: getName(item, index, data),
- type: 'line',
- smooth: true,
- data: item.data,
- itemStyle: {
- color: colorSchemes[randomColorIndex].color,
- },
- lineStyle: {
- color: colorSchemes[randomColorIndex].color,
- },
- };
- })[0]
- );
- if (industryCompare.value == 'time') {
- chartTimes.value.push(data.dates);
- lineChartData.value.items[0].name = props.formData?.time[0] + ' ~ ' + props.formData?.time[1];
- } else {
- chartTimes.value = [];
- }
- }
- lineChartData.value.dates = masterData.value.dates;
- initLineChart();
- };
- const getBottomDetail = async () => {
- // 下方明细表
- const resDetail = await getTrendDetail({ ...formData.value, ...pagination.value });
- const dataDetail = resDetail?.data || [];
- pagination.value.current = dataDetail.current; //当前页码
- pagination.value.total = dataDetail.total; //总条数
- pagination.value.size = dataDetail.size; //每页条数
- tableData.value = dataDetail.records;
- };
- const getName = (item: any, index: number, data: any) => {
- if (industryCompare.value === 'time') {
- //日期
- return formData.value.fromDate + ' ~ ' + formData.value.toDate;
- } else if (industryCompare.value === 'version') {
- //版本
- return item.version === 'All' ? item.name : item.version + ' ' + item.name;
- } else if (industryCompare.value === 'channel') {
- //渠道
- return formData.value.channel[0];
- } else {
- return item.name;
- }
- };
- function formatNumber(value: number | string): string {
- const num = typeof value === 'number' ? value : Number(value || 0);
- return num.toLocaleString('zh-CN');
- }
- const formatterTips = (params: any) => {
- if (!params || !params.length) return '';
- const date = params[0]?.axisValue || '';
- const rows = params
- .map((p: any, index: number) => {
- const name = p.seriesName || '';
- const val = formatNumber(p.data);
- if (industryCompare.value === 'time' && index != 0) {
- return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
- <span style="display:flex;align-items:center;gap:6px;">
- <span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
- <span>${chartTimes.value[0][p.dataIndex]}</span>
- </span>
- <span style="font-variant-numeric: tabular-nums;">${val}</span>
- </div>`;
- } else {
- return `
- <div style="margin-bottom:6px;color:#93c5fd;">${industryCompare.value === 'time' ? '' : date}</div>
-
- <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
- <span style="display:flex;align-items:center;gap:6px;">
- <span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
- <span>${industryCompare.value === 'time' ? p.axisValue : name} </span>
- </span>
- <span style="font-variant-numeric: tabular-nums;">${val}</span>
- </div>`;
- }
- })
- .join('');
- return `<div style="font-size:12px;">
- ${rows}
- </div>`;
- };
- // 根据日期范围禁用不合适的粒度
- const rangeDays = computed(() => {
- const start = props.formData?.time?.[0];
- const end = props.formData?.time?.[1];
- if (!start || !end) return 0;
- return getDaysBetweenDates(start, end);
- });
- const isHourDisabled = computed(() => rangeDays.value >= 7);
- const isWeekDisabled = computed(() => rangeDays.value < 7);
- const isMonthDisabled = computed(() => rangeDays.value < 28);
- const isDayDisabled = computed(() => false);
- function ensureValidTimeUnit() {
- // 不进行自动纠正,保持用户选择
- }
- function disableAfterToday(date: Date) {
- const today = new Date();
- today.setHours(0, 0, 0, 0);
- return date.getTime() > today.getTime();
- }
- // 弹窗相关函数
- function handleCompareChange(value: string) {
- selectedCompareItems.value = [];
- initCompareOptions();
- timeCompareVisible.value = value === 'time';
- }
- function clearCompare() {
- selectedCompareItems.value = [];
- timeCompareRange.value = '';
- formData.value = {
- ...formData.value,
- channel: props.formData?.channel ? [props.formData?.channel] : [],
- version: props.formData?.version ? [props.formData?.version] : [],
- fromDate: props.formData?.time[0],
- toDate: props.formData?.time[1],
- };
- }
- function getCompareTitle(): string {
- const typeMap = {
- version: '选择版本',
- channel: '选择渠道',
- time: '选择时段',
- };
- return typeMap[industryCompare.value as keyof typeof typeMap] || '选择对比项';
- }
- function getCompareTypeText(): string {
- const typeMap = {
- version: '版本',
- channel: '渠道',
- time: '时段',
- };
- return typeMap[industryCompare.value as keyof typeof typeMap] || '';
- }
- function filterCompareOptions() {
- if (!searchKeyword.value) {
- filteredCompareOptions.value = compareOptions.value;
- } else {
- filteredCompareOptions.value = compareOptions.value.filter((item) => item.toLowerCase().includes(searchKeyword.value.toLowerCase()));
- }
- }
- function handleCompareItemsChange(items: string[]) {
- selectedCompareItems.value = items;
- // 对比当前值和之前的值
- const currentItems = new Set(items);
- const previousItems = new Set(previousCompareItems.value);
-
- // 找出新增的项
- const addedItems = items.filter((item) => !previousItems.has(item));
- // 找出减少的项(在之前有但现在没有的项)
- const removedItemIndices = previousCompareItems.value
- .map((item, index) => ({ item, index }))
- .filter(({ item }) => !currentItems.has(item))
- .map(({ index }) => index);
- // 更新 previousCompareItems 为当前值,用于下次对比
-
- previousCompareItems.value = items;
- // 先处理主数据项的name
- if (industryCompare.value === 'version') {
- lineChartData.value.items[0].name = '全部版本';
- } else if (industryCompare.value === 'channel') {
- lineChartData.value.items[0].name = '全部渠道';
- }
- // 清理被取消的对比项
- if (removedItemIndices.length > 0) {
- // 从后往前删除,避免索引变化影响
- removedItemIndices
- .sort((a, b) => b - a)
- .forEach(index => {
- lineChartData.value.items.splice(index + 1, 1);
- });
- }
- // 更新表单数据
- const isVersion = industryCompare.value === 'version';
- if (isVersion) {
- formData.value.version = addedItems;
- formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
- } else {
- formData.value.channel = addedItems;
- formData.value.version = props.formData?.version ? [props.formData?.version] : [];
- }
- // 根据是否有新增项来决定是否重新获取数据
- if (addedItems.length > 0) {
- getData('');
- } else if (removedItemIndices.length > 0) {
- // 如果只是移除了项,则重新初始化图表
- initLineChart();
- }
- }
- // 初始化对比选项
- async function initCompareOptions() {
- if (industryCompare.value === 'version') {
- const res = await getAppVersion();
- const list: Array<string> = res?.data || [];
- compareOptions.value = list;
- } else if (industryCompare.value === 'channel') {
- const res = await getAppChannel();
- const list: Array<string> = res?.data || [];
- compareOptions.value = list;
- }
- filteredCompareOptions.value = compareOptions.value;
- }
- // 时段对比相关函数
- function handleTimeCompareChange(value: string) {
- timeCompareRange.value = value;
- formData.value.toDate = timeCompareRange.value;
- formData.value.fromDate = dayjs(timeCompareRange.value).subtract(getDaysBetweenDates(props.formData?.time[0], props.formData?.time[1]), 'day').format('YYYY-MM-DD');
- formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
- formData.value.version = props.formData?.version ? [props.formData?.version] : [];
- getData('');
- timeCompareVisible.value = false;
- timeCompareRange.value = '';
- }
- const lineChartRef = ref<HTMLDivElement | null>(null);
- let chartInstance: echarts.ECharts | null = null;
- const lineChartData = ref<any>({
- //图表数据//显示数据
- dates: [],
- items: [],
- });
- function initLineChart(): void {
- if (!lineChartRef.value) return;
- if (chartInstance) chartInstance.dispose();
- chartInstance = echarts.init(lineChartRef.value);
- const option: echarts.EChartsOption = {
- tooltip: {
- trigger: 'axis',
- confine: true,
- axisPointer: { type: 'line' },
- borderWidth: 0,
- backgroundColor: 'rgba(17,24,39,0.9)',
- textStyle: { color: '#fff' },
- formatter: (params: any) => {
- return formatterTips(params);
- },
- },
- legend: {
- data: lineChartData.value.items.map((item: any) => item.name),
- top: 'bottom',
- type: 'scroll', // 支持图例滚动
- },
- grid: {
- left: 40,
- right: 20,
- top: 20, // 为图例留出空间
- bottom: 60,
- },
- xAxis: {
- type: 'category',
- data: lineChartData.value.dates,
- axisLine: { lineStyle: { color: '#e5e7eb' } },
- axisLabel: { color: '#6b7280' },
- axisTick: { alignWithLabel: true },
- },
- yAxis: {
- type: 'value',
- axisLine: { show: false },
- splitLine: { lineStyle: { color: '#f3f4f6' } },
- axisLabel: { color: '#6b7280' },
- },
- series: lineChartData.value.items,
- };
- chartInstance.setOption(option);
- }
- // 展开/收起明细
- const showDetail = ref(true);
- onMounted(() => {
- getData('');
- getBottomDetail();
- initCompareOptions();
- });
- watch(props.formData, () => {
- formData.value = {
- ...formData.value,
- channel: props.formData?.channel ? [props.formData?.channel] : [],
- version: props.formData?.version ? [props.formData?.version] : [],
- fromDate: props.formData?.time[0],
- toDate: props.formData?.time[1],
- };
- formData.value.timeUnit = 'day';
- ensureValidTimeUnit();
- getData('');
- });
- // 监听外部日期与当前粒度,统一在此处做7天规则的静默纠正
- watch(
- () => [props.formData?.time?.[0], props.formData?.time?.[1], formData.value.timeUnit],
- ([start, end, unit]) => {
- if (!start || !end) return;
- // 对齐内部查询参数
- formData.value.fromDate = start as string;
- formData.value.toDate = end as string;
- const span = getDaysBetweenDates(start as string, end as string);
- // 不自动纠正,仅在合法范围内时刷新
- if (unit === 'hour' && span >= 7) {
- getData('');
- }
- },
- { immediate: true }
- );
- </script>
- <style lang="scss" scoped>
- .highlight {
- color: #2196f3;
- }
- </style>
|