Просмотр исходного кода

Merge branch 'dev-ly' into dev-cmn

cmy 1 день назад
Родитель
Сommit
ad2e09072b

+ 2 - 2
.env

@@ -5,9 +5,9 @@ VITE_IS_MICRO= true
 VITE_PUBLIC_PATH = /
 
 # 后端请求前缀
+# VITE_API_URL = http://192.168.10.101:9999
 # VITE_API_URL = http://192.168.3.118:9999
-VITE_API_URL = http://192.168.10.101:9999
-# VITE_API_URL = http://192.168.3.17:9999
+VITE_API_URL = http://192.168.3.17:9999
 
 # OAUTH2 密码模式客户端信息
 VITE_OAUTH2_PASSWORD_CLIENT='pig:pig'

+ 2 - 0
.gitignore

@@ -8,6 +8,7 @@ dist_16888
 # local env files
 .env.local
 .env.*.local
+.env
 
 # Log files
 npm-debug.log*
@@ -24,5 +25,6 @@ pnpm-debug.log*
 *.sln
 *.sw?
 
+
 # lock files
 yarn.lock

+ 0 - 1
auto-imports.d.ts

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

+ 32 - 0
src/api/common/common.ts

@@ -0,0 +1,32 @@
+import request from '/@/utils/request';
+
+export const appID = 'Fqs2CL9CUn7U1AqilSFXgb'
+
+
+/**
+ * 获取应用版本信息
+ * @param query 查询参数对象,包含额外的查询条件
+ * @returns 返回请求结果,包含应用版本信息
+ */
+export function getAppVersion() {
+    return request({
+        url: '/stats/user/version/list', // 请求的API地址
+        method: 'get', // 请求方法为GET
+        params: {appId: appID}, // 将查询参数作为URL参数传递,其中appid是必须参数,query对象中的其他参数会被展开添加
+    });
+}
+
+/**
+ * 获取应用渠道列表
+ * @param query 查询参数对象,用于筛选和获取特定渠道信息
+ * @returns 返回请求结果,包含渠道列表数据
+ */
+export function getAppChannel() {
+    return request({
+        url: '/stats/user/channel/list', // 请求的API地址
+        method: 'get', // 请求方法为GET
+        params: {appId: appID}, // 将查询参数作为URL参数传递,其中appid是必须参数,query对象中的其他参数会被展开添加
+    });
+}
+
+

+ 52 - 0
src/api/count/activeUser.ts

