Parcourir la source

用户分析模块添加选择应用下拉框

jcq il y a 3 jours
Parent
commit
7bd50fa3fb

+ 4 - 2
.env

@@ -5,10 +5,12 @@ VITE_IS_MICRO= true
 VITE_PUBLIC_PATH = /
 
 # 后端请求前缀
-# VITE_API_URL = http://43.199.205.45:9999
+# VITE_API_URL = http://192.168.10.101:9999
+VITE_API_URL = http://43.199.205.45:9999
 # VITE_API_URL = http://192.168.3.129:999
-VITE_API_URL = http://192.168.10.101:9999
+# VITE_API_URL = http://192.168.10.101:9999
 # VITE_API_URL = http://192.168.3.118:9999
+# VITE_API_URL = http://192.168.3.120:9999
 #  VITE_API_URL = http://192.168.3.17:9999
 #  VITE_API_URL = http://16.162.25.197:9999
 

+ 1 - 1
.env.production

@@ -10,4 +10,4 @@ ENV = 'production'
 # ADMIN 服务地址
 VITE_ADMIN_PROXY_PATH = http://127.0.0.1:9999
 # VITE_API_URL = http://16.162.25.197:9999
-VITE_API_URL = http://43.199.205.45:9999
+# VITE_API_URL = http://43.199.205.45:9999

+ 26 - 9
src/api/common/common.ts

@@ -1,31 +1,48 @@
 import request from '/@/utils/request';
 
-export const appID = 'Fqs2CL9CUn7U1AqilSFXgb'
+export const APP_ID_STORAGE_KEY = 'dm_app_id';
 
+// 初始化时从本地缓存读取 appID
+export let appID = (typeof window !== 'undefined' && window.localStorage)
+    ? (localStorage.getItem(APP_ID_STORAGE_KEY) || '-1')
+    : '-1';
+
+export function setAppID(newAppId: string) {
+    appID = newAppId || '-1';
+    try {
+        if (typeof window !== 'undefined' && window.localStorage) {
+            localStorage.setItem(APP_ID_STORAGE_KEY, appID);
+        }
+    } catch (e) {
+        // ignore storage errors
+    }
+}
+
+export function getAppID(): string {
+    return appID;
+}
 
 /**
  * 获取应用版本信息
- * @param query 查询参数对象,包含额外的查询条件
  * @returns 返回请求结果,包含应用版本信息
  */
 export function getAppVersion() {
     return request({
-        url: '/stats/user/version/list', // 请求的API地址
-        method: 'get', // 请求方法为GET
-        params: {appId: appID}, // 将查询参数作为URL参数传递,其中appid是必须参数,query对象中的其他参数会被展开添加
+        url: '/stats/user/version/list',
+        method: 'get',
+        params: { appId: appID },
     });
 }
 
 /**
  * 获取应用渠道列表
- * @param query 查询参数对象,用于筛选和获取特定渠道信息
  * @returns 返回请求结果,包含渠道列表数据
  */
 export function getAppChannel() {
     return request({
-        url: '/stats/user/channel/list', // 请求的API地址
-        method: 'get', // 请求方法为GET
-        params: {appId: appID}, // 将查询参数作为URL参数传递,其中appid是必须参数,query对象中的其他参数会被展开添加
+        url: '/stats/user/channel/list',
+        method: 'get',
+        params: { appId: appID },
     });
 }
 

+ 3 - 1
src/api/count/version.ts

@@ -42,4 +42,6 @@ export const getTrendSource = (data?: Object) => {
 		method: 'post',
 		data:{appId: appID, ...data},
 	});
-};
+};
+
+

+ 2 - 1
src/api/marketing/config.ts

@@ -126,10 +126,11 @@ export const getRule = (params?: Object) => {
 	});
 };
 //获取app列表
-export const getAppList = () => {
+export const getAppList = (params?: Object) => {
 	return request({
 		url: '/marketing/app/list',
 		method: 'get',
+		params,
 	});
 };
 //保存主动推送

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

@@ -0,0 +1,135 @@
+<template>
+	<el-form :inline="true" :model="local" ref="formRef">
+		<el-form-item>
+			<el-select v-model="appID" filterable remote class="w-full">
+				<el-option v-for="item in appOptions" :key="item.value" :label="item.label" :value="item.value" />
+			</el-select>
+		</el-form-item>
+		<el-form-item>
+			<el-date-picker v-model="local.time" type="daterange" value-format="YYYY-MM-DD"
+				:disabled-date="disableFuture" @change="onRangeChange" class="!w-[250px]" start-placeholder="开始时间"
+				end-placeholder="结束时间" />
+		</el-form-item>
+
+		<el-form-item>
+			<FilterSelect v-model="local.channel" type="channel" :disabled="channelDisabled" @change="onChange"
+				class="!w-[180px] ml-2" />
+		</el-form-item>
+		<el-form-item>
+			<FilterSelect v-model="local.version" type="version" :disabled="versionDisabled" @change="onChange"
+				class="!w-[180px] ml-2" />
+		</el-form-item>
+	</el-form>
+</template>
+
+<script setup lang="ts">
+import dayjs from 'dayjs';
+import { getAppList } from '/@/api/marketing/config';
+import { setAppID,APP_ID_STORAGE_KEY } from '/@/api/common/common';
+
+const props = defineProps<{
+	modelValue: Record<string, any>
+	channelDisabled?: boolean
+	versionDisabled?: boolean
+}>();
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', v: Record<string, any>): void
+	(e: 'change', payload: { field?: string; value: Record<string, any> }): void
+}>();
+
+const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+
+const formRef = ref();
+const appID = ref(localStorage.getItem(APP_ID_STORAGE_KEY) || '-1');
+
+const appOptions = ref<any[]>([]);
+const getAppListData = async () => {
+	const res = await getAppList({ isAll: false });
+	const data = res.data;
+	appOptions.value = data.map((item: any, index: number) => {
+		return {
+			label: item.appName,
+			value: item.appId,
+		};
+	});
+	appOptions.value.unshift({
+		label: '全部应用',
+		value: '-1',
+	});
+	// 初始化全局 appID 与父级 formData
+	setAppID(appID.value);
+	if (props.modelValue) {
+		(props.modelValue as any).appId = appID.value;
+		emit('update:modelValue', props.modelValue);
+	}
+};
+
+const local = reactive<Record<string, any>>({
+	time: props.modelValue?.time,
+	channel: props.modelValue?.channel,
+	version: props.modelValue?.version,
+});
+
+function disableFuture(date: Date) {
+	const today = new Date();
+	today.setHours(0, 0, 0, 0);
+	return date.getTime() > today.getTime();
+}
+
+function onRangeChange(val: [string, string] | null) {
+	if (!val) {
+		// 同步到外部对象引用
+		if (props.modelValue) {
+			(props.modelValue as any).time = local.time;
+			emit('update:modelValue', props.modelValue);
+		}
+		emit('change', { value: props.modelValue || { ...local } });
+		return;
+	}
+	const [startStr, endStr] = val;
+	const start = dayjs(startStr);
+	let end = dayjs(endStr);
+	if (end.diff(start, 'day') >= 365 * 2) {
+		end = start.add(2, 'year').subtract(1, 'day');
+		local.time = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
+	}
+	if (props.modelValue) {
+		(props.modelValue as any).time = local.time;
+		emit('update:modelValue', props.modelValue);
+	}
+	emit('change', { field: 'time', value: props.modelValue || { ...local } });
+}
+const onChange = () => {
+	(props.modelValue as any).time = local.time;
+	(props.modelValue as any).channel = local.channel;
+	(props.modelValue as any).version = local.version;
+	(props.modelValue as any).appId = appID.value;
+	setAppID(appID.value);
+
+	emit('update:modelValue', props.modelValue);
+}
+onMounted(() => {
+	getAppListData();
+});
+
+// 监听应用选择变化,更新全局 appID 与父级数据
+watch(appID, (val) => {
+	setAppID(val || '');
+	if(val === '-1'){
+		versionDisabled.value = true
+	}else if(!props.versionDisabled){
+		versionDisabled.value = false
+	}
+	if (props.modelValue) {
+		(props.modelValue as any).appId = val || '';
+		emit('update:modelValue', props.modelValue);
+		emit('change', { field: 'appId', value: props.modelValue });
+	}
+});
+
+const channelDisabled = ref( props.channelDisabled === true);
+const versionDisabled = ref(props.versionDisabled === true || appID.value === '-1');
+</script>
+
+<style scoped lang="scss"></style>

+ 126 - 84
src/components/common/ChineseRegionSelector.vue

@@ -1,6 +1,7 @@
 <template>
 	<div class="chinese-region-selector">
-		<el-select v-model="selectedCountry" placeholder="选择国家" filterable class="!w-48" @change="onCountryChange">
+		<el-select v-model="selectedCountry" placeholder="选择国家" filterable class="!w-48" @change="onCountryChange"
+			:loading="loading">
 			<el-option-group v-if="props.showAll !== false" label="全部">
 				<el-option :label="'全部国家'" :value="ALL_COUNTRY" />
 			</el-option-group>
@@ -34,9 +35,19 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue';
+import { ref, onMounted, defineAsyncComponent } from 'vue';
 import { State, City } from 'country-state-city';
