Bladeren bron

fix: 营销规则联调

jcq 13 uur geleden
bovenliggende
commit
a813545415

+ 19 - 0
src/api/count/activations.ts

@@ -0,0 +1,19 @@
+import request from '/@/utils/request';
+import { appID } from '/@/api/common/common';
+
+//启动次数列表
+export const getTrendDetail = (data?: Object) => {
+	return request({
+		url: '/stats/user/launch/detail',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+//活跃趋势
+export const getTrend = (data?: Object) => {
+	return request({
+		url: '/stats/user/launch/trend',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};

+ 27 - 0
src/api/marketing/rules.ts

@@ -0,0 +1,27 @@
+import request from '/@/utils/request';
+//获取营销规则
+export const pageListIp = (data?: Object) => {
+	return request({
+		url: '/marketing/rule/page',
+		method: 'post',
+		data,
+	});
+};
+
+//修改营销规则
+export const update = (data?: Object) => {
+	return request({
+		url: '/marketing/rule/save',
+		method: 'post',
+		data,
+	});
+};
+//删除营销规则
+export const del = (params?: Object) => {
+	return request({
+		url: '/marketing/rule/del',
+		method: 'get',
+		params,
+	});
+};
+

+ 4 - 1
src/components/Upload/Image.vue

@@ -86,7 +86,7 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
 	height: '150px',
 	width: '150px',
 	borderRadius: '8px',
-  dir: '',
+	dir: '',
 	fileId: '',
 });
 
@@ -111,6 +111,7 @@ const self_disabled = computed(() => {
 interface UploadEmits {
 	(e: 'update:imageUrl', value: string): void;
 	(e: 'update:fileId', value: string): void;
+	(e: 'change', value: string): void;
 }
 const emit = defineEmits<UploadEmits>();
 const handleHttpUpload = async (options: UploadRequestOptions) => {
@@ -128,6 +129,7 @@ const handleHttpUpload = async (options: UploadRequestOptions) => {
 		});
 		emit('update:imageUrl', data.url);
 		emit('update:fileId', data.fileId);
+		emit('change', data.url);
 		// 调用 el-form 内部的校验方法(可自动校验)
 		formItemContext?.prop && formContext?.validateField([formItemContext.prop as string]);
 	} catch (error) {
@@ -141,6 +143,7 @@ const handleHttpUpload = async (options: UploadRequestOptions) => {
 const deleteImg = () => {
 	emit('update:imageUrl', '');
 	emit('update:fileId', '');
+	emit('change', '');
 };
 
 /**

+ 583 - 0
src/views/count/user/activations/components/initiateNumber.vue

@@ -0,0 +1,583 @@
+<template>
+	<div class="el-card p-9 mt-3">
+		<!-- 新增趋势 -->
+		<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('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="日期" align="center" min-width="140" />
+				<el-table-column prop="launch" label="启动次数" align="center" min-width="140" />
+				<el-table-column label="启动次数占比" align="center" min-width="140">
+					<template #default="{ row }"> {{ (row.launchRate * 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="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/activations';
+
+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,
+	};
+	console.log(option);
+
+	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>

+ 87 - 199
src/views/count/user/activations/index.vue

@@ -1,237 +1,125 @@
 <template>
 	<div class="layout-padding">
 		<div class="!overflow-auto px-1">
-			<div class="el-card  p-9">
-				<div class="flex justify-between">
-					<Title :title="t('activations.analytics')">
+			
+
+			<!-- 头部筛选区域 -->
+			<div class="el-card p-9">
+				<Title :title="t('activations.analytics')">
+				<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>
-							<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
-													>打开应用视为启动。完全退出或后台运行超过30s后再次进入应用,视为一次新启动。开发过程中可以通过setSessionContinueMills来自定义两次启动的间隔,默认30s</span
-												>
-											</p>
-											<p><span class="highlight">启动次数占比:</span><span>某日/周/月的启动次数占所选时间段总启动次数的比例</span></p>
-											<p>
-												<span
-													>按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)</span
-												>
-											</p>
-										</div>
-									</div>
-								</template>
-							</el-popover>
+							<div class="ant-popover-inner-content">
+								<div class="um-page-tips-content" style="line-height: 24px">
+									<p>
+										<span class="highlight">启动次数:</span
+										><span
+											>打开应用视为启动。完全退出或后台运行超过30s后再次进入应用,视为一次新启动。开发过程中可以通过setSessionContinueMills来自定义两次启动的间隔,默认30s</span
+										>
+									</p>
+									<p><span class="highlight">启动次数占比:</span><span>某日/周/月的启动次数占所选时间段总启动次数的比例</span></p>
+									<p>
+										<span
+											>按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)</span
+										>
+									</p>
+								</div>
+							</div>
 						</template>
-					</Title>
-				</div>
-				<div>
-					<el-row shadow="hover" class="ml10 mt-2">
-						<el-form :inline="true" :model="formData" @keyup.enter="query" ref="queryRef">
+					</el-popover>
+				</template>
+			</Title>
+				<div class="mt-2">
+					<el-row shadow="hover" class="">
+						<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="">
-					<div class="flex items-center justify-between mb-2 mt-3">
-						<div>
-							<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('activations.version') }}</el-button>
-						</div>
-
-						<div class="flex items-center">
-							<el-radio-group v-model="timeGranularity" size="small">
-								<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-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="日期" align="center" min-width="140" />
-						<el-table-column prop="hyyh" label="启动次数" align="center" min-width="140" />
-						<el-table-column prop="ratio" label="启动次数(占比)" align="center" min-width="220"> </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">
+				<InitiateNumber :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 InitiateNumber = defineAsyncComponent(() => import('./components/initiateNumber.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',
-		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>

+ 52 - 42
src/views/count/user/activeUser/components/AddTrend.vue

@@ -443,48 +443,58 @@ function filterCompareOptions() {
 }
 
 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();
-	}
+  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();
+  }
 }
 
 // 初始化对比选项

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

@@ -41,7 +41,7 @@
 					</div> -->
 				</div>
 				<div class="mt-2">
-					<el-row shadow="hover" class="ml10">
+					<el-row shadow="hover" class="">
 						<el-form :inline="true" :model="formData" ref="queryRef">
 							<el-form-item>
 								<el-date-picker

+ 70 - 76
src/views/count/user/adduser/components/AddTrend.vue

@@ -1,33 +1,9 @@
 <template>
-	<div class="el-card p-9">
+	<div class="">
 		<!-- 新增趋势 -->
-		<div class="">
-			<Title left-line :title="t('addUser.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>某时段内新增用户占该时段活跃用户的比例</span></p>
-									<p>
-										<span>
-											按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)
-										</span>
-									</p>
-								</div>
-							</div>
-						</template>
-					</el-popover>
-				</template>
-			</Title>
-			<div class="flex items-center justify-between mb-2 mt-3">
+		<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" />
@@ -86,7 +62,7 @@
 					<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(''),getBottomDetail()">
+					<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>
@@ -95,7 +71,7 @@
 				</div>
 			</div>
 
-			<div class="relative">
+			<div class="relative ">
 				<div ref="lineChartRef" style="width: 100%; height: 320px"></div>
 			</div>
 		</div>
@@ -114,11 +90,12 @@
 					<el-button type="primary" text>导出</el-button>
 				</div>
 			</div>
-			<el-table v-if="showDetail" :data="tableData" border>
+			<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-end w-full">
+						<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>
@@ -145,8 +122,15 @@ 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 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('');
@@ -393,48 +377,58 @@ function filterCompareOptions() {
 }
 
 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();
-	}
+  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();
+  }
 }
 
 // 初始化对比选项

+ 14 - 26
src/views/count/user/adduser/components/UserQuality.vue

@@ -1,27 +1,9 @@
 <template>
-	<div class="el-card p-9">
+	<div class=" p-14">
 		<!-- 新增用户质量 -->
 		<div class="">
 			<div class="flex items-center justify-between mb-2">
-				<Title left-line :title="t('addUser.userQuality')">
-					<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>
-									</div>
-								</div>
-							</template>
-						</el-popover>
-					</template>
-				</Title>
+				
 			</div>
 
 			<div class="relative">
@@ -45,9 +27,10 @@
 
 				</div>
 			</div>
-			<el-table v-if="showDetail" :data="tableData" border>
+			<el-table v-if="showDetail" :data="tableData" border :cell-style="tableStyle.cellStyle"
+				:header-cell-style="tableStyle.headerCellStyle">
 				<el-table-column prop="date" label="日期" />
-				<el-table-column label="新增用户(占比)" align="right">
+				<el-table-column label="新增用户(占比)" >
 					<template #default="scope">
 						{{ (scope.row.retention * 100).toFixed(2) }}%
 					</template>
@@ -65,12 +48,17 @@
 <script setup lang="ts">
 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'));
-
+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' },
+};
 interface TableRow {
 	date: string;
 	retention: string;

+ 62 - 14
src/views/count/user/adduser/index.vue

@@ -11,21 +11,26 @@
 					</div> -->
 				</div>
 				<div class="mt-2">
-					<el-row shadow="hover" class="ml10">
+					<el-row shadow="hover" class="">
 						<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"
-									:disabled-date="disableFuture" @change="handleRangeChange" 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>
@@ -33,15 +38,58 @@
 			</div>
 
 			<!-- 新增趋势模块 -->
-			<div class="mt-3">
+			<div class="mt-3 el-card  p-4">
+				<Title left-line :title="t('addUser.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>某时段内新增用户占该时段活跃用户的比例</span></p>
+										<p>
+											<span>
+												按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)
+											</span>
+										</p>
+									</div>
+								</div>
+							</template>
+						</el-popover>
+					</template>
+				</Title>
 				<AddTrend :form-data="formData" @query="query" />
 			</div>
 
 			<!-- 用户质量模块 -->
-			<div class="mt-3">
+			<div class="mt-3 el-card p-4">
+				<Title left-line :title="t('addUser.userQuality')">
+					<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>
+									</div>
+								</div>
+							</template>
+						</el-popover>
+					</template>
+				</Title>
 				<UserQuality :form-data="formData" @query="query" />
 			</div>
-
 		</div>
 	</div>
 </template>
@@ -58,9 +106,9 @@ const AddTrend = defineAsyncComponent(() => import('./components/AddTrend.vue'))
 const UserQuality = defineAsyncComponent(() => import('./components/UserQuality.vue'));
 // 计算默认时间范围(半个月前到今天)
 const getDefaultDateRange = () => {
-  const endDate = dayjs();
-  const startDate = endDate.subtract(15, 'day');
-  return [startDate.format('YYYY-MM-DD'), endDate.format('YYYY-MM-DD')];
+	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(), // 时间范围

+ 1 - 1
src/views/count/user/versionDistribution/index.vue

@@ -9,7 +9,7 @@
 					</div>
 				</div>
 				<div>
-					<el-row shadow="hover" class="ml10 mt-2">
+					<el-row shadow="hover" class=" mt-2">
 						<el-form :inline="true" :model="formData" @keyup.enter="query" ref="queryRef">
 							<el-form-item>
 								<el-date-picker v-model="formData.time" type="daterange" start-placeholder="开始时间" end-placeholder="结束时间" />

+ 403 - 353
src/views/marketing/rules/components/Edit.vue

@@ -1,202 +1,105 @@
 <template>
-	<el-dialog title="修改规则" width="1000" v-model="props.open" :close-on-click-modal="false" :destroy-on-close="true" @close="onCancel" draggable>
-		<div class="p-4 rounded overflow-y-auto" style="max-height: calc(100vh - 350px)">
-			<div class="flex items-start">
-                <label class="w-[50px] leading-8">关键字</label>
-				<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
-					<el-tag v-for="tag in dynamicTags" :key="tag" size="large" closable :disable-transitions="false" @close="handleClose(tag)">
-						{{ tag }}
-					</el-tag>
-					<el-input
-						v-if="inputVisible"
-						ref="InputRef"
-						v-model="inputValue"
-						class="!w-32"
-						@keyup.enter="handleInputConfirm"
-						@blur="handleInputConfirm"
-					/>
-					<el-button v-else class="button-new-tag"  @click="showInput"> 添加关键字</el-button>
+	<el-dialog :title="props.rowData?.id ? '修改规则' : '新增规则'" width="1000" v-model="props.open"
+		:close-on-click-modal="false" :destroy-on-close="true" @close="onCancel" draggable>
+		<div class="w-full ml-[-8px]">
+			<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px" class="flex flex-wrap">
+				<el-form-item label="规则名称" prop="ruleName" class="w-1/3">
+					<el-input v-model="formData.ruleName" type="text" placeholder="请输入规则名称" />
+				</el-form-item>
+			</el-form>
+			<div class="px-4 rounded overflow-y-auto mt-4" style="max-height: calc(100vh - 350px)">
+				<div class="flex items-start">
+					<label class="w-[65px] leading-8 text-right"><span class="text-[#f56c6c] mr-1">*</span> 关键字</label>
+					<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
+						<el-tag v-for="tag in keywordData" :key="tag" size="large" closable :disable-transitions="false"
+							@close="handleClose(tag, 'keyword')">
+							{{ tag }}
+						</el-tag>
+						<el-input v-if="inputVisible" ref="InputRef" v-model="inputValue" class="!w-32"
+							@keyup.enter="handleInputConfirm('keyword')" @blur="handleInputConfirm('keyword')" />
+						<el-button v-else class="button-new-tag" @click="showInput"> 添加关键字</el-button>
+						<!-- 注册隐藏的表单项以启用校验 -->
+						<el-form-item prop="keyword" style="display:none;"></el-form-item>
+					</div>
 				</div>
 			</div>
-
-			<JCollapse
-				:data="[{ title: 'IP集合', id: '1' }]"
-				:activeNames="['1']"
-				@update="(item) => (listEditOpen = true)"
-				@delete="(item) => (closeIpTags = !closeIpTags)"
-				:deleteText="closeIpTags ? t('marketingConfig.cancel') : t('marketingConfig.deleteIp')"
-				:updateText="t('marketingConfig.addIp')"
-			>
-				<template #default>
-					<div class="border-b p-2 items-center flex flex-wrap">
-						<span class="mr-2">白名单</span>
-						<span v-for="item in ipWhiteList" :key="item.id">
-							<el-popover v-if="item?.sourceType === 1" width="280" @show="onLoadDetail(item)" trigger="hover" placement="top">
-								<div v-if="item.list.length > 0" class="flex flex-wrap">
-									<span v-for="ip in item.list" :key="ip" class="ml-2">
-										{{ ip }}
-									</span>
-								</div>
-								<div v-else>暂无数据</div>
-								<template #reference>
-									<el-tag effect="light" color="#f4f4f4" :closable="closeIpTags" @close="handleDelete(item, 'ip')" round class="ml-1 cursor-pointer">
-										{{ item.groupName }}
-									</el-tag>
-								</template>
-							</el-popover>
-							<el-tag
-								v-else
-								effect="light"
-								color="#f4f4f4"
-								:closable="closeIpTags"
-								@close="handleDelete(item, 'ip')"
-								round
-								class="ml-1 cursor-pointer"
-							>
-								{{ ipSplicing(item.startIp, item.endIp) }}
-							</el-tag>
-						</span>
+			<div class="px-4 rounded overflow-y-auto mt-4" style="max-height: calc(100vh - 350px)">
+				<div class="flex items-start">
+					<label class="w-[65px] leading-8 text-right">IP</label>
+					<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
+						<el-tag v-for="tag in ipData" :key="tag" size="large" closable :disable-transitions="false"
+							@close="handleClose(tag, 'ip')">
+							{{ tag }}
+						</el-tag>
+						<el-input v-if="ipInputVisible" ref="ipInputRef" v-model="ipInputValue" class="!w-32"
+							@keyup.enter="handleInputConfirm('ip')" @blur="handleInputConfirm('ip')" />
+						<el-button v-else class="button-new-tag" @click="showIpInput"> 添加IP</el-button>
+						<!-- 注册隐藏的表单项以启用校验 -->
+						<el-form-item prop="ip" style="display:none;"></el-form-item>
 					</div>
-					<div class="p-2 items-center flex flex-wrap">
-						<span class="mr-2">黑名单</span>
-
-						<span v-for="item in ipBlackList" :key="item.id">
-							<el-popover v-if="item?.sourceType === 1" width="280" @show="onLoadDetail(item, ipList)" trigger="hover" placement="top">
-								<div v-if="item.list.length > 0" class="flex flex-wrap">
-									<span v-for="ip in item.list" :key="ip" class="ml-2">
-										{{ ip }}
-									</span>
-								</div>
-								<div v-else>暂无数据</div>
-								<template #reference>
-									<el-tag effect="light" color="#f4f4f4" :closable="closeIpTags" @close="handleDelete(item, 'ip')" round class="ml-1 cursor-pointer">
-										{{ item.groupName }}
-									</el-tag>
-								</template>
-							</el-popover>
-							<el-tag
-								v-else
-								effect="light"
-								color="#f4f4f4"
-								:closable="closeIpTags"
-								@close="handleDelete(item, 'ip')"
-								round
-								class="ml-1 cursor-pointer"
-							>
-								{{ ipSplicing(item.startIp, item.endIp) }}
-							</el-tag>
-						</span>
-					</div>
-				</template>
-			</JCollapse>
-			<JCollapse
-				class="mt-4"
-				:data="[{ title: '域名集合', id: '1' }]"
-				:activeNames="['1']"
-				@update="() => (domainEditOpen = true)"
-				@delete="() => (closeDomainTags = !closeDomainTags)"
-				:deleteText="closeDomainTags ? t('marketingConfig.cancel') : t('marketingConfig.deleteDomain')"
-				:updateText="t('marketingConfig.addDomain')"
-			>
-				<template #default>
-					<div class="p-2 items-center flex flex-wrap">
-						<span v-if="domainList.length > 0">
-							<span v-for="item in domainList" :key="item.id">
-								<el-popover v-if="item?.sourceType === 1" width="280" @show="onLoadDetail(item)" trigger="hover" placement="top">
-									<div v-if="item.list.length > 0" class="flex flex-wrap">
-										<span v-for="domain in item.list" :key="domain.id" class="ml-2">
-											{{ domain.domain }}
-										</span>
-									</div>
-									<div v-else>暂无数据</div>
-									<template #reference>
-										<el-tag
-											effect="light"
-											color="#f4f4f4"
-											:closable="closeDomainTags"
-											@close="handleDelete(item, 'domain')"
-											round
-											class="ml-1 cursor-pointer"
-										>
-											{{ item.groupName }}
-										</el-tag>
-									</template>
-								</el-popover>
-								<el-tag
-									v-else
-									effect="light"
-									color="#f4f4f4"
-									:closable="closeDomainTags"
-									@close="handleDelete(item, 'domain')"
-									round
-									class="ml-1 cursor-pointer"
-								>
-									{{ item.domain }}
-								</el-tag>
-							</span>
-						</span>
-						<div class="text-gray-400 ml-2" v-else>--</div>
+				</div>
+			</div>
+
+			<div class="px-4 rounded overflow-y-auto mt-4" style="max-height: calc(100vh - 350px)">
+				<div class="flex items-start">
+					<label class="w-[66px] leading-8 text-right">添加域名</label>
+					<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
+						<el-tag v-for="tag in domainData" :key="tag" size="large" closable :disable-transitions="false"
+							@close="handleClose(tag, 'domain')">
+							{{ tag }}
+						</el-tag>
+						<el-input v-if="domainInputVisible" ref="domainInputRef" v-model="domainInputValue"
+							class="!w-32" @keyup.enter="handleInputConfirm('domain')"
+							@blur="handleInputConfirm('domain')" placeholder="如: example.com" />
+						<el-button v-else class="button-new-tag" @click="showDomainInput"> 添加域名</el-button>
+						<!-- 注册隐藏的表单项以启用校验 -->
+						<el-form-item prop="domain" style="display:none;"></el-form-item>
 					</div>
-				</template>
-			</JCollapse>
-		</div>
-		<div class="w-full ml-[-8px] mt-5">
-			<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px" class="flex flex-wrap">
-				<el-form-item :label="t('marketingConfig.jumpMode')" prop="triggerMode" class="w-1/3">
-					<JDictSelect
-						v-model:value="formData.triggerMode"
-						:placeholder="t('marketingConfig.jumpModeTip')"
-						:dictType="'triggerMode'"
-						:selectFirst="true"
-						:styleClass="'w-full'"
-					/>
+				</div>
+			</div>
+			<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px"
+				class="flex flex-wrap mt-4">
+				<el-form-item label="推送方式" prop="action" class="w-1/2">
+					<JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" :dictType="'pushMode'"
+						:selectFirst="true" :styleClass="'w-full'" />
 				</el-form-item>
-				<el-form-item :label="t('marketingConfig.triggerType')" prop="triggerRule" class="w-1/3">
-					<JDictSelect
-						v-model:value="formData.triggerRule"
-						:placeholder="t('marketingConfig.triggerTypeTip')"
-						:dictType="'triggerRule'"
-						:selectFirst="true"
-						:styleClass="'w-full'"
-					/>
+
+				<el-form-item label="推送频率" prop="pushFrequency" class="w-1/2">
+					<el-input v-model="formData.pushFrequency" type="text" placeholder="请输入推送频率" />
 				</el-form-item>
-				<el-form-item :label="t('marketingConfig.triggerFrequency')" prop="triggerNum" class="w-1/3">
-					<el-input v-model="formData.triggerNum" type="text" :placeholder="t('marketingConfig.triggerFrequencyTip')" />
+				<el-form-item label="推送图片" prop="pushContent" class="w-1/2 ">
+					<div class="flex items-start">
+						<el-switch v-model="formData.pushType" class="mr-2"
+							@change="oldUrl = formData.pushContent, formData.pushContent = ''" />
+						<Image v-if="formData.pushType" @change="success" :imageUrl="formData.pushContent"
+							@update="success" />
+					</div>
 				</el-form-item>
 
-				<div class="w-full mb-[18px]">
-					<el-form-item
-						v-if="formData.triggerMode == '2' || formData.triggerMode == '3'"
-						:label="t('marketingConfig.prompt')"
-						prop="promptMsg"
-						class="w-1/3"
-					>
-						<el-input v-model="formData.promptMsg" type="text" :placeholder="t('marketingConfig.promptTip')"></el-input>
-					</el-form-item>
-					<el-form-item
-						v-if="formData.triggerMode == '1' || formData.triggerMode == '3'"
-						:label="t('marketingConfig.jumpLink')"
-						prop="url"
-						class="w-1/3"
-					>
-						<el-input v-model="formData.url" type="text" :placeholder="t('marketingConfig.jumpLinkTip')"></el-input>
-					</el-form-item>
-				</div>
-				<div class="w-full">
-					<el-button type="primary" @click="onSubmit(ruleFormRef)" class="w-[80px] ml-5">{{ t('common.saveBtn') }}</el-button>
-				</div>
+				<el-form-item v-if="!formData.pushType" label="推送内容" prop="pushContent" class="w-full">
+					<el-input v-model="formData.pushContent" type="textarea" placeholder="请输入推送内容" />
+				</el-form-item>
+				<el-form-item label="备注" prop="remark" class="w-full">
+					<el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" />
+				</el-form-item>
 			</el-form>
 		</div>
 		<template #footer>
 			<span class="dialog-footer">
 				<el-button @click="onCancel">{{ t('common.cancelButtonText') }}</el-button>
-				<el-button type="primary" @click="onSubmit" :disabled="loading">{{ t('common.confirmButtonText') }}</el-button>
+				<el-button type="primary" @click="onSubmit"
+					:disabled="loading">{{ t('common.confirmButtonText') }}</el-button>
 			</span>
 		</template>
 	</el-dialog>
 </template>
 
 <script setup name="Edit" lang="ts">
+import { update } from '/@/api/marketing/rules';
+import { useI18n } from 'vue-i18n';
+import { useMessage } from '/@/hooks/message';
+let baseURL = import.meta.env.VITE_API_URL;
+
 // 定义子组件向父组件传值/事件
 const emit = defineEmits(['update:open', 'onsuccess']);
 
@@ -206,77 +109,94 @@ const props = defineProps({
 		default: false,
 	},
 	rowData: {
-		type: Array,
-		default: () => [],
+		type: Object,
+		default: () => { },
 	},
 });
-const rowData = ref({});
 const onCancel = () => {
 	emit('update:open', false);
 };
-
-watch(
-	() => props.open,
-	(val) => {
-		if (val) {
-			rowData.value = props.rowData;
-		}
-	}
-);
-import { pageListIp, getConfigIpList, getConfigDomainList, getGroupDetail, getConfigDetail, saveConfigDetail } from '/@/api/marketing/config';
-import { useI18n } from 'vue-i18n';
-import { useMessage } from '/@/hooks/message';
-import { rule } from '/@/utils/validate';
-import { ipSplicing } from '/@/utils/ipUpdate';
+const oldUrl = ref('');
+const success = (val: string) => {
+	formData.value.pushContent = val;
+};
 
 // 引入组件
-const JCollapse = defineAsyncComponent(() => import('/@/components/JCollapse/index.vue'));
 const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.vue'));
+const Image = defineAsyncComponent(() => import('/@/components/Upload/Image.vue'));
 const { t } = useI18n();
 // 定义变量内容
 
-//关闭或打开tabs的关闭按钮
-const closeDomainTags = ref(false);
-const closeIpTags = ref(false);
-
-// 弹窗
-const domainEditOpen = ref(false);
-const listEditOpen = ref(false);
-const delOpen = ref(false);
 const loading = ref(false);
-const ipActiveId = ref([]);
-const ipData = ref([]);
-const delObj = ref({});
-const ipWhiteList = ref([]); // ip白名单
-const ipBlackList = ref([]); // ip黑名单
-const domainList = ref([]);
+
 const ruleFormRef = ref();
-const formData = ref({
-	promptMsg: '',
-	triggerMode: '',
-	triggerRule: '',
-	url: '',
-	triggerNum: '',
-});
+interface FormDataType {
+	ruleName: string;
+	keyword: string[];
+	ip: string[];
+	domain: string[];
+	pushContent: string;
+	pushFrequency: string;
+	action: string;
+	remark: string;
+	pushType: boolean;
+}
 
-const onLoadDetail = async (item: any) => {
-	if (item.list.length !== 0) return;
-	await getGroupDetail({ id: item.groupId }).then((val) => {
-		item.list = val.data.domains.map((item) => {
-			return item;
-		});
-	});
-};
+const formData = ref<FormDataType>({
+	ruleName: '',
+	keyword: [],
+	ip: [],
+	domain: [],
+	pushContent: '',
+	pushFrequency: '',
+	action: '1',
+	remark: '',
+	pushType: false,
+});
 
 // // 表单校验规则
 const dataRules = reactive({
-	url: [
-		{ required: true, message: '跳转连接不能为空', trigger: 'blur' },
-		{ validator: rule.domain, trigger: 'blur' },
+	ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
+	keyword: [
+		{
+			required: true,
+			validator: (rule: any, value: any, callback: any) => {
+				if (!keywordData.value || keywordData.value.length === 0) {
+					callback(new Error('关键字不能为空'));
+				} else {
+					callback();
+				}
+			},
+			trigger: 'blur',
+		},
+	],
+	pushContent: [{ required: true, message: '推送内容不能为空', trigger: 'blur' }],
+	ip: [
+		{
+			validator: (rule: any, value: any, callback: any) => {
+				if (!ipData.value || ipData.value.length === 0) {
+					callback(new Error('IP不能为空'));
+				} else {
+					callback();
+				}
+			},
+			trigger: 'blur',
+		},
+	],
+	domain: [
+		{
+			validator: (rule: any, value: any, callback: any) => {
+				if (!domainData.value || domainData.value.length === 0) {
+					callback(new Error('域名不能为空'));
+				} else {
+					callback();
+				}
+			},
+			trigger: 'blur',
+		},
 	],
-	promptMsg: [{ required: true, message: '提示信息不能为空', trigger: 'blur' }],
-	triggerNum: [
-		{ required: true, message: '触发频率不能为空', trigger: 'blur' },
+	pushFrequency: [
+		{ required: true, message: '推送频率不能为空', trigger: 'blur' },
 		{
 			pattern: /^(10000|[1-9]\d{0,3}|0)$|^(100%|[1-9]?\d%|0%)$/,
 			message: '请输入 0-10000 的正整数或 0%-100% 的百分比',
@@ -288,14 +208,20 @@ const dataRules = reactive({
 //tab相关
 
 const inputValue = ref('');
-const dynamicTags = ref(['关键字1', '关键字2', '关键字3']);//关键字数组
+const ipInputValue = ref('');
+const domainInputValue = ref('');
+
 const inputVisible = ref(false);
+const ipInputVisible = ref(false);
+const domainInputVisible = ref(false);
+
 const InputRef = ref();
+const ipInputRef = ref();
+const domainInputRef = ref();
 
-//删除关键字
-const handleClose = (tag: string) => {
-	dynamicTags.value.splice(dynamicTags.value.indexOf(tag), 1);
-};
+const keywordData = ref<string[]>([]);
+const ipData = ref<string[]>([]);
+const domainData = ref<string[]>([]);
 
 const showInput = () => {
 	inputVisible.value = true;
@@ -303,146 +229,270 @@ const showInput = () => {
 		InputRef.value!.input!.focus();
 	});
 };
+const showIpInput = () => {
+	ipInputVisible.value = true;
+	nextTick(() => {
+		ipInputRef.value!.input!.focus();
+	});
+};
+const showDomainInput = () => {
+	domainInputVisible.value = true;
+	nextTick(() => {
+		domainInputRef.value!.input!.focus();
+	});
+};
+const handleClose = (tag: string, type: string) => {
+	if (type === 'keyword') {
+		keywordData.value = keywordData.value.filter((item: string) => item !== tag);
+	} else if (type === 'ip') {
+		ipData.value = ipData.value.filter((item: string) => item !== tag);
+	} else if (type === 'domain') {
+		domainData.value = domainData.value.filter((item: string) => item !== tag);
+	}
+};
+
+// 自定义校验函数
+const validateIP = (ip: string): boolean => {
+	// 支持IP段格式:192.168.3.4/255
+	if (ip.includes('/')) {
+		const [ipPart, rangePart] = ip.split('/');
+		const range = parseInt(rangePart);
+
+		// 验证IP部分
+		const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
+		if (!ipRegex.test(ipPart)) return false;
 
-const handleInputConfirm = () => {
-	if (inputValue.value) {
-		dynamicTags.value.push(inputValue.value);
+		// 验证范围部分(1-255)
+		if (isNaN(range) || range < 1 || range > 255) return false;
+
+		// 验证IP部分的每个段
+		const parts = ipPart.split('.');
+		return parts.every((part) => {
+			const num = parseInt(part);
+			return num >= 0 && num <= 255;
+		});
 	}
-	inputVisible.value = false;
-	inputValue.value = '';
+
+	// 普通IP格式
+	const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
+	if (!ipRegex.test(ip)) return false;
+
+	const parts = ip.split('.');
+	return parts.every((part) => {
+		const num = parseInt(part);
+		return num >= 0 && num <= 255;
+	});
 };
 
-const onSubmit = async () => {
-	try {
-		await ruleFormRef.value.validateField('triggerNum');
-	} catch (error) {
-		// 验证失败,阻止后续逻辑执行
-		return;
+// 检查IP是否在某个IP段内
+const isIPInRange = (ip: string, ipRange: string): boolean => {
+	if (!ipRange.includes('/')) return false;
+
+	const [rangeIP, rangePart] = ipRange.split('/');
+	const range = parseInt(rangePart);
+
+	// 将IP转换为数字进行比较
+	const ipToNumber = (ipStr: string): number => {
+		const parts = ipStr.split('.').map((part) => parseInt(part));
+		return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
+	};
+
+	const ipNum = ipToNumber(ip);
+	const rangeIPNum = ipToNumber(rangeIP);
+	const rangeEndNum = rangeIPNum + range - 1;
+
+	return ipNum >= rangeIPNum && ipNum <= rangeEndNum;
+};
+
+// 检查IP是否与现有IP段冲突
+const isIPConflict = (newIP: string): boolean => {
+	// 如果新添加的是IP段,检查是否与现有IP冲突
+	if (newIP.includes('/')) {
+		const [rangeIP, rangePart] = newIP.split('/');
+		const range = parseInt(rangePart);
+		const rangeIPNum = ipToNumber(rangeIP);
+		const rangeEndNum = rangeIPNum + range - 1;
+
+		// 检查现有IP是否在新IP段内
+		for (const existingIP of ipData.value) {
+			if (!existingIP.includes('/')) {
+				const existingIPNum = ipToNumber(existingIP);
+				if (existingIPNum >= rangeIPNum && existingIPNum <= rangeEndNum) {
+					return true;
+				}
+			}
+		}
+	} else {
+		// 如果新添加的是单个IP,检查是否在现有IP段内
+		for (const existingIP of ipData.value) {
+			if (existingIP.includes('/') && isIPInRange(newIP, existingIP)) {
+				return true;
+			}
+		}
+	}
+
+	return false;
+};
+
+// 辅助函数:将IP转换为数字
+const ipToNumber = (ipStr: string): number => {
+	const parts = ipStr.split('.').map((part) => parseInt(part));
+	return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
+};
+
+const validateDomain = (domain: string): boolean => {
+	// 移除首尾空格
+	const trimmedDomain = domain.trim();
+
+	// 基本长度检查
+	if (trimmedDomain.length === 0 || trimmedDomain.length > 253) {
+		return false;
+	}
+
+	// 检查是否包含至少一个点号(顶级域名)
+	if (!trimmedDomain.includes('.')) {
+		return false;
 	}
 
-	if (formData.value.triggerMode === '1' || formData.value.triggerMode === '3') {
-		try {
-			await ruleFormRef.value.validateField('url');
-		} catch (error) {
-			// 验证失败,阻止后续逻辑执行
-			return;
+	// 分割域名部分
+	const parts = trimmedDomain.split('.');
+
+	// 检查域名部分数量(至少2部分:主域名.顶级域名)
+	if (parts.length < 2) {
+		return false;
+	}
+
+	// 检查每个部分
+	for (let i = 0; i < parts.length; i++) {
+		const part = parts[i];
+
+		// 每个部分不能为空
+		if (part.length === 0) {
+			return false;
+		}
+
+		// 顶级域名至少2个字符
+		if (i === parts.length - 1 && part.length < 2) {
+			return false;
+		}
+
+		// 每个部分不能超过63个字符
+		if (part.length > 63) {
+			return false;
+		}
+
+		// 检查字符格式:只能包含字母、数字、连字符,不能以连字符开头或结尾
+		const partRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
+		if (!partRegex.test(part)) {
+			return false;
+		}
+
+		// 顶级域名只能包含字母
+		if (i === parts.length - 1) {
+			const tldRegex = /^[a-zA-Z]+$/;
+			if (!tldRegex.test(part)) {
+				return false;
+			}
 		}
 	}
 
-	try {
-		loading.value = true;
+	return true;
+};
 
-		// 处理 triggerNum 参数
-		let triggerNum = formData.value.triggerNum;
-		if (typeof triggerNum === 'string' && triggerNum.includes('%')) {
-			// 如果包含百分比符号,去除百分比符号并除以100
-			const num = parseFloat(triggerNum.replace('%', ''));
-			if (!isNaN(num)) {
-				triggerNum = num / 100;
+const handleInputConfirm = (type: string) => {
+	if (type === 'keyword') {
+		if (inputValue.value) {
+			keywordData.value.push(inputValue.value);
+		}
+		inputVisible.value = false;
+		inputValue.value = '';
+	} else if (type === 'ip') {
+		if (ipInputValue.value) {
+			// 校验IP格式
+			if (validateIP(ipInputValue.value)) {
+				// 检查IP冲突
+				if (isIPConflict(ipInputValue.value)) {
+					useMessage().error('该IP与现有IP段冲突,无法添加');
+					return;
+				}
+				ipData.value.push(ipInputValue.value);
+			} else {
+				useMessage().error('请输入正确的IP地址格式,支持单个IP(192.168.1.1)或IP段(192.168.1.1/255)');
+				return;
 			}
 		}
+		ipInputVisible.value = false;
+		ipInputValue.value = '';
+	} else if (type === 'domain') {
+		if (domainInputValue.value) {
+			// 校验域名格式
+			if (validateDomain(domainInputValue.value)) {
+				domainData.value.push(domainInputValue.value);
+			} else {
+				useMessage().error('请输入正确的域名格式,例如:example.com、sub.example.com');
+				return;
+			}
+		}
+		domainInputVisible.value = false;
+		domainInputValue.value = '';
+	}
+};
+const getpushContent = () => {
+	if (formData.value.pushContent) {
+		return formData.value.pushContent.includes('http') ? formData.value.pushContent : baseURL + formData.value.pushContent
+	} else {
+		return ''
+	}
+};
+
+const onSubmit = async () => {
+	// 触发表单校验(包含隐藏注册的 keyword/ip/domain 规则)
+	try {
+		await ruleFormRef.value.validate();
+	} catch (e) {
+		return;
+	}
+	formData.value = {
+		...formData.value,
+		ip: ipData.value,
+		keyword: keywordData.value,
+		domain: domainData.value,
+		pushContent: getpushContent(),
+	};
+	if (!formData.value.pushContent) {
+		return useMessage().error(formData.value.pushType ? '推送图片不能为空' : '推送内容不能为空');
+	}
+	try {
+		loading.value = true;
 
-		await saveConfigDetail({
+		await update({
 			...formData.value,
-			triggerNum: triggerNum.toString(),
 		});
 
-		useMessage().success(t('common.editSuccessText'));
-	} catch (err) {
+		useMessage().success(props.rowData?.id ? t('common.editSuccessText') : t('common.addSuccessText'));
+		emit('onsuccess');
+		onCancel();
+	} catch (err: any) {
 		useMessage().error(err.msg);
 	} finally {
 		loading.value = false;
-		getConfig();
 	}
 };
 
-const handleDelete = async (item: any, type: string) => {
-	console.log(item, type);
-	delObj.value = { ...item, delListType: type };
-	delOpen.value = true;
-};
-
-const getConfig = () => {
-	configIp();
-	configDomain();
-};
-const configDomain = async () => {
-	await getConfigDomainList().then((val) => {
-		domainList.value = val.data.map((item) => {
-			return {
-				...item,
-				list: [],
-			};
-		});
-	});
-};
-const configIp = async () => {
-	await getConfigIpList().then((val) => {
-		ipWhiteList.value = val.data
-			.filter((item) => item.ipType === 1)
-			.map((item) => {
-				return { ...item, list: [] };
-			});
-		ipBlackList.value = val.data
-			.filter((item) => item.ipType === 2)
-			.map((item) => {
-				return { ...item, list: [] };
-			});
-	});
-};
-const getIpData = async () => {
-	await pageListIp().then((val) => {
-		ipActiveId.value = [];
-		ipData.value = val.data.map((item: any) => {
-			ipActiveId.value.push(item.id);
-			return {
-				...item,
-				title: item.groupName,
-				id: item.id,
-				list: item.ips.map((items: any) => {
-					return {
-						...items,
-						id: items.id,
-						value: ipSplicing(items.startIp, items.endIp),
-					};
-				}),
+watch(
+	() => props.open,
+	(val) => {
+		if (val) {
+			formData.value = {
+				...props.rowData,
 			};
-		});
-	});
-
-	await getConfigDetail().then((val) => {
-		formData.value = {
-			promptMsg: '',
-			triggerMode: '',
-			triggerRule: '',
-			url: '',
-			triggerNum: '',
-		};
-
-		formData.value = {
-			...val.data,
-			triggerMode: val.data?.triggerMode.toString(),
-			triggerRule: val.data?.triggerRule.toString(),
-			triggerNum: formatNum(val.data?.triggerNum),
-		};
-	});
-};
-// 格式化数据展示
-const formatNum = (value: string | number = 0) => {
-	let num = Number(value);
-	if (num > 0 && num < 1) {
-		return (num * 100).toFixed(0) + '%';
-	} else if (num >= 1 && num < 10000) {
-		return num;
+			ipData.value = props.rowData.ip || [];
+			keywordData.value = props.rowData.keyword || [];
+			domainData.value = props.rowData.domain || [];
+			oldUrl.value = props.rowData.pushContent;
+		}
 	}
-	return '--';
-};
-
-onMounted(() => {
-	//获取IP列表
-	getIpData();
-	getConfig();
-});
+);
 </script>
 <style lang="scss">
 </style>

+ 57 - 131
src/views/marketing/rules/index.vue

@@ -1,49 +1,58 @@
 <template>
 	<div class="layout-padding">
 		<div class="layout-padding-auto layout-padding-view">
-			<el-row class="ml10" v-show="showSearch">
+			<el-row class="ml10">
 				<el-form :inline="true" :model="state.queryForm" @keyup.enter="getDataList" ref="queryRef">
-					<el-form-item label="关键字" prop="logType">
-						<el-input placeholder="请输入关键字"  v-model="state.queryForm.queryIPName" />
+					<el-form-item label="关键字" prop="keyword">
+						<el-input placeholder="请输入关键字" v-model="state.queryForm.keyword" />
 					</el-form-item>
-          <el-form-item label="IP" prop="logType">
-						<el-input placeholder="请输入IP"  v-model="state.queryForm.queryIPName" />
+					<el-form-item label="IP" prop="ip">
+						<el-input placeholder="请输入IP" v-model="state.queryForm.ip" />
 					</el-form-item>
-					<el-form-item label="域名" prop="logType">
-						<el-input placeholder="请输入域名"  v-model="state.queryForm.queryIPName" />
+					<el-form-item label="域名" prop="domain">
+						<el-input placeholder="请输入域名" v-model="state.queryForm.domain" />
 					</el-form-item>
 					<el-form-item>
 						<el-button @click="getDataList" type="primary">查询</el-button>
 						<el-button @click="resetQuery" icon="Refresh">重置</el-button>
+						<el-button @click="openEdit({})"  type="primary"> 新建规则 </el-button>
+
 					</el-form-item>
 				</el-form>
 			</el-row>
-		
+
 			<el-table
-				:data="tableData"
+				:data="state.dataList"
 				v-loading="state.loading"
 				border
 				:cell-style="tableStyle.cellStyle"
 				:header-cell-style="tableStyle.headerCellStyle"
-			>
-				<el-table-column label="规则名称" prop="title" show-overflow-tooltip></el-table-column>
-				<el-table-column label="关键字" prop="keywords" show-overflow-tooltip></el-table-column>
+				style="height: calc(100vh - 200px)"
+				>;">
+				<el-table-column label="规则名称" prop="ruleName" show-overflow-tooltip></el-table-column>
+				<el-table-column label="关键字" prop="keyword" show-overflow-tooltip></el-table-column>
 				<el-table-column label="IP" prop="ip" show-overflow-tooltip></el-table-column>
 				<el-table-column label="域名" prop="domain" show-overflow-tooltip width="200"></el-table-column>
-				<el-table-column label="触发规则" prop="rules" show-overflow-tooltip width="200"></el-table-column>
-				<el-table-column label="备注" prop="createBy" show-overflow-tooltip width="200"></el-table-column>
-				<el-table-column label="操作" width="150">
-					<template #default="scope">
-						<el-button @click="openEdit({})" size="small" text type="primary">
-							新增
-						</el-button>
-						<el-button @click="openEdit(scope.row)" size="small" text type="primary">
-							编辑
-						</el-button>
+				<el-table-column label="推送内容" prop="pushContent" show-overflow-tooltip width="200"></el-table-column>
+				<el-table-column label="推送频率" prop="pushFrequency" show-overflow-tooltip width="100"></el-table-column>
+				<el-table-column label="备注" prop="remark" show-overflow-tooltip width="200"></el-table-column>
+				<el-table-column label="操作" width="100">
+					<template #default="{ row }">
+						<el-button @click="openEdit(row)" size="small" text type="primary"> 编辑 </el-button>
+						<el-button @click="(delOpen = true), (delObj = row)" size="small" text type="primary"> 删除 </el-button>
 					</template>
 				</el-table-column>
 			</el-table>
-      <Edit v-model:open="open" @close="open = false" />
+			<Edit v-model:open="open" :row-data="rowData" @close="open = false"  @onsuccess="getDataList"/>
+			<el-dialog v-model="delOpen" title="提示" width="500" @close="delOpen = false">
+				<span>确认删除吗?</span>
+				<template #footer>
+					<div class="dialog-footer">
+						<el-button @click="delOpen = false">取消</el-button>
+						<el-button type="primary" :disabled="loading" @click="onDel(delObj)"> 确定 </el-button>
+					</div>
+				</template>
+			</el-dialog>
 			<pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination"> </pagination>
 		</div>
 	</div>
@@ -51,124 +60,32 @@
 
 <script lang="ts" setup>
 import { BasicTableProps, useTable } from '/@/hooks/table';
-import { delObj, pageList } from '/@/api/admin/log';
+import { pageListIp, del } from '/@/api/marketing/rules';
+import { useMessage } from '/@/hooks/message';
 import { useI18n } from 'vue-i18n';
-
+const { t } = useI18n();
 
 const Edit = defineAsyncComponent(() => import('./components/Edit.vue'));
 
 const open = ref(false);
 const rowData = ref({});
-const { t } = useI18n();
+const delOpen = ref(false);
+const loading = ref(false);
+const delObj = ref({});
 
 // 定义变量内容
 const queryRef = ref();
-const showSearch = ref(true);
-
 
 const state: BasicTableProps = reactive<BasicTableProps>({
 	queryForm: {
-		logType: '',
-		createTime: '',
+		keyword: '',
+		ip: '',
+		domain: '',
 	},
 	selectObjs: [],
-	pageList: pageList,
-	descs: ['create_time'],
+	pageList: pageListIp,
 });
 
-const tableData = ref([
-  {
-    title: '系统安全检测',
-    keywords: '安全,检测,漏洞',
-    ip: '192.168.1.2',
-    domain: 'www.google.com',
-    rules: '基础安全扫描规则',
-    createBy: '张三',
-    createTime: '2021-02-15 09:30:00'
-  },
-  {
-    title: '性能压力测试',
-    keywords: '性能,压力,并发',
-    ip: '192.168.1.3',
-    domain: 'www.github.com',
-    rules: '高并发压力测试规则',
-    createBy: '李四',
-    createTime: '2021-03-20 14:15:00'
-  },
-  {
-    title: '接口兼容性测试',
-    keywords: '接口,兼容,API',
-    ip: '192.168.1.4',
-    domain: 'www.api.com',
-    rules: '接口版本兼容规则',
-    createBy: '王五',
-    createTime: '2021-04-05 10:45:00'
-  },
-  {
-    title: '数据备份验证',
-    keywords: '数据,备份,验证',
-    ip: '192.168.1.5',
-    domain: 'www.backup.com',
-    rules: '数据完整性校验规则',
-    createBy: '赵六',
-    createTime: '2021-05-18 16:20:00'
-  },
-  {
-    title: '用户体验测试',
-    keywords: '用户,体验,UI',
-    ip: '192.168.1.6',
-    domain: 'www.ux.com',
-    rules: '界面交互测试规则',
-    createBy: '孙七',
-    createTime: '2021-06-30 11:10:00'
-  },
-  {
-    title: '权限控制测试',
-    keywords: '权限,控制,安全',
-    ip: '192.168.1.7',
-    domain: 'www.auth.com',
-    rules: 'RBAC权限验证规则',
-    createBy: '周八',
-    createTime: '2021-07-12 15:50:00'
-  },
-  {
-    title: '数据库性能测试',
-    keywords: '数据库,性能,查询',
-    ip: '192.168.1.8',
-    domain: 'www.db.com',
-    rules: 'SQL执行效率测试规则',
-    createBy: '吴九',
-    createTime: '2021-08-25 08:35:00'
-  },
-  {
-    title: '移动端适配测试',
-    keywords: '移动,适配,响应式',
-    ip: '192.168.1.9',
-    domain: 'www.mobile.com',
-    rules: '多终端适配测试规则',
-    createBy: '郑十',
-    createTime: '2021-09-10 13:25:00'
-  },
-  {
-    title: '日志分析测试',
-    keywords: '日志,分析,监控',
-    ip: '192.168.1.10',
-    domain: 'www.log.com',
-    rules: '日志完整性检查规则',
-    createBy: '钱十一',
-    createTime: '2021-10-01 17:40:00'
-  },
-  {
-    title: '缓存机制测试',
-    keywords: '缓存,机制,性能',
-    ip: '192.168.1.11',
-    domain: 'www.cache.com',
-    rules: '缓存有效性验证规则',
-    createBy: '孙十二',
-    createTime: '2021-11-15 10:15:00'
-  }
-]);
-
 //  table hook
 const { downBlobFile, getDataList, currentChangeHandle, sortChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
 
@@ -183,13 +100,22 @@ const exportExcel = () => {
 	downBlobFile('/admin/log/export', state.queryForm, 'log.xlsx');
 };
 const openEdit = (row: any) => {
-  rowData.value = row;
-  open.value = true;
-
-}
-
-
+	console.log(row);
 
+	rowData.value = row;
+	open.value = true;
+};
+const onDel = async (row: any) => {
+	loading.value = true;
+	await del({ ids: [row.id] }).then((res) => {
+		loading.value = false;
+		if (res.code !== 0) return useMessage().success(t('common.editSuccessText'));
+		useMessage().success(t('common.delSuccessText'));
+		delOpen.value = false;
+		delObj.value = {};
+		getDataList();
+	});
+};
 </script>
 
 <style lang="scss" scoped>