瀏覽代碼

feat: 用户活跃分析逻辑修改

zhaonan 8 小時之前
父節點
當前提交
d4459ca358

+ 169 - 0
src/components/analytics-filter-header/index.vue

@@ -0,0 +1,169 @@
+<template>
+	<el-form :inline="true" :model="local" ref="formRef">
+		<el-form-item>
+			<el-select v-model="appID" filterable remote class="w-full">
+				<el-option v-for="item in appOptions" :key="item.value" :label="item.label" :value="item.value" />
+			</el-select>
+		</el-form-item>
+		<el-form-item>
+			<el-date-picker v-model="local.time" type="daterange" value-format="YYYY-MM-DD"
+				:disabled-date="disableFuture" @change="onRangeChange" class="!w-[250px]" start-placeholder="开始时间"
+				end-placeholder="结束时间" />
+		</el-form-item>
+
+		<el-form-item>
+			<FilterSelect v-model="local.channel" type="channel" :disabled="channelDisabled" @change="onChange"
+				class="!w-[180px] ml-2" />
+		</el-form-item>
+		<el-form-item>
+			<FilterSelect v-model="local.version" type="version" :disabled="versionDisabled" @change="onChange"
+				class="!w-[180px] ml-2" />
+		</el-form-item>
+	</el-form>
+</template>
+
+<script setup lang="ts">
+import dayjs from 'dayjs';
+import { getAppList } from '/@/api/marketing/config';
+import { setAppID, APP_ID_STORAGE_KEY } from '/@/api/common/common';
+import { useMessage } from '/@/hooks/message';
+import { ElMessageBox } from 'element-plus';
+
+const props = defineProps<{
+	modelValue: Record<string, any>
+	channelDisabled?: boolean;
+	versionDisabled?: boolean;
+	chartType?: string;
+}>();
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', v: Record<string, any>): void
+	(e: 'change', payload: { field?: string; value: Record<string, any> }): void
+}>();
+
+const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+
+const formRef = ref();
+const appID = ref(localStorage.getItem(APP_ID_STORAGE_KEY) || '-1');
+
+const appOptions = ref<any[]>([]);
+
+const getAppListData = async () => {
+	const res = await getAppList({ isAll: false });
+	const data = res.data;
+	appOptions.value = data.map((item: any, index: number) => {
+		return {
+			label: item.appName,
+			value: item.appId,
+		};
+	});
+	appOptions.value.unshift({
+		label: '全部应用',
+		value: '-1',
+	});
+	// 初始化全局 appID 与父级 formData
+	setAppID(appID.value);
+	if (props.modelValue) {
+		(props.modelValue as any).appId = appID.value;
+		emit('update:modelValue', props.modelValue);
+	}
+};
+
+const local = reactive<Record<string, any>>({
+	time: props.modelValue?.time,
+	channel: props.modelValue?.channel,
+	version: props.modelValue?.version,
+});
+const lastDate = ref(local.time);
+function disableFuture(date: Date) {
+	const today = new Date();
+	today.setHours(0, 0, 0, 0);
+	return date.getTime() > today.getTime();
+}
+
+function onRangeChange(val: [string, string] | null) {
+	// 分时活跃用户限制只能选择7天以内
+	if (props.chartType === "分时活跃用户") {
+		const diff = dayjs(val?.[1]).diff(val?.[0], "day");
+		if (diff >= 7) {
+			local.time = lastDate.value;
+			ElMessageBox.confirm("“分时活跃用户”最多可选择7天(或以下)的时间长度", "提示", {
+				showCancelButton: false
+			})
+			return;
+		}
+	} else if (props.chartType === "周活跃度") {
+		const diff = dayjs(val?.[1]).diff(val?.[0], "day");
+		if (diff < 7) {
+			local.time = lastDate.value;
+			ElMessageBox.confirm("“用户周活跃率”须最少选择8天(或以上)的时间长度", "提示", {
+				showCancelButton: false
+			})
+			return;
+		}
+	} else if (props.chartType === "月活跃度") {
+		const diff = dayjs(val?.[1]).diff(val?.[0], "day");
+		if (diff < 30) {
+			local.time = lastDate.value;
+			ElMessageBox.confirm("用户月活跃率”须最少选择31天(或以上)的时间长度", "提示", {
+				showCancelButton: false
+			})
+			return;
+		}
+	}
+	lastDate.value = val;
+	if (!val) {
+		// 同步到外部对象引用
+		if (props.modelValue) {
+			(props.modelValue as any).time = local.time;
+			emit('update:modelValue', props.modelValue);
+		}
+		emit('change', { value: props.modelValue || { ...local } });
+		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');
+		local.time = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
+	}
+	if (props.modelValue) {
+		(props.modelValue as any).time = local.time;
+		emit('update:modelValue', props.modelValue);
+	}
+	emit('change', { field: 'time', value: props.modelValue || { ...local } });
+}
+const onChange = () => {
+	(props.modelValue as any).time = local.time;
+	(props.modelValue as any).channel = local.channel;
+	(props.modelValue as any).version = local.version;
+	(props.modelValue as any).appId = appID.value;
+	setAppID(appID.value);
+
+	emit('update:modelValue', props.modelValue);
+}
+onMounted(() => {
+	getAppListData();
+});
+
+// 监听应用选择变化,更新全局 appID 与父级数据
+watch(appID, (val) => {
+	setAppID(val || '');
+	if (val === '-1') {
+		versionDisabled.value = true
+	} else if (!props.versionDisabled) {
+		versionDisabled.value = false
+	}
+	if (props.modelValue) {
+		(props.modelValue as any).appId = val || '';
+		emit('update:modelValue', props.modelValue);
+		emit('change', { field: 'appId', value: props.modelValue });
+	}
+});
+
+const channelDisabled = ref(props.channelDisabled === true);
+const versionDisabled = ref(props.versionDisabled === true || appID.value === '-1');
+</script>
+
+<style scoped lang="scss"></style>

