ソースを参照

fix: 页面接口联调

jcq 3 日 前
コミット
8a8044aff6

+ 0 - 1
auto-imports.d.ts

@@ -53,7 +53,6 @@ declare global {
   const readonly: typeof import('vue')['readonly']
   const ref: typeof import('vue')['ref']
   const resolveComponent: typeof import('vue')['resolveComponent']
-  const resolveDirective: (typeof import('vue'))['resolveDirective']
   const setActivePinia: typeof import('pinia')['setActivePinia']
   const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
   const shallowReactive: typeof import('vue')['shallowReactive']

+ 15 - 0
src/api/count/addUser.ts

@@ -17,3 +17,18 @@ export const getTrend = (data?: Object) => {
 	});
 };
 
+export const getRetentionDetail = (data?: Object) => {
+	return request({
+		url: '/stats/user/retention/detail',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+export const getRetention = (data?: Object) => {
+	return request({
+		url: '/stats/user/new/retention',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+

+ 545 - 0
src/views/count/user/activeUser/components/AddTrend.vue

@@ -0,0 +1,545 @@
+<template>
+	<div class="el-card p-9">
+		<!-- 新增趋势 -->
+		<div class="">
+			<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">
+						<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>
+				<el-table-column prop="date" label="日期" min-width="140" />
+				<el-table-column prop="date" label="活跃用户数" min-width="140" />
+				<el-table-column prop="date" label="活跃构成(新用户占比)" min-width="140" />
+				<el-table-column prop="date" label="DAU/过去7日活跃用户" min-width="140" />
+				<el-table-column prop="date" label="DAU/过去30日活跃用户" min-width="140" />
+				<el-table-column label="新增用户(占比)" align="right" min-width="220">
+					<template #default="scope">
+						<div class="flex items-center justify-end 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 Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+
+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 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);
+
+	// 输出结果用于调试或后续处理
+	console.log('新增的项:', addedItems);
+	console.log('减少的项:', removedItemIndices);
+	if (removedItemIndices.length > 0) {
+		lineChartData.value.items.splice(removedItemIndices[0] + 1, 1);
+	}
+	if (industryCompare.value === 'version') {
+		lineChartData.value.items[0].name = '全部版本';
+	} else if (industryCompare.value === 'channel') {
+		lineChartData.value.items[0].name = '全部渠道';
+	}
+	// 更新 previousCompareItems 为当前值,用于下次对比
+	previousCompareItems.value = items;
+
+	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 {
+		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>

+ 81 - 220
src/views/count/user/activeUser/index.vue

@@ -1,260 +1,121 @@
 <template>
 	<div class="layout-padding">
 		<div class="!overflow-auto px-1">
-			<div class="el-card  p-9">
+			<!-- 头部筛选区域 -->
+			<div class="el-card p-9">
 				<div class="flex justify-between">
-					<Title :title="t('activeUser.analytics')" />
-					<div class="">
-						<el-button type="primary">{{ t('activeUser.growth') }}</el-button>
-						<el-button type="primary">{{ t('activeUser.ai') }}</el-button>
-					</div>
+					<Title :title="t('activeUser.analytics')" >
+						<el-popover class="box-item" placement="right" trigger="hover" width="250">
+						<template #reference>
+							<el-icon class="ml-1" style="color: #a4b8cf">
+								<QuestionFilled />
+							</el-icon>
+						</template>
+						<template #default>
+							<div class="ant-popover-inner-content">
+								<div class="um-page-tips-content" style="line-height: 24px">
+									<p><span class="highlight">新增用户:</span><span>第一次启动应用的用户(以设备为判断标准)</span></p>
+									<p><span class="highlight">新增账号:</span><span>第一次启动应用的账号</span></p>
+									<p><span class="highlight">新增用户占比:</span><span>某时段内新增用户占该时段活跃用户的比例</span></p>
+									<p>
+										<span>
+											按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)
+										</span>
+									</p>
+								</div>
+							</div>
+						</template>
+					</el-popover>	
+					
+					
+					</Title>
+					<!-- <div class="">
+						<el-button type="primary">{{ t('addUser.growth') }}</el-button>
+						<el-button type="primary">{{ t('addUser.ai') }}</el-button>
+					</div> -->
 				</div>
-				<div>
+				<div class="mt-2">
 					<el-row shadow="hover" class="ml10">
-						<el-form :inline="true" :model="formData" @keyup.enter="query" ref="queryRef">
+						<el-form :inline="true" :model="formData" ref="queryRef">
 							<el-form-item>
-								<el-date-picker v-model="formData.time" type="daterange" start-placeholder="开始时间" end-placeholder="结束时间" />
+								<el-date-picker v-model="formData.time" type="daterange" value-format="YYYY-MM-DD"
+									:disabled-date="disableFuture" @change="handleRangeChange" class="!w-[250px]"
+									start-placeholder="开始时间" end-placeholder="结束时间" />
 							</el-form-item>
+
 							<el-form-item>
-								<el-select v-model="selectedChannelCompare" class="w-[140px]" placeholder="全部渠道">
-									<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-								</el-select>
+								<FilterSelect v-model="formData.channel" type="channel" @change="query"
+									class="!w-[180px] ml-2" />
 							</el-form-item>
 							<el-form-item>
-								<el-select v-model="selectedChannelCompare" class="w-[140px]" placeholder="全部版本">
-									<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-								</el-select>
+								<FilterSelect v-model="formData.version" type="version" @change="query"
+									class="!w-[180px] ml-2" />
 							</el-form-item>
 						</el-form>
 					</el-row>
 				</div>
 			</div>
-			<div class="mt-2 el-card  p-9">
-				<!-- 新增趋势 -->
-				<div class="">
-					<Title left-line :title="t('activeUser.addtrend')">
-						<template #default>
-							<el-popover class="box-item" placement="right" trigger="hover" width="250">
-								<template #reference>
-									<el-icon class="ml-1" style="color: #a4b8cf"><QuestionFilled /></el-icon>
-								</template>
-								<template #default>
-									<div class="ant-popover-inner-content">
-										<div class="um-page-tips-content" style="line-height: 24px">
-											<p>
-												<span class="highlight">活跃用户:</span>
-												<span>启动过应用的用户(去重),启动过一次的用户即视为活跃用户,包括新用户与老用户</span>
-											</p>
-											<p><span class="highlight">活跃构成:</span><span>活跃用户中新增用户的占比比例</span></p>
-											<p><span class="highlight">活跃粘度:</span><span>DAU/过去7日活跃用户,DAU/过去30日活跃用户</span></p>
-											<p><span class="highlight">过去7日活跃用户:</span><span>过去7日(不含今日)的活跃用户数(去重)</span></p>
-											<p><span class="highlight">过去30日活跃用户:</span><span>过去30日(不含今日)的活跃用户数(去重)</span></p>
-											<p><span class="highlight">分时活跃用户:</span><span>活跃用户在24小时中的分布情况(每小时间去重)&ZeroWidthSpace;</span></p>
-											<p><span class="highlight">周活跃率:</span><span>周活跃用户占截止本周累计用户的比例</span></p>
-											<p><span class="highlight">月活跃率:</span><span>月活跃用户占截止本月累计用户的比例</span></p>
-											<p>
-												<span>
-													按天、周或月查看数据可进行版本、渠道的交叉筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)
-												</span>
-											</p>
-										</div>
-									</div>
-								</template>
-							</el-popover>
-						</template>
-					</Title>
-					<div class="flex items-center justify-between mb-2 mt-3">
-						<div>
-							<el-select v-model="selectedChannelCompare" class="!w-[140px]" placeholder="渠道对比">
-								<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-							</el-select>
 
-							<el-select v-model="selectedChannelCompare" class="w-[140px] ml-2" style="width: 140px" placeholder="时间段对比">
-								<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-							</el-select>
-							<el-button type="primary" class="ml-2">{{ t('activeUser.selectTime') }}</el-button>
-						</div>
-
-						<div class="flex items-center">
-							<el-radio-group v-model="timeGranularity" size="small">
-								<el-radio-button label="day">天</el-radio-button>
-								<el-radio-button label="week">周</el-radio-button>
-								<el-radio-button label="month">月</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="showDetail1 = !showDetail1">
-								{{ showDetail1 ? '收起明细数据' : '展开明细数据' }} <el-icon class="ml-2"><ArrowDown  v-if="showDetail1"/> <ArrowUp  v-else/> </el-icon>
-							</div>
-						<div>
-							<el-button>导出</el-button>
-						</div>
-					</div>
-					<el-table v-if="showDetail1" :data="pagedTableRows" border>
-						<el-table-column prop="date" label="日期" min-width="140" />
-						<el-table-column prop="hyyh" label="活跃用户数" min-width="140" />
-						<el-table-column prop="xyhzb" label="活跃构成(新用户占比)" min-width="140" />
-						<el-table-column prop="gqqr" label="DAU/过去7日活跃用户" min-width="140" />
-						<el-table-column label="新增用户(占比)" min-width="220">
-							<template #default="scope">
-								<div class="flex items-center justify-between w-full">
-									<span>{{ scope.row.newUsers }}</span>
-									<span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
-								</div>
-							</template>
-						</el-table-column>
-					</el-table>
-					<div v-if="showDetail1" class="flex justify-end mt-2">
-						<el-pagination
-							v-model:current-page="currentPage"
-							v-model:page-size="pageSize"
-							background
-							layout="total, prev, pager, next, sizes"
-							:total="tableRows.length"
-							:page-sizes="[5, 10, 20]"
-						/>
-					</div>
-				</div>
+			<!-- 新增趋势模块 -->
+			<div class="mt-3">
+				<AddTrend :form-data="formData" @query="query" />
 			</div>
+
 		</div>
 	</div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
-import * as echarts from 'echarts';
+import { ref, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
 
 const { t } = useI18n();
-
-interface TableRow {
-	date: string;
-	newUsers: number;
-	ratio: string;
-}
-
-const formData = ref<Record<string, any>>({});
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+const AddTrend = defineAsyncComponent(() => import('./components/AddTrend.vue'));
+// 计算默认时间范围(半个月前到今天)
+const getDefaultDateRange = () => {
+  const endDate = dayjs();
+  const startDate = endDate.subtract(15, 'day');
+  return [startDate.format('YYYY-MM-DD'), endDate.format('YYYY-MM-DD')];
+};
+const formData = ref<Record<string, any>>({
+	time: getDefaultDateRange(), // 时间范围
+});
 const query = () => {
 	console.log(formData.value);
 };
 
-const selectedChannelCompare = ref('');
-const channelCompareOptions = [
-	{ label: '渠道对比', value: 'compare' },
-	{ label: '渠道A', value: 'a' },
-	{ label: '渠道B', value: 'b' },
-];
-
-// 图表相关
-const timeGranularity = ref<'hour' | 'day' | 'week' | 'month'>('week');
-const lineChartRef = ref<HTMLDivElement | null>(null);
-let chartInstance: echarts.ECharts | null = null;
-
-const lineChartData = ref<Array<{ x: string; value: number }>>([
-	{ x: '2025-07-01', value: 900 },
-	{ x: '2025-07-08', value: 1000 },
-	{ x: '2025-07-15', value: 1100 },
-	{ x: '2025-07-22', value: 1000 },
-	{ x: '2025-07-29', value: 600 },
-	{ x: '2025-08-05', value: 300 },
-	{ x: '2025-08-12', value: 250 },
-	{ x: '2025-08-19', value: 200 },
-	{ x: '2025-08-26', value: 650 },
-	{ x: '2025-09-02', value: 950 },
-	{ x: '2025-09-09', value: 900 },
-	{ x: '2025-09-16', value: 120 },
-]);
-
-function initLineChart(): void {
-	if (!lineChartRef.value) return;
-	if (chartInstance) chartInstance.dispose();
-	chartInstance = echarts.init(lineChartRef.value);
-	const option: echarts.EChartsOption = {
-		tooltip: { trigger: 'axis' },
-		grid: { left: 40, right: 20, top: 20, bottom: 30 },
-		xAxis: {
-			type: 'category',
-			data: lineChartData.value.map((d) => d.x),
-			axisLine: { lineStyle: { color: '#e5e7eb' } },
-			axisLabel: { color: '#6b7280' },
-			axisTick: { alignWithLabel: true },
-		},
-		yAxis: {
-			type: 'value',
-			axisLine: { show: false },
-			splitLine: { lineStyle: { color: '#f3f4f6' } },
-			axisLabel: { color: '#6b7280' },
-		},
-		series: [
-			{
-				name: '新增人数',
-				type: 'line',
-				smooth: true,
-				showSymbol: true,
-				symbolSize: 6,
-				itemStyle: { color: '#409EFF' },
-				lineStyle: { color: '#409EFF' },
-				data: lineChartData.value.map((d) => d.value),
-			},
-		],
-	};
-	chartInstance.setOption(option);
+function disableFuture(date: Date) {
+	const today = new Date();
+	today.setHours(0, 0, 0, 0);
+	return date.getTime() > today.getTime();
 }
 
-onMounted(() => {
-	initLineChart();
-});
-
-watch(timeGranularity, () => {
-	// 静态页面:仅重新渲染
-	initLineChart();
-});
-
-// 表格相关(静态数据)
-const tableRows = ref<TableRow[]>(
-	Array.from({ length: 42 }).map((_, idx) => ({
-		date: `2025-08-${String(11).padStart(2, '0')}`,
-		newUsers: 727,
-        hyyh:'115',
-        xyhzb:'111',
-        gqqr:'112',
-		ratio: '97.45%',
-	}))
-);
-
-const currentPage = ref(1);
-const pageSize = ref(5);
-const pagedTableRows = computed(() => {
-	const startIndex = (currentPage.value - 1) * pageSize.value;
-    
-	return tableRows.value.slice(startIndex, startIndex + pageSize.value);
-});
-
-const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
-
-// 展开/收起明细
-const showDetail1 = ref(true);
-
-
+function handleRangeChange(val: [string, string] | null) {
+	if (!val) {
+		query();
+		return;
+	}
+	const [startStr, endStr] = val;
+	const start = dayjs(startStr);
+	let end = dayjs(endStr);
+	// 限制最大跨度为两年(按天计算,不含边界再减一天)
+	if (end.diff(start, 'day') >= 365 * 2) {
+		end = start.add(2, 'year').subtract(1, 'day');
+		formData.value.time = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
+	}
+	query();
+}
 </script>
 
 <style lang="scss" scoped>
-.highlight {
-	color: #2196f3;
-}
-.el-form-item--default{
+.el-form-item--default {
 	margin-bottom: 0;
 }
-.el-form.el-form--inline .el-form-item--default.el-form-item:last-of-type, .el-form.el-form--inline .el-form-item--small.el-form-item:last-of-type{
+.el-form.el-form--inline .el-form-item--default.el-form-item:last-of-type,
+.el-form.el-form--inline .el-form-item--small.el-form-item:last-of-type {
 	margin-bottom: 0 !important;
 }
 </style>

+ 87 - 52
src/views/count/user/adduser/components/AddTrend.vue

@@ -6,7 +6,9 @@
 				<template #default>
 					<el-popover class="box-item" placement="right" trigger="hover" width="250">
 						<template #reference>
-							<el-icon class="ml-1" style="color: #a4b8cf"><QuestionFilled /></el-icon>
+							<el-icon class="ml-1" style="color: #a4b8cf">
+								<QuestionFilled />
+							</el-icon>
 						</template>
 						<template #default>
 							<div class="ant-popover-inner-content">
@@ -34,7 +36,8 @@
 					</el-select>
 
 					<!-- 版本对比和渠道对比使用popover -->
-					<el-popover v-if="industryCompare !== 'time' && !industryCompare" placement="bottom" trigger="click" width="400">
+					<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>
@@ -42,16 +45,12 @@
 							<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"
-									/>
+									<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">
+									<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>
@@ -62,31 +61,19 @@
 					</el-popover>
 
 					<!-- 时段对比使用日期选择 -->
-					<el-popover
-						v-if="industryCompare === 'time'"
-						placement="bottom"
-						trigger="click"
-						width="300"
-						:visible="timeCompareVisible"
-						:hide-after="0"
-						:persistent="true"
-					>
+					<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>
+							<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"
-										@change="handleTimeCompareChange"
-										style="width: 100%"
-										:clearable="false"
-									/>
+									<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">
@@ -100,10 +87,10 @@
 				</div>
 				<div class="flex items-center">
 					<el-radio-group v-model="formData.timeUnit" @change="getData">
-						<el-radio-button label="hour">小时</el-radio-button>
-						<el-radio-button label="day">天</el-radio-button>
-						<el-radio-button label="week">周</el-radio-button>
-						<el-radio-button label="month">月</el-radio-button>
+						<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>
@@ -116,34 +103,33 @@
 		<!-- 明细表格 -->
 		<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 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>导出</el-button>
+					<el-button type="primary" text>导出</el-button>
 				</div>
 			</div>
 			<el-table v-if="showDetail" :data="tableData" border>
 				<el-table-column prop="date" label="日期" min-width="140" />
-				<el-table-column label="新增用户(占比)" min-width="220">
+				<el-table-column label="新增用户(占比)" align="right" min-width="220">
 					<template #default="scope">
-						<div class="flex items-center justify-between w-full">
+						<div class="flex items-center justify-end w-full">
 							<span>{{ scope.row.newUser }}</span>
-							<span class="text-gray-500 text-xs">{{ (scope.row.newUserRate * 100).toFixed(2) }}%</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]"
-				/>
+				<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>
@@ -161,7 +147,7 @@ import { getTrend, getTrendDetail } from '/@/api/count/addUser';
 const { t } = useI18n();
 const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
 
-const industryCompare = ref('');
+const industryCompare = ref('');	
 const selectedCompareItems = ref<string[]>([]);
 const searchKeyword = ref('');
 const compareOptions = ref<string[]>([]);
@@ -219,6 +205,13 @@ const getData = async (type: string) => {
 		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 || [];
 
@@ -332,6 +325,29 @@ const formatterTips = (params: any) => {
 		</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 = [];
@@ -478,14 +494,14 @@ function initLineChart(): void {
 		},
 		legend: {
 			data: lineChartData.value.items.map((item: any) => item.name),
-			top: 'top',
+			top: 'bottom',
 			type: 'scroll', // 支持图例滚动
 		},
 		grid: {
 			left: 40,
 			right: 20,
-			top: 50, // 为图例留出空间
-			bottom: 30,
+			top: 20, // 为图例留出空间
+			bottom: 60,
 		},
 		xAxis: {
 			type: 'category',
@@ -522,8 +538,27 @@ watch(props.formData, () => {
 		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>

+ 96 - 52
src/views/count/user/adduser/components/UserQuality.vue

@@ -34,29 +34,29 @@
 			<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">
+					{{ showDetail ? '收起明细数据' : '展开明细数据' }}
+					<el-icon class="ml-2">
 						<ArrowDown v-if="showDetail" />
 						<ArrowUp v-else />
 					</el-icon>
 				</div>
 				<div>
-					<el-button>导出</el-button>
+					<el-button type="primary" text>导出</el-button>
+
 				</div>
 			</div>
-			<el-table v-if="showDetail" :data="pagedTableRows" border>
-				<el-table-column prop="date" label="日期" min-width="140" />
-				<el-table-column label="新增用户(占比)" min-width="220">
+			<el-table v-if="showDetail" :data="tableData" border>
+				<el-table-column prop="date" label="日期" />
+				<el-table-column label="新增用户(占比)" align="right">
 					<template #default="scope">
-						<div class="flex items-center justify-between w-full">
-							<span>{{ scope.row.newUsers }}</span>
-							<span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
-						</div>
+						{{ (scope.row.retention * 100).toFixed(2) }}%
 					</template>
 				</el-table-column>
 			</el-table>
 			<div v-if="showDetail" class="flex justify-end mt-3">
-				<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" background
-					layout="total, prev, pager, next, sizes" :total="tableRows.length" :page-sizes="[5, 10, 20]" />
+				<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>
@@ -66,66 +66,90 @@
 import { ref, onMounted, computed, defineAsyncComponent } from 'vue';
 import * as echarts from 'echarts';
 import { useI18n } from 'vue-i18n';
+import { getRetentionDetail, getRetention } from '/@/api/count/addUser';
+
 const { t } = useI18n();
 const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
 
 interface TableRow {
 	date: string;
-	newUsers: number;
-	ratio: string;
+	retention: string;
 }
 
+const emit = defineEmits(['query']);
+const props = defineProps({
+	formData: {
+		type: Object,
+		default: () => ({}),
+	},
+});
+const colorSchemes = [
+	{ color: '#409EFF' }, // 蓝色
+	{ color: '#67C23A' }, // 绿色
+	{ color: '#E6A23C' }, // 黄色
+	{ color: '#F56C6C' }, // 红色
+];
+const pagination = ref({
+	current: 1, //当前页数
+	total: 0, // 数据总数
+	size: 5, // 每页显示条数
+});
 // 用户质量(留存率)
 const qualityChartRef = ref<HTMLDivElement | null>(null);
 let qualityChart: echarts.ECharts | null = null;
+const tableData = ref<TableRow[]>([]);
 
-const qualityXAxis = ref<string[]>([
-	'2025-07-01',
-	'2025-07-08',
-	'2025-07-15',
-	'2025-07-22',
-	'2025-07-29',
-	'2025-08-05',
-	'2025-08-12',
-	'2025-08-19',
-	'2025-08-26',
-	'2025-09-02',
-	'2025-09-09',
-	'2025-09-16',
-]);
-
-const retentionSeries = ref<number[]>([20, 23, 27, 24, 22, 15, 5, 4, 16, 26, 25, 2]);
-const industryAvgSeries = ref<number[]>([16, 18, 20, 24, 25, 24, 16, 10, 15, 22, 21, 12]);
-const peerSameScaleSeries = ref<number[]>([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
-
-// 表格相关(静态数据)
-const tableRows = ref<TableRow[]>(
-	Array.from({ length: 42 }).map((_, idx) => ({
-		date: `2025-08-${String(11).padStart(2, '0')}`,
-		newUsers: 727,
-		ratio: '97.45%',
-	}))
-);
-
-const currentPage = ref(1);
-const pageSize = ref(5);
-const pagedTableRows = computed(() => {
-	const startIndex = (currentPage.value - 1) * pageSize.value;
-	return tableRows.value.slice(startIndex, startIndex + pageSize.value);
+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 lineCartData = ref<{ dates: any; retentions: any }>({
+	dates: [],
+	retentions: [],
 });
 
+const getData = async () => {
+	const res = await getRetention({ ...formData.value });
+	const data = res?.data || [];
+	lineCartData.value = data;
+	lineCartData.value.retentions = lineCartData.value.retentions.map((item: any) => (item * 100).toFixed(2));
+	initQualityChart();
+	getBottomDetail()
+};
+
+const getBottomDetail = async () => {
+	// 下方明细表
+	const res = await getRetentionDetail({ ...formData.value, ...pagination.value });
+	const data = res?.data || [];
+	tableData.value = data.records;
+	pagination.value.current = data.current; //当前页码
+	pagination.value.total = data.total; //总条数
+	pagination.value.size = data.size; //每页条数
+};
+
 // 展开/收起明细
 const showDetail = ref(true);
 
 function initQualityChart(): void {
+	const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
 	if (!qualityChartRef.value) return;
 	if (qualityChart) qualityChart.dispose();
 	qualityChart = echarts.init(qualityChartRef.value);
 	const option: echarts.EChartsOption = {
 		tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
-		legend: { data: ['留存率', '同行业App', '同行业同规模App'] },
 		grid: { left: 40, right: 20, top: 30, bottom: 30 },
-		xAxis: { type: 'category', data: qualityXAxis.value },
+		xAxis: { type: 'category', data: lineCartData.value.dates },
 		yAxis: {
 			type: 'value',
 			min: 0,
@@ -134,16 +158,36 @@ function initQualityChart(): void {
 			splitLine: { lineStyle: { color: '#f3f4f6' } },
 		},
 		series: [
-			{ name: '留存率', type: 'line', smooth: true, data: retentionSeries.value },
-			{ name: '同行业App', type: 'line', smooth: true, data: industryAvgSeries.value, color: '#f59e0b' },
-			{ name: '同行业同规模App', type: 'line', smooth: true, data: peerSameScaleSeries.value, color: '#60a5fa' },
+			{
+				name: '留存率',
+				type: 'line',
+				smooth: true,
+				data: lineCartData.value.retentions,
+				itemStyle: {
+					color: colorSchemes[randomColorIndex].color,
+				},
+				lineStyle: {
+					color: colorSchemes[randomColorIndex].color,
+				},
+			},
 		],
 	};
 	qualityChart.setOption(option);
 }
 
 onMounted(() => {
-	initQualityChart();
+	getData();
+
+});
+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],
+	};
+	getData();
 });
 </script>
 

+ 34 - 16
src/views/count/user/adduser/index.vue

@@ -5,47 +5,43 @@
 			<div class="el-card p-9">
 				<div class="flex justify-between">
 					<Title :title="t('addUser.analytics')" />
-					<div class="">
+					<!-- <div class="">
 						<el-button type="primary">{{ t('addUser.growth') }}</el-button>
 						<el-button type="primary">{{ t('addUser.ai') }}</el-button>
-					</div>
+					</div> -->
 				</div>
 				<div class="mt-2">
 					<el-row shadow="hover" class="ml10">
 						<el-form :inline="true" :model="formData" ref="queryRef">
 							<el-form-item>
-								<el-date-picker
-									v-model="formData.time"
-									type="daterange"
-									value-format="YYYY-MM-DD"
-									@change="query"
-									class="!w-[250px]"
-									start-placeholder="开始时间"
-									end-placeholder="结束时间"
-								/>
+								<el-date-picker v-model="formData.time" type="daterange" value-format="YYYY-MM-DD"
+									:disabled-date="disableFuture" @change="handleRangeChange" class="!w-[250px]"
+									start-placeholder="开始时间" end-placeholder="结束时间" />
 							</el-form-item>
 
 							<el-form-item>
-								<FilterSelect v-model="formData.channel" type="channel" @change="query" class="!w-[180px] ml-2" />
+								<FilterSelect v-model="formData.channel" type="channel" @change="query"
+									class="!w-[180px] ml-2" />
 							</el-form-item>
 							<el-form-item>
-								<FilterSelect v-model="formData.version" type="version" @change="query" class="!w-[180px] ml-2" />
+								<FilterSelect v-model="formData.version" type="version" @change="query"
+									class="!w-[180px] ml-2" />
 							</el-form-item>
 						</el-form>
 					</el-row>
 				</div>
 			</div>
-			
+
 			<!-- 新增趋势模块 -->
 			<div class="mt-3">
 				<AddTrend :form-data="formData" @query="query" />
 			</div>
-			
+
 			<!-- 用户质量模块 -->
 			<div class="mt-3">
 				<UserQuality :form-data="formData" @query="query" />
 			</div>
-			
+
 		</div>
 	</div>
 </template>
@@ -72,6 +68,28 @@ const formData = ref<Record<string, any>>({
 const query = () => {
 	console.log(formData.value);
 };
+
+function disableFuture(date: Date) {
+	const today = new Date();
+	today.setHours(0, 0, 0, 0);
+	return date.getTime() > today.getTime();
+}
+
+function handleRangeChange(val: [string, string] | null) {
+	if (!val) {
+		query();
+		return;
+	}
+	const [startStr, endStr] = val;
+	const start = dayjs(startStr);
+	let end = dayjs(endStr);
+	// 限制最大跨度为两年(按天计算,不含边界再减一天)
+	if (end.diff(start, 'day') >= 365 * 2) {
+		end = start.add(2, 'year').subtract(1, 'day');
+		formData.value.time = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
+	}
+	query();
+}
 </script>
 
 <style lang="scss" scoped>