@@ -0,0 +1,52 @@
+import request from '/@/utils/request';
+import { appID } from '/@/api/common/common';
+
+
+export const getTrendDetail = (data?: Object) => {
+	return request({
+		url: '/stats/user/active/detail',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+//活跃趋势
+export const getTrend = (data?: Object) => {
+	return request({
+		url: '/stats/user/active/trend',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+
+//活跃构成
+export const getCompose = (data?: Object) => {
+	return request({
+		url: '/stats/user/active/compose',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+//活跃粘度
+export const getViscosity = (data?: Object) => {
+	return request({
+		url: '/stats/user/active/viscosity',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+//周活跃率
+export const getWeekrate = (data?: Object) => {
+	return request({
+		url: '/stats/user/active/weekrate',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+//月活跃率
+export const getMonthrate = (data?: Object) => {
+	return request({
+		url: '/stats/user/active/monthrate',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};

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

@@ -0,0 +1,34 @@
+import request from '/@/utils/request';
+import { appID } from '/@/api/common/common';
+
+
+export const getTrendDetail = (data?: Object) => {
+	return request({
+		url: '/stats/user/new/detail',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+export const getTrend = (data?: Object) => {
+	return request({
+		url: '/stats/user/new/trend',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+
+export const getRetentionDetail = (data?: Object) => {
+	return request({
+		url: '/stats/user/retention/detail',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+export const getRetention = (data?: Object) => {
+	return request({
+		url: '/stats/user/new/retention',
+		method: 'post',
+		data:{appId: appID, ...data},
+	});
+};
+

+ 110 - 0
src/components/common/filter-select.vue

@@ -0,0 +1,110 @@
+<template>
+  <el-select
+    v-model="innerValue"
+    :placeholder="placeholderText"
+    :class="wrapperClass"
+    :clearable="clearable"
+    :disabled="disabled"
+    :filterable="isRemote || filterable"
+    :remote="isRemote"
+    :remote-method="isRemote ? handleRemoteSearch : undefined"
+    @change="onChange"
+  >
+    <el-option
+      v-for="opt in optionList"
+      :key="opt.value"
+      :label="opt.label"
+      :value="opt.value"
+    />
+  </el-select>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, computed } from 'vue'
+import { useDebounceFn } from '@vueuse/core'
+import request from '/@/utils/request'
+import { getAppVersion, getAppChannel } from '/@/api/common/common'
+
+interface SelectOption {
+  label: string
+  value: string | number
+}
+
+interface Props {
+  modelValue?: string | number | null
+  type?: 'version' | 'channel'
+  placeholder?: string
+  clearable?: boolean
+  disabled?: boolean
+  filterable?: boolean
+  wrapperClass?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: null,
+  type: 'version',
+  placeholder: '',
+  clearable: true,
+  disabled: false,
+  filterable: false,
+  wrapperClass: '',
+})
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: string | number | null): void
+  (e: 'change', value: string | number | null): void
+}>()
+
+const innerValue = ref<string | number | null>(props.modelValue)
+watch(
+  () => props.modelValue,
+  (val) => {
+    innerValue.value = val
+  }
+)
+
+function onChange(val: string | number | null) {
+  emit('update:modelValue', val)
+  emit('change', val)
+}
+
+const optionList = ref<SelectOption[]>([])
+
+const isRemote = computed(() => true)
+
+const placeholderText = computed(() => {
+  if (props.placeholder) return props.placeholder
+  return props.type === 'version' ? '全部版本' : '全部渠道'
+})
+
+async function requestOptions(query: string): Promise<SelectOption[]> {
+  if (props.type === 'version') {
+    const res = await getAppVersion()
+    const list: Array<string> = (res as any)?.data || []
+    return list.map((name) => ({ label: String(name), value: name }))
+  }
+  if (props.type === 'channel') {
+    const res = await getAppChannel()
+    const list: Array<string> = (res as any)?.data || []
+    return list.map((name) => ({ label: String(name), value: name }))
+  }
+  return []
+}
+
+const doRemoteSearch = useDebounceFn(async (query: string) => {
+  const list = await requestOptions(query)
+  optionList.value = list
+}, 250)
+
+function handleRemoteSearch(query: string) {
+  doRemoteSearch(query)
+}
+
+// initial fetch for remote
+requestOptions('').then((list) => (optionList.value = list))
+</script>
+
+<style scoped lang="scss">
+</style>
+
+

+ 1 - 0
src/utils/commonFunction.ts

@@ -54,6 +54,7 @@ export default function () {
 			}
 		});
 	};
+	
 	return {
 		percentFormat,
 		dateFormatYMD,

+ 19 - 0
src/utils/formatTime.ts

@@ -181,6 +181,25 @@ export function parseTime(time, pattern) {
 	});
 	return time_str;
 }
+/**
+ * 计算两个日期之间的天数
+ * @param startDate 开始日期 (YYYY-MM-DD格式)
+ * @param endDate 结束日期 (YYYY-MM-DD格式)
+ * @returns 两个日期之间的天数
+ */
+export const getDaysBetweenDates = (startDate: string, endDate: string): number => {
+	const start = new Date(startDate);
+	const end = new Date(endDate);
+	
+	// 计算时间差(毫秒)
+	const timeDifference = end.getTime() - start.getTime();
+	
+	// 转换为天数
+	const daysDifference = Math.ceil(timeDifference / (1000 * 3600 * 24));
+	
+	return daysDifference;
+};
+
 
 // 日期格式化
 export function parseDate(time, pattern) {

+ 12 - 21
src/views/count/featureUsage/event/eventData.vue

@@ -9,17 +9,11 @@
 
 				<div class="flex items-center justify-between space-x-4">
 					<div class="flex items-center">
-						<el-select v-model="formData.selectedChannelCompare" class="!w-[180px]" 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" class="!w-[180px]" />
 					</div>
 					<div class="flex items-center ml-2">
-						<el-select v-model="formData.selectedChannelCompare" class="!w-[180px] ml-2" placeholder="全部版本">
-							<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-						</el-select>
-						<el-select v-model="formData.selectedChannelCompare" class="!w-[180px] ml-2" 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" class="!w-[180px] ml-2" />
+						<FilterSelect v-model="formData.channel" type="channel" class="!w-[180px] ml-2" />
 						<el-date-picker v-model="formData.time" class="w-[200px] ml-2" type="daterange" start-placeholder="开始时间" end-placeholder="结束时间" />
 					</div>
 				</div>
@@ -48,9 +42,7 @@
 				</div>
 				<div class="flex justify-between items-center mb-2">
 					<div class="flex items-center">
-						<el-select v-model="formData.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" class="!w-[140px]" />
 						<el-button type="primary" class="ml-2">行业均值</el-button>
 					</div>
 					<el-radio-group v-model="timeGranularity">
@@ -101,9 +93,7 @@
 				<!-- 选择模块 -->
 				<div v-if="!showTrendChart" class="flex justify-between items-center mt-4">
 					<div class="flex items-center">
-						<el-select v-model="formData.selectedChannelCompare" class="!w-[180px]" 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" class="!w-[180px]" />
 						<el-popover class="box-item" placement="right" trigger="hover" width="600">
 							<template #reference>
 								<el-icon class="ml-1" style="color: #a4b8cf"><QuestionFilled /></el-icon>
@@ -159,19 +149,20 @@ import { Close } from '@element-plus/icons-vue';
 
 const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
 const Trend = defineAsyncComponent(() => import('./component/trend.vue'));
+const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
 interface TableRow {
 	date: string;
 	newUsers: number;
 	ratio: string;
 }
 const formData = ref<Record<string, any>>({
-	selectedChannelCompare: '',
+	version: '',
+	channel: '',
+	time: '',
 });
-const channelCompareOptions = [
-	{ label: '全部版本', value: '' },
-	{ label: '1.0', value: '1.0' },
-	{ label: '2.0', value: '2.0' },
-];
+const channelCompareOptions: Array<{label: string; value: string}> = [];
+
+
 
 // 图表相关
 const timeGranularity = ref<'hour' | 'day' | 'week' | 'month'>('week');

+ 6 - 7
src/views/count/featureUsage/eventConversion/index.vue

@@ -43,12 +43,11 @@
 					</Title>
 				</div>
 				<div class="bg-[#f8f8f8] p-4 rounded" style="line-height: 24px">
-					
 					<p class="">
 						<el-icon class="!text-[#167af0] pian-2"><InfoFilled /></el-icon>
 						事件转化率指的是多个自定义事件序列按照指定顺序依次触发的流程中的量化转化模型。通常我们会对应用中的一些关键路径进行分析,来确定整个流程的设计是否合理,各步骤的优劣,是否存在优化的空间等,进而提高最终目标的转化率。
-					事件转化率是基于自定义事件创建的。想要分析关键流程的转化率,需要先集成并<el-button type="primary" link>添加自定义事件</el-button
-					>,并在事件转化率管理页面<el-button type="primary" link>添加转化报表</el-button>。
+						事件转化率是基于自定义事件创建的。想要分析关键流程的转化率,需要先集成并<el-button type="primary" link>添加自定义事件</el-button
+						>,并在事件转化率管理页面<el-button type="primary" link>添加转化报表</el-button>。
 					</p>
 				</div>
 				<div class="flex items-center justify-start mt-2 w-[480px]">
@@ -56,9 +55,10 @@
 						<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
 					</el-select>
 					<el-date-picker v-model="formData.time" class="!w-[200px] ml-2" type="daterange" start-placeholder="开始时间" end-placeholder="结束时间" />
-
 					<div class="flex items-center ml-2">
-						<el-button link type="primary"><el-icon class="!text-blue-500 mr-1"><Tools /></el-icon>设置</el-button>
+						<el-button link type="primary"
+							><el-icon class="!text-blue-500 mr-1"><Tools /></el-icon>设置</el-button
+						>
 					</div>
 				</div>
 			</div>
@@ -70,7 +70,6 @@
 						<el-table-column prop="hyyh" label="目标事件" align="center" min-width="140" />
 						<el-table-column prop="hyyh" label="步骤数" align="center" sortable min-width="140" />
 						<el-table-column prop="hyyh" label="所选时段转化率" align="center" sortable min-width="140" />
-						
 					</el-table>
 					<div class="flex justify-end mt-2">
 						<el-pagination
@@ -100,7 +99,7 @@ interface TableRow {
 }
 const formData = ref({
 	selectedChannelCompare: '',
-	time:''
+	time: '',
 });
 const channelCompareOptions = [
 	{ label: '全部版本', value: '' },

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

@@ -0,0 +1,620 @@
+<template>
+	<div class="el-card p-9">
+		<!-- 新增趋势 -->
+		<div class="">
+			<div class="flex items-center justify-between mb-2 mt-3">
+				<div class="flex items-center">
+					<el-select v-model="chartType" class="!w-[140px]" @change="onChangeType">
+						<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-select>
+					<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">
+						<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(''), getBottomDetail()">
+						<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 v-loading.fullscreen.lock="loading">
+				<el-table-column prop="date" label="日期" min-width="100" />
+				<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>
+						<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>
+						<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>
+						<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]"
+				/>
+			</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, getCompose, getViscosity, getWeekrate, getMonthrate } from '/@/api/count/activeUser';
+
+const { t } = useI18n();
+
+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 chartType = ref('活跃趋势');
+
+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 loading = ref(false);
+
+const onChangeType = (value: string) => {
+	if (value !== '分时活跃用户') {
+		getData('clearAll');
+		emit('query', '');
+
+	}
+	if(value === '分时活跃用户' || value === '周活跃度' || value === '月活跃率'){
+		emit('query', 'disabled');
+	}
+};
+
+const getData = async (type: string) => {
+	loading.value = true;
+	//上方图表
+	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;
+		}
+	}
+	let res;
+	let data;
+	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 || [];
+	}
+
+	// const res = await getTrend({ ...formData.value });
+	// const data = res?.data || [];
+	initChartData(data);
+	loading.value = false;
+};
+
+const initChartData = async (data: any) => {
+	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) => {
+				const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
+				return {
+					name: getName(item, index, data),
+					type: 'line',
+					smooth: true,
+					data: item.data,
+					itemStyle: {
+						color: colorSchemes[randomColorIndex].color,
+					},
+					lineStyle: {
+						color: colorSchemes[randomColorIndex].color,
+					},
+				};
+			})[0]
+		);
+		if (industryCompare.value == 'time') {
+			chartTimes.value.push(data.dates);
+			lineChartData.value.items[0].name = props.formData?.time[0] + ' ~ ' + props.formData?.time[1];
+		} else {
+			chartTimes.value = [];
+		}
+	}
+	lineChartData.value.dates = masterData.value.dates;
+	initLineChart();
+};
+
+const getBottomDetail = async () => {
+	// 下方明细表
+	const resDetail = await getTrendDetail({ ...formData.value, ...pagination.value });
+	const dataDetail = resDetail?.data || [];
+	pagination.value.current = dataDetail.current; //当前页码
+	pagination.value.total = dataDetail.total; //总条数
+	pagination.value.size = dataDetail.size; //每页条数
+	tableData.value = dataDetail.records;
+};
+const getName = (item: any, index: number, data: any) => {
+	if (industryCompare.value === 'time') {
+		//日期
+		return formData.value.fromDate + ' ~ ' + formData.value.toDate;
+	} else if (industryCompare.value === 'version') {
+		//版本
+		return item.version === 'All' ? item.name : item.version + ' ' + item.name;
+	} else if (industryCompare.value === 'channel') {
+		//渠道
+		return formData.value.channel[0];
+	} else {
+		return item.name;
+	}
+};
+
+function formatNumber(value: number | string): string {
+	const num = typeof value === 'number' ? value : Number(value || 0);
+	return num.toLocaleString('zh-CN');
+}
+const formatterTips = (params: any) => {
+	if (!params || !params.length) return '';
+	const date = params[0]?.axisValue || '';
+	const rows = params
+		.map((p: any, index: number) => {
+			const name = p.seriesName || '';
+			const val = formatNumber(p.data);
+			if (industryCompare.value === 'time' && index != 0) {
+				return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
+							<span style="display:flex;align-items:center;gap:6px;">
+								<span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
+								<span>${chartTimes.value[0][p.dataIndex]}</span>
+							</span>
+							<span style="font-variant-numeric: tabular-nums;">${val}</span>
+						</div>`;
+			} else {
+				return `
+			<div style="margin-bottom:6px;color:#93c5fd;">${industryCompare.value === 'time' ? '' : date}</div>
+				
+				<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
+							<span style="display:flex;align-items:center;gap:6px;">
+								<span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
+								<span>${industryCompare.value === 'time' ? p.axisValue : name} </span>
+							</span>
+							<span style="font-variant-numeric: tabular-nums;">${val}</span>
+						</div>`;
+			}
+		})
+		.join('');
+	return `<div style="font-size:12px;">
+			${rows}
+		</div>`;
+};
+
+// 根据日期范围禁用不合适的粒度
+const rangeDays = computed(() => {
+	const start = props.formData?.time?.[0];
+	const end = props.formData?.time?.[1];
+	if (!start || !end) return 0;
+	return getDaysBetweenDates(start, end);
+});
+
+const isWeekDisabled = computed(() => rangeDays.value < 7);
+const isMonthDisabled = computed(() => rangeDays.value < 28);
+const isDayDisabled = computed(() => false);
+
+function ensureValidTimeUnit() {
+	// 不进行自动纠正,保持用户选择
+}
+
+function disableAfterToday(date: Date) {
+	const today = new Date();
+	today.setHours(0, 0, 0, 0);
+	return date.getTime() > today.getTime();
+}
+
+// 弹窗相关函数
+function handleCompareChange(value: string) {
+	selectedCompareItems.value = [];
+	initCompareOptions();
+	timeCompareVisible.value = value === 'time';
+}
+function clearCompare() {
+	selectedCompareItems.value = [];
+	timeCompareRange.value = '';
+	formData.value = {
+		...formData.value,
+		channel: props.formData?.channel ? [props.formData?.channel] : [],
+		version: props.formData?.version ? [props.formData?.version] : [],
+		fromDate: props.formData?.time[0],
+		toDate: props.formData?.time[1],
+	};
+}
+
+function getCompareTitle(): string {
+	const typeMap = {
+		version: '选择版本',
+		channel: '选择渠道',
+		time: '选择时段',
+	};
+	return typeMap[industryCompare.value as keyof typeof typeMap] || '选择对比项';
+}
+
+function getCompareTypeText(): string {
+	const typeMap = {
+		version: '版本',
+		channel: '渠道',
+		time: '时段',
+	};
+	return typeMap[industryCompare.value as keyof typeof typeMap] || '';
+}
+
+function filterCompareOptions() {
+	if (!searchKeyword.value) {
+		filteredCompareOptions.value = compareOptions.value;
+	} else {
+		filteredCompareOptions.value = compareOptions.value.filter((item) => item.toLowerCase().includes(searchKeyword.value.toLowerCase()));
+	}
+}
+
+function handleCompareItemsChange(items: string[]) {
+	selectedCompareItems.value = items;
+
+	// 对比当前值和之前的值
+	const currentItems = new Set(items);
+	const previousItems = new Set(previousCompareItems.value);
+
+	// 找出新增的项
+	const addedItems = items.filter((item) => !previousItems.has(item));
+
+	// 找出减少的项(在之前有但现在没有的项)
+	const removedItemIndices = previousCompareItems.value
+		.map((item, index) => ({ item, index }))
+		.filter(({ item }) => !currentItems.has(item))
+		.map(({ index }) => index);
+
+	// 输出结果用于调试或后续处理
+	console.log('新增的项:', addedItems);
+	console.log('减少的项:', removedItemIndices);
+	if (removedItemIndices.length > 0) {
+		lineChartData.value.items.splice(removedItemIndices[0] + 1, 1);
+	}
+	if (industryCompare.value === 'version') {
+		lineChartData.value.items[0].name = '全部版本';
+	} else if (industryCompare.value === 'channel') {
+		lineChartData.value.items[0].name = '全部渠道';
+	}
+	// 更新 previousCompareItems 为当前值,用于下次对比
+	previousCompareItems.value = items;
+
+	const isVersion = industryCompare.value === 'version'; // 判断是否是版本对比
+	if (isVersion) {
+		formData.value.version = addedItems;
+		formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
+	} else {
+		formData.value.channel = addedItems;
+		formData.value.version = props.formData?.version ? [props.formData?.version] : [];
+	}
+	if (addedItems.length > 0) {
+		getData('');
+	} else {
+		initLineChart();
+	}
+}
+
+// 初始化对比选项
+async function initCompareOptions() {
+	if (industryCompare.value === 'version') {
+		const res = await getAppVersion();
+		const list: Array<string> = res?.data || [];
+		compareOptions.value = list;
+	} else if (industryCompare.value === 'channel') {
+		const res = await getAppChannel();
+		const list: Array<string> = res?.data || [];
+		compareOptions.value = list;
+	}
+	filteredCompareOptions.value = compareOptions.value;
+}
+
+// 时段对比相关函数
+function handleTimeCompareChange(value: string) {
+	timeCompareRange.value = value;
+	formData.value.toDate = timeCompareRange.value;
+	formData.value.fromDate = dayjs(timeCompareRange.value)
+		.subtract(getDaysBetweenDates(props.formData?.time[0], props.formData?.time[1]), 'day')
+		.format('YYYY-MM-DD');
+	formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
+	formData.value.version = props.formData?.version ? [props.formData?.version] : [];
+
+	getData('');
+
+	timeCompareVisible.value = false;
+	timeCompareRange.value = '';
+}
+
+const lineChartRef = ref<HTMLDivElement | null>(null);
+let chartInstance: echarts.ECharts | null = null;
+
+const lineChartData = ref<any>({
+	//图表数据//显示数据
+
+	dates: [],
+	items: [],
+});
+
+function initLineChart(): void {
+	if (!lineChartRef.value) return;
+	if (chartInstance) chartInstance.dispose();
+	chartInstance = echarts.init(lineChartRef.value);
+
+	const option: echarts.EChartsOption = {
+		tooltip: {
+			trigger: 'axis',
+			confine: true,
+			axisPointer: { type: 'line' },
+			borderWidth: 0,
+			backgroundColor: 'rgba(17,24,39,0.9)',
+			textStyle: { color: '#fff' },
+			formatter: (params: any) => {
+				return formatterTips(params);
+			},
+		},
+		legend: {
+			data: lineChartData.value.items.map((item: any) => item.name),
+			top: 'bottom',
+			type: 'scroll', // 支持图例滚动
+		},
+		grid: {
+			left: 40,
+			right: 20,
+			top: 20, // 为图例留出空间
+			bottom: 60,
+		},
+		xAxis: {
+			type: 'category',
+			data: lineChartData.value.dates,
+			axisLine: { lineStyle: { color: '#e5e7eb' } },
+			axisLabel: { color: '#6b7280' },
+			axisTick: { alignWithLabel: true },
+		},
+		yAxis: {
+			type: 'value',
+			axisLine: { show: false },
+			splitLine: { lineStyle: { color: '#f3f4f6' } },
+			axisLabel: { color: '#6b7280' },
+		},
+		series: lineChartData.value.items,
+	};
+	chartInstance.setOption(option);
+}
+
+// 展开/收起明细
+const showDetail = ref(true);
+
+onMounted(() => {
+	getData('');
+	getBottomDetail();
+	initCompareOptions();
+});
+
+watch(props.formData, () => {
+	formData.value = {
+		...formData.value,
+		channel: props.formData?.channel ? [props.formData?.channel] : [],
+		version: props.formData?.version ? [props.formData?.version] : [],
+		fromDate: props.formData?.time[0],
+		toDate: props.formData?.time[1],
+	};
+	formData.value.timeUnit = 'day';
+	ensureValidTimeUnit();
+	getData('');
+});
+
+// 监听外部日期与当前粒度,统一在此处做7天规则的静默纠正
+watch(
+	() => [props.formData?.time?.[0], props.formData?.time?.[1], formData.value.timeUnit],
+	([start, end, unit]) => {
+		if (!start || !end) return;
+		// 对齐内部查询参数
+		formData.value.fromDate = start as string;
+		formData.value.toDate = end as string;
+		const span = getDaysBetweenDates(start as string, end as string);
+		// 不自动纠正,仅在合法范围内时刷新
+		if (unit === 'hour' && span >= 7) {
+			getData('');
+		}
+	},
+	{ immediate: true }
+);
+</script>
+
+<style lang="scss" scoped>
+.highlight {
+	color: #2196f3;
+}
+</style>

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

@@ -1,260 +1,140 @@
 <template>
 	<div class="layout-padding">
 		<div class="!overflow-auto px-1">
-			<div class="el-card  p-9">
+			<!-- 头部筛选区域 -->
+			<div class="el-card p-9">
 				<div class="flex justify-between">
-					<Title :title="t('activeUser.analytics')" />
-					<div class="">
-						<el-button type="primary">{{ t('activeUser.growth') }}</el-button>
-						<el-button type="primary">{{ t('activeUser.ai') }}</el-button>
-					</div>
+					<Title :title="t('activeUser.analytics')">
+						<el-popover class="box-item" placement="right" trigger="hover" width="400">
+							<template #reference>
+								<el-icon class="ml-1" style="color: #a4b8cf">
+									<QuestionFilled />
+								</el-icon>
+							</template>
+							<template #default>
+								<div class="ant-popover-inner-content">
+									<div class="um-page-tips-content" style="line-height: 24px">
+										<p>
+											<span class="highlight">活跃用户:</span
+											><span>启动过应用的用户(去重),启动过一次的用户即视为活跃用户,包括新用户与老用户</span>
+										</p>
+										<p><span class="highlight">活跃构成:</span><span>活跃用户中新增用户的占比比例</span></p>
+										<p><span class="highlight">活跃粘度:</span><span>DAU/过去7日活跃用户,DAU/过去30日活跃用户</span></p>
+										<p><span class="highlight">过去7日活跃用户:</span><span>过去7日(不含今日)的活跃用户数(去重)</span></p>
+										<p><span class="highlight">过去30日活跃用户:</span><span>过去30日(不含今日)的活跃用户数(去重)</span></p>
+										<p><span class="highlight">分时活跃用户:</span><span>活跃用户在24小时中的分布情况(每小时间去重)&ZeroWidthSpace;</span></p>
+										<p><span class="highlight">周活跃率:</span><span>周活跃用户占截止本周累计用户的比例</span></p>
+										<p><span class="highlight">月活跃率:</span><span>月活跃用户占截止本月累计用户的比例</span></p>
+										<p>
+											<span
+												>按天、周或月查看数据可进行版本、渠道的交叉筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)</span
+											>
+										</p>
+									</div>
+								</div>
+							</template>
+						</el-popover>
+					</Title>
+					<!-- <div class="">
+						<el-button type="primary">{{ t('addUser.growth') }}</el-button>
+						<el-button type="primary">{{ t('addUser.ai') }}</el-button>
+					</div> -->
 				</div>
-				<div>
+				<div class="mt-2">
 					<el-row shadow="hover" class="ml10">
-						<el-form :inline="true" :model="formData" @keyup.enter="query" ref="queryRef">
+						<el-form :inline="true" :model="formData" ref="queryRef">
 							<el-form-item>
-								<el-date-picker v-model="formData.time" type="daterange" start-placeholder="开始时间" end-placeholder="结束时间" />
+								<el-date-picker
+									v-model="formData.time"
+									type="daterange"
+									value-format="YYYY-MM-DD"
+									:disabled-date="disableFuture"
+									@change="handleRangeChange"
+									class="!w-[250px]"
+									start-placeholder="开始时间"
+									end-placeholder="结束时间"
+								/>
 							</el-form-item>
+
 							<el-form-item>
-								<el-select v-model="selectedChannelCompare" class="w-[140px]" placeholder="全部渠道">
-									<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-								</el-select>
+								<FilterSelect v-model="formData.channel" :disabled="channelDisabled" type="channel" 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" :disabled="versionDisabled" type="version" class="!w-[180px] ml-2" />
 							</el-form-item>
 						</el-form>
 					</el-row>
 				</div>
 			</div>
-			<div class="mt-2 el-card  p-9">
-				<!-- 新增趋势 -->
-				<div class="">
-					<Title left-line :title="t('activeUser.addtrend')">
-						<template #default>
-							<el-popover class="box-item" placement="right" trigger="hover" width="250">
-								<template #reference>
-									<el-icon class="ml-1" style="color: #a4b8cf"><QuestionFilled /></el-icon>
-								</template>
-								<template #default>
-									<div class="ant-popover-inner-content">
-										<div class="um-page-tips-content" style="line-height: 24px">
-											<p>
-												<span class="highlight">活跃用户:</span>
-												<span>启动过应用的用户(去重),启动过一次的用户即视为活跃用户,包括新用户与老用户</span>
-											</p>
-											<p><span class="highlight">活跃构成:</span><span>活跃用户中新增用户的占比比例</span></p>
-											<p><span class="highlight">活跃粘度:</span><span>DAU/过去7日活跃用户,DAU/过去30日活跃用户</span></p>
-											<p><span class="highlight">过去7日活跃用户:</span><span>过去7日(不含今日)的活跃用户数(去重)</span></p>
-											<p><span class="highlight">过去30日活跃用户:</span><span>过去30日(不含今日)的活跃用户数(去重)</span></p>
-											<p><span class="highlight">分时活跃用户:</span><span>活跃用户在24小时中的分布情况(每小时间去重)&ZeroWidthSpace;</span></p>
-											<p><span class="highlight">周活跃率:</span><span>周活跃用户占截止本周累计用户的比例</span></p>
-											<p><span class="highlight">月活跃率:</span><span>月活跃用户占截止本月累计用户的比例</span></p>
-											<p>
-												<span>
-													按天、周或月查看数据可进行版本、渠道的交叉筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)
-												</span>
-											</p>
-										</div>
-									</div>
-								</template>
-							</el-popover>
-						</template>
-					</Title>
-					<div class="flex items-center justify-between mb-2 mt-3">
-						<div>
-							<el-select v-model="selectedChannelCompare" class="!w-[140px]" placeholder="渠道对比">
-								<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-							</el-select>
-
-							<el-select v-model="selectedChannelCompare" class="w-[140px] ml-2" style="width: 140px" placeholder="时间段对比">
-								<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-							</el-select>
-							<el-button type="primary" class="ml-2">{{ t('activeUser.selectTime') }}</el-button>
-						</div>
 
-						<div class="flex items-center">
-							<el-radio-group v-model="timeGranularity" size="small">
-								<el-radio-button label="day">天</el-radio-button>
-								<el-radio-button label="week">周</el-radio-button>
-								<el-radio-button label="month">月</el-radio-button>
-							</el-radio-group>
-						</div>
-					</div>
-
-					<div class="relative">
-						<div ref="lineChartRef" style="width: 100%; height: 320px"></div>
-					</div>
-				</div>
-
-				<!-- 明细表格 -->
-				<div class="mt-3">
-					<div class="flex items-center justify-between mb-2">
-						<div class="text-base font-medium cursor-pointer select-none ml-3 items-center flex text-[#167AF0]" @click="showDetail1 = !showDetail1">
-								{{ showDetail1 ? '收起明细数据' : '展开明细数据' }} <el-icon class="ml-2"><ArrowDown  v-if="showDetail1"/> <ArrowUp  v-else/> </el-icon>
-							</div>
-						<div>
-							<el-button>导出</el-button>
-						</div>
-					</div>
-					<el-table v-if="showDetail1" :data="pagedTableRows" border>
-						<el-table-column prop="date" label="日期" min-width="140" />
-						<el-table-column prop="hyyh" label="活跃用户数" min-width="140" />
-						<el-table-column prop="xyhzb" label="活跃构成(新用户占比)" min-width="140" />
-						<el-table-column prop="gqqr" label="DAU/过去7日活跃用户" min-width="140" />
-						<el-table-column label="新增用户(占比)" min-width="220">
-							<template #default="scope">
-								<div class="flex items-center justify-between w-full">
-									<span>{{ scope.row.newUsers }}</span>
-									<span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
-								</div>
-							</template>
-						</el-table-column>
-					</el-table>
-					<div v-if="showDetail1" class="flex justify-end mt-2">
-						<el-pagination
-							v-model:current-page="currentPage"
-							v-model:page-size="pageSize"
-							background
-							layout="total, prev, pager, next, sizes"
-							:total="tableRows.length"
-							:page-sizes="[5, 10, 20]"
-						/>
-					</div>
-				</div>
+			<!-- 新增趋势模块 -->
+			<div class="mt-3">
+				<AddTrend :form-data="formData" @query="isDisabled" />
 			</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 query = () => {
-	console.log(formData.value);
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+const AddTrend = defineAsyncComponent(() => import('./components/AddTrend.vue'));
+// 计算默认时间范围(半个月前到今天)
+const getDefaultDateRange = () => {
+	const endDate = dayjs();
+	const startDate = endDate.subtract(15, 'day');
+	return [startDate.format('YYYY-MM-DD'), endDate.format('YYYY-MM-DD')];
 };
-
-const 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);
-}
-
-onMounted(() => {
-	initLineChart();
-});
-
-watch(timeGranularity, () => {
-	// 静态页面:仅重新渲染
-	initLineChart();
+const formData = ref<Record<string, any>>({
+	time: getDefaultDateRange(), // 时间范围
 });
+const channelDisabled = ref(false);
+const versionDisabled = ref(false);
+const isDisabled = (type: string) => {
+	if (type == 'disabled') {
+		versionDisabled.value = true;
+		channelDisabled.value = true;
+	}else{
+		versionDisabled.value = false;
+		channelDisabled.value = false;
+	}
+};
 
-// 表格相关(静态数据)
-const tableRows = ref<TableRow[]>(
-	Array.from({ length: 42 }).map((_, idx) => ({
-		date: `2025-08-${String(11).padStart(2, '0')}`,
-		newUsers: 727,
-        hyyh:'115',
-        xyhzb:'111',
-        gqqr:'112',
-		ratio: '97.45%',
-	}))
-);
-
-const currentPage = ref(1);
-const pageSize = ref(5);
-const pagedTableRows = computed(() => {
-	const startIndex = (currentPage.value - 1) * pageSize.value;
-    
-	return tableRows.value.slice(startIndex, startIndex + pageSize.value);
-});
-
-const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
-
-// 展开/收起明细
-const showDetail1 = ref(true);
-
+function disableFuture(date: Date) {
+	const today = new Date();
+	today.setHours(0, 0, 0, 0);
+	return date.getTime() > today.getTime();
+}
 
+function handleRangeChange(val: [string, string] | null) {
+	if (!val) {
+		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')];
+	}
+}
 </script>
 
 <style lang="scss" scoped>
 .highlight {
-	color: #2196f3;
+	color: #409eff;
 }
-.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>

+ 568 - 0
src/views/count/user/adduser/components/AddTrend.vue

@@ -0,0 +1,568 @@
+<template>
+	<div class="el-card p-9">
+		<!-- 新增趋势 -->
+		<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="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(''),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>
+				<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">
+							<span>{{ scope.row.newUser }}</span>
+							<span class="text-gray-500 text-xs ml-2">({{ (scope.row.newUserRate * 100).toFixed(2)
+							}}%)</span>
+						</div>
+					</template>
+				</el-table-column>
+			</el-table>
+			<div v-if="showDetail" class="flex justify-end mt-3">
+				<el-pagination v-model:current-page="pagination.current" v-model:page-size="pagination.size"
+					@change="getBottomDetail" background layout="total, prev, pager, next, sizes"
+					:total="pagination.total" :page-sizes="[5, 10, 20]" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+import * as echarts from 'echarts';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { getDaysBetweenDates } from '/@/utils/formatTime';
+import { getAppVersion, getAppChannel } from '/@/api/common/common';
+import { getTrend, getTrendDetail } from '/@/api/count/addUser';
+
+const { t } = useI18n();
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+
+const industryCompare = ref('');	
+const selectedCompareItems = ref<string[]>([]);
+const searchKeyword = ref('');
+const compareOptions = ref<string[]>([]);
+const filteredCompareOptions = ref<string[]>([]);
+const timeCompareRange = ref<string>('');
+const timeCompareVisible = ref(false);
+
+const masterData = ref(<any>{
+	//图表主数据
+	dates: [],
+	items: [],
+});
+
+const emit = defineEmits(['query']);
+const props = defineProps({
+	formData: {
+		type: Object,
+		default: () => ({}),
+	},
+});
+
+interface FormDataType {
+	timeUnit: string;
+	version: string[];
+	channel: string[];
+	fromDate: string;
+	toDate: string;
+}
+
+const formData = ref<FormDataType>({
+	timeUnit: 'day',
+	version: [],
+	channel: [],
+	fromDate: props.formData?.time[0],
+	toDate: props.formData?.time[1],
+});
+const colorSchemes = [
+	{ color: '#409EFF' }, // 蓝色
+	{ color: '#67C23A' }, // 绿色
+	{ color: '#E6A23C' }, // 黄色
+	{ color: '#F56C6C' }, // 红色
+];
+const tableData = ref([]); //表格数据
+const pagination = ref({
+	current: 1, //当前页数
+	total: 0, // 数据总数
+	size: 5, // 每页显示条数
+});
+const previousCompareItems = ref<string[]>([]);
+const chartTimes = ref<string[]>([]);
+
+const getData = async (type: string) => {
+	//上方图表
+	if (type === 'clearAll') {
+		lineChartData.value = { dates: [], items: [] };
+		industryCompare.value = '';
+	}
+	// Guard: 小时维度跨度>=7天时不发起查询,不做自动纠正
+	if (formData.value.timeUnit === 'hour') {
+		const span = getDaysBetweenDates(formData.value.fromDate, formData.value.toDate);
+		if (span >= 7) {
+			return;
+		}
+	}
+	const res = await getTrend({ ...formData.value });
+	const data = res?.data || [];
+
+	if (!industryCompare.value) {
+		masterData.value = data;
+		lineChartData.value.items = data.items.map((item: any, index: number) => {
+			const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
+			return {
+				name: getName(item, index, data),
+				type: 'line',
+				smooth: true,
+				data: item.data,
+				itemStyle: {
+					color: colorSchemes[randomColorIndex].color,
+				},
+				lineStyle: {
+					color: colorSchemes[randomColorIndex].color,
+				},
+			};
+		});
+		chartTimes.value = [];
+	} else {
+		lineChartData.value.items.push(
+			data.items.map((item: any, index: number) => {
+				console.log(item);
+
+				const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
+				return {
+					name: getName(item, index, data),
+					type: 'line',
+					smooth: true,
+					data: item.data,
+					itemStyle: {
+						color: colorSchemes[randomColorIndex].color,
+					},
+					lineStyle: {
+						color: colorSchemes[randomColorIndex].color,
+					},
+				};
+			})[0]
+		);
+		if (industryCompare.value == 'time') {
+			chartTimes.value.push(data.dates);
+			lineChartData.value.items[0].name = props.formData?.time[0] + ' ~ ' + props.formData?.time[1];
+		} else {
+			chartTimes.value = [];
+		}
+	}
+	lineChartData.value.dates = masterData.value.dates;
+	initLineChart();
+};
+const getBottomDetail = async () => {
+	// 下方明细表
+	const resDetail = await getTrendDetail({ ...formData.value, ...pagination.value });
+	const dataDetail = resDetail?.data || [];
+	pagination.value.current = dataDetail.current; //当前页码
+	pagination.value.total = dataDetail.total; //总条数
+	pagination.value.size = dataDetail.size; //每页条数
+	tableData.value = dataDetail.records;
+};
+const getName = (item: any, index: number, data: any) => {
+	if (industryCompare.value === 'time') {
+		//日期
+		return formData.value.fromDate + ' ~ ' + formData.value.toDate;
+	} else if (industryCompare.value === 'version') {
+		//版本
+		return item.version === 'All' ? item.name : item.version + ' ' + item.name;
+	} else if (industryCompare.value === 'channel') {
+		//渠道
+		return formData.value.channel[0];
+	} else {
+		return item.name;
+	}
+};
+
+function formatNumber(value: number | string): string {
+	const num = typeof value === 'number' ? value : Number(value || 0);
+	return num.toLocaleString('zh-CN');
+}
+const formatterTips = (params: any) => {
+	if (!params || !params.length) return '';
+	const date = params[0]?.axisValue || '';
+	const rows = params
+		.map((p: any, index: number) => {
+			const name = p.seriesName || '';
+			const val = formatNumber(p.data);
+			if (industryCompare.value === 'time' && index != 0) {
+				return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
+							<span style="display:flex;align-items:center;gap:6px;">
+								<span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
+								<span>${chartTimes.value[0][p.dataIndex]}</span>
+							</span>
+							<span style="font-variant-numeric: tabular-nums;">${val}</span>
+						</div>`;
+			} else {
+				return `
+			<div style="margin-bottom:6px;color:#93c5fd;">${industryCompare.value === 'time' ? '' : date}</div>
+				
+				<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
+							<span style="display:flex;align-items:center;gap:6px;">
+								<span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
+								<span>${industryCompare.value === 'time' ? p.axisValue : name} </span>
+							</span>
+							<span style="font-variant-numeric: tabular-nums;">${val}</span>
+						</div>`;
+			}
+		})
+		.join('');
+	return `<div style="font-size:12px;">
+			${rows}
+		</div>`;
+};
+
+// 根据日期范围禁用不合适的粒度
+const rangeDays = computed(() => {
+	const start = props.formData?.time?.[0];
+	const end = props.formData?.time?.[1];
+	if (!start || !end) return 0;
+	return getDaysBetweenDates(start, end);
+});
+
+const 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);
+
+	// 输出结果用于调试或后续处理
+	console.log('新增的项:', addedItems);
+	console.log('减少的项:', removedItemIndices);
+	if (removedItemIndices.length > 0) {
+		lineChartData.value.items.splice(removedItemIndices[0] + 1, 1);
+	}
+	if (industryCompare.value === 'version') {
+		lineChartData.value.items[0].name = '全部版本';
+	} else if (industryCompare.value === 'channel') {
+		lineChartData.value.items[0].name = '全部渠道';
+	}
+	// 更新 previousCompareItems 为当前值,用于下次对比
+	previousCompareItems.value = items;
+
+	const isVersion = industryCompare.value === 'version'; // 判断是否是版本对比
+	if (isVersion) {
+		formData.value.version = addedItems;
+		formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
+	} else {
+		formData.value.channel = addedItems;
+		formData.value.version = props.formData?.version ? [props.formData?.version] : [];
+	}
+	if (addedItems.length > 0) {
+		getData('');
+	} else {
+		initLineChart();
+	}
+}
+
+// 初始化对比选项
+async function initCompareOptions() {
+	if (industryCompare.value === 'version') {
+		const res = await getAppVersion();
+		const list: Array<string> = res?.data || [];
+		compareOptions.value = list;
+	} else if (industryCompare.value === 'channel') {
+		const res = await getAppChannel();
+		const list: Array<string> = res?.data || [];
+		compareOptions.value = list;
+	}
+	filteredCompareOptions.value = compareOptions.value;
+}
+
+// 时段对比相关函数
+function handleTimeCompareChange(value: string) {
+	timeCompareRange.value = value;
+	formData.value.toDate = timeCompareRange.value;
+	formData.value.fromDate = dayjs(timeCompareRange.value).subtract(getDaysBetweenDates(props.formData?.time[0], props.formData?.time[1]), 'day').format('YYYY-MM-DD');
+	formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
+	formData.value.version = props.formData?.version ? [props.formData?.version] : [];
+
+	getData('');
+
+	timeCompareVisible.value = false;
+	timeCompareRange.value = '';
+}
+
+const lineChartRef = ref<HTMLDivElement | null>(null);
+let chartInstance: echarts.ECharts | null = null;
+
+const lineChartData = ref<any>({
+	//图表数据//显示数据
+
+	dates: [],
+	items: [],
+});
+
+function initLineChart(): void {
+	if (!lineChartRef.value) return;
+	if (chartInstance) chartInstance.dispose();
+	chartInstance = echarts.init(lineChartRef.value);
+
+	const option: echarts.EChartsOption = {
+		tooltip: {
+			trigger: 'axis',
+			confine: true,
+			axisPointer: { type: 'line' },
+			borderWidth: 0,
+			backgroundColor: 'rgba(17,24,39,0.9)',
+			textStyle: { color: '#fff' },
+			formatter: (params: any) => {
+				return formatterTips(params);
+			},
+		},
+		legend: {
+			data: lineChartData.value.items.map((item: any) => item.name),
+			top: 'bottom',
+			type: 'scroll', // 支持图例滚动
+		},
+		grid: {
+			left: 40,
+			right: 20,
+			top: 20, // 为图例留出空间
+			bottom: 60,
+		},
+		xAxis: {
+			type: 'category',
+			data: lineChartData.value.dates,
+			axisLine: { lineStyle: { color: '#e5e7eb' } },
+			axisLabel: { color: '#6b7280' },
+			axisTick: { alignWithLabel: true },
+		},
+		yAxis: {
+			type: 'value',
+			axisLine: { show: false },
+			splitLine: { lineStyle: { color: '#f3f4f6' } },
+			axisLabel: { color: '#6b7280' },
+		},
+		series: lineChartData.value.items,
+	};
+	chartInstance.setOption(option);
+}
+
+// 展开/收起明细
+const showDetail = ref(true);
+
+onMounted(() => {
+	getData('');
+	getBottomDetail();
+	initCompareOptions();
+});
+
+watch(props.formData, () => {
+	formData.value = {
+		...formData.value,
+		channel: props.formData?.channel ? [props.formData?.channel] : [],
+		version: props.formData?.version ? [props.formData?.version] : [],
+		fromDate: props.formData?.time[0],
+		toDate: props.formData?.time[1],
+	};
+	formData.value.timeUnit = 'day';
+	ensureValidTimeUnit();
+	getData('');
+});
+
+// 监听外部日期与当前粒度,统一在此处做7天规则的静默纠正
+watch(
+	() => [props.formData?.time?.[0], props.formData?.time?.[1], formData.value.timeUnit],
+	([start, end, unit]) => {
+		if (!start || !end) return;
+		// 对齐内部查询参数
+		formData.value.fromDate = start as string;
+		formData.value.toDate = end as string;
+		const span = getDaysBetweenDates(start as string, end as string);
+		// 不自动纠正,仅在合法范围内时刷新
+		if (unit === 'hour' && span >= 7) {
+			getData('');
+		}
+	},
+	{ immediate: true }
+);
+</script>
+
+<style lang="scss" scoped>
+.highlight {
+	color: #2196f3;
+}
+</style>

+ 198 - 0
src/views/count/user/adduser/components/UserQuality.vue

@@ -0,0 +1,198 @@
+<template>
+	<div class="el-card p-9">
+		<!-- 新增用户质量 -->
+		<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">
+				<div ref="qualityChartRef" style="width: 100%; height: 320px"></div>
+			</div>
+		</div>
+
+		<!-- 明细表格 -->
+		<div class="mt-3">
+			<div class="flex items-center justify-between mb-2">
+				<div class="text-base font-medium cursor-pointer select-none ml-3 items-center flex text-[#167AF0]"
+					@click="showDetail = !showDetail">
+					{{ showDetail ? '收起明细数据' : '展开明细数据' }}
+					<el-icon class="ml-2">
+						<ArrowDown v-if="showDetail" />
+						<ArrowUp v-else />
+					</el-icon>
+				</div>
+				<div>
+					<el-button type="primary" text>导出</el-button>
+
+				</div>
+			</div>
+			<el-table v-if="showDetail" :data="tableData" border>
+				<el-table-column prop="date" label="日期" />
+				<el-table-column label="新增用户(占比)" align="right">
+					<template #default="scope">
+						{{ (scope.row.retention * 100).toFixed(2) }}%
+					</template>
+				</el-table-column>
+			</el-table>
+			<div v-if="showDetail" class="flex justify-end mt-3">
+				<el-pagination v-model:current-page="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, computed, defineAsyncComponent } from 'vue';
+import * as echarts from 'echarts';
+import { useI18n } from 'vue-i18n';
+import { getRetentionDetail, getRetention } from '/@/api/count/addUser';
+
+const { t } = useI18n();
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+
+interface TableRow {
+	date: string;
+	retention: string;
+}
+
+const emit = defineEmits(['query']);
+const props = defineProps({
+	formData: {
+		type: Object,
+		default: () => ({}),
+	},
+});
+const colorSchemes = [
+	{ color: '#409EFF' }, // 蓝色
+	{ color: '#67C23A' }, // 绿色
+	{ color: '#E6A23C' }, // 黄色
+	{ color: '#F56C6C' }, // 红色
+];
+const pagination = ref({
+	current: 1, //当前页数
+	total: 0, // 数据总数
+	size: 5, // 每页显示条数
+});
+// 用户质量(留存率)
+const qualityChartRef = ref<HTMLDivElement | null>(null);
+let qualityChart: echarts.ECharts | null = null;
+const tableData = ref<TableRow[]>([]);
+
+interface FormDataType {
+	timeUnit: string;
+	version: string[];
+	channel: string[];
+	fromDate: string;
+	toDate: string;
+}
+
+const formData = ref<FormDataType>({
+	timeUnit: 'day',
+	version: [],
+	channel: [],
+	fromDate: props.formData?.time[0],
+	toDate: props.formData?.time[1],
+});
+const lineCartData = ref<{ dates: any; retentions: any }>({
+	dates: [],
+	retentions: [],
+});
+
+const getData = async () => {
+	const res = await getRetention({ ...formData.value });
+	const data = res?.data || [];
+	lineCartData.value = data;
+	lineCartData.value.retentions = lineCartData.value.retentions.map((item: any) => (item * 100).toFixed(2));
+	initQualityChart();
+	getBottomDetail()
+};
+
+const getBottomDetail = async () => {
+	// 下方明细表
+	const res = await getRetentionDetail({ ...formData.value, ...pagination.value });
+	const data = res?.data || [];
+	tableData.value = data.records;
+	pagination.value.current = data.current; //当前页码
+	pagination.value.total = data.total; //总条数
+	pagination.value.size = data.size; //每页条数
+};
+
+// 展开/收起明细
+const showDetail = ref(true);
+
+function initQualityChart(): void {
+	const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
+	if (!qualityChartRef.value) return;
+	if (qualityChart) qualityChart.dispose();
+	qualityChart = echarts.init(qualityChartRef.value);
+	const option: echarts.EChartsOption = {
+		tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
+		grid: { left: 40, right: 20, top: 30, bottom: 30 },
+		xAxis: { type: 'category', data: lineCartData.value.dates },
+		yAxis: {
+			type: 'value',
+			min: 0,
+			max: 30,
+			axisLabel: { formatter: '{value}%' },
+			splitLine: { lineStyle: { color: '#f3f4f6' } },
+		},
+		series: [
+			{
+				name: '留存率',
+				type: 'line',
+				smooth: true,
+				data: lineCartData.value.retentions,
+				itemStyle: {
+					color: colorSchemes[randomColorIndex].color,
+				},
+				lineStyle: {
+					color: colorSchemes[randomColorIndex].color,
+				},
+			},
+		],
+	};
+	qualityChart.setOption(option);
+}
+
+onMounted(() => {
+	getData();
+
+});
+watch(props.formData, () => {
+	formData.value = {
+		...formData.value,
+		channel: props.formData?.channel ? [props.formData?.channel] : [],
+		version: props.formData?.version ? [props.formData?.version] : [],
+		fromDate: props.formData?.time[0],
+		toDate: props.formData?.time[1],
+	};
+	getData();
+});
+</script>
+
+<style lang="scss" scoped>
+.highlight {
+	color: #2196f3;
+}
+</style>

+ 1 - 1
src/views/count/user/adduser/i18n/zh-cn.ts

@@ -6,6 +6,6 @@ export default {
 		addtrend:'新增趋势',
 		channel:'选择渠道',
 		userQuality:'用户质量',
-		average:'行业平均值'
+		average:'添加对比值'
 	},
 };

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

@@ -1,354 +1,103 @@
 <template>
 	<div class="layout-padding">
 		<div class="!overflow-auto px-1">
-			<div class="el-card  p-9">
+			<!-- 头部筛选区域 -->
+			<div class="el-card p-9">
 				<div class="flex justify-between">
 					<Title :title="t('addUser.analytics')" />
-					<div class="">
+					<!-- <div class="">
 						<el-button type="primary">{{ t('addUser.growth') }}</el-button>
 						<el-button type="primary">{{ t('addUser.ai') }}</el-button>
-					</div>
+					</div> -->
 				</div>
-				<div>
+				<div class="mt-2">
 					<el-row shadow="hover" class="ml10">
-						<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="结束时间" />
+						<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-form-item>
-							<el-form-item   prop="appId">
-								<el-input :placeholder="'请输入应用ID'" clearable v-model="formData.appId" />
+
+							<el-form-item>
+								<FilterSelect v-model="formData.channel" type="channel" @change="query"
+									class="!w-[180px] ml-2" />
 							</el-form-item>
-						</el-form>
-					</el-row>
-				</div>
-			</div>
-			<div class="mt-3 el-card  p-9">
-				<!-- 新增趋势 -->
-				<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">
-						<el-form>
 							<el-form-item>
-								<el-select v-model="selectedChannelCompare" style="width: 140px" placeholder="渠道对比">
-									<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
-								</el-select>
-								<el-button class="ml-2">{{ t('addUser.channel') }}</el-button>
+								<FilterSelect v-model="formData.version" type="version" @change="query"
+									class="!w-[180px] ml-2" />
 							</el-form-item>
 						</el-form>
-						<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>
+					</el-row>
 				</div>
+			</div>
 
-				<!-- 明细表格 -->
-				<div class="mt-3">
-					<div class="flex items-center justify-between mb-2">
-						<div class="text-base font-medium cursor-pointer select-none ml-3 items-center flex text-[#167AF0]" @click="showDetail1 = !showDetail1">
-								{{ showDetail1 ? '收起明细数据' : '展开明细数据' }} <el-icon class="ml-2"><ArrowDown  v-if="showDetail1"/> <ArrowUp  v-else/> </el-icon>
-							</div>
-						<div>
-							<el-button>导出</el-button>
-						</div>
-					</div>
-					<el-table v-if="showDetail1" :data="pagedTableRows" border>
-						<el-table-column prop="date" label="日期" min-width="140" />
-						<el-table-column label="新增用户(占比)" min-width="220">
-							<template #default="scope">
-								<div class="flex items-center justify-between w-full">
-									<span>{{ scope.row.newUsers }}</span>
-									<span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
-								</div>
-							</template>
-						</el-table-column>
-					</el-table>
-					<div v-if="showDetail1" class="flex justify-end mt-3">
-						<el-pagination
-							v-model:current-page="currentPage"
-							v-model:page-size="pageSize"
-							background
-							layout="total, prev, pager, next, sizes"
-							:total="tableRows.length"
-							:page-sizes="[5, 10, 20]"
-						/>
-					</div>
-				</div>
+			<!-- 新增趋势模块 -->
+			<div class="mt-3">
+				<AddTrend :form-data="formData" @query="query" />
 			</div>
-			<div class="mt-3 el-card  p-9">
-				<!-- 新增用户质量 -->
-				<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="flex items-center ">
-							<el-select v-model="industryCompare" style="width: 120px">
-								<el-option label="行业对比" value="industry" />
-							</el-select>
-							<el-button class="ml-2">{{ t('addUser.average') }}</el-button>
-							<el-button link class="ml-2">清除</el-button>
-						</div>
-					<div class="relative">
-						<div ref="qualityChartRef" style="width: 100%; height: 320px"></div>
-					</div>
-				</div>
-				<!-- 明细表格 -->
-				<div class="mt-3">
-					<div class="flex items-center justify-between mb-2">
-						<div class="text-base font-medium cursor-pointer select-none ml-3 items-center flex text-[#167AF0]" @click="showDetail1 = !showDetail1">
-								{{ showDetail1 ? '收起明细数据' : '展开明细数据' }} <el-icon class="ml-2"><ArrowDown  v-if="showDetail1"/> <ArrowUp  v-else/> </el-icon>
-							</div>
-						<div>
-							<el-button>导出</el-button>
-						</div>
-					</div>
-					<el-table v-if="showDetail1" :data="pagedTableRows" border>
-						<el-table-column prop="date" label="日期" min-width="140" />
-						<el-table-column label="新增用户(占比)" min-width="220">
-							<template #default="scope">
-								<div class="flex items-center justify-between w-full">
-									<span>{{ scope.row.newUsers }}</span>
-									<span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
-								</div>
-							</template>
-						</el-table-column>
-					</el-table>
-					<div v-if="showDetail1" class="flex justify-end mt-3">
-						<el-pagination
-							v-model:current-page="currentPage"
-							v-model:page-size="pageSize"
-							background
-							layout="total, prev, pager, next, sizes"
-							:total="tableRows.length"
-							:page-sizes="[5, 10, 20]"
-						/>
-					</div>
-				</div>
+
+			<!-- 用户质量模块 -->
+			<div class="mt-3">
+				<UserQuality :form-data="formData" @query="query" />
 			</div>
+
 		</div>
 	</div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
-import * as echarts from 'echarts';
+import { ref, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
 
 const { t } = useI18n();
-
-interface TableRow {
-	date: string;
-	newUsers: number;
-	ratio: string;
-}
-
-const formData = ref<Record<string, any>>({});
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+const AddTrend = defineAsyncComponent(() => import('./components/AddTrend.vue'));
+const 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 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();
-	initQualityChart();
-});
-
-watch(timeGranularity, () => {
-	// 静态页面:仅重新渲染
-	initLineChart();
-});
-
-// 表格相关(静态数据)
-const tableRows = ref<TableRow[]>(
-	Array.from({ length: 42 }).map((_, idx) => ({
-		date: `2025-08-${String(11).padStart(2, '0')}`,
-		newUsers: 727,
-		ratio: '97.45%',
-	}))
-);
-
-const currentPage = ref(1);
-const pageSize = ref(5);
-const pagedTableRows = computed(() => {
-	const startIndex = (currentPage.value - 1) * pageSize.value;
-	return tableRows.value.slice(startIndex, startIndex + pageSize.value);
-});
-
-const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
-
-// 展开/收起明细
-const showDetail1 = ref(true);
-
-// 用户质量(留存率)
-const industryCompare = ref('industry');
-const qualityChartRef = ref<HTMLDivElement | null>(null);
-let qualityChart: echarts.ECharts | null = null;
-
-const qualityXAxis = ref<string[]>([
-	'2025-07-01',
-	'2025-07-08',
-	'2025-07-15',
-	'2025-07-22',
-	'2025-07-29',
-	'2025-08-05',
-	'2025-08-12',
-	'2025-08-19',
-	'2025-08-26',
-	'2025-09-02',
-	'2025-09-09',
-	'2025-09-16',
-]);
-
-const retentionSeries = ref<number[]>([20, 23, 27, 24, 22, 15, 5, 4, 16, 26, 25, 2]);
-const industryAvgSeries = ref<number[]>([16, 18, 20, 24, 25, 24, 16, 10, 15, 22, 21, 12]);
-const peerSameScaleSeries = ref<number[]>([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
-
-function initQualityChart(): void {
-	if (!qualityChartRef.value) return;
-	if (qualityChart) qualityChart.dispose();
-	qualityChart = echarts.init(qualityChartRef.value);
-	const option: echarts.EChartsOption = {
-		tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
-		legend: { data: ['留存率', '同行业App', '同行业同规模App'] },
-		grid: { left: 40, right: 20, top: 30, bottom: 30 },
-		xAxis: { type: 'category', data: qualityXAxis.value },
-		yAxis: {
-			type: 'value',
-			min: 0,
-			max: 30,
-			axisLabel: { formatter: '{value}%' },
-			splitLine: { lineStyle: { color: '#f3f4f6' } },
-		},
-		series: [
-			{ name: '留存率', type: 'line', smooth: true, data: retentionSeries.value },
-			{ name: '同行业App', type: 'line', smooth: true, data: industryAvgSeries.value, color: '#f59e0b' },
-			{ name: '同行业同规模App', type: 'line', smooth: true, data: peerSameScaleSeries.value, color: '#60a5fa' },
-		],
-	};
-	qualityChart.setOption(option);
+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>

+ 1 - 1
src/views/marketing/config/index.vue

@@ -1,7 +1,7 @@
 <template>
 	<div class="layout-padding">
 		<el-tabs v-model="activeName" type="card" class="demo-tabs" @tab-click="handleClick">
-			<el-tab-pane :label="t('marketingConfig.ipList')" name="IP分组" class="layout-padding-auto layout-padding-view">
+			<el-tab-pane :label="t('marketingConfig.ipList')" name="IP分组" class="layout-padding-auto layout-padding-view" :loading="loading">
 				<Title class="ml-4" :title="t('marketingConfig.ipList')" />
 				<div class="p-4 rounded">
 					<el-button style="margin-bottom: 10px;" type="primary" @click="onClickAdd('ip')">{{ t('marketingConfig.addIpList') }}</el-button><br>

+ 448 - 0
src/views/marketing/rules/components/Edit.vue

@@ -0,0 +1,448 @@
+<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-[59vw]">
+					<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>
+				</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>
+					<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>
+				</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'"
+					/>
+				</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>
+				<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>
+
+				<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>
+		</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>
+			</span>
+		</template>
+	</el-dialog>
+</template>
+
+<script setup name="Edit" lang="ts">
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['update:open', 'onsuccess']);
+
+const props = defineProps({
+	open: {
+		type: Boolean,
+		default: false,
+	},
+	rowData: {
+		type: Array,
+		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 JCollapse = defineAsyncComponent(() => import('/@/components/JCollapse/index.vue'));
+const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.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: '',
+});
+
+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 dataRules = reactive({
+	url: [
+		{ required: true, message: '跳转连接不能为空', trigger: 'blur' },
+		{ validator: rule.domain, trigger: 'blur' },
+	],
+	promptMsg: [{ required: true, message: '提示信息不能为空', trigger: 'blur' }],
+	triggerNum: [
+		{ required: true, message: '触发频率不能为空', trigger: 'blur' },
+		{
+			pattern: /^(10000|[1-9]\d{0,3}|0)$|^(100%|[1-9]?\d%|0%)$/,
+			message: '请输入 0-10000 的正整数或 0%-100% 的百分比',
+			trigger: 'blur',
+		},
+	],
+});
+
+//tab相关
+
+const inputValue = ref('');
+const dynamicTags = ref(['关键字1', '关键字2', '关键字3']);//关键字数组
+const inputVisible = ref(false);
+const InputRef = ref();
+
+//删除关键字
+const handleClose = (tag: string) => {
+	dynamicTags.value.splice(dynamicTags.value.indexOf(tag), 1);
+};
+
+const showInput = () => {
+	inputVisible.value = true;
+	nextTick(() => {
+		InputRef.value!.input!.focus();
+	});
+};
+
+const handleInputConfirm = () => {
+	if (inputValue.value) {
+		dynamicTags.value.push(inputValue.value);
+	}
+	inputVisible.value = false;
+	inputValue.value = '';
+};
+
+const onSubmit = async () => {
+	try {
+		await ruleFormRef.value.validateField('triggerNum');
+	} catch (error) {
+		// 验证失败,阻止后续逻辑执行
+		return;
+	}
+
+	if (formData.value.triggerMode === '1' || formData.value.triggerMode === '3') {
+		try {
+			await ruleFormRef.value.validateField('url');
+		} catch (error) {
+			// 验证失败,阻止后续逻辑执行
+			return;
+		}
+	}
+
+	try {
+		loading.value = 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;
+			}
+		}
+
+		await saveConfigDetail({
+			...formData.value,
+			triggerNum: triggerNum.toString(),
+		});
+
+		useMessage().success(t('common.editSuccessText'));
+	} catch (err) {
+		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),
+					};
+				}),
+			};
+		});
+	});
+
+	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;
+	}
+	return '--';
+};
+
+onMounted(() => {
+	//获取IP列表
+	getIpData();
+	getConfig();
+});
+</script>
+<style lang="scss">
+</style>