+ 158 - 51
src/views/count/user/activeUser/components/AddTrend.vue

@@ -8,18 +8,20 @@
 						<el-option label="活跃趋势" value="活跃趋势" />
 						<el-option label="活跃构成" value="活跃构成" />
 						<el-option label="活跃粘度" value="活跃粘度" />
-						<el-option label="分时活跃用户" value="分时活跃用户" />
-						<el-option label="周活跃度" value="周活跃度" />
-						<el-option label="月活跃度" value="月活跃度" />
+						<el-option :disabled="isTrendDisabled" label="分时活跃用户" value="分时活跃用户" />
+						<el-option :disabled="isWeekRateDisabled" label="周活跃度" value="周活跃度" />
+						<el-option :disabled="isMonthRateDisabled" label="月活跃度" value="月活跃度" />
 					</el-select>
-					<el-select v-model="industryCompare" class="!w-[120px] ml-2" clearable @change="handleCompareChange">
+					<el-select v-model="industryCompare" class="!w-[120px] ml-2" 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">
+					<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>
@@ -27,16 +29,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>
@@ -47,32 +45,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"
-										:disabled-date="disableAfterToday"
-										@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">
@@ -84,7 +69,7 @@
 
 					<el-button link class="ml-2" @click="clearCompare(), getData('clearAll')">清除</el-button>
 				</div>
-				<div class="flex items-center">
+				<div class="flex items-center" v-if="chartType !== '分时活跃用户'">
 					<el-radio-group v-model="formData.timeUnit" @change="getData(''), getBottomDetail()">
 						<el-radio-button label="day" :disabled="isDayDisabled">天</el-radio-button>
 						<el-radio-button label="week" :disabled="isWeekDisabled">周</el-radio-button>
@@ -101,7 +86,8 @@
 		<!-- 明细表格 -->
 		<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">
+				<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" />
@@ -117,33 +103,30 @@
 				<el-table-column prop="activeUser" label="活跃用户数" align="right" min-width="100" />
 				<el-table-column label="活跃构成(新用户占比)" align="right" min-width="140">
 					<template #default="{ row }">