-import regionData from './region-data.json';
+import {
+	loadRegionData,
+	loadCountriesData,
+	loadChinaProvinces,
+	loadChinaCities,
+	loadOtherStates,
+	loadOtherCities,
+	loadUsStateZhMap,
+	loadUsCityZhMap,
+	preloadCriticalData
+} from './region-data-loader';
 
 interface RegionData {
 	country: string;
@@ -61,60 +72,57 @@ const selectedCity = ref('');
 const states = ref<any[]>([]);
 const cities = ref<any[]>([]);
 const countryCode = ref('');
+const loading = ref(false);
 const ALL_COUNTRY = '__ALL_COUNTRY__';
 const ALL_STATE = '__ALL_STATE__';
 const ALL_CITY = '__ALL_CITY__';
 
-// 预定义的中文国家数据
-const countriesData = (regionData as any).countriesData;
-
-// 中国省份数据
-const chinaProvinces = (regionData as any).chinaProvinces;
-
-// 中国主要城市数据
-const chinaCities: { [key: string]: string[] } = (regionData as any).chinaCities;
-
-// (已移除未使用的美国州静态数组)
-
-// 其他国家的主要省份/州
-const otherStates: { [key: string]: string[] } = (regionData as any).otherStates;
-
-// 其他国家的主要城市数据
-const otherCities: { [key: string]: { [key: string]: string[] } } = (regionData as any).otherCities;
-
-// 美国州中文映射(ISO/英文名 -> 中文名)
-const usStateZhMap: Record<string, string> = (regionData as any).usStateZhMap;
-
-// 美国常见城市中文映射(按州代码 -> 英文名到中文名)
-const usCityZhMap: Record<string, Record<string, string>> = (regionData as any).usCityZhMap;
+// 动态加载的数据
+let countriesData: any[] = [];
+let chinaProvinces: string[] = [];
+let chinaCities: { [key: string]: string[] } = {};
+let otherStates: { [key: string]: string[] } = {};
+let otherCities: { [key: string]: { [key: string]: string[] } } = {};
+let usStateZhMap: Record<string, string> = {};
+let usCityZhMap: Record<string, Record<string, string>> = {};
 
 // 初始化地区数据
-const initRegionData = () => {
-	// 按大洲分组
-	const continentMap: { [key: string]: any[] } = {
-		'亚洲': [],
-		'欧洲': [],
-		'北美洲': [],
-		'南美洲': [],
-		'大洋洲': [],
-		'非洲': []
-	};
-
-	(regionData as any).countriesData.forEach((country: any) => {
-		if (continentMap[country.continent]) {
-			continentMap[country.continent].push(country);
-		}
-	});
+const initRegionData = async () => {
+	loading.value = true;
+	try {
+		// 动态加载国家数据
+		countriesData = await loadCountriesData();
+
+		// 按大洲分组
+		const continentMap: { [key: string]: any[] } = {
+			'亚洲': [],
+			'欧洲': [],
+			'北美洲': [],
+			'南美洲': [],
+			'大洋洲': [],
+			'非洲': []
+		};
+
+		countriesData.forEach((country: any) => {
+			if (continentMap[country.continent]) {
+				continentMap[country.continent].push(country);
+			}
+		});
 
-	// 转换为数组格式
-	continentOptions.value = Object.keys(continentMap).map(continent => ({
-		label: continent,
-		countries: continentMap[continent].sort((a, b) => a.name.localeCompare(b.name))
-	}));
+		// 转换为数组格式
+		continentOptions.value = Object.keys(continentMap).map(continent => ({
+			label: continent,
+			countries: continentMap[continent].sort((a, b) => a.name.localeCompare(b.name))
+		}));
+	} catch (error) {
+		console.error('Failed to initialize region data:', error);
+	} finally {
+		loading.value = false;
+	}
 };
 
 // 国家变化处理
-const onCountryChange = (countryCodeValue: string) => {
+const onCountryChange = async (countryCodeValue: string) => {
 	selectedState.value = '';
 	selectedCity.value = '';
 	cities.value = [];
@@ -127,20 +135,34 @@ const onCountryChange = (countryCodeValue: string) => {
 		return;
 	}
 
-	if (countryCodeValue === 'CN') {
-		states.value = ((regionData as any).chinaProvinces as string[]).map(name => ({ name, code: name }));
-	} else if (countryCodeValue === 'US') {
-		const usStates = State.getStatesOfCountry('US') || [];
-		states.value = usStates.map((s: any) => ({ name: ((regionData as any).usStateZhMap || {})[s.name] || s.name, code: s.isoCode, enName: s.name }));
-	} else if ((regionData as any).otherStates[countryCodeValue]) {
-		states.value = (regionData as any).otherStates[countryCodeValue].map((name: string) => ({ name, code: name }));
-	} else {
+	try {
+		if (countryCodeValue === 'CN') {
+			chinaProvinces = await loadChinaProvinces();
+			states.value = chinaProvinces.map(name => ({ name, code: name }));
+		} else if (countryCodeValue === 'US') {
+			const usStates = State.getStatesOfCountry('US') || [];
+			usStateZhMap = await loadUsStateZhMap();
+			states.value = usStates.map((s: any) => ({
+				name: usStateZhMap[s.name] || s.name,
+				code: s.isoCode,
+				enName: s.name
+			}));
+		} else {
+			otherStates = await loadOtherStates();
+			if (otherStates[countryCodeValue]) {
+				states.value = otherStates[countryCodeValue].map((name: string) => ({ name, code: name }));
+			} else {
+				states.value = [];
+			}
+		}
+	} catch (error) {
+		console.error('Failed to load states for country:', countryCodeValue, error);
 		states.value = [];
 	}
 };
 
 // 省份变化处理
-const onStateChange = (stateCodeOrName: string) => {
+const onStateChange = async (stateCodeOrName: string) => {
 	selectedCity.value = '';
 
 	// 根据国家和省份获取城市
@@ -150,9 +172,15 @@ const onStateChange = (stateCodeOrName: string) => {
 			cities.value = [];
 			return;
 		}
-		if ((regionData as any).chinaCities[stateName]) {
-			cities.value = (regionData as any).chinaCities[stateName].map((name: string) => ({ name, code: name }));
-		} else {
+		try {
+			chinaCities = await loadChinaCities();
+			if (chinaCities[stateName]) {
+				cities.value = chinaCities[stateName].map((name: string) => ({ name, code: name }));
+			} else {
+				cities.value = [];
+			}
+		} catch (error) {
+			console.error('Failed to load China cities:', error);
 			cities.value = [];
 		}
 		return;
@@ -164,32 +192,46 @@ const onStateChange = (stateCodeOrName: string) => {
 			cities.value = [];
 			return;
 		}
-		// 优先使用内置中文城市表(按州中文名)
-		const st = states.value.find((s: any) => s.code === stateCodeOrName);
-		const zhStateName = st ? st.name : '';
-		if ((regionData as any).otherCities['US'] && (regionData as any).otherCities['US'][zhStateName]) {
-			cities.value = (regionData as any).otherCities['US'][zhStateName].map((name: string) => ({ name, code: name }));
-			return;
-		}
-
-		let usCities = City.getCitiesOfState('US', stateCodeOrName) || [];
-		if (!usCities.length) {
-			// 兜底:按国家取所有城市再按州代码过滤
-			const allUsCities = City.getCitiesOfCountry('US') || [];
-			usCities = allUsCities.filter((c: any) => c.stateCode === stateCodeOrName);
+		try {
+			// 优先使用内置中文城市表(按州中文名)
+			const st = states.value.find((s: any) => s.code === stateCodeOrName);
+			const zhStateName = st ? st.name : '';
+			otherCities = await loadOtherCities();
+
+			if (otherCities['US'] && otherCities['US'][zhStateName]) {
+				cities.value = otherCities['US'][zhStateName].map((name: string) => ({ name, code: name }));
+				return;
+			}
+
+			let usCities = City.getCitiesOfState('US', stateCodeOrName) || [];
+			if (!usCities.length) {
+				// 兜底:按国家取所有城市再按州代码过滤
+				const allUsCities = City.getCitiesOfCountry('US') || [];
+				usCities = allUsCities.filter((c: any) => c.stateCode === stateCodeOrName);
+			}
+			usCityZhMap = await loadUsCityZhMap();
+			const cityMap = usCityZhMap[stateCodeOrName] || {};
+			cities.value = usCities.map((c: any) => {
+				const zhName = cityMap[c.name] || c.name;
+				return { name: zhName, code: zhName };
+			});
+		} catch (error) {
+			console.error('Failed to load US cities:', error);
+			cities.value = [];
 		}
-		const cityMap = ((regionData as any).usCityZhMap || {})[stateCodeOrName] || {};
-		cities.value = usCities.map((c: any) => {
-			const zhName = cityMap[c.name] || c.name;
-			return { name: zhName, code: zhName };
-		});
 		return;
 	}
 
-	const stateName = stateCodeOrName;
-	if ((regionData as any).otherCities[countryCode.value] && (regionData as any).otherCities[countryCode.value][stateName]) {
-		cities.value = (regionData as any).otherCities[countryCode.value][stateName].map((name: string) => ({ name, code: name }));
-	} else {
+	try {
+		const stateName = stateCodeOrName;
+		otherCities = await loadOtherCities();
+		if (otherCities[countryCode.value] && otherCities[countryCode.value][stateName]) {
+			cities.value = otherCities[countryCode.value][stateName].map((name: string) => ({ name, code: name }));
+		} else {
+			cities.value = [];
+		}
+	} catch (error) {
+		console.error('Failed to load other cities:', error);
 		cities.value = [];
 	}
 };
@@ -203,7 +245,7 @@ const addRegion = () => {
 	let countryName = '全部国家';
 	let countryCodeFinal = '';
 	if (selectedCountry.value && selectedCountry.value !== ALL_COUNTRY) {
-		const country = ((regionData as any).countriesData as any[]).find((c: any) => c.code === countryCode.value);
+		const country = countriesData.find((c: any) => c.code === countryCode.value);
 		if (!country) return;
 		countryName = country.name;
 		countryCodeFinal = country.code;
@@ -244,8 +286,8 @@ const addRegion = () => {
 };
 
 // 组件挂载时初始化地区数据
-onMounted(() => {
-	initRegionData();
+onMounted(async () => {
+	await initRegionData();
 });
 </script>
 

+ 358 - 0
src/components/common/SimpleRegionSelector.vue

@@ -0,0 +1,358 @@
+<template>
+	<div class="simple-region-selector">
+		<el-select 
+			v-model="selectedCountry" 
+			placeholder="选择国家" 
+			filterable 
+			class="!w-48" 
+			@change="onCountryChange"
+			:loading="loading"
+		>
+			<el-option-group v-if="props.showAll !== false" label="全部">
+				<el-option :label="'全部国家'" :value="ALL_COUNTRY" />
+			</el-option-group>
+			<el-option-group v-for="continent in continentOptions" :key="continent.label" :label="continent.label">
+				<el-option v-for="country in continent.countries" :key="country.code" :label="country.name"
+					:value="country.code" />
+			</el-option-group>
+		</el-select>
+
+		<el-select 
+			v-if="selectedCountry && selectedCountry !== ALL_COUNTRY && states.length > 0"
+			v-model="selectedState" 
+			placeholder="选择省份/州" 
+			filterable 
+			class="!w-48 ml-2" 
+			@change="onStateChange"
+		>
+			<el-option-group v-if="props.showAll !== false" label="全部">
+				<el-option :label="'全部省/州'" :value="ALL_STATE" />
+			</el-option-group>
+			<el-option v-for="state in states" :key="state.code" :label="state.name" :value="state.code" />
+		</el-select>
+
+		<el-select 
+			v-if="selectedState && selectedState !== ALL_STATE && cities.length > 0" 
+			v-model="selectedCity"
+			placeholder="选择城市" 
+			filterable 
+			class="!w-48 ml-2"
+		>
+			<el-option-group v-if="props.showAll !== false" label="全部">
+				<el-option :label="'全部城市'" :value="ALL_CITY" />
+			</el-option-group>
+			<el-option v-for="city in cities" :key="city.code" :label="city.name" :value="city.name" />
+		</el-select>
+
+		<el-button 
+			v-if="(props.showAll !== false) || selectedCountry" 
+			type="primary" 
+			size="small" 
+			class="ml-2"
+			@click="addRegion"
+		>
+			添加地区
+		</el-button>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { 
+  loadCountriesData, 
+  loadChinaProvinces, 
+  loadChinaCities,
+  loadOtherStates,
+  loadOtherCities,
+  loadUsStateZhMap,
+  loadUsCityZhMap
+} from './region-data-loader';
+
+interface RegionData {
+	country: string;
+	state?: string;
+	city?: string;
+	code: string;
+}
+
+const props = defineProps<{
+	modelValue?: RegionData[];
+	showAll?: boolean;
+}>();
+
+const emit = defineEmits<{
+	'update:modelValue': [value: RegionData[]];
+}>();
+
+const continentOptions = ref<any[]>([]);
+const selectedCountry = ref('');
+const selectedState = ref('');
+const selectedCity = ref('');
+const states = ref<any[]>([]);
+const cities = ref<any[]>([]);
+const countryCode = ref('');
+const loading = ref(false);
+const ALL_COUNTRY = '__ALL_COUNTRY__';
+const ALL_STATE = '__ALL_STATE__';
+const ALL_CITY = '__ALL_CITY__';
+
+// 动态加载的数据
+let countriesData: any[] = [];
+let chinaProvinces: string[] = [];
+let chinaCities: { [key: string]: string[] } = {};
+let otherStates: { [key: string]: string[] } = {};
+let otherCities: { [key: string]: { [key: string]: string[] } } = {};
+let usStateZhMap: Record<string, string> = {};
+let usCityZhMap: Record<string, Record<string, string>> = {};
+
+// 简化的美国州数据(避免使用 country-state-city)
+const usStates = [
+	{ name: '阿拉巴马州', code: 'AL' },
+	{ name: '阿拉斯加州', code: 'AK' },
+	{ name: '亚利桑那州', code: 'AZ' },
+	{ name: '阿肯色州', code: 'AR' },
+	{ name: '加利福尼亚州', code: 'CA' },
+	{ name: '科罗拉多州', code: 'CO' },
+	{ name: '康涅狄格州', code: 'CT' },
+	{ name: '特拉华州', code: 'DE' },
+	{ name: '佛罗里达州', code: 'FL' },
+	{ name: '佐治亚州', code: 'GA' },
+	{ name: '夏威夷州', code: 'HI' },
+	{ name: '爱达荷州', code: 'ID' },
+	{ name: '伊利诺伊州', code: 'IL' },
+	{ name: '印第安纳州', code: 'IN' },
+	{ name: '爱荷华州', code: 'IA' },
+	{ name: '堪萨斯州', code: 'KS' },
+	{ name: '肯塔基州', code: 'KY' },
+	{ name: '路易斯安那州', code: 'LA' },
+	{ name: '缅因州', code: 'ME' },
+	{ name: '马里兰州', code: 'MD' },
+	{ name: '马萨诸塞州', code: 'MA' },
+	{ name: '密歇根州', code: 'MI' },
+	{ name: '明尼苏达州', code: 'MN' },
+	{ name: '密西西比州', code: 'MS' },
+	{ name: '密苏里州', code: 'MO' },
+	{ name: '蒙大拿州', code: 'MT' },
+	{ name: '内布拉斯加州', code: 'NE' },
+	{ name: '内华达州', code: 'NV' },
+	{ name: '新罕布什尔州', code: 'NH' },
+	{ name: '新泽西州', code: 'NJ' },
+	{ name: '新墨西哥州', code: 'NM' },
+	{ name: '纽约州', code: 'NY' },
+	{ name: '北卡罗来纳州', code: 'NC' },
+	{ name: '北达科他州', code: 'ND' },
+	{ name: '俄亥俄州', code: 'OH' },
+	{ name: '俄克拉荷马州', code: 'OK' },
+	{ name: '俄勒冈州', code: 'OR' },
+	{ name: '宾夕法尼亚州', code: 'PA' },
+	{ name: '罗得岛州', code: 'RI' },
+	{ name: '南卡罗来纳州', code: 'SC' },
+	{ name: '南达科他州', code: 'SD' },
+	{ name: '田纳西州', code: 'TN' },
+	{ name: '德克萨斯州', code: 'TX' },
+	{ name: '犹他州', code: 'UT' },
+	{ name: '佛蒙特州', code: 'VT' },
+	{ name: '弗吉尼亚州', code: 'VA' },
+	{ name: '华盛顿州', code: 'WA' },
+	{ name: '西弗吉尼亚州', code: 'WV' },
+	{ name: '威斯康星州', code: 'WI' },
+	{ name: '怀俄明州', code: 'WY' }
+];
+
+// 初始化地区数据
+const initRegionData = async () => {
+	loading.value = true;
+	try {
+		// 动态加载国家数据
+		countriesData = await loadCountriesData();
+		
+		// 按大洲分组
+		const continentMap: { [key: string]: any[] } = {
+			'亚洲': [],
+			'欧洲': [],
+			'北美洲': [],
+			'南美洲': [],
+			'大洋洲': [],
+			'非洲': []
+		};
+
+		countriesData.forEach((country: any) => {
+			if (continentMap[country.continent]) {
+				continentMap[country.continent].push(country);
+			}
+		});
+
+		// 转换为数组格式
+		continentOptions.value = Object.keys(continentMap).map(continent => ({
+			label: continent,
+			countries: continentMap[continent].sort((a, b) => a.name.localeCompare(b.name))
+		}));
+	} catch (error) {
+		console.error('Failed to initialize region data:', error);
+	} finally {
+		loading.value = false;
+	}
+};
+
+// 国家变化处理
+const onCountryChange = async (countryCodeValue: string) => {
+	selectedState.value = '';
+	selectedCity.value = '';
+	cities.value = [];
+	countryCode.value = countryCodeValue === ALL_COUNTRY ? '' : countryCodeValue;
+
+	// 根据国家代码获取省份/州
+	if (countryCodeValue === ALL_COUNTRY && props.showAll !== false) {
+		states.value = [];
+		cities.value = [];
+		return;
+	}
+
+	try {
+		if (countryCodeValue === 'CN') {
+			chinaProvinces = await loadChinaProvinces();
+			states.value = chinaProvinces.map(name => ({ name, code: name }));
+		} else if (countryCodeValue === 'US') {
+			// 使用简化的美国州数据,避免 country-state-city 依赖
+			states.value = usStates;
+		} else {
+			otherStates = await loadOtherStates();
+			if (otherStates[countryCodeValue]) {
+				states.value = otherStates[countryCodeValue].map((name: string) => ({ name, code: name }));
+			} else {
+				states.value = [];
+			}
+		}
+	} catch (error) {
+		console.error('Failed to load states for country:', countryCodeValue, error);
+		states.value = [];
+	}
+};
+
+// 省份变化处理
+const onStateChange = async (stateCodeOrName: string) => {
+	selectedCity.value = '';
+
+	// 根据国家和省份获取城市
+	if (countryCode.value === 'CN') {
+		const stateName = stateCodeOrName === ALL_STATE ? '' : stateCodeOrName;
+		if (stateCodeOrName === ALL_STATE && props.showAll !== false) {
+			cities.value = [];
+			return;
+		}
+		try {
+			chinaCities = await loadChinaCities();
+			if (chinaCities[stateName]) {
+				cities.value = chinaCities[stateName].map((name: string) => ({ name, code: name }));
+			} else {
+				cities.value = [];
+			}
+		} catch (error) {
+			console.error('Failed to load China cities:', error);
+			cities.value = [];
+		}
+		return;
+	}
+
+	if (countryCode.value === 'US') {
+		// 加载美国州城市
+		if (stateCodeOrName === ALL_STATE && props.showAll !== false) {
+			cities.value = [];
+			return;
+		}
+		try {
+			// 优先使用内置中文城市表(按州中文名)
+			const st = states.value.find((s: any) => s.code === stateCodeOrName);
+			const zhStateName = st ? st.name : '';
+			otherCities = await loadOtherCities();
+			
+			if (otherCities['US'] && otherCities['US'][zhStateName]) {
+				cities.value = otherCities['US'][zhStateName].map((name: string) => ({ name, code: name }));
+				return;
+			}
+
+			// 如果没有预定义的城市数据,返回空数组
+			cities.value = [];
+		} catch (error) {
+			console.error('Failed to load US cities:', error);
+			cities.value = [];
+		}
+		return;
+	}
+
+	try {
+		const stateName = stateCodeOrName;
+		otherCities = await loadOtherCities();
+		if (otherCities[countryCode.value] && otherCities[countryCode.value][stateName]) {
+			cities.value = otherCities[countryCode.value][stateName].map((name: string) => ({ name, code: name }));
+		} else {
+			cities.value = [];
+		}
+	} catch (error) {
+		console.error('Failed to load other cities:', error);
+		cities.value = [];
+	}
+};
+
+// 添加地区
+const addRegion = () => {
+	if (!selectedCountry.value && props.showAll === false) return;
+
+	let countryName = '全部国家';
+	let countryCodeFinal = '';
+	if (selectedCountry.value && selectedCountry.value !== ALL_COUNTRY) {
+		const country = countriesData.find((c: any) => c.code === countryCode.value);
+		if (!country) return;
+		countryName = country.name;
+		countryCodeFinal = country.code;
+	}
+
+	const newRegion: RegionData = {
+		country: countryName,
+		code: countryCodeFinal
+	};
+
+	if (selectedState.value || (props.showAll !== false && selectedCountry.value && selectedState.value === ALL_STATE)) {
+		if (countryCode.value === 'US') {
+			const st = states.value.find((s: any) => s.code === selectedState.value);
+			newRegion.state = selectedState.value === ALL_STATE ? '' : (st ? st.name : selectedState.value);
+		} else {
+			newRegion.state = selectedState.value === ALL_STATE ? '' : selectedState.value;
+		}
+	}
+
+	if (selectedCity.value || (props.showAll !== false && selectedState.value && selectedCity.value === ALL_CITY)) {
+		newRegion.city = selectedCity.value === ALL_CITY ? '' : selectedCity.value;
+	}
+
+	console.log('添加的地区数据:', newRegion);
+
+	const currentRegions = props.modelValue || [];
+	const filteredRegions = currentRegions.filter(item => item != 'All');
+	const newRegions = [...filteredRegions, newRegion];
+
+	emit('update:modelValue', newRegions);
+	
+	// 重置选择
+	selectedCountry.value = '';
+	selectedState.value = '';
+	selectedCity.value = '';
+	states.value = [];
+	cities.value = [];
+	countryCode.value = '';
+};
+
+// 组件挂载时初始化地区数据
+onMounted(async () => {
+	await initRegionData();
+});
+</script>
+
+<style lang="scss" scoped>
+.simple-region-selector {
+	display: flex;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+</style>

+ 4 - 17
src/components/common/filter-select.vue

@@ -1,21 +1,8 @@
 <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 v-model="innerValue" :placeholder="placeholderText" :class="wrapperClass" :clearable="clearable"
+    :disabled="disabled" :filterable="isRemote || filterable" :remote="isRemote"
+    :remote-method="isRemote ? handleRemoteSearch : undefined" @change="onChange" @clear="onChange">
+    <el-option v-for="opt in optionList" :key="opt.value" :label="opt.label" :value="opt.value" />
   </el-select>
 </template>
 

+ 127 - 0
src/components/common/region-data-loader.ts

@@ -0,0 +1,127 @@
+// 地区数据按需加载器
+interface RegionData {
+  countriesData: Array<{ name: string; code: string; continent: string }>;
+  chinaProvinces: string[];
+  chinaCities: { [key: string]: string[] };
+  otherStates: { [key: string]: string[] };
+  otherCities: { [key: string]: { [key: string]: string[] } };
+  usStateZhMap: Record<string, string>;
+  usCityZhMap: Record<string, Record<string, string>>;
+}
+
+// 缓存已加载的数据
+const dataCache = new Map<string, any>();
+
+// 动态导入地区数据
+export async function loadRegionData(): Promise<RegionData> {
+  if (dataCache.has('regionData')) {
+    return dataCache.get('regionData');
+  }
+
+  try {
+    // 使用动态导入,这样会被 Vite 自动分割为独立的 chunk
+    const module = await import('./region-data.json');
+    const data = module.default;
+    dataCache.set('regionData', data);
+    return data;
+  } catch (error) {
+    console.error('Failed to load region data:', error);
+    throw error;
+  }
+}
+
+// 按需加载国家数据
+export async function loadCountriesData() {
+  if (dataCache.has('countriesData')) {
+    return dataCache.get('countriesData');
+  }
+
+  const regionData = await loadRegionData();
+  const countriesData = regionData.countriesData;
+  dataCache.set('countriesData', countriesData);
+  return countriesData;
+}
+
+// 按需加载中国省份数据
+export async function loadChinaProvinces() {
+  if (dataCache.has('chinaProvinces')) {
+    return dataCache.get('chinaProvinces');
+  }
+
+  const regionData = await loadRegionData();
+  const chinaProvinces = regionData.chinaProvinces;
+  dataCache.set('chinaProvinces', chinaProvinces);
+  return chinaProvinces;
+}
+
+// 按需加载中国城市数据
+export async function loadChinaCities() {
+  if (dataCache.has('chinaCities')) {
+    return dataCache.get('chinaCities');
+  }
+
+  const regionData = await loadRegionData();
+  const chinaCities = regionData.chinaCities;
+  dataCache.set('chinaCities', chinaCities);
+  return chinaCities;
+}
+
+// 按需加载其他国家州/省数据
+export async function loadOtherStates() {
+  if (dataCache.has('otherStates')) {
+    return dataCache.get('otherStates');
+  }
+
+  const regionData = await loadRegionData();
+  const otherStates = regionData.otherStates;
+  dataCache.set('otherStates', otherStates);
+  return otherStates;
+}
+
+// 按需加载其他国家城市数据
+export async function loadOtherCities() {
+  if (dataCache.has('otherCities')) {
+    return dataCache.get('otherCities');
+  }
+
+  const regionData = await loadRegionData();
+  const otherCities = regionData.otherCities;
+  dataCache.set('otherCities', otherCities);
+  return otherCities;
+}
+
+// 按需加载美国州中文映射
+export async function loadUsStateZhMap() {
+  if (dataCache.has('usStateZhMap')) {
+    return dataCache.get('usStateZhMap');
+  }
+
+  const regionData = await loadRegionData();
+  const usStateZhMap = regionData.usStateZhMap;
+  dataCache.set('usStateZhMap', usStateZhMap);
+  return usStateZhMap;
+}
+
+// 按需加载美国城市中文映射
+export async function loadUsCityZhMap() {
+  if (dataCache.has('usCityZhMap')) {
+    return dataCache.get('usCityZhMap');
+  }
+
+  const regionData = await loadRegionData();
+  const usCityZhMap = regionData.usCityZhMap;
+  dataCache.set('usCityZhMap', usCityZhMap);
+  return usCityZhMap;
+}
+
+// 预加载关键数据(可选)
+export async function preloadCriticalData() {
+  try {
+    await Promise.all([
+      loadCountriesData(),
+      loadChinaProvinces()
+    ]);
+  } catch (error) {
+    console.warn('Failed to preload critical region data:', error);
+  }
+}

+ 4 - 0
src/main.ts

@@ -13,6 +13,7 @@ import '/@/theme/index.scss';
 
 import { ElementIcons, Pagination, RightToolbar, DictTag, UploadExcel, UploadFile, UploadImg, Editor, Tip, DelWrap } from '/@/components/index';
 import { parseTime, parseDate, dateTimeStr, dateStr, timeStr } from '/@/utils/formatTime';
+import { startRegionDataPreload } from '/@/utils/region-preloader';
 
 // 布局工具
 import { Splitpanes, Pane } from 'splitpanes';
@@ -50,3 +51,6 @@ app
 	.use(ElementIcons) // elementIcons 图标全局引入
 	.use(i18n) // 国际化
 	.mount('#app');
+
+// 启动地区数据预加载
+startRegionDataPreload();

+ 64 - 0
src/utils/region-preloader.ts

@@ -0,0 +1,64 @@
+// 地区数据预加载器
+import { preloadCriticalData } from '/@/components/common/region-data-loader';
+
+// 预加载状态
+let isPreloading = false;
+let isPreloaded = false;
+
+// 预加载地区数据
+export async function preloadRegionData(): Promise<void> {
+  if (isPreloaded || isPreloading) {
+    return;
+  }
+
+  isPreloading = true;
+  
+  try {
+    // 在空闲时间预加载关键数据
+    if ('requestIdleCallback' in window) {
+      await new Promise<void>((resolve) => {
+        requestIdleCallback(async () => {
+          try {
+            await preloadCriticalData();
+            isPreloaded = true;
+          } catch (error) {
+            console.warn('Failed to preload region data:', error);
+          } finally {
+            resolve();
+          }
+        });
+      });
+    } else {
+      // 降级处理:使用 setTimeout
+      await new Promise<void>((resolve) => {
+        setTimeout(async () => {
+          try {
+            await preloadCriticalData();
+            isPreloaded = true;
+          } catch (error) {
+            console.warn('Failed to preload region data:', error);
+          } finally {
+            resolve();
+          }
+        }, 100);
+      });
+    }
+  } catch (error) {
+    console.warn('Failed to preload region data:', error);
+  } finally {
+    isPreloading = false;
+  }
+}
+
+// 检查是否已预加载
+export function isRegionDataPreloaded(): boolean {
+  return isPreloaded;
+}
+
+// 在页面加载完成后开始预加载
+export function startRegionDataPreload(): void {
+  // 延迟预加载,避免影响页面初始加载
+  setTimeout(() => {
+    preloadRegionData();
+  }, 2000);
+}

+ 2 - 1
src/views/count/user/activations/components/initiateNumber.vue

@@ -262,6 +262,8 @@ const getData = async (type: string) => {
 	}
 	lineChartData.value.dates = masterData.value.dates;
 	initLineChart();
+	getBottomDetail();
+
 };
 const getBottomDetail = async () => {
 	// 下方明细表
@@ -541,7 +543,6 @@ const showDetail = ref(true);
 
 onMounted(() => {
 	getData('');
-	getBottomDetail();
 	initCompareOptions();
 });
 

+ 30 - 69
src/views/count/user/activations/index.vue

@@ -1,60 +1,39 @@
 <template>
 	<div class="layout-padding">
 		<div class="!overflow-auto px-1">
-			
+
 
 			<!-- 头部筛选区域 -->
 			<div class="el-card p-9">
 				<Title :title="t('activations.analytics')">
-				<template #default>
-					<el-popover class="box-item" placement="right" trigger="hover" width="250">
-						<template #reference>
-							<el-icon class="ml-1" style="color: #a4b8cf"><QuestionFilled /></el-icon>
-						</template>
-						<template #default>
-							<div class="ant-popover-inner-content">
-								<div class="um-page-tips-content" style="line-height: 24px">
-									<p>
-										<span class="highlight">启动次数:</span
-										><span
-											>打开应用视为启动。完全退出或后台运行超过30s后再次进入应用,视为一次新启动。开发过程中可以通过setSessionContinueMills来自定义两次启动的间隔,默认30s</span
-										>
-									</p>
-									<p><span class="highlight">启动次数占比:</span><span>某日/周/月的启动次数占所选时间段总启动次数的比例</span></p>
-									<p>
-										<span
-											>按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)</span
-										>
-									</p>
+					<template #default>
+						<el-popover class="box-item" placement="right" trigger="hover" width="250">
+							<template #reference>
+								<el-icon class="ml-1" style="color: #a4b8cf">
+									<QuestionFilled />
+								</el-icon>
+							</template>
+							<template #default>
+								<div class="ant-popover-inner-content">
+									<div class="um-page-tips-content" style="line-height: 24px">
+										<p>
+											<span
+												class="highlight">启动次数:</span><span>打开应用视为启动。完全退出或后台运行超过30s后再次进入应用,视为一次新启动。开发过程中可以通过setSessionContinueMills来自定义两次启动的间隔,默认30s</span>
+										</p>
+										<p><span class="highlight">启动次数占比:</span><span>某日/周/月的启动次数占所选时间段总启动次数的比例</span>
+										</p>
+										<p>
+											<span>按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)</span>
+										</p>
+									</div>
 								</div>
-							</div>
-						</template>
-					</el-popover>
-				</template>
-			</Title>
+							</template>
+						</el-popover>
+					</template>
+				</Title>
 				<div class="mt-2">
 					<el-row shadow="hover" class="">
-						<el-form :inline="true" :model="formData" ref="queryRef">
-							<el-form-item>
-								<el-date-picker
-									v-model="formData.time"
-									type="daterange"
-									value-format="YYYY-MM-DD"
-									:disabled-date="disableFuture"
-									@change="handleRangeChange"
-									class="!w-[250px]"
-									start-placeholder="开始时间"
-									end-placeholder="结束时间"
-								/>
-							</el-form-item>
-
-							<el-form-item>
-								<FilterSelect v-model="formData.channel" type="channel" @change="query" class="!w-[180px] ml-2" />
-							</el-form-item>
-							<el-form-item>
-								<FilterSelect v-model="formData.version" type="version" @change="query" class="!w-[180px] ml-2" />
-							</el-form-item>
-						</el-form>
+						<AnalyticsFilterHeader v-model:model-value="formData" @change="query" />
 					</el-row>
 				</div>
 			</div>
@@ -64,7 +43,7 @@
 				<InitiateNumber :form-data="formData" @query="query" />
 			</div>
 
-			
+
 		</div>
 	</div>
 </template>
@@ -76,7 +55,7 @@ import dayjs from 'dayjs';
 
 const { t } = useI18n();
 const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
-const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+const AnalyticsFilterHeader = defineAsyncComponent(() => import('/@/components/analytics-filter-header/index.vue'));
 const InitiateNumber = defineAsyncComponent(() => import('./components/initiateNumber.vue'));
 // 计算默认时间范围(半个月前到今天)
 const getDefaultDateRange = () => {
@@ -86,32 +65,14 @@ const getDefaultDateRange = () => {
 };
 const formData = ref<Record<string, any>>({
 	time: getDefaultDateRange(), // 时间范围
+	version: '', // 版本
+	channel: '', // 渠道
 });
 const query = () => {
 	console.log(formData.value);
 };
 
-function disableFuture(date: Date) {
-	const today = new Date();
-	today.setHours(0, 0, 0, 0);
-	return date.getTime() > today.getTime();
-}
 
-function handleRangeChange(val: [string, string] | null) {
-	if (!val) {
-		query();
-		return;
-	}
-	const [startStr, endStr] = val;
-	const start = dayjs(startStr);
-	let end = dayjs(endStr);
-	// 限制最大跨度为两年(按天计算,不含边界再减一天)
-	if (end.diff(start, 'day') >= 365 * 2) {
-		end = start.add(2, 'year').subtract(1, 'day');
-		formData.value.time = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
-	}
-	query();
-}
 </script>
 
 <style lang="scss" scoped>

+ 25 - 18
src/views/count/user/activeUser/components/AddTrend.vue

@@ -241,29 +241,37 @@ const getData = async (type: string) => {
 	}
 	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 || [];
+	try {
+
+		if (chartType.value == '活跃趋势') {
+			res = await getTrend(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '活跃构成') {
+			res = await getCompose(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '活跃粘度') {
+			res = await getViscosity(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '周活跃度') {
+			formData.value.timeUnit = 'week';
+			res = await getWeekrate(formData.value);
+			data = res.data || [];
+		} else if (chartType.value == '月活跃度') {
+			formData.value.timeUnit = 'month';
+			res = await getMonthrate(formData.value);
+			data = res.data || [];
+		}
+
+	} catch (error) {
+		console.log(error);
 	}
 
 	// const res = await getTrend({ ...formData.value });
 	// const data = res?.data || [];
 	initChartData(data);
 	loading.value = false;
+	getBottomDetail();
+
 };
 
 const initChartData = async (data: any) => {
@@ -588,7 +596,6 @@ const showDetail = ref(true);
 
 onMounted(() => {
 	getData('');
-	getBottomDetail();
 	initCompareOptions();
 });
 

+ 15 - 30
src/views/count/user/activeUser/index.vue

@@ -15,20 +15,22 @@
 								<div class="ant-popover-inner-content">
 									<div class="um-page-tips-content" style="line-height: 24px">
 										<p>
-											<span class="highlight">活跃用户:</span
-											><span>启动过应用的用户(去重),启动过一次的用户即视为活跃用户,包括新用户与老用户</span>
+											<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">活跃粘度:</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">过去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
-											>
+											<span>按天、周或月查看数据可进行版本、渠道的交叉筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)</span>
 										</p>
 									</div>
 								</div>
@@ -42,27 +44,8 @@
 				</div>
 				<div class="mt-2">
 					<el-row shadow="hover" class="">
-						<el-form :inline="true" :model="formData" ref="queryRef">
-							<el-form-item>
-								<el-date-picker
-									v-model="formData.time"
-									type="daterange"
-									value-format="YYYY-MM-DD"
-									:disabled-date="disableFuture"
-									@change="handleRangeChange"
-									class="!w-[250px]"
-									start-placeholder="开始时间"
-									end-placeholder="结束时间"
-								/>
-							</el-form-item>
-
-							<el-form-item>
-								<FilterSelect v-model="formData.channel" :disabled="channelDisabled" type="channel" class="!w-[180px] ml-2" />
-							</el-form-item>
-							<el-form-item>
-								<FilterSelect v-model="formData.version" :disabled="versionDisabled" type="version" class="!w-[180px] ml-2" />
-							</el-form-item>
-						</el-form>
+						<AnalyticsFilterHeader v-model:model-value="formData" :channel-disabled="channelDisabled"
+							:version-disabled="versionDisabled" />
 					</el-row>
 				</div>
 			</div>
@@ -82,7 +65,7 @@ import dayjs from 'dayjs';
 
 const { t } = useI18n();
 const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
-const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+const AnalyticsFilterHeader = defineAsyncComponent(() => import('/@/components/analytics-filter-header/index.vue'));
 const AddTrend = defineAsyncComponent(() => import('./components/AddTrend.vue'));
 // 计算默认时间范围(半个月前到今天)
 const getDefaultDateRange = () => {
@@ -92,6 +75,8 @@ const getDefaultDateRange = () => {
 };
 const formData = ref<Record<string, any>>({
 	time: getDefaultDateRange(), // 时间范围
+	version: '',
+	channel: '',
 });
 const channelDisabled = ref(false);
 const versionDisabled = ref(false);

+ 4 - 1
src/views/count/user/adduser/components/AddTrend.vue

@@ -526,6 +526,7 @@ onMounted(() => {
 });
 
 watch(props.formData, () => {
+
 	formData.value = {
 		...formData.value,
 		channel: props.formData?.channel ? [props.formData?.channel] : [],
@@ -536,7 +537,9 @@ watch(props.formData, () => {
 	formData.value.timeUnit = 'day';
 	ensureValidTimeUnit();
 	getData('');
-});
+	getBottomDetail();
+
+},{ deep: true });
 
 // 监听外部日期与当前粒度,统一在此处做7天规则的静默纠正
 watch(

+ 10 - 51
src/views/count/user/adduser/index.vue

@@ -12,33 +12,13 @@
 				</div>
 				<div class="mt-2">
 					<el-row shadow="hover" class="">
-						<el-form :inline="true" :model="formData" ref="queryRef">
-							<el-form-item>
-								<el-date-picker
-									v-model="formData.time"
-									type="daterange"
-									value-format="YYYY-MM-DD"
-									:disabled-date="disableFuture"
-									@change="handleRangeChange"
-									class="!w-[250px]"
-									start-placeholder="开始时间"
-									end-placeholder="结束时间"
-								/>
-							</el-form-item>
-
-							<el-form-item>
-								<FilterSelect v-model="formData.channel" type="channel" @change="query" class="!w-[180px] ml-2" />
-							</el-form-item>
-							<el-form-item>
-								<FilterSelect v-model="formData.version" type="version" @change="query" class="!w-[180px] ml-2" />
-							</el-form-item>
-						</el-form>
+						<AnalyticsFilterHeader v-model:model-value="formData" />
 					</el-row>
 				</div>
 			</div>
 
 			<!-- 新增趋势模块 -->
-			<div class="mt-3 el-card  p-4">
+			<div class="mt-3 el-card p-4">
 				<Title left-line :title="t('addUser.addtrend')">
 					<template #default>
 						<el-popover class="box-item" placement="right" trigger="hover" width="250">
@@ -64,7 +44,7 @@
 						</el-popover>
 					</template>
 				</Title>
-				<AddTrend :form-data="formData" @query="query" />
+				<AddTrend :form-data="formData" />
 			</div>
 
 			<!-- 用户质量模块 -->
@@ -80,15 +60,14 @@
 							<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>
 									</div>
 								</div>
 							</template>
 						</el-popover>
 					</template>
 				</Title>
-				<UserQuality :form-data="formData" @query="query" />
+				<UserQuality :form-data="formData" />
 			</div>
 		</div>
 	</div>
@@ -101,7 +80,7 @@ import dayjs from 'dayjs';
 
 const { t } = useI18n();
 const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
-const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+const AnalyticsFilterHeader = defineAsyncComponent(() => import('/@/components/analytics-filter-header/index.vue'));
 const AddTrend = defineAsyncComponent(() => import('./components/AddTrend.vue'));
 const UserQuality = defineAsyncComponent(() => import('./components/UserQuality.vue'));
 // 计算默认时间范围(半个月前到今天)
@@ -112,32 +91,12 @@ const getDefaultDateRange = () => {
 };
 const formData = ref<Record<string, any>>({
 	time: getDefaultDateRange(), // 时间范围
+	version: '',
+	channel: '',
 });
-const query = () => {
+watch(formData, () => {
 	console.log(formData.value);
-};
-
-function disableFuture(date: Date) {
-	const today = new Date();
-	today.setHours(0, 0, 0, 0);
-	return date.getTime() > today.getTime();
-}
-
-function handleRangeChange(val: [string, string] | null) {
-	if (!val) {
-		query();
-		return;
-	}
-	const [startStr, endStr] = val;
-	const start = dayjs(startStr);
-	let end = dayjs(endStr);
-	// 限制最大跨度为两年(按天计算,不含边界再减一天)
-	if (end.diff(start, 'day') >= 365 * 2) {
-		end = start.add(2, 'year').subtract(1, 'day');
-		formData.value.time = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
-	}
-	query();
-}
+});
 </script>
 
 <style lang="scss" scoped>

+ 61 - 51
src/views/count/user/versionDistribution/components/version-source-card.vue

@@ -34,14 +34,16 @@
 			</Title>
 		</div>
 		<div class="flex items-center justify-between mt-3">
-			<el-select v-model="channelDistribution" style="width: 180px" placeholder="新增用户渠道分布">
-				<el-option label="新增用户渠道分布" value="distribution" />
+			<el-select v-model="channelDistribution" style="width: 180px" placeholder="新增用户渠道分布" @change="getData">
+				<el-option label="新增用户渠道分布" value="newUserChannel" />
+				<el-option label="升级用户渠道分布" value="upgradeChannel" />
+				<el-option label="升级用户版本来源" value="upgradeVersion" />
 			</el-select>
 			<div class="ml-4">
-				<el-radio-group v-model="timeRange">
-					<el-radio-button label="yesterday">昨天</el-radio-button>
-					<el-radio-button label="7days">过去7天</el-radio-button>
-					<el-radio-button label="30days">过去30天</el-radio-button>
+				<el-radio-group v-model="timeRange" @change="getData">
+					<el-radio-button label="day">昨天</el-radio-button>
+					<el-radio-button label="week">过去7天</el-radio-button>
+					<el-radio-button label="month">过去30天</el-radio-button>
 				</el-radio-group>
 			</div>
 		</div>
@@ -64,18 +66,19 @@
 					<el-button>导出</el-button>
 				</div>
 			</div>
-			<el-table v-if="showDetail" :data="pagedSourceRows" border>
-				<el-table-column prop="channel" label="渠道" min-width="140" />
-				<el-table-column prop="newUsers" label="新增用户" min-width="140" />
-				<el-table-column prop="upgradeRatio" label="升级用户比例" min-width="140" />
+			<el-table v-if="showDetail" :data="tableData" border>
+				<el-table-column prop="name" label="渠道" min-width="140" />
+				<el-table-column prop="value" label="新增用户" min-width="140" />
+				<el-table-column prop="rate" label="升级用户比例" min-width="140" />
 			</el-table>
 			<div v-if="showDetail" class="flex justify-end mt-3">
 				<el-pagination
-					v-model:current-page="sourcePage"
-					v-model:page-size="sourcePageSize"
+					v-model:current-page="pagination.current"
+					v-model:page-size="pagination.size"
+					@change="getDataDetail"
 					background
 					layout="total, prev, pager, next, sizes"
-					:total="sourceRows.length"
+					:total="pagination.total"
 					:page-sizes="[5, 10, 20]"
 				/>
 			</div>
@@ -84,34 +87,32 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+import { ref, onMounted, watch, defineAsyncComponent } from 'vue';
 import * as echarts from 'echarts';
-import { useI18n } from 'vue-i18n';
 import { QuestionFilled } from '@element-plus/icons-vue';
-
-const { t } = useI18n();
+import { getTrendDetailSource, getTrendSource } from '/@/api/count/version';
 
 const props = defineProps<{
 	selectedVersion?: string;
+	formData: {
+		time?: string[];
+		version?: string;
+	};
 }>();
+const formData = ref({});
+const pagination = ref({
+	current: 1, //当前页数
+	total: 0, // 数据总数
+	size: 5, // 每页显示条数
+});
 
-const channelDistribution = ref('distribution');
-const timeRange = ref('yesterday');
+const channelDistribution = ref('newUserChannel');
+const timeRange = ref('day');
 const barChartRef = ref<HTMLDivElement | null>(null);
 let barChartInstance: echarts.ECharts | null = null;
+const tableData = ref([]);
+const chartData = ref<Array<{ name: string; value: number }>>([])
 
-const sourceRows = ref<Array<{ channel: string; newUsers: number; upgradeRatio: string }>>([
-	{ channel: '渠道A', newUsers: 100, upgradeRatio: '10%' },
-	{ channel: '渠道B', newUsers: 80, upgradeRatio: '15%' },
-	{ channel: '渠道C', newUsers: 70, upgradeRatio: '20%' },
-	{ channel: '渠道D', newUsers: 60, upgradeRatio: '25%' },
-	{ channel: '渠道E', newUsers: 50, upgradeRatio: '30%' },
-	{ channel: '渠道F', newUsers: 40, upgradeRatio: '35%' },
-	{ channel: '渠道G', newUsers: 30, upgradeRatio: '40%' },
-	{ channel: '渠道H', newUsers: 20, upgradeRatio: '45%' },
-	{ channel: '渠道I', newUsers: 10, upgradeRatio: '50%' },
-	{ channel: '渠道J', newUsers: 5, upgradeRatio: '55%' },
-]);
 
 function initBarChart(): void {
 	if (!barChartRef.value) return;
@@ -128,7 +129,7 @@ function initBarChart(): void {
 		},
 		yAxis: {
 			type: 'category',
-			data: sourceRows.value.map((d) => d.channel),
+			data: chartData.value.map((d) => d.name),
 			axisLine: { lineStyle: { color: '#e5e7eb' } },
 			axisLabel: { color: '#6b7280' },
 			axisTick: { alignWithLabel: true },
@@ -139,35 +140,44 @@ function initBarChart(): void {
 				type: 'bar',
 				barWidth: '60%',
 				itemStyle: { color: '#409EFF' },
-				data: sourceRows.value.map((d) => d.newUsers),
+				data: chartData.value.map((d) => d.value),
 			},
 		],
 	};
 	barChartInstance.setOption(option);
 }
 
-onMounted(() => {
+const getDataDetail = async () => {
+	const res = await getTrendDetailSource({
+		...formData.value,
+		...pagination.value,
+		timeUnit: timeRange.value,
+		type: channelDistribution.value,
+	});
+	const dataDetail = res?.data || [];
+	pagination.value.current = dataDetail.current; //当前页码
+	pagination.value.total = dataDetail.total; //总条数
+	pagination.value.size = dataDetail.size; //每页条数
+	tableData.value = dataDetail.records;
+};
+
+const getData = async () => {
+	console.log(formData.value);
+	const res = await getTrendSource({
+		...formData.value,
+		timeUnit: timeRange.value,
+		type: channelDistribution.value,
+	});
+	chartData.value = res?.data || [];
 	initBarChart();
-});
+	getDataDetail();
+};
 
-watch(timeRange, () => {
-	initBarChart();
+onMounted(() => {
+	formData.value = { ...props.formData, version: props.selectedVersion || '' };
+	getData();
 });
 
-watch(
-	() => props.selectedVersion,
-	() => {
-		initBarChart();
-	}
-);
-
-const sourcePage = ref(1);
-const sourcePageSize = ref(5);
-const pagedSourceRows = computed(() => {
-	const startIndex = (sourcePage.value - 1) * sourcePageSize.value;
-
-	return sourceRows.value.slice(startIndex, startIndex + sourcePageSize.value);
-});
 
 const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
 const showDetail = ref(true);

+ 23 - 21
src/views/count/user/versionDistribution/components/version-trend-card.vue

@@ -44,15 +44,7 @@
 		<div class="p-10">
 			<div class="flex items-center justify-between mb-2 mt-3">
 				<div class="flex items-center">
-					<el-select
-						v-if="selectedVersionLocal !== ''"
-						v-model="selectedVersionLocal"
-						class="w-[140px] ml-2"
-						style="width: 140px"
-						placeholder="全部频道"
-					>
-						<el-option v-for="item in channelOptions" :key="item.value" :label="item.label" :value="item.value" />
-					</el-select>
+					
 					<el-select v-model="industryCompare" class="!w-[120px] ml-2" clearable @change="handleCompareChange">
 						<el-option label="版本对比" value="version" />
 						<el-option v-if="selectedVersionLocal !== ''" label="渠道对比" value="channel" />
@@ -144,7 +136,7 @@
 					<div v-if="selectedVersionLocal == ''" class="flex items-center">
 						<el-radio-group v-model="queryDate">
 							<el-radio-button :label="0">今日</el-radio-button>
-							<el-radio-button :label="1">日 </el-radio-button>
+							<el-radio-button :label="1">日 </el-radio-button>
 						</el-radio-group>
 					</div>
 					<div class="text-base font-medium cursor-pointer select-none ml-3 items-center flex text-[#167AF0]" @click="showDetail = !showDetail">
@@ -160,14 +152,14 @@
 					<el-button>导出</el-button>
 				</div>
 			</div>
-			<el-table v-if="showDetail && formData?.version.length > 0 " :data="tableData" border>
+			<el-table v-if="showDetail && formData?.version.length > 0" :data="tableData" border>
 				<el-table-column prop="date" label="日期" align="center" min-width="140" />
 				<el-table-column prop="newUser" label="新增用户" align="center" min-width="140" />
 				<el-table-column prop="activeUser" label="活跃用户" align="center" min-width="140" />
 				<el-table-column prop="launch" label="启动次数" align="center" min-width="140" />
 				<el-table-column prop="upgradeUser" label="升级用户" align="center" min-width="140" />
 			</el-table>
-			<el-table v-else-if="showDetail &&  formData?.version.length === 0 " :data="tableData" border>
+			<el-table v-else-if="showDetail && formData?.version.length === 0" :data="tableData" border>
 				<el-table-column prop="version" label="版本" align="center" min-width="140" />
 				<el-table-column prop="newUser" label="截至至今版本累计用户(%)" align="center" min-width="140" />
 				<el-table-column prop="totalUser" label="累计用户" align="center" min-width="140" />
@@ -209,7 +201,6 @@ import { getTrendUpgrade, getTrendDetailAll, getTrendDetailOne, getTrendDetailSo
 
 const { t } = useI18n();
 
-
 interface ChannelOption {
 	label: string;
 	value: string;
@@ -312,22 +303,22 @@ const getData = async (type: string) => {
 		lineChartData.value = { dates: [], items: [] };
 		industryCompare.value = '';
 	}
-
+	console.log(formData.value);
+	
 	let res;
 	let data;
-	console.log(props.formData);
 	formData.value = {
 		...formData.value,
 		fromDate: props.formData?.time?.[0] || '',
 		toDate: props.formData?.time?.[1] || '',
+		timeUnit: 'day',
+		channel: []
 	};
-	console.log(formData.value);
 
 	// 根据选择的类型调用不同的API
 	if (timeGranularity.value === 'addUser') {
 		res = await getAddUserTrend({ ...formData.value });
 	}
-	console.log(formData.value);
 
 	// 根据选择的类型调用不同的API
 	if (timeGranularity.value === 'addUser') {
@@ -345,6 +336,7 @@ const getData = async (type: string) => {
 	}
 
 	initChartData(data);
+	getDataDetail();
 };
 
 // 为对比项添加数据到图表
@@ -478,10 +470,12 @@ function formatNumber(value: number | string): string {
 	return num.toLocaleString('zh-CN');
 }
 const getDataDetail = async () => {
+	
 	if (props.formData.version === '') {
 		const res = await getTrendDetailAll({
 			...formData.value,
 			...pagination.value,
+			queryDate: queryDate.value,
 		});
 		const dataDetail = res?.data || [];
 		pagination.value.current = dataDetail.current; //当前页码
@@ -596,6 +590,18 @@ watch(selectedVersionLocal, () => {
 	industryCompare.value = '';
 	getData('');
 });
+watch(
+	() => props.formData,
+	(val) => {
+		formData.value = {
+			...val,
+			version: val?.version ? [val?.version] : [],
+		};
+		industryCompare.value = '';
+		getData('');
+	},
+	{ deep: true }
+);
 
 // 监听showAllVersions变化,重新初始化对比选项
 watch(
@@ -607,9 +613,6 @@ watch(
 	}
 );
 
-
-
-
 // 版本和渠道对比相关函数
 function handleCompareChange(value: string) {
 	selectedCompareItems.value = [];
@@ -704,7 +707,6 @@ async function handleCompareItemsChange(items: string[]) {
 	if (industryCompare.value === 'version' && items.includes('全部版本')) {
 		// 如果选择了"全部版本",可以在这里添加特殊处理逻辑
 		// 例如:清空其他版本选择,或者显示所有版本的数据
-		console.log('选择了全部版本选项');
 	}
 
 	// 清理被取消的对比项

+ 44 - 11
src/views/count/user/versionDistribution/index.vue

@@ -1,22 +1,32 @@
 <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('versionDistribution.analytics')" />
-
 				</div>
 				<div class="mt-2">
 					<el-row shadow="hover" class="">
 						<el-form :inline="true" :model="formData" ref="queryRef">
 							<el-form-item>
-								<el-date-picker v-model="formData.time" type="daterange" value-format="YYYY-MM-DD"
-									:disabled-date="disableFuture" @change="handleRangeChange" class="!w-[250px]"
-									start-placeholder="开始时间" end-placeholder="结束时间" />
+								<el-select v-model="appID" filterable remote class="w-full" @change="onChangeAppId">
+									<el-option v-for="item in appOptions" :key="item.value" :label="item.label" :value="item.value" />
+								</el-select>
+							</el-form-item>
+							<el-form-item>
+								<el-date-picker
+									v-model="formData.time"
+									type="daterange"
+									value-format="YYYY-MM-DD"
+									:disabled-date="disableFuture"
+									@change="handleRangeChange"
+									class="!w-[250px]"
+									start-placeholder="开始时间"
+									end-placeholder="结束时间"
+								/>
 							</el-form-item>
 							<el-form-item>
-								<FilterSelect v-model="formData.version" showAllVersions type="version"
-									class="!w-[180px] ml-2" />
+								<FilterSelect v-model="formData.version" showAllVersions type="version" class="!w-[180px] ml-2" />
 							</el-form-item>
 						</el-form>
 					</el-row>
@@ -31,14 +41,36 @@
 <script setup lang="ts">
 import { ref, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { getAppList } from '/@/api/marketing/config';
+import { setAppID, APP_ID_STORAGE_KEY } from '/@/api/common/common';
 import dayjs from 'dayjs';
 
 const VersionTrendCard = defineAsyncComponent(() => import('./components/version-trend-card.vue'));
 const VersionSourceCard = defineAsyncComponent(() => import('./components/version-source-card.vue'));
 const FilterSelect = defineAsyncComponent(() => import('/@/components/common/filter-select.vue'));
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
 
+const appID = ref(localStorage.getItem(APP_ID_STORAGE_KEY) || '-1');
+const appOptions = ref<any[]>([]);
 const { t } = useI18n();
-
+const getAppListData = async () => {
+	const res = await getAppList({ isAll: false });
+	const data = res.data;
+	appOptions.value = data.map((item: any, index: number) => {
+		return {
+			label: item.appName,
+			value: item.appId,
+		};
+	});
+	appOptions.value.unshift({
+		label: '全部应用',
+		value: '-1',
+	});
+};
+const onChangeAppId = (val: string) => {
+	formData.value.version = '';
+	setAppID(val);
+};
 const getDefaultDateRange = () => {
 	const endDate = dayjs();
 	const startDate = endDate.subtract(15, 'day');
@@ -46,10 +78,9 @@ const getDefaultDateRange = () => {
 };
 const formData = ref<Record<string, any>>({
 	time: getDefaultDateRange(), // 时间范围
-	version:''
+	version: '',
 });
 
-
 function disableFuture(date: Date) {
 	const today = new Date();
 	today.setHours(0, 0, 0, 0);
@@ -69,7 +100,9 @@ function handleRangeChange(val: [string, string] | null) {
 		formData.value.time = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
 	}
 }
-const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+onMounted(() => {
+	getAppListData();
+});
 </script>
  
 <style lang="scss" scoped>

+ 24 - 27
src/views/marketing/config/components/push.vue

@@ -4,18 +4,14 @@
 			<div class="flex items-start">
 				<label class="w-[65px] leading-8 text-right">地区</label>
 				<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
-					<el-tag
-						v-for="(tag, index) in regionData"
-						:key="index"
-						size="large"
-						closable
-						:disable-transitions="false"
-						@close="handleClose(tag, 'region')"
-					>
+					<el-tag v-for="(tag, index) in regionData" :key="index" size="large" closable
+						:disable-transitions="false" @close="handleClose(tag, 'region')">
 						{{ tag == 'All' ? '全部国家' : tag }}
 					</el-tag>
-					<ChineseRegionSelector v-if="regionInputVisible" v-model="regionData" showAll @update:modelValue="handleRegionDataUpdate" />
-					<el-button v-else class="button-new-tag" @click="showRegionInput"> 添加地区</el-button>
+					<ChineseRegionSelector v-show="regionInputVisible" v-model="regionData" showAll
+						@update:modelValue="handleRegionDataUpdate" />
+					<el-button v-show="!regionInputVisible" class="button-new-tag" @click="showRegionInput">
+						添加地区</el-button>
 					<!-- 注册隐藏的表单项以启用校验 -->
 					<el-form-item prop="region" style="display: none"></el-form-item>
 				</div>
@@ -58,19 +54,12 @@
 				</div>
 			</div>
 		</div> -->
-		<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px" class="flex flex-wrap mt-4 w-1/2">
+		
+		<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px"
+			class="flex flex-wrap mt-4 w-1/2">
 			<el-form-item label="推送应用" prop="pushApp" class="w-full">
-				<el-select
-					v-model="formData.pushApp"
-					placeholder="请选择推送方式"
-					multiple
-					collapse-tags
-					collapse-tags-tooltip
-					:max-collapse-tags="4"
-					filterable
-					remote
-					class="w-full"
-				>
+				<el-select v-model="formData.pushApp" placeholder="请选择推送方式" multiple collapse-tags collapse-tags-tooltip
+					:max-collapse-tags="4" filterable remote class="w-full">
 					<el-option v-for="item in appOptions" :key="item.value" :label="item.label" :value="item.value" />
 				</el-select>
 			</el-form-item>
@@ -79,7 +68,8 @@
 			</el-form-item>
 
 			<el-form-item label="推送方式" prop="action" class="w-1/2">
-				<JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" dictType="pushMode" :styleClass="'w-full'" />
+				<JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" dictType="pushMode"
+					:styleClass="'w-full'" />
 			</el-form-item>
 
 			<!-- <el-form-item label="推送频率" prop="pushFrequency" class="w-1/2">
@@ -103,7 +93,8 @@
 				<el-input v-model="formData.pushContent" type="text" placeholder="请输入推送内容" />
 			</el-form-item>
 		</el-form>
-		<el-button type="primary" class="ml-5 mt-4 w-[80px]" @click="onSubmit" :disabled="loading">{{ t('common.saveBtn') }}</el-button>
+		<el-button type="primary" class="ml-5 mt-4 w-[80px]" @click="onSubmit"
+			:disabled="loading">{{ t('common.saveBtn') }}</el-button>
 	</div>
 </template>
 
@@ -123,7 +114,7 @@ const props = defineProps({
 	},
 	rowData: {
 		type: Object,
-		default: () => {},
+		default: () => { },
 	},
 });
 
@@ -135,7 +126,13 @@ const success = (val: string) => {
 // 引入组件
 const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.vue'));
 const Image = defineAsyncComponent(() => import('/@/components/Upload/Image.vue'));
-const ChineseRegionSelector = defineAsyncComponent(() => import('/@/components/common/ChineseRegionSelector.vue'));
+// 懒加载地区选择器,只有在需要时才加载
+const ChineseRegionSelector = defineAsyncComponent({
+	loader: () => import('/@/components/common/SimpleRegionSelector.vue'),
+	loadingComponent: () => h('div', { class: 'text-center p-4' }, '加载地区数据中...'),
+	delay: 200,
+	timeout: 10000
+});
 const { t } = useI18n();
 // 定义变量内容
 
@@ -159,7 +156,7 @@ const formData = ref<any>({
 });
 const appOptions = ref<any[]>([]);
 const getAppListData = async () => {
-	const res = await getAppList();
+	const res = await getAppList({ isAll: true });
 	// console.log(res);
 
 	const data = res.data;

+ 96 - 49
src/views/marketing/rules/components/Edit.vue

@@ -1,13 +1,6 @@
 <template>
-	<el-dialog
-		:title="props.rowData?.id ? '修改规则' : '新增规则'"
-		width="1000"
-		v-model="props.open"
-		:close-on-click-modal="false"
-		:destroy-on-close="true"
-		@close="onCancel"
-		draggable
-	>
+	<el-dialog :title="props.rowData?.id ? '修改规则' : '新增规则'" width="1000" v-model="props.open"
+		:close-on-click-modal="false" :destroy-on-close="true" @close="onCancel" draggable>
 		<div class="w-full ml-[-8px]">
 			<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px" class="flex flex-wrap">
 				<el-form-item label="规则名称" prop="ruleName" class="w-1/3">
@@ -18,17 +11,12 @@
 				<div class="flex items-start">
 					<label class="w-[65px] leading-8 text-right"><span class="text-[#f56c6c] mr-1">*</span> 关键字</label>
 					<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
-						<el-tag v-for="tag in keywordData" :key="tag" size="large" closable :disable-transitions="false" @close="handleClose(tag, 'keyword')">
+						<el-tag v-for="tag in keywordData" :key="tag" size="large" closable :disable-transitions="false"
+							@close="handleClose(tag, 'keyword')">
 							{{ tag }}
 						</el-tag>
-						<el-input
-							v-if="inputVisible"
-							ref="InputRef"
-							v-model="inputValue"
-							class="!w-32"
-							@keyup.enter="handleInputConfirm('keyword')"
-							@blur="handleInputConfirm('keyword')"
-						/>
+						<el-input v-if="inputVisible" ref="InputRef" v-model="inputValue" class="!w-32"
+							@keyup.enter="handleInputConfirm('keyword')" @blur="handleInputConfirm('keyword')" />
 						<el-button v-else class="button-new-tag" @click="showInput"> 添加关键字</el-button>
 						<!-- 注册隐藏的表单项以启用校验 -->
 						<el-form-item prop="keyword" style="display: none"></el-form-item>
@@ -39,19 +27,13 @@
 				<div class="flex items-start">
 					<label class="w-[65px] leading-8 text-right">IP</label>
 					<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
-						<el-tag v-for="tag in ipData" :key="tag" size="large" closable :disable-transitions="false" @close="handleClose(tag, 'ip')">
+						<el-tag v-for="tag in ipData" :key="tag" size="large" closable :disable-transitions="false"
+							@close="handleClose(tag, 'ip')">
 							{{ tag }}
 						</el-tag>
-						<el-input
-							v-if="ipInputVisible"
-							ref="ipInputRef"
-							v-model="ipInputValue"
-							class="!w-32"
-							@keyup.enter="handleInputConfirm('ip')"
-							@blur="handleInputConfirm('ip')"
-						/>
+						<el-input v-if="ipInputVisible" ref="ipInputRef" v-model="ipInputValue" class="!w-32"
+							@keyup.enter="handleInputConfirm('ip')" @blur="handleInputConfirm('ip')" />
 						<el-button v-else class="button-new-tag" @click="showIpInput"> 添加IP</el-button>
-						<!-- 注册隐藏的表单项以启用校验 -->
 						<el-form-item prop="ip" style="display: none"></el-form-item>
 					</div>
 				</div>
@@ -61,27 +43,42 @@
 				<div class="flex items-start">
 					<label class="w-[66px] leading-8 text-right">添加域名</label>
 					<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
-						<el-tag v-for="tag in domainData" :key="tag" size="large" closable :disable-transitions="false" @close="handleClose(tag, 'domain')">
+						<el-tag v-for="tag in domainData" :key="tag" size="large" closable :disable-transitions="false"
+							@close="handleClose(tag, 'domain')">
 							{{ tag }}
 						</el-tag>
-						<el-input
-							v-if="domainInputVisible"
-							ref="domainInputRef"
-							v-model="domainInputValue"
-							class="!w-32"
-							@keyup.enter="handleInputConfirm('domain')"
-							@blur="handleInputConfirm('domain')"
-							placeholder="如: example.com"
-						/>
+						<el-input v-if="domainInputVisible" ref="domainInputRef" v-model="domainInputValue"
+							class="!w-32" @keyup.enter="handleInputConfirm('domain')"
+							@blur="handleInputConfirm('domain')" placeholder="如: example.com" />
 						<el-button v-else class="button-new-tag" @click="showDomainInput"> 添加域名</el-button>
-						<!-- 注册隐藏的表单项以启用校验 -->
 						<el-form-item prop="domain" style="display: none"></el-form-item>
 					</div>
 				</div>
 			</div>
-			<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px" class="flex flex-wrap mt-4">
+
+			<!-- 地区选择 -->
+			<div class="px-4 rounded overflow-y-auto mt-4" style="max-height: calc(100vh - 350px)">
+				<div class="flex items-start">
+					<label class="w-[65px] leading-8 text-right">地区</label>
+					<div class="flex gap-2 ml-2 flex-wrap w-57vw]">
+						<el-tag v-for="(tag, index) in regionData" :key="index" size="large" closable
+							:disable-transitions="false" @close="handleClose(tag, 'region')">
+							{{ tag == 'All' ? '全部国家' : tag }}
+						</el-tag>
+						<ChineseRegionSelector v-show="regionInputVisible" v-model="regionData" showAll
+							@update:modelValue="handleRegionDataUpdate" />
+						<el-button v-show="!regionInputVisible" class="button-new-tag" @click="showRegionInput">
+							添加地区</el-button>
+						<!-- 注册隐藏的表单项以启用校验 -->
+						<el-form-item prop="region" style="display: none"></el-form-item>
+					</div>
+				</div>
+			</div>
+			<el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px"
+				class="flex flex-wrap mt-4">
 				<el-form-item label="推送方式" prop="action" class="w-1/2">
-					<JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" dictType="pushMode" :styleClass="'w-full'" />
+					<JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" dictType="pushMode"
+						:styleClass="'w-full'" />
 				</el-form-item>
 
 				<el-form-item label="推送频率" prop="pushFrequency" class="w-1/2">
@@ -92,10 +89,12 @@
 						<el-input v-model="formData.delayPush" type="text" placeholder="请输入推送延时" />
 					</div>
 				</el-form-item>
-				<el-form-item label="推送图片" prop="pushContent" class="w-full">
+				<el-form-item label="推送图片" class="w-full">
 					<div class="flex items-start">
-						<el-switch v-model="formData.pushType" class="mr-2" @change="(oldUrl = formData.pushContent), (formData.pushContent = '')" />
-						<Image v-if="formData.pushType" @change="success" :imageUrl="formData.pushContent" @update="success" />
+						<el-switch v-model="formData.pushType" class="mr-2"
+							@change="(oldUrl = formData.pushContent), (formData.pushContent = '')" />
+						<Image v-if="formData.pushType" @change="success" :imageUrl="formData.pushContent"
+							@update="success" />
 					</div>
 				</el-form-item>
 
@@ -110,7 +109,8 @@
 		<template #footer>
 			<span class="dialog-footer">
 				<el-button @click="onCancel">{{ t('common.cancelButtonText') }}</el-button>
-				<el-button type="primary" @click="onSubmit" :disabled="loading">{{ t('common.confirmButtonText') }}</el-button>
+				<el-button type="primary" @click="onSubmit"
+					:disabled="loading">{{ t('common.confirmButtonText') }}</el-button>
 			</span>
 		</template>
 	</el-dialog>
@@ -132,7 +132,7 @@ const props = defineProps({
 	},
 	rowData: {
 		type: Object,
-		default: () => {},
+		default: () => { },
 	},
 });
 const onCancel = () => {
@@ -146,6 +146,13 @@ const success = (val: string) => {
 // 引入组件
 const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.vue'));
 const Image = defineAsyncComponent(() => import('/@/components/Upload/Image.vue'));
+// 懒加载地区选择器(与 push 页面一致)
+const ChineseRegionSelector = defineAsyncComponent({
+	loader: () => import('/@/components/common/SimpleRegionSelector.vue'),
+	loadingComponent: () => h('div', { class: 'text-center p-4' }, '加载地区数据中...'),
+	delay: 200,
+	timeout: 10000
+});
 const { t } = useI18n();
 // 定义变量内容
 
@@ -163,13 +170,17 @@ const formData = ref<any>({
 	action: '1',
 	remark: '',
 	pushType: false,
-	delayPush:''
+	delayPush: '',
+	pushApp: [],
+	pushBundle: [],
+	pushAddr: [],
 });
 
 // // 表单校验规则
 const dataRules = reactive({
 	ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
 	delayPush: [{ required: true, message: '推送延时不能为空', trigger: 'blur' }],
+	
 	keyword: [
 		{
 			required: true,
@@ -235,6 +246,10 @@ const domainInputRef = ref();
 const keywordData = ref<string[]>([]);
 const ipData = ref<string[]>([]);
 const domainData = ref<string[]>([]);
+// 地区相关
+const regionData = ref<any[]>([]);
+const regionInputVisible = ref(false);
+const regionInputRef = ref();
 
 const showInput = () => {
 	inputVisible.value = true;
@@ -254,6 +269,12 @@ const showDomainInput = () => {
 		domainInputRef.value!.input!.focus();
 	});
 };
+const showRegionInput = () => {
+	regionInputVisible.value = true;
+	nextTick(() => {
+		regionInputRef.value?.focus();
+	});
+};
 const handleClose = (tag: string, type: string) => {
 	if (type === 'keyword') {
 		keywordData.value = keywordData.value.filter((item: string) => item !== tag);
@@ -261,6 +282,8 @@ const handleClose = (tag: string, type: string) => {
 		ipData.value = ipData.value.filter((item: string) => item !== tag);
 	} else if (type === 'domain') {
 		domainData.value = domainData.value.filter((item: string) => item !== tag);
+	} else if (type === 'region') {
+		regionData.value = regionData.value.filter((item: any) => item !== tag);
 	}
 };
 
@@ -457,6 +480,30 @@ const getpushContent = () => {
 		return formData.value.pushContent;
 	}
 };
+// 地区选择数据更新
+const handleRegionDataUpdate = (newRegionData: any[]) => {
+	const mapped = newRegionData
+		.map((item: any) => {
+			if (item || item == '全部国家') {
+				return (
+					(item?.city ? `${item.country} - ${item.state} - ${item.city}` : item?.state ? `${item.country} - ${item.state}` : item?.country) || item
+				);
+			}
+			return false;
+		})
+		.filter((v: any) => v !== undefined && v !== false && v !== null && v !== '');
+
+	const unique = Array.from(new Set(mapped)) as string[];
+	const hasNonAll = unique.some((v) => v !== '全部国家');
+
+	if (hasNonAll) {
+		regionData.value = unique.filter((v) => v !== '全部国家');
+	} else {
+		regionData.value = unique.length > 0 ? ['全部国家'] : [];
+	}
+
+	regionInputVisible.value = false;
+};
 
 const onSubmit = async () => {
 	// 触发表单校验(包含隐藏注册的 keyword/ip/domain 规则)
@@ -478,6 +525,7 @@ const onSubmit = async () => {
 		ip: ipData.value,
 		keyword: keywordData.value,
 		domain: domainData.value,
+		pushAddr: regionData.value.length === 1 && regionData.value[0] === '全部国家' ? ['All'] : regionData.value,
 		pushContent: getpushContent(),
 		pushType: formData.value.pushType || false,
 		pushFrequency: pushFrequency.toString(),
@@ -526,8 +574,7 @@ watch(
 			keywordData.value = props.rowData.keyword || [];
 			domainData.value = props.rowData.domain || [];
 			oldUrl.value = props.rowData.pushContent;
-			console.log(formData.value);
-			console.log(props.rowData);
+			regionData.value = props.rowData.pushAddr || [];
 		}
 	}
 );

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

@@ -30,6 +30,11 @@
 				>;">
 				<el-table-column label="规则名称" prop="ruleName" show-overflow-tooltip></el-table-column>
 				<el-table-column label="关键字" prop="keyword" show-overflow-tooltip></el-table-column>
+				<el-table-column label="地区" prop="pushAddr" show-overflow-tooltip>
+					<template #default="{ row }">
+						<div v-for="(item,index) in row.pushAddr" :key="index" size="small">{{ item=== 'All' ? '全部国家' : item}}</div>
+					</template>
+				</el-table-column>
 				<el-table-column label="IP" prop="ip" show-overflow-tooltip></el-table-column>
 				<el-table-column label="域名" prop="domain" show-overflow-tooltip width="200"></el-table-column>
 				<el-table-column label="推送内容" prop="pushContent" show-overflow-tooltip width="200">

+ 19 - 3
vite.config.ts

@@ -84,9 +84,25 @@ const viteConfig = defineConfig((mode: ConfigEnv) => {
 					chunkFileNames: `assets/[name].[hash].js`,
 					assetFileNames: `assets/[name].[hash].[ext]`,
 					compact: true,
-					manualChunks: {
-						vue: ['vue', 'vue-router', 'pinia'],
-						echarts: ['echarts'],
+					manualChunks: (id) => {
+						// 仅保留必要的显式分包,避免 element-plus 被强制拆分导致初始化顺序问题
+						// 将 Vue 相关库分离
+						if (id.includes('node_modules/vue') || id.includes('node_modules/vue-router') || id.includes('node_modules/pinia')) {
+							return 'vue-vendor';
+						}
+						// 将 ECharts 分离(体积较大,使用场景明确)
+						if (id.includes('node_modules/echarts')) {
+							return 'echarts';
+						}
+						// 将地区数据与选择器相关代码分离
+						if (id.includes('region-data.json') || id.includes('ChineseRegionSelector') || id.includes('SimpleRegionSelector')) {
+							return 'region-data';
+						}
+						// 将 country-state-city 放入独立块(若被用到)
+						if (id.includes('node_modules/country-state-city')) {
+							return 'country-data';
+						}
+					// 其他模块交给 Rollup 默认策略,避免产生不安全的拆分
 					},
 				},
 			},