+ 103 - 0
src/views/marketing/rules/index.vue

@@ -0,0 +1,103 @@
+<template>
+	<div class="layout-padding">
+		<div class="layout-padding-auto layout-padding-view">
+			<el-row class="ml10" v-show="showSearch">
+				<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>
+          <el-form-item label="IP" prop="logType">
+						<el-input placeholder="请输入IP"  v-model="state.queryForm.queryIPName" />
+					</el-form-item>
+					<el-form-item label="域名" prop="logType">
+						<el-input placeholder="请输入域名"  v-model="state.queryForm.queryIPName" />
+					</el-form-item>
+					<el-form-item>
+						<el-button @click="getDataList" type="primary">查询</el-button>
+						<el-button @click="resetQuery" icon="Refresh">重置</el-button>
+					</el-form-item>
+				</el-form>
+			</el-row>
+		
+			<el-table
+				: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="remoteAddr" show-overflow-tooltip></el-table-column>
+				<el-table-column label="IP" prop="method" show-overflow-tooltip></el-table-column>
+				<el-table-column label="域名" prop="createTime" 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="备注" prop="createBy" show-overflow-tooltip width="200"></el-table-column>
+				<el-table-column label="操作" width="150">
+					<template #default="scope">
+						<el-button @click="openEdit(scope.row)" size="small" text type="primary">
+							编辑
+						</el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+      <Edit v-model:open="open" @close="open = false" />
+			<pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination"> </pagination>
+		</div>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { BasicTableProps, useTable } from '/@/hooks/table';
+import { delObj, pageList } from '/@/api/admin/log';
+import { useI18n } from 'vue-i18n';
+
+
+const Edit = defineAsyncComponent(() => import('./components/Edit.vue'));
+
+const open = ref(false);
+const rowData = ref({});
+const { t } = useI18n();
+
+// 定义变量内容
+const queryRef = ref();
+const showSearch = ref(true);
+
+
+const state: BasicTableProps = reactive<BasicTableProps>({
+	queryForm: {
+		logType: '',
+		createTime: '',
+	},
+	selectObjs: [],
+	pageList: pageList,
+	descs: ['create_time'],
+});
+
+//  table hook
+const { downBlobFile, getDataList, currentChangeHandle, sortChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+
+// 清空搜索条件
+const resetQuery = () => {
+	queryRef.value?.resetFields();
+	getDataList();
+};
+
+// 导出excel
+const exportExcel = () => {
+	downBlobFile('/admin/log/export', state.queryForm, 'log.xlsx');
+};
+const openEdit = (row: any) => {
+  rowData.value = row;
+  open.value = true;
+
+}
+
+
+
+</script>
+
+<style lang="scss" scoped>
+pre code.hljs {
+	width: 65%;
+}
+</style>