-						<div v-if="row.newUserRate" class="flex items-center justify-end w-full">{{ (row.newUserRate * 100).toFixed(2) }}%</div>
+						<div v-if="row.newUserRate" class="flex items-center justify-end w-full">{{ (row.newUserRate *
+							100).toFixed(2) }}%</div>
 						<span v-else>--</span>
 					</template>
 				</el-table-column>
 				<el-table-column label="DAU/过去7日活跃用户" align="right" min-width="140">
 					<template #default="{ row }">
-						<div v-if="row.wauRate" class="flex items-center justify-end w-full">{{ (row.wauRate * 100).toFixed(2) }}%</div>
+						<div v-if="row.wauRate" class="flex items-center justify-end w-full">{{ (row.wauRate *
+							100).toFixed(2) }}%</div>
 						<span v-else>--</span>
 					</template>
 				</el-table-column>
 				<el-table-column label="DAU/过去30日活跃用户" align="right" min-width="140">
 					<template #default="{ row }">
-						<div v-if="row.mauRate" class="flex items-center justify-end w-full">{{ (row.mauRate * 100).toFixed(2) }}%</div>
+						<div v-if="row.mauRate" class="flex items-center justify-end w-full">{{ (row.mauRate *
+							100).toFixed(2) }}%</div>
 						<span v-else>--</span>
 					</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>
@@ -182,6 +165,11 @@ const props = defineProps({
 		default: () => ({}),
 	},
 });
+defineExpose({
+	chartType
+})
+
+const timeDiff = computed(() => dayjs(props.formData.time[1]).diff(props.formData.time[0], "day"));
 
 interface FormDataType {
 	timeUnit: string;
@@ -216,11 +204,15 @@ const loading = ref(false);
 
 const onChangeType = (value: string) => {
 	if (value !== '分时活跃用户') {
+		if (value === "活跃粘度") {
+			formData.value.timeUnit = "day";
+		}
 		getData('clearAll');
 		emit('query', '');
 
 	}
-	if(value === '分时活跃用户' || value === '周活跃度' || value === '月活跃率'){
+	if (value === '分时活跃用户' || value === '周活跃度' || value === '月活跃率') {
+		getData('clearAll');
 		emit('query', 'disabled');
 	}
 };
@@ -241,6 +233,7 @@ const getData = async (type: string) => {
 	}
 	let res;
 	let data;
+<<<<<<< Updated upstream
 	if (chartType.value == '活跃趋势') {
 		res = await getTrend(formData.value);
 		data = res.data || [];
@@ -258,6 +251,38 @@ const getData = async (type: string) => {
 		formData.value.timeUnit = 'month';
 		res = await getMonthrate(formData.value);
 		data = res.data || [];
+=======
+	try {
+
+		if (chartType.value == '活跃趋势') {
+			res = await getTrend(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '活跃构成') {
+			res = await getCompose(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '活跃粘度') {
+			res = await getViscosity(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '周活跃度') {
+			formData.value.timeUnit = 'week';
+			res = await getWeekrate(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '月活跃度') {
+			formData.value.timeUnit = 'month';
+			res = await getMonthrate(formData.value);
+			data = res.data || [];
+		} else if (chartType.value === '分时活跃用户') {
+			const params = {
+				...formData.value
+			};
+			params.timeUnit = "hour";
+			res = await getTrend(params);
+			data = res.data || [];
+		}
+
+	} catch (error) {
+		console.log(error);
+>>>>>>> Stashed changes
 	}
 
 	// const res = await getTrend({ ...formData.value });
@@ -383,10 +408,58 @@ const rangeDays = computed(() => {
 	if (!start || !end) return 0;
 	return getDaysBetweenDates(start, end);
 });
+const isTrendDisabled = computed(() => {
+	return timeDiff.value >= 7;
+})
+const isWeekRateDisabled = computed(() => {
+	return timeDiff.value < 7
+})
+const isMonthRateDisabled = computed(() => {
+	return timeDiff.value < 30
+})
+const isWeekDisabled = computed(() => {
+	if (chartType.value === "活跃粘度") {
+
+		return true;
+	}
+	if (chartType.value === "月活跃度") {
+		return true;
+	}
+	if (!isTrendDisabled.value) {
+		return true;
+	}
+	if (!isWeekRateDisabled.value && isMonthRateDisabled.value) {
+		if (chartType.value === "活跃粘度") {
 
-const isWeekDisabled = computed(() => rangeDays.value < 7);
-const isMonthDisabled = computed(() => rangeDays.value < 28);
-const isDayDisabled = computed(() => false);
+			return true;
+		}
+	}
+});
+const isMonthDisabled = computed(() => {
+	// rangeDays.value < 28
+	if (chartType.value === "活跃粘度") {
+
+		return true;
+	}
+	if (chartType.value === "周活跃度") {
+		return true;
+	}
+	if (!isTrendDisabled.value) {
+		return true;
+	}
+	if (!isWeekRateDisabled.value && isMonthRateDisabled.value) {
+		return true;
+	}
+});
+const isDayDisabled = computed(() => {
+	if (chartType.value === "周活跃度") {
+		return true;
+	}
+	if (chartType.value === "月活跃度") {
+		return true;
+	}
+	return false;
+});
 
 function ensureValidTimeUnit() {
 	// 不进行自动纠正,保持用户选择
@@ -458,6 +531,7 @@ function handleCompareItemsChange(items: string[]) {
 		.filter(({ item }) => !currentItems.has(item))
 		.map(({ index }) => index);
 
+<<<<<<< Updated upstream
 	// 输出结果用于调试或后续处理
 	console.log('新增的项:', addedItems);
 	console.log('减少的项:', removedItemIndices);
@@ -473,6 +547,30 @@ function handleCompareItemsChange(items: string[]) {
 	previousCompareItems.value = items;
 
 	const isVersion = industryCompare.value === 'version'; // 判断是否是版本对比
+=======
+	// 更新 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';
+>>>>>>> Stashed changes
 	if (isVersion) {
 		formData.value.version = addedItems;
 		formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
@@ -480,9 +578,18 @@ function handleCompareItemsChange(items: string[]) {
 		formData.value.channel = addedItems;
 		formData.value.version = props.formData?.version ? [props.formData?.version] : [];
 	}
+<<<<<<< Updated upstream
 	if (addedItems.length > 0) {
 		getData('');
 	} else {
+=======
+
+	// 根据是否有新增项来决定是否重新获取数据
+	if (addedItems.length > 0) {
+		getData('');
+	} else if (removedItemIndices.length > 0) {
+		// 如果只是移除了项,则重新初始化图表
+>>>>>>> Stashed changes
 		initLineChart();
 	}
 }

+ 9 - 1
src/views/count/user/activeUser/index.vue

@@ -41,6 +41,7 @@
 					</div> -->
 				</div>
 				<div class="mt-2">
+<<<<<<< Updated upstream
 					<el-row shadow="hover" class="ml10">
 						<el-form :inline="true" :model="formData" ref="queryRef">
 							<el-form-item>
@@ -63,13 +64,18 @@
 								<FilterSelect v-model="formData.version" :disabled="versionDisabled" type="version" class="!w-[180px] ml-2" />
 							</el-form-item>
 						</el-form>
+=======
+					<el-row shadow="hover" class="">
+						<AnalyticsFilterHeader v-model:model-value="formData" :channel-disabled="channelDisabled"
+							:version-disabled="versionDisabled" :chartType="addTrendRef?.chartType" />
+>>>>>>> Stashed changes
 					</el-row>
 				</div>
 			</div>
 
 			<!-- 新增趋势模块 -->
 			<div class="mt-3">
-				<AddTrend :form-data="formData" @query="isDisabled" />
+				<AddTrend :form-data="formData" @query="isDisabled" ref="addTrendRef" />
 			</div>
 		</div>
 	</div>
@@ -84,6 +90,8 @@ const { t } = useI18n();
 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 addTrendRef = ref<{chartType: string}>();
+
 // 计算默认时间范围(半个月前到今天)
 const getDefaultDateRange = () => {
 	const endDate = dayjs();