65 Commity af5a8c2744 ... 73cf0520fc

Autor SHA1 Wiadomość Data
  cmy 73cf0520fc feat:时间范围调整 3 dni temu
  cmy 0c7e300d5e fix:bug修复 6 dni temu
  cmy 4cc75562fb fix:bug修复 6 dni temu
  cmy 706e2fb4f4 fix:代码优化 1 tydzień temu
  cmy 58ff29423a fix:代码优化 1 tydzień temu
  cmy dc94facbc8 fix:代码优化 1 tydzień temu
  cmy 508219e589 fix:更新传参 1 tydzień temu
  cmy 733b676c4f fix:代码优化 1 tydzień temu
  cmy 3e9354f5c5 feat:营销管理-应用列表-应用统计,表格修改 1 tydzień temu
  cmy de978012de Merge branch 'dev-cmn' into dev-ly 1 tydzień temu
  cmy 20a8473704 fix:代码优化 1 tydzień temu
  luoy 439be87e29 fix:配置中心bug修复 1 tydzień temu
  cmy 41801ed7b7 feat:bug修复 1 tydzień temu
  cmy 358dd0dba7 Merge branch 'dev-cmn' into dev-ly 1 tydzień temu
  cmy cb85954672 feat:增加校验及优化超长展示效果 1 tydzień temu
  jcq 0808cda6b2 fix: 并解决冲突 1 tydzień temu
  jcq e416566436 fix: 代码校验修复; 1 tydzień temu
  cmy 51d58b2948 fix:更新参数 1 tydzień temu
  cmy 5918f96276 feat:增加校验 1 tydzień temu
  cmy b455e0e142 Merge branch 'dev-cmn' into dev-ly 1 tydzień temu
  cmy 75ab065675 feat:应用列表联调 1 tydzień temu
  jcq 2107e79653 fix: 全局配置结束 1 tydzień temu
  jcq db31eda782 Merge branch 'dev-jcq' into dev-ly 1 tydzień temu
  jcq 5d1f8ed6e7 feat: 接口联调; 1 tydzień temu
  cmy 85b88108e8 Merge branch 'dev-cmn' into dev-ly 1 tydzień temu
  cmy 13f7bfba3d 暂存 1 tydzień temu
  jcq 92d8c382a6 Merge branch 'dev-jcq' into dev-ly 2 tygodni temu
  jcq 5827ac8e63 fix: 接口更改提交 2 tygodni temu
  cmy 66262efc75 Merge branch 'dev-cmn' into dev-ly 2 tygodni temu
  cmy 85c2d4ceb3 fix:页面样式优化 2 tygodni temu
  jcq c78e034f60 fix: ip分组/域名分组接口联调结束 2 tygodni temu
  jcq 9fcecf2d30 Merge branch 'dev-jcq' into dev-ly 2 tygodni temu
  jcq 54b6f17046 fix: 接口联调 2 tygodni temu
  jcq fb573da00b fix: 接口联调 2 tygodni temu
  cmy 390df9f84a feature:新增修改ip、修改域名弹窗 2 tygodni temu
  cmy 99940023aa fix:页面细节调整 2 tygodni temu
  cmy c18121b244 Merge branch 'dev-ly' into dev-cmn 2 tygodni temu
  cmy 628b6b74eb 暂存 2 tygodni temu
  jcq 720309c7c5 fix: 公共组件bug修改 2 tygodni temu
  jcq aef740ec5a fix: 合并解决冲突 2 tygodni temu
  jcq afd43bf816 fix: 合并前 2 tygodni temu
  cmy 3cd78e99e6 Merge branch 'dev-cmn' into dev-ly 2 tygodni temu
  cmy c9f3357542 暂存 2 tygodni temu
  jcq 9fe1321fe6 fix: 提交哦啊 2 tygodni temu
  jcq 63b3820ff9 Merge branch 'dev-jcq' into dev-ly 2 tygodni temu
  jcq 6781ade6c6 fix: 配置中心 2 tygodni temu
  cmy 66cd63a4cb feature:应用列表及配置中心功能完善 2 tygodni temu
  cmy 2d35279824 Merge branch 'dev-ly' into dev-cmn 2 tygodni temu
  cmy 702207a07d 暂存 2 tygodni temu
  jcq 9e6b75b297 fix: 弹窗 2 tygodni temu
  jcq 08039b9fd6 Merge branch 'dev-jcq' into dev-ly 2 tygodni temu
  jcq 807de3cf04 fix: 域名弹窗 2 tygodni temu
  cmy ea21523e61 暂存 2 tygodni temu
  jcq 812466aa5c fix: 域名弹窗 2 tygodni temu
  cmy d608a7a912 fix:样式兼容 2 tygodni temu
  jcq f44921a835 fix: 全局配置 2 tygodni temu
  jcq eee7591d20 feat: 公共组件添加插槽 2 tygodni temu
  jcq ddcf798b66 Merge branch 'dev-jcq' into dev-ly 2 tygodni temu
  jcq 1bf212e783 feat: ip 公共组件 2 tygodni temu
  cmy b7d612f4ea 暂存 2 tygodni temu
  cmy c9def4553c Merge branch 'dev-ly' of https://s-20coaj910c.zht2.com/cmy/data-marketing-platform into dev-ly 2 tygodni temu
  cmy 1f74c989fa 暂存 2 tygodni temu
  jcq 1386183bbc feat: ip 公共组件 2 tygodni temu
  cmy be81207286 feature:营销列表二轮调整接口联调 2 tygodni temu
  jcq 15c1edc5ea feat: 添加地图过滤 2 tygodni temu
37 zmienionych plików z 4093 dodań i 335 usunięć
  1. 1 1
      .gitignore
  2. 177 0
      src/api/marketing/apps.ts
  3. 92 20
      src/api/marketing/config.ts
  4. 17 2
      src/api/marketing/statistics.ts
  5. 98 0
      src/components/JCollapse/index.vue
  6. 104 0
      src/components/JDictSelect/index.vue
  7. 15 0
      src/components/Title/index.vue
  8. 3 0
      src/i18n/pages/form/zh-cn.ts
  9. 3 3
      src/layout/navMenu/subItem.vue
  10. 5 1
      src/theme/app.scss
  11. 40 0
      src/utils/ipUpdate.ts
  12. 54 1
      src/utils/validate.ts
  13. 1 1
      src/views/admin/menu/form.vue
  14. 65 24
      src/views/home/echarts/visitor-map.vue
  15. 79 0
      src/views/marketing/apps/components/domainCell.vue
  16. 163 0
      src/views/marketing/apps/components/domainCollapse.vue
  17. 143 0
      src/views/marketing/apps/components/domainEdit.vue
  18. 83 0
      src/views/marketing/apps/components/domainForm.vue
  19. 286 0
      src/views/marketing/apps/components/form.vue
  20. 94 0
      src/views/marketing/apps/components/ipCell.vue
  21. 178 0
      src/views/marketing/apps/components/ipCollapse.vue
  22. 211 0
      src/views/marketing/apps/components/ipEdit.vue
  23. 79 0
      src/views/marketing/apps/components/ipForm.vue
  24. 233 0
      src/views/marketing/apps/components/statistical.vue
  25. 36 0
      src/views/marketing/apps/i18n/en.ts
  26. 72 0
      src/views/marketing/apps/i18n/zh-cn.ts
  27. 359 0
      src/views/marketing/apps/index.vue
  28. 64 0
      src/views/marketing/apps/types.ts
  29. 148 0
      src/views/marketing/config/components/domainEdit.vue
  30. 102 0
      src/views/marketing/config/components/ipGroupingEdit.vue
  31. 207 0
      src/views/marketing/config/components/ipListEdit.vue
  32. 207 0
      src/views/marketing/config/components/listEdit.vue
  33. 33 1
      src/views/marketing/config/i18n/zh-cn.ts
  34. 530 131
      src/views/marketing/config/index.vue
  35. 1 1
      src/views/marketing/statistics/i18n/en.ts
  36. 11 1
      src/views/marketing/statistics/i18n/zh-cn.ts
  37. 99 148
      src/views/marketing/statistics/index.vue

+ 1 - 1
.gitignore

@@ -1,7 +1,7 @@
 .DS_Store
 node_modules
 dist
-
+.vscode/settings.json
 
 # local env files
 .env.local

+ 177 - 0
src/api/marketing/apps.ts

@@ -0,0 +1,177 @@
+import request from '/@/utils/request';
+
+/**
+ * 分页查询应用列表
+ * @param size	每页显示条数
+ * @param current	当前页
+ * @param appId	应用ID
+ * @param appName	应用名称
+ * @param domainType	应用类型
+ * @param remark	备注
+ * @returns 
+ */
+export const pageList = (params?: Object) => {
+	return request({
+		url: '/marketing/app/page',
+		// url: 'https://m1.apifoxmock.com/m1/6687089-6396408-default/app/page',
+		method: 'get',
+		params,
+	});
+};
+
+/**
+ * 分页查询拉黑应用列表
+ * @param size	每页显示条数
+ * @param current	当前页
+ * @param appId	应用ID
+ * @param appName	应用名称
+ * @param domainType	应用类型
+ * @param remark	备注
+ * @returns 
+ */
+export const pageDel = (params?: Object) => {
+	return request({
+		url: '/marketing/app/pageDel',
+		method: 'get',
+		params,
+	});
+};
+
+/**
+ * 拉黑/恢复应用(设置应用状态)
+ * @param id  true	
+ * @param status 状态,true-启用,false-拉黑
+ * @returns 
+ */
+export const delAppById = (data: Object) => {
+	return request({
+		url: '/marketing/app/setStatus',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 批量更新应用信息
+ * @param 
+ [
+  {
+    "id": 0,
+    "domainLimit": true,
+    "launch": true,
+    "triggerRule": 0,
+    "triggerNum": 0,
+    "remark": "",
+    "delIps": [], // 删除的ip和域名id放这里
+    "delDomains": [],
+    "ips": [ // 新增,修改 不变的都放在这里
+      {
+        "id": 0, // 新增 没有id
+        "ipType": 0,
+        "sourceType": 0,
+        "groupId": 0,
+        "groupName": "",
+        "ipMode": 0,
+        "startIp": "",
+        "endIp": "",
+        "modify": true // 修改 为 true
+      }
+    ],
+    "domains": [
+      {
+        "id": 0,
+        "sourceType": 0,
+        "groupId": 0,
+        "groupName": "",
+        "domain": "",
+        "modify": true
+      }
+    ]
+  }
+]
+ * @returns 
+ */
+export const appUpdate = (data: Object) => {
+	return request({
+		url: '/marketing/app/update',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 修改应用IP集合
+ * @param id 
+ * @param delIps 
+ * @param delIps 
+ * @returns ips
+ */
+export const updateModIp = (data: Object) => {
+	return request({
+		url: '/marketing/app/modIp',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 修改应用域名集合
+ * @param id 
+ * @param delDomains 
+ * @param delIps 
+ * @returns domains
+ */
+export const updateModDomains = (data: Object) => {
+	return request({
+		url: '/marketing/app/modDomain',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 设置应用信息
+ * @param id	id
+ * @param domainType	应用类型,0-Android,1-IOS
+ * @param domainLimit	域名限制
+ * @param launch	营销投放
+ * @param triggerRule	触发规则,1-仅一次,2-多次
+ */
+export const setAppInfo = (data: Object) => {
+	return request({
+		url: '/marketing/app/setInfo',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 分页统计应用一级营销数据
+ * @param appId	应用ID
+ * @param timeRange	时间范围,0-全部,1-24小时,2-15分钟
+ * @param size	每页显示条数
+ * @param current	当前页
+ */
+export const getStatPage = (params: Object) => {
+	return request({
+		url: '/marketing/app/stat/page',
+		method: 'get',
+		params,
+	});
+};
+
+/**
+ * 分页统计应用二级营销数据
+ * @param appId	应用ID
+ * @param timeRange	时间范围,0-全部,1-24小时,2-15分钟
+ * @param ip	ip
+ * @param size	每页显示条数
+ * @param current	当前页
+ */
+export const getStatSecondPage = (params: Object) => {
+	return request({
+		url: '/marketing/app/stat/second/page',
+		method: 'get',
+		params,
+	});
+};

+ 92 - 20
src/api/marketing/config.ts

@@ -1,52 +1,124 @@
 import request from '/@/utils/request';
-
-export const pageList = (params?: Object) => {
+//获取域名分组
+export const pageListDomain = () => {
+	return request({
+		url: '/marketing/config/getDomainGroup',
+		method: 'get',
+	});
+};
+//获取ip分组
+export const pageListIp = (params?: Object) => {
+	return request({
+		url: '/marketing/config/getIpGroup',
+		method: 'get',
+		params,
+	});
+};
+//获取全局配置
+export const getConfigDetail = (params?: Object) => {
+	return request({
+		url: '/marketing/config/getConfig',
+		method: 'get',
+		params,
+	});
+};
+//获取配置ip集合
+export const getConfigIpList = (params?: Object) => {
+	return request({
+		url: '/marketing/config/ipList',
+		method: 'get',
+		params,
+	});
+};
+//获取配置域名集合
+export const getConfigDomainList = (params?: Object) => {
+	return request({
+		url: '/marketing/config/domainList',
+		method: 'get',
+		params,
+	});
+};
+//获取分组详细信息
+export const getGroupDetail = (params?: Object) => {
 	return request({
-		// url: '/admin/marketing/config/page',
-		url: 'https://m1.apifoxmock.com/m1/6687089-6396408-default/marketing/config/page',
+		url: '/marketing/config/group/detail',
 		method: 'get',
 		params,
 	});
 };
-export const update = (data: Object) => {
+//添加配置ip集合
+export const saveIpList = (data: Object) => {
 	return request({
-		// url: `/admin/marketing/config/${id}`,
-		url: 'https://m1.apifoxmock.com/m2/6687089-6396408-default/323895237',
+		url: '/marketing/config/addIp',
 		method: 'post',
 		data: data,
 	});
 };
-export const info = (id: String) => {
+//保存全局配置
+export const saveConfigDetail = (data: Object) => {
 	return request({
-		url: `/admin/marketing/config/${id}`,
+		url: '/marketing/config/setConfig',
+		method: 'post',
+		data: data,
+	});
+};
+//删除配置ip集合
+export const delIpList = (id?: string) => {
+	return request({
+		url: '/marketing/config/delIp/'+id,
 		method: 'get',
 	});
 };
-
-export const save = (data: Object) => {
+//删除配置域名集合
+export const delDomainList = (id?: string) => {
 	return request({
-		url: '/admin/marketing/config/save',
+		url: '/marketing/config/delDomain/'+id,
+		method: 'get',
+	});
+};
+//添加配置域名集合
+export const addDomains = (data: Object) => {
+	return request({
+		url: '/marketing/config/addDomain',
 		method: 'post',
 		data: data,
 	});
 };
-
-export const delObj = (ids: Array<String>) => {
+//修改域名分组
+export const saveDomains = (data: Object) => {
+	return request({
+		url: '/marketing/config/modDomainGroup',
+		method: 'post',
+		data: data,
+	});
+};
+//修改IP分组
+export const saveIps = (data: Object) => {
 	return request({
-		url: '/admin/marketing/config/remove',
+		url: '/marketing/config/modIpGroup',
 		method: 'post',
-		data: ids
+		data: data,
 	});
 };
 
-export const getAppList = (params?: Object) => {
+//删除分组
+export const delGroup = (data: object) => {
 	return request({
-		url: '/admin/marketing/config/apps',
-		method: 'get',
-		params
+		url: '/marketing/config/delGroup',
+		method: 'post',
+		data: data
+	});
+};
+//添加分组
+export const addGroup = (data: object) => {
+	return request({
+		url: '/marketing/config/addGroup',
+		method: 'post',
+		data: data
 	});
 };
 
+
 /**
  * 后端控制路由,isRequestRoutes 为 true,则开启后端控制路由
  * @method getAdminMenu 获取后端动态路由菜单(admin)

+ 17 - 2
src/api/marketing/statistics.ts

@@ -1,9 +1,24 @@
 import request from '/@/utils/request';
 
+/**
+ * 获取数据统计一级列表
+ */
 export function pageList(query: object) {
     return request({
-        url: '/admin/marketing/config/pageData',
+        url: '/marketing/data/page',
         method: 'get',
-        data: query,
+        params: query,
+    });
+}
+
+
+/**
+ * 获取数据统计二级列表
+ */
+export function detailList(query: object) {
+    return request({
+        url: '/marketing/data/detail',
+        method: 'get',
+        params: query,
     });
 }

+ 98 - 0
src/components/JCollapse/index.vue

@@ -0,0 +1,98 @@
+
+<template>
+	<el-collapse v-model="innerActiveNames" class="coll-left mt-2 border">
+		<el-collapse-item v-for="item in props.data" :key="item.id" :name="item.id">
+			<template #title>
+				<div class="collapse-title flex items-center justify-between coll-left-title">
+					<div style="font-size: 14px">{{ item.title }}</div>
+					<div class="action-buttons">
+						<el-button size="text" type="primary" @click.stop="handleEdit(item)" class="edit-btn">{{ props.updateText }}</el-button>
+						<el-button size="text" type="danger" @click.stop="handleDelete(item)" class="delete-btn">{{ props.deleteText }}</el-button>
+					</div>
+				</div>
+			</template>
+			<div v-if="item.list" class="">
+				<div v-if="item.list.length && typeof item.list[0] === 'object'">
+					<div v-if="item.list.length > 0">
+						<div v-for="ip in item.list" :key="ip.id" class="m-2 float-left" style="color: #646464; font-size: 14px">{{ ip.value }}</div>
+					</div>
+					<div class="text-gray-400 m-2" style="line-height: 24px;" v-else>--</div>
+				</div>
+				<div v-else>
+					<div v-if="item.list.length > 0">
+						<div v-for="ip in item.list" :key="ip" class="m-2 float-left" style="color: #646464; font-size: 14px">{{ ip }}</div>
+					</div>
+					<div class="text-gray-400 m-2" style="line-height: 24px;" v-else>--</div>
+
+				</div>
+			</div>
+			<slot v-else></slot>
+		</el-collapse-item>
+	</el-collapse>
+</template>
+
+<script setup name="jCollapse">
+const emit = defineEmits(['update', 'delete']);
+const props = defineProps({
+	data: {
+		type: Array,
+		default: () => [],
+	},
+	deleteText: {
+		type: String,
+		default: '删除',
+	},
+	updateText: {
+		type: String,
+		default: '修改',
+	},
+	activeNames: {
+		type: Array,
+		default: () => [],
+	},
+});
+
+const handleDelete = (item) => {
+	emit('delete', item);
+};
+const handleEdit = (item) => {
+	emit('update', item);
+};
+const innerActiveNames = computed({
+	get: () => props.activeNames,
+	set: (val) => emit('update:activeNames', val),
+});
+</script>
+<style lang="scss">
+.collapse-title {
+	width: 100%;
+	padding-right: 10px;
+}
+
+.coll-left {
+	position: relative;
+}
+.coll-left {
+	.collapse-title {
+		padding-right: 0;
+	}
+	button {
+		background-color: #f4f5fa !important;
+	}
+	.el-button {
+		padding: 0 10px;
+		height: 50px;
+	}
+	.el-button + .el-button {
+		margin-left: 0;
+	}
+	.coll-left-title,
+	span.el-collapse-item__title {
+		order: 1;
+		// margin-left: 5px;
+	}
+	.el-icon {
+		margin-left: 10px;
+	}
+}
+</style>

+ 104 - 0
src/components/JDictSelect/index.vue

@@ -0,0 +1,104 @@
+<template>
+	<el-select :class="styleClass" :placeholder="prompt" v-model="state.value" :loading="state.loading" @change="onChange" @focus="onFocus">
+		<el-option v-for="item in state.data" :key="item.value" :label="item.label" :value="item.value" />
+	</el-select>
+</template>
+
+<script setup>
+import { reactive, watch, onMounted } from 'vue';
+import { fetchItemList } from '/@/api/admin/dict';
+
+const state = reactive({
+	data: [],
+	loading: false,
+});
+const emit = defineEmits(['change', 'update:value']);
+
+const props = defineProps({
+	value: {
+		type: [String, Number, Array],
+		default: '',
+	},
+	valueKey: {
+		type: String,
+		default: 'value',
+	},
+	trigger: {
+		desc: '请求数据时机init:初始化/focus:聚焦',
+		type: String,
+		default: 'init',
+	},
+	dictType: {
+		type: String,
+		default: '',
+	},
+	isSelectAll: {
+		desc: '是否需要全部选项',
+		type: Boolean,
+		default: false,
+	},
+	selectFirst: {
+		desc: '是否选择第一个',
+		type: Boolean,
+		default: false,
+	},
+	prompt: {
+		type: String,
+		default: '请选择',
+	},
+	styleClass: {
+		type: String,
+		default: 'min-w-[160px]',
+	},
+});
+watch(
+	() => props.value,
+	() => {
+		state.value = props.value || null;
+		if (!props.value && props.selectFirst && state.data[0]) {
+			state.value = state.data[0].value;
+			emit('update:value', state.value);
+		}
+	},
+	{
+		immediate: true,
+	}
+);
+const onChange = (record, option) => {
+	emit('change', record, option);
+	emit('update:value', record);
+};
+//获取数据
+const getData = async () => {
+	state.data = [];
+	state.loading = true;
+	const { data = [], code } = await fetchItemList({ dictType: props.dictType });
+		
+	if (code === 0) {
+		state.data = data.records.map((item) => {
+			return {
+				label: item.label,
+				value: item[props.valueKey],
+			};
+		});
+		if(props.isSelectAll){
+			state.data.unshift({
+				label: '全部',
+				value: '',
+			});
+		}
+		state.loading = false;
+		if (props.selectFirst) {
+			state.value = state.data[0].value;
+			emit('update:value', state.value);
+		}
+	}
+};
+const onFocus = () => {
+	props.trigger === 'focus' && getData();
+};
+onMounted(() => {	
+	props.trigger === 'init' && getData();
+});
+</script>
+<style lang="less" scoped></style>

+ 15 - 0
src/components/Title/index.vue

@@ -0,0 +1,15 @@
+<template>
+	<div class=" font-medium " style="font-size: 16px;">
+		{{ props.title }}
+		<slot></slot>
+	</div>
+</template>
+
+<script setup lang="ts" name="title">
+const props = defineProps({
+	title: {
+		type: String,
+        default: '标题',
+	},
+});
+</script>

+ 3 - 0
src/i18n/pages/form/zh-cn.ts

@@ -1,3 +1,5 @@
+import { save } from "/@/api/admin/menu";
+
 // 定义通用内容
 export default {
 	common: {
@@ -14,6 +16,7 @@ export default {
 		queryDeptTip: '请输入部门名称',
 		resetBtn: '重置',
 		action: '操作',
+		saveBtn: '保 存',
 		optSuccessText: '操作成功',
 		editSuccessText: '修改成功',
 		addSuccessText: '添加成功',

+ 3 - 3
src/layout/navMenu/subItem.vue

@@ -2,7 +2,7 @@
 	<template v-for="val in chils">
 		<el-sub-menu :index="val.path" :key="val.path" v-if="val.children && val.children.length > 0">
 			<template #title>
-				<SvgIcon :name="val.meta.icon" />
+				<!-- <SvgIcon :name="val.meta.icon" /> -->
 				<span>{{ other.setMenuI18n(val) }}</span>
 			</template>
 			<sub-item :chil="val.children" />
@@ -10,12 +10,12 @@
 		<template v-else>
 			<el-menu-item :index="val.path" :key="val.path">
 				<template v-if="!val.meta.isLink || (val.meta.isLink && val.meta.isIframe)">
-					<SvgIcon :name="val.meta.icon" />
+					<!-- <SvgIcon :name="val.meta.icon" /> -->
 					<span>{{ other.setMenuI18n(val) }}</span>
 				</template>
 				<template v-else>
 					<a class="w100" @click.prevent="onALinkClick(val)">
-						<SvgIcon :name="val.meta.icon" />
+						<!-- <SvgIcon :name="val.meta.icon" /> -->
 						{{ other.setMenuI18n(val) }}
 					</a>
 				</template>

+ 5 - 1
src/theme/app.scss

@@ -354,6 +354,10 @@ body,
 		height: 48px;
 		border-bottom: 2px solid transparent !important;
 	}
+	.el-menu-item,
+	.el-sub-menu {
+		border-right: 2px solid var(--menu-bar-active-font-color)!important;
+	}
 	.el-menu-item:hover,
 	.el-sub-menu:hover>.el-sub-menu__title,
 	.el-sub-menu.is-active>.el-sub-menu__title,
@@ -375,7 +379,7 @@ body,
 	.el-sub-menu.is-active svg,
 	.el-sub-menu:hover svg {
 	    path {
-	        fill: rgba(22, 122, 240, 1) !important;
+	        fill: var(--menu-bar-active-font-color) !important;
 		}
 	}
 }

+ 40 - 0
src/utils/ipUpdate.ts

@@ -0,0 +1,40 @@
+// src/utils/ipUpdate.ts
+
+
+
+/**
+ * 将 "startIp/number" 格式拆分为 start 和 end IP
+ * @param ipWithRange 例如 "192.168.1.1/100"
+ * @returns { start: string, end: string }
+ */
+ const parseIpRange = (ipWithRange: string): { start: string; end: string } => {
+  if (!ipWithRange.includes('/')) {
+    // 如果没有 /,说明是普通 IP
+    return { start: ipWithRange, end: '' };
+  }
+
+  const [startIp, numberStr] = ipWithRange.split('/');
+  const baseIpParts = startIp.split('.');
+  const prefix = baseIpParts.slice(0, 3).join('.'); // 192.168.1
+  const startLastOctet = parseInt(baseIpParts[3], 10); // 1
+  const endLastOctet = parseInt(numberStr, 10); // 100
+
+  return {
+    start: `${prefix}.${Math.min(startLastOctet, endLastOctet)}`,
+    end: `${prefix}.${Math.max(startLastOctet, endLastOctet)}`,
+  };
+};
+//将ip分开由end和start组成
+const ipSplicing = (startIp: string, endIp: string | null | undefined): string => {
+  if (!endIp || endIp.trim() === '' || startIp === endIp) {
+    return startIp;
+  }
+
+  const startParts = startIp.split('.');
+  const endParts = endIp.split('.');
+  const lastPartOfEndIp = endParts[endParts.length - 1];
+
+  return `${startIp}/${lastPartOfEndIp}`;
+};
+
+export { ipSplicing, parseIpRange };

+ 54 - 1
src/utils/validate.ts

@@ -154,6 +154,19 @@ export const rule = {
 	url(rule, value, callback) {
 		validateFn('url', rule, value, callback, 'URL格式有误');
 	},
+	/* IP地址 */
+	ip(rule, value, callback) {
+		validateFn('ip', rule, value, callback, 'IP格式有误');
+	},
+	/* 域名 */
+	domain(rule, value, callback) {
+		const domainRegex = /^((?!-)[\u4e00-\u9fa5a-zA-Z0-9-]{1,63}(?<!-)\.)+[\u4e00-\u9fa5a-zA-Z]{2,}$/;
+		if (!value || domainRegex.test(value)) {
+			callback();
+		} else {
+			callback(new Error('域名格式有误'));
+		}
+	},
 
 	regExp(rule, value, callback) {
 		if (validateNull(value) || value.length <= 0) {
@@ -170,8 +183,46 @@ export const rule = {
 			callback();
 		}
 	},
+	ipRange(rule: any, value: any, callback: any) {
+		if (validateNull(value) || value.length <= 0) {
+			callback();
+			return;
+		}
+
+		// 校验普通 IP 格式(如 192.168.1.1)
+		const ipOnlyRegex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
+
+		// 校验 IP 范围格式(如 192.168.1.1/100)
+		const ipRangeRegex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/;
+
+		// 如果是普通 IP,直接通过
+		if (ipOnlyRegex.test(value)) {
+			callback();
+			return;
+		}
+
+		// 如果是 IP 范围格式,进一步校验
+		if (ipRangeRegex.test(value)) {
+			const [ip, rangeStr] = value.split('/');
+			const range = parseInt(rangeStr, 10);
+			const ipParts = ip.split('.');
+			const lastOctet = parseInt(ipParts[3], 10);
+
+			if (lastOctet > range) {
+			callback(new Error('/ 前的数字不能大于 / 后的数字'));
+			} else {
+			callback();
+			}
+			return;
+		}
+
+		// 格式都不匹配
+		callback(new Error('IP 格式不正确'));
+	},
 };
 
+
+
 /**
  * @desc  [自定义校验规则]
  * @example
@@ -191,7 +242,9 @@ export const getRegExp = function (validatorName) {
 		noChinese: '^[^\u4e00-\u9fa5]+$',
 		chinese: '^[\u4e00-\u9fa5]+$',
 		email: '^([-_A-Za-z0-9.]+)@([_A-Za-z0-9]+\\.)+[A-Za-z0-9]{2,3}$',
-		url: '(https?|ftp|file|http)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]'
+		url: '(https?|ftp|file|http)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]',
+		 domain: '^(?=.{1,253}$)(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}|(?:[0-9]{1,3}\\.){3}[0-9]{1,3})(?::[0-9]{1,5})?$',
+		ip:'^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))?$',
 	};
 	return commonRegExp[validatorName];
 };

+ 1 - 1
src/views/admin/menu/form.vue

@@ -75,7 +75,7 @@ import {useMessage} from '/@/hooks/message';
 import {rule} from '/@/utils/validate';
 // 定义子组件向父组件传值/事件
 const emit = defineEmits(['refresh']);
-const {t} = useI18n();
+const {t:$t} = useI18n();
 // 引入组件
 const IconSelector = defineAsyncComponent(() => import('/@/components/IconSelector/index.vue'));
 

+ 65 - 24
src/views/home/echarts/visitor-map.vue

@@ -1,13 +1,16 @@
 <template>
 	<div class="visitor-trend">
-		<div ref="chartRef" style="width: 100%; min-height: 290px;" class="mt-10"></div>
+		<div ref="chartRef" style="width: 100%; min-height: 290px" class="mt-10"></div>
 		<div style="position: absolute; top: 20px; left: 0">
 			<el-select v-model="selectedMap" style="width: 120px" @change="initChart">
 				<el-option v-for="item in mapOptions" :key="item.value" :label="item.label" :value="item.value" />
 			</el-select>
+			<el-select v-model="classify" class="ml-2" style="width: 160px" @change="initChart">
+				<el-option v-for="item in classifyOptions" :key="item.value" :label="item.label" :value="item.value" />
+			</el-select>
 		</div>
 		<div style="position: absolute; top: 20px; right: 0">
-				<el-select v-model="value" style="width: 84px" @change="initChart">
+			<el-select v-model="value" style="width: 84px" @change="initChart">
 				<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
 			</el-select>
 		</div>
@@ -34,19 +37,58 @@ const mapOptions = [
 	{ value: 'africa', label: '非洲' },
 	{ value: 'oceania', label: '大洋洲' },
 ];
-	const value = ref('7');
-	const options = [
-		{
-			value: '7',
-			label: '7天',
-			selected: true,
-		},
-		{
-			value: '30',
-			label: '30天',
-		},
-	];
 const selectedMap = ref('world');
+const value = ref('7');
+const options = [
+	{
+		value: '7',
+		label: '7天',
+		selected: true,
+	},
+	{
+		value: '30',
+		label: '30天',
+	},
+];
+
+const classify = ref('1');
+const classifyOptions = [
+	{
+		value: '1',
+		label: '访问',
+		selected: true,
+	},
+	{
+		value: '2',
+		label: '访客数',
+		selected: true,
+	},
+	{
+		value: '3',
+		label: '活动',
+		selected: true,
+	},
+	{
+		value: '4',
+		label: '用户管理',
+		selected: true,
+	},
+	{
+		value: '5',
+		label: '平均活动次数',
+		selected: true,
+	},
+	{
+		value: '6',
+		label: '平均网站停留时间',
+		selected: true,
+	},
+	{
+		value: '7',
+		label: '跳出率',
+		selected: true,
+	},
+];
 
 const mapFileMap: Record<string, string> = {
 	world: '/map/custom.geo.json',
@@ -75,15 +117,14 @@ async function initChart() {
 		tooltip: {
 			trigger: 'item',
 			formatter(val: any) {
-                console.log(val)
+				console.log(val);
 				if (!val.data) return;
-                mapOptions.forEach((item) => {
-                    if (item.value === mapKey) {
-                        val.data.name = item.label;
+				mapOptions.forEach((item) => {
+					if (item.value === mapKey) {
+						val.data.name = item.label;
 						console.log(val.data.name);
-						
-                    }
-                });
+					}
+				});
 				return val.name + ': ' + val.data.value;
 			},
 		},
@@ -105,16 +146,16 @@ async function initChart() {
 				zoom: 1.2,
 				itemStyle: {
 					borderWidth: 0.5,
-					borderColor: '#000',
+					borderColor: '#e4e7ed',
 					borderType: 'solid',
 				},
 				emphasis: {
 					label: {
 						show: true,
-						color: '#fff',
+						color: '#21272e',
 					},
 					itemStyle: {
-						areaColor: 'green',
+						areaColor: '#58a0f4',
 					},
 				},
 				data: [

+ 79 - 0
src/views/marketing/apps/components/domainCell.vue

@@ -0,0 +1,79 @@
+<template>
+  <div class="cell">
+    <div class="ellipsis" style="text-align: left;">
+      <span class="item" v-for="item in list" :key="item">{{item}}</span>
+    </div>
+    <div class="action" :style="{ textAlign: list.length > 0 ? 'left' : 'center'}">
+      <el-link type="info" class="btn-more" :class="{disabled: !state}" :disabled="!state" @click="handleEdit" >
+        {{ list.length > 0 ? t('marketingApps.viewDetail') : t('marketingApps.addDomain') }}
+      </el-link>
+    </div>
+  </div>
+  <DomainForm ref="DomainFormRef" @refresh="handleUpdate()" />
+</template>
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+const DomainForm = defineAsyncComponent(() => import('./domainForm.vue'));
+import { SourceType  } from '../types';
+
+const { t } = useI18n();
+const props = defineProps(['domainList', 'rowData', 'state']);
+const emit = defineEmits(['refresh']);
+const list = ref<string[]>([]);
+const DomainFormRef = ref();
+
+watch(
+  () => props.domainList,
+  (newVal) => {
+    list.value = [];
+    let temp = '';
+    (newVal || []).forEach((item: { sourceType: SourceType; domain: string; groupName: string; }) => {
+      if (item.sourceType == SourceType.DOMAIN) {
+        temp = item.domain;
+      } else {
+        temp = item.groupName;
+      }
+      list.value.push(temp);
+    });
+  },
+  { immediate: true }
+);
+
+// 修改域名弹窗
+const handleEdit = () => {
+  DomainFormRef.value.openDialog(props.domainList, props.rowData);
+};
+
+const handleUpdate = () => {
+  emit('refresh');
+}
+</script>
+<style scoped lang="scss">
+@import '/@/theme/mixins/index.scss';
+
+.cell {
+  padding: 10px 0;
+  .item {
+    box-sizing: border-box;
+    padding: 4px 0;
+    text-align: left;
+    margin-right: 15px;
+    color: #646464;
+    line-height: 14px;
+  }
+  .action {
+    width: 100%;
+  }
+  .ellipsis {
+    @include text-ellipsis(2);
+  }
+  .btn-more {
+    color: #167AF0;
+    line-height: 18px;
+    &.disabled {
+      opacity: 0.68;
+    }
+  }
+}
+</style>

+ 163 - 0
src/views/marketing/apps/components/domainCollapse.vue

@@ -0,0 +1,163 @@
+<template>
+  <JCollapse :data="[
+    { title: '域名集合', id: '1' },
+  ]" @update="(item) => domainEditOpen = true" @delete="(item) => domainDeletable = !domainDeletable"
+    :deleteText="domainDeletable ? t('common.cancelButtonText') : t('marketingApps.delete')" :updateText="t('marketingApps.add')" :activeNames="['1']">
+    <template #default>
+      <div class="p-2 items-center flex flex-wrap">
+        <template v-for="item in domains" >
+          <!-- 具体域名 -->
+            <el-tag v-if="item.sourceType == SourceType.DOMAIN" effect="light" :closable="domainDeletable"
+            @close="handleDeleteDomain(item, SourceType.DOMAIN)" color="#f4f4f4" round class="mr-1 mb-1 cursor-pointer">
+              <span class="ellipsis">{{ item.domain }}</span>
+          </el-tag>
+          <!-- 分组域名 -->
+          <el-popover v-else width="200" trigger="hover" placement="top" @show="onLoadDetail(item)">
+            <div class="flex flex-wrap break-all">
+              {{ item.domains && item.domains.length ? item.domains.map(i=>i.domain).join(',') : t('marketingApps.noData') }}
+            </div>
+            <template #reference>
+              <el-tag effect="light" :closable="domainDeletable"
+                @close="handleDeleteDomain(item, SourceType.GROUP)" color="#f4f4f4" round class="mr-1 mb-1 cursor-pointer">
+                <span class="ellipsis">{{ item.groupName }}</span>
+              </el-tag>
+            </template>
+          </el-popover>
+        </template>
+        <div class="text-gray-400" v-if="domains.length <= 0">
+          --
+        </div>
+      </div>
+    </template>
+  </JCollapse>
+  <DomainEdit v-model:open="domainEditOpen" @onsuccess="addDomain" />
+</template>
+
+<script setup lang="ts" name="domainCollapse">
+const JCollapse = defineAsyncComponent(() => import('/@/components/JCollapse/index.vue'));
+const DomainEdit = defineAsyncComponent(() => import('./domainEdit.vue'));
+import { useI18n } from 'vue-i18n';
+import { useMessage } from '/@/hooks/message';
+import { getGroupDetail } from '/@/api/marketing/config';
+import { SourceType, DomainItem } from '../types'
+
+const props = defineProps(['data']);
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['refresh','domains', 'delDomains']);
+const { t } = useI18n();
+
+// 定义变量内容
+const domainDeletable = ref(false);// 控制域名列表项是否可删除
+const domainEditOpen = ref(false);
+const delDomains = ref<string[]>([]);
+const domains = ref<DomainItem[]>([]);
+
+watch(() => props.data, (newVal = []) => {
+  domains.value = [];
+  newVal.forEach((item: DomainItem) => {
+    item.modify = false;
+    domains.value = [...domains.value, item];
+  })
+}, { immediate: true });
+
+// 监听domains变化,emit给父组件
+watch(domains, (newVal) => {
+  emit('domains', newVal);
+}, { deep: true, immediate: true });
+watch(delDomains, (newVal) => {
+  emit('delDomains', newVal);
+}, { deep: true, immediate: true });
+
+// 删除域名
+const handleDeleteDomain = (deleteItem: DomainItem, sourceType: SourceType) => {
+  domains.value = domains.value.filter(i=>{
+    if(!i.modify && i.id == deleteItem.id && !delDomains.value.includes(i.id as string)) {
+      delDomains.value = [...delDomains.value, i.id as string];
+    }
+    if(sourceType == SourceType.GROUP) {
+      return i.groupId != deleteItem.groupId
+    }else{
+      return i.domain != deleteItem.domain
+    }
+  });
+}
+
+// 分组去重
+function addGroupsUnique(newGroups: DomainItem[]) {
+  const existIds = new Set([
+    ...domains.value.map(item => item.groupId),
+  ]);
+  const filtered:DomainItem[] = newGroups.filter(item => !existIds.has(item.id as string))
+    .map(item=>{
+      return {
+        "sourceType": SourceType.GROUP,
+        "modify": true,
+        "groupId": item.id?.toString() || '',
+        "groupName": item.groupName,
+        "domains": item.domains,
+        "domain": "",
+      }
+    });
+  if(filtered.length) {
+    domains.value = [...domains.value, ...filtered];
+    console.log(filtered);
+		useMessage().success(t('common.addSuccessText'));
+  }else{
+    useMessage().warning(t('marketingApps.addDomainTip'));
+  }
+}
+
+// 域名去重
+function addSinglesUnique(newSingles: DomainItem[]) {
+  const existDomains = new Set([
+    ...domains.value.map(item => item.domain),
+  ]);
+  const filtered = newSingles.filter(item => !existDomains.has(item.domain));
+  if(filtered.length) {
+    filtered.forEach(item => {
+      Object.assign(item, {
+        "groupId": "",
+        "groupName": "",
+        "domains": [],
+        "domain": item.domain,
+        "sourceType": SourceType.DOMAIN,
+        "modify": true,
+      })
+    })
+    domains.value = [...domains.value, ...filtered];
+    console.log('去重后的域名:', filtered);
+		useMessage().success(t('common.addSuccessText'));
+  }else{
+    useMessage().warning(t('marketingApps.addDomainTip2'));
+  }
+}
+
+const addDomain = (data: DomainItem[], type: 'group' | 'domain') => {
+  if (type === 'domain') {
+    data.length && addSinglesUnique(data);
+  } else {
+    data.length && addGroupsUnique(data);
+  }
+}
+
+const onLoadDetail = async (item: any) => {
+  item.loading = true;
+	if (item?.domains && item?.domains?.length !== 0) return;
+	await getGroupDetail({ id: item.groupId }).then((val) => {
+		item.domains = val.data.domains
+	});
+  item.loading = false;
+};
+
+</script>
+<style lang="scss">
+@import '/@/theme/mixins/index.scss';
+
+.el-collapse-item__content {
+  padding: 0;
+}
+.ellipsis {
+  @include text-ellipsis(1);
+}
+</style>

+ 143 - 0
src/views/marketing/apps/components/domainEdit.vue

@@ -0,0 +1,143 @@
+<template>
+	<el-dialog :title="t('marketingApps.addDomain')" width="600" v-model="props.open" :close-on-click-modal="false"
+		:destroy-on-close="true" @close="onCancel" draggable>
+		<el-form style="height: 80px;" ref="dialogFormRef" :rules="dataRules" :model="state.ruleForm"
+			v-loading="loading">
+			<div class="custom-style">
+				<el-segmented v-model="activeName" :options="['group', 'domain']" size="default">
+					<template #default="scope">
+						<div style="min-width: 50px; line-height: 32px;">{{ scope.item == 'group' ?
+							t('marketingApps.group') : t('marketingApps.domain') }}</div>
+					</template>
+				</el-segmented>
+			</div>
+			<el-form-item label="" prop="grouping" v-if="activeName === 'group'">
+				<el-select multiple collapse-tags collapse-tags-tooltip :max-collapse-tags="5"
+					v-model="state.ruleForm.selectedIds" :placeholder="t('marketingApps.inputGroupTip')" clearable>
+					<el-option v-for="item in listSelect" :key="item.id" :label="item.groupName" :value="item.id" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="" prop="domain" v-else>
+				<el-input v-model="state.ruleForm.domain" type="text"
+					:placeholder="t('marketingApps.inputDomainTip')"></el-input>
+			</el-form-item>
+		</el-form>
+		<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 lang="ts" name="domainEdit">
+import { useI18n } from 'vue-i18n';
+import { pageListDomain } from '/@/api/marketing/config';
+import { useMessage } from '/@/hooks/message';
+import { rule } from '/@/utils/validate';
+import { DomainItem } from '../types'
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['update:open', 'onsuccess']);
+const { t } = useI18n();
+
+// 定义变量内容
+const loading = ref(false);
+const activeName = ref('group');
+const dialogFormRef = ref();
+const listSelect = ref<DomainItem[]>([]);
+const selectedObjects = ref<DomainItem[]>([]);
+
+// 定义需要的数据
+const state = reactive({
+	ruleForm: {
+		selectedIds: [] as string[], // 选中域名分组
+		domain: [],
+	},
+});
+
+const props = defineProps({
+	open: {
+		type: Boolean,
+		default: false,
+	},
+});
+
+watch(() => props.open, async (val) => {
+	if (val) {
+		state.ruleForm.selectedIds = [];
+		state.ruleForm.domain = [];
+		getDomainList();
+	}
+})
+
+// 表单校验规则
+const dataRules = reactive({
+	domain: [
+		{ required: true, message: '域名不能为空', trigger: 'blur' },
+		{ validator: rule.domain, trigger: 'blur' },
+		{ validator: rule.overLength, trigger: 'blur' },
+	],
+});
+const onCancel = () => {
+	emit('update:open', false);
+};
+// 保存数据
+const onSubmit = async () => {
+
+	const valid = await dialogFormRef.value.validate();
+	console.log('valid', valid);
+
+	try {
+		loading.value = true;
+		if (activeName.value == 'group') {
+			selectedObjects.value = [];
+			selectedObjects.value = listSelect.value.filter(item => state.ruleForm.selectedIds.includes(item.id as string));
+			emit('onsuccess', selectedObjects.value, activeName.value);
+		} else if (state.ruleForm.domain.length > 0) {
+			emit('onsuccess', [{ domain: state.ruleForm.domain }], activeName.value);
+		}
+		onCancel();
+	} catch (err) {
+		console.log(err);
+		useMessage().error(err.msg);
+	} finally {
+		loading.value = false;
+	}
+};
+
+const getDomainList = async () => {
+	const data = await pageListDomain();
+	listSelect.value = data.data;
+}
+
+</script>
+<style scoped>
+.apps-loadmore.el-select-dropdown .el-scrollbar__wrap {
+	height: 330px !important;
+}
+
+.custom-style {
+	margin-top: 14px;
+
+	.el-segmented__item {
+		border-right: 1px solid rgba(230, 230, 230, 1);
+		min-width: 72px;
+		flex: unset;
+	}
+
+	.el-segmented {
+		border: 1px solid rgba(230, 230, 230, 1);
+		border-bottom: unset;
+		--el-segmented-bg-color: #ffffff;
+		--el-segmented-item-hover-bg-color: rgba(22, 122, 240, 0.1);
+		--el-segmented-item-selected-color: rgba(22, 122, 240, 1);
+		--el-segmented-item-hover-color: rgba(22, 122, 240, 1);
+		--el-segmented-item-selected-bg-color: rgba(22, 122, 240, 0.1);
+		--el-border-radius-base: 4px 4px 0 0;
+		--el-segmented-padding: 0;
+	}
+}
+</style>

+ 83 - 0
src/views/marketing/apps/components/domainForm.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="apps-form">
+    <el-dialog
+      append-to-body :title="t('marketingApps.editDomain')" width="880" v-model="visible" :close-on-click-modal="false" :destroy-on-close="true"
+      draggable>
+      <DomainCollapse :data=domains @domains="updateDomains"
+        @delDomains="handleDelDomains"></DomainCollapse>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="visible = false">{{ t('common.cancelButtonText') }}</el-button>
+          <el-button type="primary" @click="onSubmit" :disabled="loading">{{ t('common.confirmButtonText')
+          }}</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="domainForm">
+import { useMessage } from '/@/hooks/message';
+import { useI18n } from 'vue-i18n';
+import { updateModDomains } from '/@/api/marketing/apps';
+import { DomainItem } from '../types';
+
+const DomainCollapse = defineAsyncComponent(() => import('./domainCollapse.vue'));
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['refresh']);
+const { t } = useI18n();
+
+// 定义变量内容
+const visible = ref(false);
+const loading = ref(false);
+const domains = ref<DomainItem[]>([]);
+const childDomains = ref();
+const delDomains = ref<string[]>([]);
+const rowData = ref();
+
+// 打开弹窗
+const openDialog = async (data: any, row) => {
+  visible.value = true;
+  domains.value = data;
+  childDomains.value = [];
+  delDomains.value = [];
+  rowData.value = row;
+};
+
+const updateDomains = (data: DomainItem[])=>{
+  childDomains.value = data;
+}
+const handleDelDomains = (data: string[]) => {
+  delDomains.value = data;
+}
+
+// 保存数据
+const onSubmit = async () => {
+  try {
+    updateModDomains({ domains: childDomains.value, delDomains: delDomains.value, id: rowData.value.id})
+    emit('refresh');
+    useMessage().success(t('marketingApps.submitSuccess'));
+    visible.value = false;
+  } catch (error) {
+    useMessage().error(t('marketingApps.submitFail'));
+    console.error(error);
+  }
+};
+
+// 暴露变量 只有暴漏出来的变量 父组件才能使用
+defineExpose({
+  openDialog,
+});
+</script>
+<style lang="scss">
+.el-overlay {
+  .el-overlay-dialog {
+    .el-dialog {
+      .el-dialog__body {
+        padding: 0 !important;
+      }
+    }
+  }
+}
+</style>

+ 286 - 0
src/views/marketing/apps/components/form.vue

@@ -0,0 +1,286 @@
+<template>
+  <div class="apps-form">
+    <el-dialog :title="(!state.ruleForm.id ? '批量' : '') + '修改营销配置'" width="880" v-model="visible"
+      :close-on-click-modal="false" :destroy-on-close="true" draggable>
+      <el-form ref="appDialogFormRef" :rules="dataRules" :model="state.ruleForm" class="demo-form-inline"
+        v-loading="loading">
+        <el-form-item v-if="state.ruleForm.appName" :label="t('marketingApps.name')" prop="appName">
+          <el-input style="width: 200px;" v-model="state.ruleForm.appName" disabled />
+        </el-form-item>
+        <el-row :gutter="20" class="mb18">
+          <el-col :span="12">
+            <el-form-item :label="'营销开关'" prop="launch">
+              <el-switch v-model="state.ruleForm.launch" style="--el-switch-on-color: rgb(48, 185, 113);" inline-prompt
+                :active-value="true" :inactive-value="false" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item :label="'域名限制'" prop="domainLimit">
+              <el-switch v-model="state.ruleForm.domainLimit" style="--el-switch-on-color: rgb(48, 185, 113);"
+                inline-prompt :active-value="true" :inactive-value="false" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item :label="'触发频率'" prop="triggerNumFormat">
+              <el-input style="width: 200px;" v-model="state.ruleForm.triggerNumFormat" :placeholder="'请输入触发频率'" clearable />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item :label="'触发规则'" prop="triggerRule">
+              <el-select style="width: 200px;" v-model="state.ruleForm.triggerRule" placeholder="">
+                <el-option v-for="item in triggerRules" :key="item.value" :label="item.label" :value="item.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <div class="mt-7">
+          <IpCollapse :data=state.ruleForm.ips @ips="updateIps" @delIps="updateDelIps"></IpCollapse>
+        </div>
+        <div class="mt-7">
+          <DomainCollapse :data=state.ruleForm.domains @domains="updateDomains" @delDomains="updateDelDomains">
+          </DomainCollapse>
+        </div>
+        <div class="title">{{ t('marketingApps.remark') }}</div>
+        <el-form-item>
+          <el-input :rows="4" v-model="state.ruleForm.remark" type="textarea" placeholder="请输入备注"></el-input>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="visible = false">{{ t('common.cancelButtonText') }}</el-button>
+          <el-button type="primary" @click="onSubmit" :disabled="loading">{{ t('common.confirmButtonText')
+          }}</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+const DomainCollapse = defineAsyncComponent(() => import('./domainCollapse.vue'));
+const IpCollapse = defineAsyncComponent(() => import('./ipCollapse.vue'));
+import { useMessage } from '/@/hooks/message';
+import { useI18n } from 'vue-i18n';
+import { appUpdate } from '/@/api/marketing/apps';
+import { AppData, DomainItem, IpItem } from '../types';
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['refresh']);
+const { t } = useI18n();
+
+// 定义变量内容
+const visible = ref(false);
+const loading = ref(false);
+const appDialogFormRef = ref();
+const domains = ref<DomainItem[]>();
+const delDomains = ref<string[]>([]);
+const childIps = ref<IpItem[]>();
+const delIps = ref<string[]>([]);
+// 批量操作
+const rows = ref<AppData[]>([]);
+
+const triggerRules = [
+  {
+    label: '仅一次',
+    value: 1,
+  },
+  {
+    label: '多次',
+    value: 2,
+  },
+]
+
+// 定义需要的数据
+const state = reactive({
+  ruleForm: {
+    remark: "",
+    domainLimit: true,
+    launch: true,
+    triggerNum: "",
+    triggerNumFormat: "",
+    triggerRule: 1,
+  } as AppData,
+});
+
+watch(() => state.ruleForm, async (val) => {
+  console.log('val', val);
+})
+
+const updateIps = (data: IpItem[]) => {
+  childIps.value = data;
+}
+const updateDelIps = (data: string[]) => {
+  delIps.value = data;
+}
+
+const updateDomains = (data: DomainItem[]) => {
+  domains.value = data;
+}
+const updateDelDomains = (data: string[]) => {
+  delDomains.value = data;
+}
+
+// 格式化数据展示
+const formatNum = (value: string | number = 0) => {
+  let num = Number(value);
+  if(num > 0 && num < 1) {
+    return num * 100 + '%';
+  }else if (num >= 1 && num < 10000) {
+    return num;
+  }
+  return ''
+}
+
+// 打开弹窗
+const openDialog = async (type: string, row: any) => {
+  visible.value = true;
+  if(row instanceof Array){
+    state.ruleForm = {
+      remark: "",
+      domainLimit: true,
+      launch: true,
+      triggerNum: "",
+      triggerRule: 1,
+    } as AppData;
+    rows.value = row;
+  }else if(row instanceof Object) {
+    if(row.triggerNum) {
+      row.triggerNumFormat = formatNum(row.triggerNum);
+    }
+    state.ruleForm = row;
+    rows.value = [row]
+  }
+  domains.value = [];
+  delDomains.value = [];
+  console.log(row);
+};
+
+// 表单校验规则
+const dataRules = reactive({
+  triggerNumFormat: [
+    { 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',
+    },
+  ],
+});
+
+/**
+ * 处理数字字符串的方法
+ * @param input - 输入值(字符串或空值)
+ * @param defaultValue - 默认值(默认为0)
+ * @returns 数值类型结果
+ */
+function processNumberString(
+  input: string | null | undefined, 
+  defaultValue: number = 0
+): number {
+  input = input?.toString() || '';
+  // 处理空值情况
+  if (input === null || input === undefined || input.trim() === '') {
+    return defaultValue;
+  }
+
+  // 去除前后空格
+  const trimmedInput = input.trim();
+
+  // 检查是否为百分比格式
+  if (trimmedInput.endsWith('%')) {
+    const percentStr = trimmedInput.slice(0, -1);
+    const percentValue = parseFloat(percentStr);
+    
+    // 验证是有效的0-100%数字
+    if (!isNaN(percentValue) && percentValue >= 0 && percentValue <= 100) {
+      return percentValue / 100;
+    }
+    return defaultValue; // 无效百分比返回默认值
+  }
+  
+  // 处理普通数字字符串
+  const numValue = parseFloat(trimmedInput);
+  
+  // 验证是0-10000的有效数字
+  if (!isNaN(numValue) && numValue >= 0 && numValue <= 10000) {
+    return numValue;
+  }
+  
+  return defaultValue; // 无效数字返回默认值
+}
+
+// 保存数据
+const onSubmit = async () => {
+
+  const valid = await appDialogFormRef.value.validate();
+  console.log('valid', valid);
+  
+  try {
+    loading.value = true;
+    const data:AppData[] = [];
+    rows.value.forEach(async (item) => {
+      data.push({
+        ...item,
+        domains: domains.value,
+        delDomains: state.ruleForm.id ? delDomains.value : item.domains.map((item: DomainItem) => item.id),
+        ips: childIps.value,
+        delIps: state.ruleForm.id ? delIps.value : item.ips.map((item: IpItem) => item.id),
+        remark: state.ruleForm.remark,
+        domainLimit: state.ruleForm.domainLimit,
+        launch: state.ruleForm.launch,
+        triggerNum: `${processNumberString(state.ruleForm.triggerNumFormat)}`,
+        triggerRule: state.ruleForm.triggerRule,
+      })
+    })
+    console.log('rows.value', data);
+    
+    await appUpdate(data);
+    useMessage().success(t('marketingApps.submitSuccess'));
+    visible.value = false;
+    emit('refresh');
+  } catch (err: any) {
+    useMessage().error(t('marketingApps.submitFail'));
+    console.error(err);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 暴露变量 只有暴漏出来的变量 父组件才能使用
+defineExpose({
+  openDialog,
+});
+</script>
+<style lang="scss">
+.apps-form {
+  .collapse-group {
+    display: flex;
+    justify-content: start;
+    align-items: first baseline;
+  }
+
+  .tag-content {
+    flex: 1;
+  }
+
+  .el-overlay {
+    .el-overlay-dialog {
+      .el-dialog {
+        .el-dialog__body {
+          padding: 0 !important;
+        }
+      }
+    }
+  }
+
+  .title {
+    line-height: 20px;
+    font-family: Source Han Sans SC;
+    font-size: 14px;
+    color: rgba(18, 18, 18, 1);
+    margin: 25px 0 12px;
+  }
+}
+</style>

+ 94 - 0
src/views/marketing/apps/components/ipCell.vue

@@ -0,0 +1,94 @@
+<template>
+  <div class="cell">
+    <div class="ellipsis" style="width: 100%; text-align: left;" v-for="(list, index) in [whitelist, blacklist]">
+      <span class="title">{{ index == 0 ? t('marketingApps.whiteList') : t('marketingApps.blackList') }}</span>
+      <span class="item" v-for="item in list" :key="item">{{item}}</span>
+      <span class="item" v-if="list.length <= 0">--</span>
+    </div>
+    <div class="action">
+      <el-link type="info" class="btn-more" :class="{disabled: !state}" :disabled="!state" @click="handleEdit" >
+        {{ ipList.length > 0 ? t('marketingApps.viewDetail') : t('marketingApps.addIp')}}
+      </el-link>
+    </div>
+  </div>
+  <IpForm ref="IpFormRef" @refresh="handleUpdate()" />
+</template>
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+const IpForm = defineAsyncComponent(() => import('./ipForm.vue'));
+import { ipSplicing } from '/@/utils/ipUpdate';
+import { SourceType, IpType } from '../types';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps(['ipList', 'rowData', 'state']);
+const emit = defineEmits(['refresh']);
+const { t } = useI18n();
+
+const blacklist = ref([]);
+const whitelist = ref([]);
+const IpFormRef = ref();
+
+watch(
+  () => props.ipList,
+  (newVal) => {
+    blacklist.value = [];
+    whitelist.value = [];
+    let temp = '';
+    (newVal || []).forEach(item => {
+      if (item.sourceType == SourceType.DOMAIN) {
+        temp = ipSplicing(item.startIp, item.endIp);
+      } else {
+        temp = item.groupName;
+      }
+      item.ipType == IpType.WHITE ? whitelist.value.push(temp) : blacklist.value.push(temp);
+    });
+  },
+  { immediate: true }
+);
+
+// 修改域名弹窗
+const handleEdit = () => {
+  IpFormRef.value.openDialog(props.ipList, props.rowData);
+};
+
+const handleUpdate = () => {
+  emit('refresh');
+}
+</script>
+<style scoped lang="scss">
+@import '/@/theme/mixins/index.scss';
+
+.cell {
+  padding: 10px 0;
+  .title {
+    text-align: left;
+    line-height: 14px;
+    height: 14px;
+    margin-right: 15px;
+    padding: 4px 0;
+  }
+  .item {
+    width: auto;
+    box-sizing: border-box;
+    padding: 4px 0;
+    text-align: left;
+    margin-right: 15px;
+    color: #646464;
+    line-height: 14px;
+  }
+  .action {
+    width: 100%;
+    text-align: left;
+  }
+  .ellipsis {
+    @include text-ellipsis(2);
+  }
+  .btn-more {
+    color: #167AF0;
+    line-height: 18px;
+    &.disabled {
+      opacity: 0.68;
+    }
+  }
+}
+</style>

+ 178 - 0
src/views/marketing/apps/components/ipCollapse.vue

@@ -0,0 +1,178 @@
+<template>
+  <JCollapse :data="[{ title: 'IP集合', id: '1' }]" @update="(item: any) => listEditOpen = true"
+    @delete="(item: any) => ipDeletable = !ipDeletable"
+    :deleteText="ipDeletable ? t('common.cancelButtonText') : t('marketingApps.delete')" :updateText="t('marketingApps.add')" :activeNames="['1']">
+    <template #default>
+      <div class="border-b p-2 items-center flex flex-wrap collapse-group min-h-[40px]" v-for="type in [IpType.WHITE, IpType.BLACK]"
+        :key="type">
+        <div class="collapse-group-name">{{ type === IpType.WHITE ? t('marketingApps.whiteList') : t('marketingApps.blackList') }}:</div>
+        <div class="tag-content" v-if="getTargetList(type).length > 0">
+          <template v-for="item in getTargetList(type)" :key="item.id">
+            <!-- 单个 -->
+            <el-tag v-if="item.sourceType == 2" effect="light" :closable="ipDeletable" @close="handleDeleteIp(item)"
+              color="#f4f4f4" round class="mr-1 mb-1 cursor-pointer">
+              <span class="ellipsis">{{ ipSplicing(item.startIp, item.endIp) }}</span>
+            </el-tag>
+            <!-- 分组 -->
+            <el-popover v-else width="200" trigger="hover" placement="top" @show="onLoadDetail(item)">
+              <div v-if="item.list.length > 0" class="flex flex-wrap break-all">
+                <span v-for="ip in item.list" :key="String(ip)" class="mr-2">{{ ip }}</span>
+              </div>
+              <div v-else>{{ t('marketingApps.noData') }}</div>
+              <template #reference>
+                <el-tag effect="light" :closable="ipDeletable" @close="handleDeleteIp(item)" color="#f4f4f4" round
+                  class="mr-1 mb-1 cursor-pointer">
+                  <span class="ellipsis" v-if="item.groupName">{{ item.groupName.length > 30 ?
+                    item.groupName.substring(0, 30) + '...' : item.groupName }}</span>
+                </el-tag>
+              </template>
+            </el-popover>
+          </template>
+        </div>
+        <div class="text-gray-400" v-else>--</div>
+      </div>
+    </template>
+  </JCollapse>
+  <IpEdit v-model:open="listEditOpen" @onsuccess="addIp" />
+</template>
+<script setup lang="ts" name="ipCollapse">
+const JCollapse = defineAsyncComponent(() => import('/@/components/JCollapse/index.vue'));
+const IpEdit = defineAsyncComponent(() => import('./ipEdit.vue'));
+import { useI18n } from 'vue-i18n';
+import { getGroupDetail } from '/@/api/marketing/config';
+import { ipSplicing } from '/@/utils/ipUpdate';
+import { useMessage } from '/@/hooks/message';
+import { SourceType, IpType, IpMode, IpItem } from '../types';
+
+const props = defineProps(['data']);
+const emit = defineEmits(['refresh', 'ips', 'delIps']);
+const { t } = useI18n();
+
+const ips = ref<IpItem[]>([]);
+const ipDeletable = ref(false);
+const listEditOpen = ref(false);
+const delIps = ref<string[]>([]);
+
+watch(ips, (newVal) => emit('ips', newVal), { deep: true, immediate: true });
+watch(delIps, (newVal) => emit('delIps', newVal), { deep: true, immediate: true });
+
+const getTargetList = (ipType: number) => ips.value.filter(i => i.ipType === ipType);
+
+const setTargetList = (ipType: number, newList: IpItem[]) => {
+  ips.value = [
+    ...ips.value.filter(i => i.ipType !== ipType),
+    ...newList
+  ];
+};
+
+watchEffect(() => {
+  if (props.data) {
+    ips.value = props.data.map((item: any) => ({ ...item, list: [] }));
+  }
+});
+
+const handleDeleteIp = (deleteItem: IpItem) => {
+  if (!deleteItem.modify && !delIps.value.includes(deleteItem.id as string)) {
+    delIps.value.push(deleteItem.id as string);
+  }
+  ips.value = ips.value.filter(i => {
+    if (i.id !== deleteItem.id) return true;
+    if (deleteItem.sourceType === SourceType.GROUP) return i.groupId !== deleteItem.groupId;
+    return i.startIp !== deleteItem.startIp || i.endIp !== deleteItem.endIp;
+  });
+};
+
+const onLoadDetail = async (item: any) => {
+  if (item.list.length || item.modify) return;
+  const val = await getGroupDetail({ id: item.groupId });
+  item.list = val.data.ips.map((item: any) => ipSplicing(item.startIp, item.endIp));
+};
+
+function addGroupsUnique(newGroups: IpItem[], ipType: number) {
+  const targetList = getTargetList(ipType);
+  const conflictType = ipType === IpType.WHITE ? IpType.BLACK : IpType.WHITE;
+  const conflictList = getTargetList(conflictType);
+  const existIds = new Set(targetList.filter(i => i.sourceType === SourceType.DOMAIN).map(i => i.groupId));
+  const conflictIds = new Set(conflictList.filter(i => i.sourceType === SourceType.GROUP).map(i => i.groupId));
+
+  const conflict = newGroups.find(i => conflictIds.has(i.id as string));
+  if (conflict) {
+    useMessage().warning(`分组 ${conflict.groupName} 已存在于 ${ipType === IpType.WHITE ? '黑名单' : '白名单'} 中`);
+    return;
+  }
+
+  const filtered = newGroups.filter(i => !existIds.has(i.id as string));
+
+  if (filtered.length) {
+    const processedGroups = filtered.map(item => ({
+      ipType,
+      sourceType: SourceType.GROUP,
+      groupId: item.id as string,
+      groupName: item.groupName,
+      ipMode: IpMode.IP,
+      startIp: '',
+      endIp: '',
+      modify: true,
+      list: item.list || [],
+    }));
+    setTargetList(ipType, [...targetList, ...processedGroups]);
+  } else {
+    useMessage().warning(t('marketingApps.addIpTip'));
+  }
+}
+
+function addSinglesUnique(newSingles: IpItem[], ipType: number) {
+  const targetList = getTargetList(ipType);
+  const conflictType = ipType === IpType.WHITE ? IpType.BLACK : IpType.WHITE;
+  const conflictList = getTargetList(conflictType);
+
+  const existIps = new Set(targetList.filter(i => i.sourceType === SourceType.DOMAIN).map(i => i.startIp));
+  const conflictIps = new Set(conflictList.filter(i => i.sourceType === SourceType.DOMAIN).map(i => i.startIp));
+
+  const conflict = newSingles.find(i => conflictIps.has(i.startIp));
+  if (conflict) {
+    console.log(conflict);
+    useMessage().warning(`IP ${conflict.startIp} 已存在于 ${ipType === IpType.WHITE ? '黑名单' : '白名单'} 中`);
+    return;
+  }
+
+  const filtered = newSingles.filter(i => !existIps.has(i.startIp));
+
+  if (filtered.length) {
+    const processedSingles = filtered.map(i => ({
+      endIp: i.endIp,
+      groupId: '',
+      groupName: '',
+      ipMode: i.endIp ? IpMode.IPRange : IpMode.IP,
+      startIp: i.startIp,
+      sourceType: SourceType.DOMAIN,
+      modify: true,
+      ipType,
+      list: []
+    }));
+    setTargetList(ipType, [...targetList, ...processedSingles]);
+  } else {
+    useMessage().warning(t('marketingApps.addIpTip2'));
+  }
+}
+
+const addIp = (data: IpItem[], sourceType: SourceType.GROUP | SourceType.DOMAIN, ipType: IpType.WHITE | IpType.BLACK) => {
+  const ipTypeNum = Number(ipType);
+  if (sourceType === SourceType.DOMAIN) {
+    data.length && addSinglesUnique(data, ipTypeNum);
+  } else {
+    data.length && addGroupsUnique(data, ipTypeNum);
+  }
+};
+</script>
+<style lang="scss">
+@import '/@/theme/mixins/index.scss';
+
+.el-collapse-item__content {
+  padding: 0;
+}
+
+.ellipsis {
+  @include text-ellipsis(1);
+}
+</style>

+ 211 - 0
src/views/marketing/apps/components/ipEdit.vue

@@ -0,0 +1,211 @@
+<template>
+  <el-dialog :title="t('marketingApps.addIp')" width="600" v-model="props.open" :close-on-click-modal="false"
+    :destroy-on-close="true" @close="onCancel" draggable>
+    <el-form style="height: 128px;" ref="formRef" :rules="dataRules" :model="state.ruleForm" v-loading="loading">
+      <el-form-item label="" prop="ipType">
+        <el-radio-group v-model="state.ruleForm.ipType">
+          <el-radio :value="IpType.WHITE">{{ t('marketingApps.whiteList') }}</el-radio>
+          <el-radio :value="IpType.BLACK">{{ t('marketingApps.blackList') }}</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <div class="custom-style">
+        <el-segmented v-model="state.ruleForm.sourceTypeText"
+          :options="[t('marketingConfig.grouping'), t('marketingConfig.ipSegment')]" size="default">
+          <template #default="scope">
+            <div style="min-width: 50px; line-height: 32px;">
+              {{ scope.item }}
+              <el-icon v-if="scope.item === t('marketingConfig.ipSegment')" size="12" style="vertical-align: middle;">
+                <el-tooltip effect="light" content="输入127.0.0.1/24格式代表网段" placement="top">
+                  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
+                    <path
+                      d="M7 14C8.93298 14 10.683 13.2165 11.9497 11.9497C13.2165 10.683 14 8.93298 14 7C14 5.06702 13.2165 3.31702 11.9497 2.05025C10.683 0.783503 8.93298 0 7 0C5.06702 0 3.31702 0.783503 2.05025 2.05025C0.783503 3.31702 0 5.06702 0 7C0 8.93298 0.783503 10.683 2.05025 11.9497C3.31702 13.2165 5.06702 14 7 14Z"
+                      fill="#1B4D88" fill-opacity="0.4" />
+                    <path fill-rule="evenodd" clip-rule="evenodd"
+                      d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434Z"
+                      fill="white" />
+                    <path
+                      d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434ZM6.99957 2.33322C6.6774 2.33322 6.41623 2.5944 6.41623 2.91656C6.41623 3.23872 6.6774 3.49989 6.99957 3.49989C7.32173 3.49989 7.5829 3.23872 7.5829 2.91656C7.5829 2.5944 7.32173 2.33322 6.99957 2.33322Z"
+                      fill="white" />
+                    <path d="M7.19477 10.8888V5.44434H6.80588H6.41699" fill="white" />
+                    <path
+                      d="M6.41645 10.8887V6.22206C5.98689 6.22206 5.63867 5.87384 5.63867 5.44428C5.63867 5.01473 5.98689 4.6665 6.41645 4.6665H7.19423L7.27398 4.6703C7.66608 4.71023 7.97201 5.04164 7.97201 5.44428V10.8887C7.97201 11.3183 7.70354 11.3447 7.27398 11.3447C6.84443 11.3447 6.41645 11.3183 6.41645 10.8887Z"
+                      fill="white" />
+                    <path
+                      d="M8.55566 10C8.98522 10 9.33344 10.3482 9.33344 10.7778C9.33344 11.2073 8.98522 11.5556 8.55566 11.5556H5.83344C5.40389 11.5556 5.05566 11.2073 5.05566 10.7778C5.05566 10.3482 5.40389 10 5.83344 10H8.55566Z"
+                      fill="white" />
+                  </svg>
+                </el-tooltip>
+              </el-icon>
+            </div>
+          </template>
+        </el-segmented>
+      </div>
+      <el-form-item label="" prop="groupId" v-if="state.ruleForm.sourceTypeText === t('marketingConfig.grouping')">
+        <el-select v-model="state.ruleForm.groupId" :placeholder="t('marketingConfig.groupingTip')" multiple clearable
+          collapse-tags collapse-tags-tooltip :max-collapse-tags="5">
+          <el-option v-for="item in selectData" :key="item.id" :label="item.groupName" :value="item.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="" prop="ip" v-else>
+        <el-input v-model="state.ruleForm.ip" type="text" :placeholder="t('marketingConfig.ipTip')"></el-input>
+      </el-form-item>
+    </el-form>
+    <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 lang="ts">
+import { useI18n } from 'vue-i18n';
+import { useMessage } from '/@/hooks/message';
+import { pageListIp } from '/@/api/marketing/config';
+import { ipSplicing } from '/@/utils/ipUpdate';
+import { rule } from '/@/utils/validate';
+import { parseIpRange } from '/@/utils/ipUpdate';
+import { SourceType, IpType, IpMode, IpItem } from '../types';
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['update:open', 'onsuccess']);
+const { t } = useI18n();
+
+// 定义变量内容
+const loading = ref(false);
+const type = ref('Edit');
+const formRef = ref();
+const selectData = ref<IpItem[]>([]);
+const selectedObjects = ref<IpItem[]>([]);
+
+// 定义需要的数据
+const state = reactive({
+  ruleForm: {
+    ipType: IpType.WHITE,
+    sourceType: SourceType.GROUP,
+    sourceTypeText: t('marketingConfig.grouping'),
+    groupId: [] as IpItem[],
+    ipMode: IpMode.IP,
+    groupName: '',
+    startIp: '',
+    endIp: '',
+    ip: '',
+  },
+});
+
+const props = defineProps({
+  open: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+// 表单校验规则
+const dataRules = reactive({
+  groupId: [{ required: true, message: '分组不能为空', trigger: 'blur' }],
+  ip: [
+    { required: true, message: 'IP不能为空', trigger: 'blur' },
+    { validator: rule.ip, trigger: 'blur' },
+  ],
+});
+
+// 获取ip列表
+const getIpData = async () => {
+  await pageListIp().then((val) => {
+    selectData.value = val.data.map((item) => {
+      return {
+        ...item,
+        title: item.groupName,
+        id: item.id,
+        list: item.ips.map((items) => {
+          return {
+            ...items,
+            id: items.id,
+            value: ipSplicing(items.startIp, items.endIp),
+          };
+        }),
+      };
+    });
+  });
+};
+
+watchEffect(() => {
+  if (props.open) {
+    state.ruleForm.groupId = [];
+    state.ruleForm.ip = '';
+    state.ruleForm.ipType = IpType.WHITE;
+    getIpData();
+  }
+})
+
+const onCancel = () => {
+  emit('update:open', false);
+};
+
+// 保存数据
+const onSubmit = async () => {
+
+  const valid = await formRef.value.validate();
+  console.log('valid', valid);
+
+  const lis = parseIpRange(state.ruleForm.ip);
+  state.ruleForm.startIp = lis.start;
+  state.ruleForm.endIp = lis.end;
+  if (lis.end !== '') {
+    state.ruleForm.ipMode = IpMode.IPRange;
+  }
+
+  state.ruleForm.sourceType = state.ruleForm.sourceTypeText === t('marketingConfig.grouping') ? SourceType.GROUP : SourceType.DOMAIN;
+  if (state.ruleForm.sourceType == SourceType.GROUP && state.ruleForm.groupId.length == 0) {
+    return useMessage().error('请选择分组');
+  } else if (state.ruleForm.sourceType == SourceType.DOMAIN && state.ruleForm.ip == '') {
+    return useMessage().error('ip不能为空');
+  }
+
+  try {
+    loading.value = true;
+    if (state.ruleForm.sourceTypeText == t('marketingConfig.grouping')) {
+      selectedObjects.value = [];
+      selectedObjects.value = selectData.value.filter(item => state.ruleForm.groupId.includes(item.id));
+      emit('onsuccess', selectedObjects.value, state.ruleForm.sourceType, state.ruleForm.ipType + '');
+    } else if (state.ruleForm.startIp.length > 0) {
+      emit('onsuccess', [state.ruleForm], state.ruleForm.sourceType, state.ruleForm.ipType + '');
+    }
+    onCancel();
+  } catch (err) {
+    useMessage().error(err);
+  } finally {
+    loading.value = false;
+  }
+};
+
+</script>
+<style>
+.apps-loadmore.el-select-dropdown .el-scrollbar__wrap {
+  height: 330px !important;
+}
+
+.custom-style {
+  margin-top: 14px;
+
+  .el-segmented__item {
+    border-right: 1px solid rgba(230, 230, 230, 1);
+    min-width: 72px;
+    flex: unset;
+  }
+
+  .el-segmented {
+    border: 1px solid rgba(230, 230, 230, 1);
+    border-bottom: unset;
+    --el-segmented-bg-color: #ffffff;
+    --el-segmented-item-hover-bg-color: rgba(22, 122, 240, 0.1);
+    --el-segmented-item-selected-color: rgba(22, 122, 240, 1);
+    --el-segmented-item-hover-color: rgba(22, 122, 240, 1);
+    --el-segmented-item-selected-bg-color: rgba(22, 122, 240, 0.1);
+    --el-border-radius-base: 4px 4px 0 0;
+    --el-segmented-padding: 0;
+  }
+}
+</style>

+ 79 - 0
src/views/marketing/apps/components/ipForm.vue

@@ -0,0 +1,79 @@
+<template>
+  <div class="apps-form">
+    <el-dialog
+      append-to-body :title="t('marketingApps.editIp')" width="880" v-model="visible" :close-on-click-modal="false" :destroy-on-close="true"
+      draggable>
+      <IpCollapse :data=ips  @ips="updateIps" @delIps="updateDelIps"></IpCollapse>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="visible = false">{{ t('common.cancelButtonText') }}</el-button>
+          <el-button type="primary" @click="onSubmit" :disabled="loading">{{ t('common.confirmButtonText')
+          }}</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useMessage } from '/@/hooks/message';
+import { useI18n } from 'vue-i18n';
+import { updateModIp } from '/@/api/marketing/apps';
+const IpCollapse = defineAsyncComponent(() => import('./ipCollapse.vue'));
+import { IpItem } from '../types';
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['refresh']);
+const { t } = useI18n();
+
+// 定义变量内容
+const visible = ref(false);
+const loading = ref(false);
+const ips = ref<IpItem[]>([]);
+const childIps = ref();
+const delIps = ref<string[]>([]);
+const rowData = ref();
+
+// 打开弹窗
+const openDialog = async (data: any, row) => {
+  visible.value = true;
+  ips.value = data;
+  rowData.value = row;
+};
+
+const updateIps = (data: IpItem[])=>{
+  childIps.value = data;
+}
+const updateDelIps = (data: string[]) => {
+  delIps.value = data;
+}
+
+// 保存数据
+const onSubmit = async () => {
+  try {
+    await updateModIp({ ips: childIps.value, delIps: delIps.value, id: rowData.value.id});
+    emit('refresh');
+    useMessage().success(t('marketingApps.submitSuccess'));
+    visible.value = false;
+  } catch (error) {
+    useMessage().error(t('marketingApps.submitFail'));
+    console.error(error);
+  }
+};
+
+// 暴露变量 只有暴漏出来的变量 父组件才能使用
+defineExpose({
+  openDialog,
+});
+</script>
+<style lang="scss">
+.el-overlay {
+  .el-overlay-dialog {
+    .el-dialog {
+      .el-dialog__body {
+        padding: 0 !important;
+      }
+    }
+  }
+}
+</style>

+ 233 - 0
src/views/marketing/apps/components/statistical.vue

@@ -0,0 +1,233 @@
+<template>
+  <div class="dialog">
+    <el-dialog
+      :title="t('marketingApps.statistical')"
+      width="80%"
+      v-model="visible"
+      :destroy-on-close="true"
+      :close-on-click-modal="false" 
+      draggable>
+      <div class="dialog-body">
+        <div class="statistics-table-wrapper">
+          <el-row v-show="showSearch">
+            <el-form :inline="true" :model="state.queryForm" @keyup.enter="withCollapsedChildren(getDataList)" ref="queryRef">
+              <el-form-item :label="t('marketingApps.time')" prop="timeRange">
+                <el-date-picker
+                  :end-placeholder="$t('syslog.inputEndPlaceholderTip')"
+                  :start-placeholder="$t('syslog.inputStartPlaceholderTip')"
+                  range-separator="To"
+                  type="datetimerange"
+                  v-model="state.queryForm.timeRange"
+                  value-format="YYYY-MM-DD HH:mm:ss"
+                  :disabled-date="disabledDate"
+                />
+              </el-form-item>
+            </el-form>
+            <el-form-item>
+              <el-button @click="withCollapsedChildren(getDataList)" type="primary" icon="Search">{{ t('common.queryBtn') }}</el-button>
+            </el-form-item>
+          </el-row>
+          <el-table class="statistics-table" :data="state.dataList" row-key="ip" @sort-change="withCollapsedChildren(sortChangeHandle)" style="width: 100%"
+            v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
+            :header-cell-style="tableStyle.headerCellStyle"
+            :expand-row-keys="expandedRowKeys"
+            @expand-change="handleExpandChange"
+            >
+            <el-table-column :formatter="statusFormatter" show-overflow-tooltip type="expand">
+              <template #default="{ row }">
+                <div class="child-table-container">
+                  <el-table :data="row.childTableData" v-loading="row.childTableData.childLoading" border :cell-style="tableStyle.cellStyle"
+                    :header-cell-style="tableStyle.headerCellStyle">
+                    <!-- <el-table-column :formatter="statusFormatter" :label="t('marketingApps.ip')" prop="ip" show-overflow-tooltip></el-table-column> -->
+                    <el-table-column :formatter="statusFormatter" :label="t('marketingApps.fingerprint')" prop="fingerprint" show-overflow-tooltip></el-table-column>
+                    <el-table-column :formatter="statusFormatter" :label="t('marketingApps.device')" prop="device" show-overflow-tooltip>
+                      <template #default="{ row }">
+                        系统类型:{{ statusFormatter('','',row.osType,'') }} <br>
+                        系统版本:{{ statusFormatter('','',row.osVersion,'') }}
+                      </template>
+                    </el-table-column>
+                    <el-table-column :formatter="statusFormatter" :label="t('marketingApps.browser')" prop="browser" show-overflow-tooltip>
+                      <template #default="{ row }">
+                        浏览器名称:{{ statusFormatter('','',row.browser,'') }} <br>
+                        浏览器版本:{{ statusFormatter('','',row.browserVersion,'') }}
+                      </template>
+                    </el-table-column>
+                    <el-table-column :formatter="statusFormatter" :label="t('marketingApps.firstAccessTime')" prop="firstTime" show-overflow-tooltip></el-table-column>
+                    <el-table-column :formatter="statusFormatter" :label="t('marketingApps.lastAccessTime')" prop="lastTime" show-overflow-tooltip></el-table-column>
+                    <el-table-column :formatter="statusFormatter" :label="t('marketingApps.lastVisitedPage')" prop="url" show-overflow-tooltip></el-table-column>
+                    <el-table-column :formatter="statusFormatter" :label="t('marketingApps.accessVolume')" prop="count" show-overflow-tooltip></el-table-column>
+                  </el-table>
+                  <div class="pagination-container" @click.stop>
+                    <pagination v-bind="row.childPagination"
+                      :current-page="row.childPagination.page"
+                      :page-size="row.childPagination.size"
+                      :total="row.childPagination.total"
+                      :key="`pagination-${row.ip}-${row.childPagination.page}`"
+                      @size-change="size => handlePageSizeChange(row, size)"
+                      @current-change="page => handlePageChange(row, page)" />
+                  </div>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column :formatter="statusFormatter" :label="t('marketingApps.IP')" prop="ip" show-overflow-tooltip></el-table-column>
+            <el-table-column :formatter="statusFormatter" :label="t('marketingApps.firstAccessTime')" prop="firstTime" show-overflow-tooltip></el-table-column>
+            <el-table-column :formatter="statusFormatter" :label="t('marketingApps.lastAccessTime')" prop="lastTime" show-overflow-tooltip></el-table-column>
+            <el-table-column :formatter="statusFormatter" :label="t('marketingApps.accessSource')" prop="utmSource" show-overflow-tooltip></el-table-column>
+            <el-table-column :formatter="statusFormatter" :label="t('marketingApps.lastVisitedPage')" prop="url" show-overflow-tooltip></el-table-column>
+            <el-table-column :formatter="statusFormatter" :label="t('marketingApps.area')" prop="region" show-overflow-tooltip></el-table-column>
+            <el-table-column :formatter="statusFormatter" :label="t('marketingApps.accessVolume')" prop="count" show-overflow-tooltip></el-table-column>
+          </el-table>
+
+          <pagination 
+            @current-change="page => withCollapsedChildren(currentChangeHandle, page)" 
+            @size-change="size => withCollapsedChildren(sizeChangeHandle, size)" 
+            v-bind="state.pagination">
+          </pagination>
+          <br>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watchEffect } from 'vue';
+import { BasicTableProps, useTable } from '/@/hooks/table';
+import { getStatPage, getStatSecondPage } from '/@/api/marketing/apps';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+
+// 定义变量内容
+const { t } = useI18n();
+const visible = ref(false);
+const queryRef = ref();
+const showSearch = ref(true);
+const expandedRowKeys = ref<number[]>([]); // 记录展开的行
+
+//  table hook
+const state: BasicTableProps = reactive<BasicTableProps>({
+  queryForm: {
+    timeRange: [],
+    appId: '',
+  },
+  pageList: getStatPage,
+  createdIsNeed: false,
+  pagination: {
+    current: 1,
+    size: 10,
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  },
+});
+
+const { getDataList, currentChangeHandle, sortChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+
+// 打开弹窗
+const openDialog = async (_row: any) => {
+  visible.value = true;
+  state.queryForm.appId = _row.appId;
+  expandedRowKeys.value = [];
+  getDataList();
+};
+
+const disabledDate = (time: Date) => {
+  return time.getTime() > new Date().setHours(23, 59, 59, 999);
+};
+
+// 初始化二级表格数据和分页
+const initChildData = (parentRow) => {
+  parentRow.expanded = false;
+  parentRow.childTableData = []
+  parentRow.childLoading = false;
+  parentRow.childPagination = reactive({
+    page: 1,
+    size: 5,
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  });
+};
+
+// 初始化所有二级表格数据
+watchEffect(() => {
+  state?.dataList?.forEach(row => {
+    initChildData(row);
+  })
+})
+
+// 获取二级表格数据
+const loadChildData = async (parentRow) => {
+  parentRow.childLoading = true;
+  try {
+    const res = await getStatSecondPage({
+      ip: parentRow.ip,
+      current: parentRow.childPagination.page,
+      size: parentRow.childPagination.size,
+      appId: state.queryForm.appId,
+      timeRange: state.queryForm.timeRange,
+    });
+    const data = res.data;
+    parentRow.childTableData = data.records;
+    parentRow.childPagination.total = data.total;
+  } finally {
+    parentRow.childLoading = false;
+  }
+};
+
+/**
+ * 关闭所有二级表格,后执行对应函数
+ * @param callback 执行函数
+ * @param args 执行函数的参数
+ */
+const withCollapsedChildren = (callback: Function, ...args: any[]) => {
+  expandedRowKeys.value = [];
+  return callback(...args);
+};
+
+// 处理二级表格分页大小变化
+const handlePageSizeChange = (row: any, size: number) => {
+  row.childPagination.size = size;
+  row.childPagination.page = 1; // 重置为第一页
+  loadChildData(row);
+};
+
+// 处理二级表格页码变化
+const handlePageChange = (row: any, page: number) => {
+  row.childPagination.page = page;
+  loadChildData(row);
+};
+
+// 二级表格展开/关闭
+const handleExpandChange = (row: any, expandedRows: any[]) => {
+  expandedRowKeys.value = expandedRows.map(item => item.ip);
+  row.expanded = !row.expanded;
+
+  // 加载二级表格第1页数据
+  if (row.expanded && (!row.childTableData || row.childTableData.length === 0)) {
+    loadChildData(row);
+  }
+};
+
+const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
+  let unknownArray = ['unknown', 'Unknown', '未知'];
+  if(unknownArray.includes(cellValue)) return '--';
+  return cellValue || '--';
+}
+
+// 暴露变量 只有暴漏出来的变量 父组件才能使用
+defineExpose({
+  openDialog,
+});
+
+</script>
+<style scoped lang="scss">
+.dialog-body {
+  padding: 0 0 10px 0;
+  .statistics-table-wrapper {
+    height: 70vh;
+    position: relative;
+    .child-table-container {
+      margin: 20px;
+    }
+  }
+}
+</style>

+ 36 - 0
src/views/marketing/apps/i18n/en.ts

@@ -0,0 +1,36 @@
+export default {
+	marketingConfig: {
+		index: '#',
+		name: 'domain name',
+		config: 'config scheme',
+		enName: 'en menu name',
+		sortOrder: 'sortOrder',
+		path: 'path',
+		menuType: 'menuType',
+		keepAlive: 'keepAlive',
+		permission: 'permission',
+		inputNameTip: 'input domain name',
+		inputEnNameTip: 'input en name',
+		parentId: 'parent menu',
+		embedded: 'embedded',
+		visible: 'visible',
+		icon: 'icon',
+		inputMenuIdTip: 'input menuId',
+		inputPermissionTip: 'input permission',
+		inputPathTip: 'input path',
+		inputParentIdTip: 'input parentId',
+		inputIconTip: 'input icon',
+		inputVisibleTip: 'input visible',
+		inputSortOrderTip: 'input sortOrder',
+		inputKeepAliveTip: 'input keepAlive',
+		inputMenuTypeTip: 'input menuType',
+		inputCreateByTip: 'input createBy',
+		inputCreateTimeTip: 'input createTime',
+		inputUpdateByTip: 'input updateBy',
+		inputUpdateTimeTip: 'input updateTime',
+		inputDelFlagTip: 'input delFlag',
+		inputTenantIdTip: 'input tenantId',
+		inputEmbeddedTip: 'input embedded',
+		deleteDisabledTip: 'menu inclusion subordinates cannot be deleted',
+	},
+};

+ 72 - 0
src/views/marketing/apps/i18n/zh-cn.ts

@@ -0,0 +1,72 @@
+export default {
+  marketingApps: {
+    /* 应用列表 */
+    index: '#',
+    name: '应用名称',
+    config: '配置方案',
+    app: '应用',
+    id: 'ID',
+    appId: '应用ID',
+    appImg: '应用图片',
+    appType: '应用类型',
+    remark: '备注',
+    triggerRule: '触发规则',
+    blackList: '黑名单',
+    whiteList: '白名单',
+    statistical: '统计',
+    ipSegment: 'IP/网段',
+    time: '时间',
+    startTime: '开始时间',
+    endTime: '结束时间',
+    browser: '浏览器',
+
+    add: '新增',
+    edit: '修改',
+    delete: '删除',
+    group: '分组',
+    domain: '域名',
+    editIp: '修改IP',
+    addIp: '添加IP',
+    noData: '暂无数据',
+    viewDetail: '查看详情',
+    addDomain: '添加域名',
+    editDomain: '修改域名',
+
+    loading: '加载中...',
+    inputAppSel: '请选择应用',
+    inputNameTip: '请输入应用名称',
+    addDomainTip: '该分组已添加到域名集合,无需重复添加',
+    addDomainTip2: '该域名已存在于集合中,无需重复添加',
+    addIpTip: '该分组已添加到IP集合,无需重复添加',
+    addIpTip2: '该IP已存在于集合中,无需重复添加',
+    inputGroupTip: '请选择分组名称',
+    submitSuccess: '提交成功!',
+    submitFail: '提交失败!',
+
+    /* 数据统计 */
+    statistics: '统计',
+    ip: 'IP',
+    content: '访问量',
+    dayActive: '日活',
+    hourActive: '时活',
+    fifteenOnline: '15分钟在线人数',
+    referrer: '来源',
+    fingerprint: '指纹',
+    timeRange: '时间范围',
+    firstAccessTime: '首次访问时间',
+    lastAccessTime: '最后访问时间',
+    accessSource: '访问来源',
+    lastVisitedPage: '最后受访页面',
+    IP: 'IP',
+    area: '地区',
+    accessVolume: '访问量',
+    device: '设备',
+
+    queryBtn: '查询',
+    resetBtn: '重置',
+
+    inputReferrer: '请输入来源',
+    inputIpAddress: '请输入IP地址',
+    inputDomainTip: '请输入域名',
+  },
+};

+ 359 - 0
src/views/marketing/apps/index.vue

@@ -0,0 +1,359 @@
+<template>
+  <div class="layout-padding">
+    <div class="layout-padding-auto layout-padding-view">
+      <el-row shadow="hover" v-show="showSearch" class="ml10">
+        <el-form :inline="true" :model="state.queryForm" @keyup.enter="query" ref="queryRef">
+          <el-form-item :label="t('marketingApps.name')" prop="appName">
+            <el-input :placeholder="t('marketingApps.inputNameTip')" clearable v-model="state.queryForm.appName" />
+          </el-form-item>
+          <el-form-item :label="t('marketingApps.appId')" prop="appId">
+            <el-input :placeholder="'请输入应用ID'" clearable v-model="state.queryForm.appId" />
+          </el-form-item>
+          <el-form-item :label="t('marketingApps.appType')" prop="domainSelected">
+            <el-select placeholder="请选择应用类型" v-model="state.queryForm.domainSelected" @change="query()">
+              <el-option :key="'0'" :label="'全部'" :value="'0'" />
+              <el-option v-for="item in domainTypeOptions" :key="item.value" :label="item.description" :value="item.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item :label="t('marketingApps.remark')" prop="remark">
+            <el-input :placeholder="'请输入备注'" clearable v-model="state.queryForm.remark" />
+          </el-form-item>
+          <el-form-item>
+            <el-button @click="query" class="ml10" icon="search" type="primary">
+              {{ t('common.queryBtn') }}
+            </el-button>
+            <el-button @click="resetQuery" icon="Refresh">{{ t('common.resetBtn') }}</el-button>
+            <el-button @click="handleEdit" icon="EditPen" :disabled="activeName === 'tab2'">{{ t('common.editBtn') }}</el-button>
+          </el-form-item>
+        </el-form>
+      </el-row>
+
+      <el-tabs v-model="activeName" class="table-tabs" @tab-click="handleClick">
+        <el-tab-pane label="使用中" name="tab1"></el-tab-pane>
+        <el-tab-pane label="回收站" name="tab2"></el-tab-pane>
+      </el-tabs>
+
+      <el-table @selection-change="handleSelectionChange" ref="tableRef" :data="state.dataList" row-key="path"
+        style="width: 100%" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
+        :header-cell-style="tableStyle?.headerCellStyle">
+        <el-table-column :formatter="statusFormatter" type="selection" fixed="left" width="55"
+          v-if="activeName == 'tab1'" />
+        <el-table-column :formatter="statusFormatter" fixed="left" :label="t('marketingApps.id')" prop="id" width="80"
+          show-overflow-tooltip>
+          <template #default="{ row }">{{ row.id }}</template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="t('marketingApps.appId')" prop="appId" width="220"
+          show-overflow-tooltip>
+          <template #default="{ row }">{{ row.appId }}</template>
+        </el-table-column>
+        <el-table-column :label="t('marketingApps.appImg')" prop="appImg" width="150" show-overflow-tooltip>
+          <template #default="{ row }" style="display: flex; align-self: center;">
+            <el-image style="width: 80px; height: 80px; border-radius: 10px;" :src="row.appImg" :fit="'cover'" />
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="t('marketingApps.name')" prop="appName" width="150"
+          show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-link :disabled="activeName === 'tab2'" :href="row.appUrl" target="_blank"
+              :style="{ color: '#409EFF', opacity: activeName == 'tab2' ? 0.68 : 1 }">{{ row.appName }}</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="t('marketingApps.appType')" prop="domainType" width="110"
+          show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-select @change="handleChange(row)" :disabled="activeName === 'tab2'" v-model="row.domainType"
+              placeholder="" style="width: 80px">
+              <el-option v-for="item in domainTypeOptions" :key="item.value" :label="item.description"
+                :value="item.value" />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" label="营销投放" prop="launch" width="120">
+          <template #default="{ row }">
+            <el-switch :disabled="activeName === 'tab2'" v-model="row.launch" inline-prompt :active-value="true"
+              :inactive-value="false" @change="handleChange(row)" />
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" label="域名限制" prop="domainLimit" width="120">
+          <template #default="{ row }">
+            <el-switch :disabled="activeName === 'tab2'" v-model="row.domainLimit" inline-prompt :active-value="true"
+              :inactive-value="false" @change="handleChange(row)" />
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="'域名集合'" prop="domains" width="280">
+          <template #default="{ row }">
+            <DomainCell :domainList="row.domains" :state="activeName == 'tab1'" :rowData="row" @refresh="query" />
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="'IP集合'" prop="ips" width="320">
+          <template #default="{ row }">
+            <IpCell :ipList="row.ips" :state="activeName == 'tab1'" :rowData="row" @refresh="query" />
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="'触发频率'" prop="triggerNum" width="120"
+          show-overflow-tooltip>
+          <template #default="{ row }">
+            <!-- <el-input-number v-model="row.triggerNum" :max=20 /> -->
+            {{ formatNum(row.triggerNum) }}
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="t('marketingApps.triggerRule')" width="140"
+          prop="triggerRule" show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-select @change="handleChange(row)" :disabled="activeName === 'tab2'" v-model="row.triggerRule"
+              placeholder="" style="width: 100px">
+              <el-option v-for="item in triggerRules" :key="item.value" :label="item.label" :value="item.value" />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column :formatter="statusFormatter" :label="t('marketingApps.remark')" prop="remark" width="150"
+          show-overflow-tooltip>
+        </el-table-column>
+        <el-table-column fixed="right" :label="t('common.action')" width="200">
+          <template #default="scope">
+            <div class="action" style="text-align: left;">
+              <el-button :disabled="activeName == 'tab2'" style="margin-right: 0px;"
+                @click="onOpenEditMenu('edit', scope.row)" text type="primary" v-auth="'sys_menu_edit'">
+                <svg style="margin-right: 5px;" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
+                  viewBox="0 0 16 16" fill="none">
+                  <mask id="mask0_362_1714" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="16"
+                    height="16">
+                    <rect width="16" height="16" fill="#D9D9D9" />
+                  </mask>
+                  <g mask="url(#mask0_362_1714)">
+                    <path
+                      d="M4.60017 12.2954L5.97782 12.2954C6.50827 12.2955 7.017 12.0847 7.39208 11.7097L13.7925 5.30925C14.4017 4.70003 14.4017 3.71229 13.7925 3.10308V3.10308C13.1833 2.49386 12.1955 2.49387 11.5863 3.10308L5.18591 9.5035C4.81082 9.87859 4.60011 10.3873 4.60012 10.9178L4.60017 12.2954Z"
+                      stroke="#167AF0" stroke-linejoin="round" />
+                    <path d="M10.4832 4.20593L12.6893 6.41211" stroke="#167AF0" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path
+                      d="M8.5 2H4C2.89543 2 2 2.89543 2 4V13C2 14.1046 2.89543 15 4 15H13C14.1046 15 15 14.1046 15 13V8.5"
+                      stroke="#167AF0" stroke-linecap="round" />
+                  </g>
+                </svg>
+                {{ t('marketingApps.edit') }}
+              </el-button>
+              <el-button style="margin-right: 0px;" @click="onOpenStatistical(scope.row)" text type="primary"
+                v-auth="'sys_menu_edit'">
+                <svg style="margin-right: 5px;" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
+                  viewBox="0 0 16 16" fill="none">
+                  <path d="M2 6V3C2 2.44772 2.44772 2 3 2H13C13.5523 2 14 2.44772 14 3V6" stroke="#167AF0"
+                    stroke-linecap="round" stroke-linejoin="round" />
+                  <path d="M10.6667 8V10.3333" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                  <path d="M8 5V10.3333" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                  <path d="M5.33325 6.33334V10.3333" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                  <path d="M2 10V13C2 13.5523 2.44772 14 3 14H13C13.5523 14 14 13.5523 14 13V10" stroke="#167AF0"
+                    stroke-linecap="round" stroke-linejoin="round" />
+                </svg>
+                {{ t('marketingApps.statistics') }}
+              </el-button>
+              <el-button v-if="activeName == 'tab1'" style="margin-right: 0px;" @click="handleDelete(scope.row)" text
+                type="danger" v-auth="'sys_menu_edit'">
+                <svg style="margin-right: 5px;" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
+                  viewBox="0 0 16 16" fill="none">
+                  <g clip-path="url(#clip0_35_722)">
+                    <path
+                      d="M3 3.33334V12.6667C3 13.7712 3.89543 14.6667 5 14.6667H11C12.1046 14.6667 13 13.7712 13 12.6667V3.33334H3Z"
+                      stroke="#E64242" stroke-linejoin="round" />
+                    <path d="M6.66675 6.66666V11" stroke="#E64242" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M9.33325 6.66666V11" stroke="#E64242" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M1.33325 3.33334H14.6666" stroke="#E64242" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path d="M5.33325 3.33334L6.42959 1.33334H9.59229L10.6666 3.33334H5.33325Z" stroke="#E64242"
+                      stroke-linejoin="round" />
+                  </g>
+                  <defs>
+                    <clipPath id="clip0_35_722">
+                      <rect width="16" height="16" fill="white" />
+                    </clipPath>
+                  </defs>
+                </svg>
+                拉黑</el-button>
+              <el-button v-if="activeName == 'tab2'" style="margin-right: 0px;" icon="refresh"
+                @click="handleDelete(scope.row)" text type="success" v-auth="'sys_menu_edit'">还原</el-button>
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+      <pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination" />
+    </div>
+    <EditDialog @refresh="query" ref="EditDialogRef" />
+    <StatisticalDialog @refresh="query" ref="statisticalDialogRef" />
+  </div>
+</template>
+
+<script lang="ts" name="marketingApps" setup>
+import { pageList, pageDel, delAppById, setAppInfo } from '/@/api/marketing/apps';
+import { BasicTableProps, useTable } from '/@/hooks/table';
+import { useMessage, useMessageBox } from '/@/hooks/message';
+import { useI18n } from 'vue-i18n';
+import { fetchItemList } from '/@/api/admin/dict';
+import { ref } from 'vue'
+const EditDialog = defineAsyncComponent(() => import('./components/form.vue'));
+const StatisticalDialog = defineAsyncComponent(() => import('./components/statistical.vue'));
+const IpCell = defineAsyncComponent(() => import('./components/ipCell.vue'));
+const DomainCell = defineAsyncComponent(() => import('./components/domainCell.vue'));
+
+import { type TabsPaneContext } from 'element-plus'
+
+const activeName = ref('tab1')
+const handleClick = (tab: TabsPaneContext, event: Event) => {
+  activeName.value = tab.paneName as string;
+  query();
+}
+
+const { t } = useI18n();
+// 定义变量内容
+const tableRef = ref();
+const EditDialogRef = ref();
+const statisticalDialogRef = ref();
+const queryRef = ref();
+const showSearch = ref(true);
+// 多选rows
+const selectObjs = ref([]) as any;
+// 是否可以多选
+const multiple = ref(true);
+const state: BasicTableProps = reactive<BasicTableProps>({
+  pageList: pageList,
+  createdIsNeed: false,
+  queryForm: {
+    appId: '',
+    appName: '',
+    domainSelected: '0',
+    domainType: '',
+    remark: '',
+  },
+  pagination: {
+    current: 1,
+    size: 10,
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  },
+});
+
+const domainTypeOptions = ref([]) as any;
+const getDomianTypeList = async () => {
+  const { data } = await fetchItemList({
+    dictType: 'DomianType'
+  });
+  domainTypeOptions.value = data?.records || [];
+  domainTypeOptions.value.map((item) => {
+    item.value = Number(item.value);
+  })
+}
+getDomianTypeList();
+
+const triggerRules = [
+  {
+    label: '仅一次',
+    value: 1,
+  },
+  {
+    label: '多次',
+    value: 2,
+  },
+]
+
+const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+
+// 搜索事件
+const query = (refresh: boolean = false) => {
+  activeName.value == 'tab1' ? state.pageList = pageList : state.pageList = pageDel;
+  state.dataList = [];
+  state.queryForm.domainType = state.queryForm.domainSelected == 0 ? '' : state.queryForm.domainSelected;
+  getDataList(refresh);
+};
+
+// 修改单条配置信息
+const handleChange = (row: any) => {
+  setAppInfo(row).then(() => {
+    useMessage().success('修改成功');
+    getDataList();
+  }).catch((err: any) => {
+    useMessage().error(err.msg);
+  });
+};
+
+// 清空搜索条件
+const resetQuery = () => {
+  queryRef.value.resetFields();
+  state.dataList = [];
+  getDataList();
+};
+
+// 批量修改配置
+const handleEdit = () => {
+  console.log('被选中的项', selectObjs);
+
+  if (selectObjs.value.length === 0) {
+    useMessage().warning('请选择要修改的项');
+    return;
+  }
+  EditDialogRef.value.openDialog('', selectObjs.value);
+};
+
+// 多选
+const handleSelectionChange = (selection: any) => {
+  selectObjs.value = selection;
+};
+
+// 打开编辑菜单弹窗
+const onOpenEditMenu = (type: string, row: any) => {
+  EditDialogRef.value.openDialog(type, row);
+};
+
+// 打开统计弹窗
+const onOpenStatistical = (row: any) => {
+  statisticalDialogRef.value.openDialog(row, true);
+};
+
+// 拉黑操作
+const handleDelete = async (row: any) => {
+  try {
+    await useMessageBox().confirm('是否确认拉黑该应用');
+    await delAppById({
+      id: row.id,
+      status: activeName.value == 'tab2'
+    });
+    useMessage().success(activeName.value == 'tab2' ? '还原成功!' : '拉黑成功!');
+  } catch {
+    return;
+  }
+  query();
+};
+
+// 格式化数据展示
+const formatNum = (value: string | number = 0) => {
+  let num = Number(value);
+  if (num > 0 && num < 1) {
+    return num * 100 + '%';
+  } else if (num >= 1 && num < 10000) {
+    return num;
+  }
+  return '--'
+}
+
+const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
+  return cellValue || '--';
+}
+
+onMounted(() => {
+  query();
+})
+
+</script>
+<style scoped lang="scss">
+:deep(.el-link__inner) {
+  display: inline-block;
+  width: 100%;
+  justify-content: start;
+}
+
+:deep(.el-link.is-underline:hover:after) {
+  display: none;
+}
+
+.table-tabs {
+  margin-bottom: 10px;
+}
+</style>

+ 64 - 0
src/views/marketing/apps/types.ts

@@ -0,0 +1,64 @@
+export enum SourceType {
+  GROUP = 1, // 分组
+  DOMAIN = 2 // 具体域名
+}
+
+export enum IpType {
+  WHITE = 1,
+  BLACK = 2
+}
+
+export enum IpMode {
+  IP = 1, // 单个IP
+  IPRange = 2 // IP段
+}
+
+export interface DomainItem {
+  id?: string;
+  domain: string;
+  sourceType: SourceType; // 1:分组 2:具体域名
+  groupId: string;
+  groupName: string;
+  modify: boolean; // 是否被修改/新增
+  domains: {
+    domain: string;
+    id: string;
+  }[];
+}
+
+export interface IpItem {
+  id?: string;
+  ipMode: IpMode;
+  ipType: IpType; // 1: 白名单, 2: 黑名单
+  sourceType: SourceType;
+  startIp: string;
+  endIp: string;
+  groupId: string;
+  groupName: string;
+  list: string[] | {
+		id: number;
+		value: string;
+	}[];
+  modify?: boolean;
+  ip: string; 
+}
+
+export interface AppData {
+  id?: string;
+  appId?: string;
+  appName?: string;
+  appImg?: string;
+  appUrl?: string;
+  backUpUrl?: string;
+  domainType?: string;
+  domainLimit: boolean;
+  launch: boolean;
+  triggerRule: number;
+  triggerNum: string;
+  triggerNumFormat: string;
+  remark: string;
+  ips?: IpItem[];
+  domains?: DomainItem[];
+  delDomains?: string[];
+  delIps?: string[];
+}

+ 148 - 0
src/views/marketing/config/components/domainEdit.vue

@@ -0,0 +1,148 @@
+<template>
+	<el-dialog :title="'添加域名'" width="600" v-model="props.open" :close-on-click-modal="false" :destroy-on-close="true" @close="onCancel" draggable>
+		<el-form ref="menuDialogFormRef" :rules="dataRules" :model="state.ruleForm" v-loading="loading">
+			<div class="custom-style">
+				<el-segmented v-model="state.ruleForm.sourceTypeText" :options="[t('marketingConfig.grouping'), t('marketingConfig.domain')]" size="default">
+					<template #default="scope">
+						<div style="min-width: 50px; line-height: 32px">
+							{{ scope.item }}
+						</div>
+					</template>
+				</el-segmented>
+			</div>
+			<el-form-item label="" prop="groupId" v-if="state.ruleForm.sourceTypeText === t('marketingConfig.grouping')">
+				<el-select v-model="state.ruleForm.groupId" @change="state.ruleForm.groupName" :placeholder="t('marketingConfig.groupingTip')">
+					<el-option v-for="item in selectData" :key="item.id" :label="item.groupName" :value="item.id" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="" prop="domain" v-else>
+				<el-input v-model="state.ruleForm.domain" type="text" :placeholder="t('marketingConfig.domainTip')"></el-input>
+			</el-form-item>
+		</el-form>
+		<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="systemMenuDialog">
+import { useI18n } from 'vue-i18n';
+import { addDomains } from '/@/api/marketing/config';
+import { useMessage } from '/@/hooks/message';
+import { rule } from '/@/utils/validate';
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['update:open', 'onsuccess']);
+const { t } = useI18n();
+
+// 定义变量内容
+const loading = ref(false);
+const menuDialogFormRef = ref();
+
+// 定义需要的数据
+const state = reactive({
+	ruleForm: {
+		ipType: 1, // 1: 白名单, 2: 黑名单
+		sourceType: 1,
+		sourceTypeText: t('marketingConfig.grouping'),
+		groupId: '',
+		groupName: '',
+		domain: '',
+	},
+});
+
+const props = defineProps({
+	open: {
+		type: Boolean,
+		default: false,
+	},
+	selectData: {
+		type: Array,
+		default: () => [],
+	},
+});
+//  表单校验规则
+const dataRules = reactive({
+	groupId: [{ required: true, message: '分组不能为空', trigger: 'blur' }],
+	domain: [
+		{ required: true, message: '域名不能为空', trigger: 'blur' },
+		{ validator: rule.domain, trigger: 'blur' },
+	],
+});
+const onCancel = () => {
+	emit('update:open', false);
+};
+// 保存数据
+const onSubmit = async () => {
+	state.ruleForm.sourceType = state.ruleForm.sourceTypeText === t('marketingConfig.grouping') ? 1 : 2;
+	if (state.ruleForm.sourceType == 1 ) {
+		try {
+			await menuDialogFormRef.value.validateField('groupId');
+		} catch {
+			return;
+		}
+	} else if (state.ruleForm.sourceType == 2 ) {
+		try {
+			await menuDialogFormRef.value.validateField('domain');
+		} catch {
+			return;
+		}
+	}
+
+	try {
+		loading.value = true;
+		await addDomains(state.ruleForm);
+		useMessage().success(t(state.ruleForm.id ? 'common.editSuccessText' : 'common.addSuccessText'));
+		emit('onsuccess');
+		onCancel();
+	} catch (err) {
+		useMessage().error(err.msg);
+	} finally {
+		loading.value = false;
+	}
+};
+watch(
+	() => props.open,
+	(val) => {
+		if (val) {
+			state.ruleForm = {
+				sourceType: 1,
+				sourceTypeText: t('marketingConfig.grouping'),
+				groupId: '',
+				groupName: '',
+				domain: '',
+			};
+		}
+	}
+);
+</script>
+<style lang="scss">
+.apps-loadmore.el-select-dropdown .el-scrollbar__wrap {
+	height: 330px !important;
+}
+
+.custom-style {
+	margin-top: 14px;
+
+	.el-segmented__item {
+		border-right: 1px solid rgba(230, 230, 230, 1);
+		min-width: 72px;
+		flex: unset;
+	}
+
+	.el-segmented {
+		border: 1px solid rgba(230, 230, 230, 1);
+		border-bottom: unset;
+		--el-segmented-bg-color: #ffffff;
+		--el-segmented-item-hover-bg-color: rgba(22, 122, 240, 0.1);
+		--el-segmented-item-selected-color: rgba(22, 122, 240, 1);
+		--el-segmented-item-hover-color: rgba(22, 122, 240, 1);
+		--el-segmented-item-selected-bg-color: rgba(22, 122, 240, 0.1);
+		--el-border-radius-base: 4px 4px 0 0;
+		--el-segmented-padding: 0;
+	}
+}
+</style>

+ 102 - 0
src/views/marketing/config/components/ipGroupingEdit.vue

@@ -0,0 +1,102 @@
+<template>
+	<el-dialog
+		:title="type == 'ip' ? '添加IP分组' : '添加域名分组'"
+		width="600"
+		v-model="props.open"
+		:close-on-click-modal="false"
+		:destroy-on-close="true"
+		@close="onCancel"
+		draggable
+	>
+		<el-form ref="menuDialogFormRef" :rules="dataRules" :model="state.ruleForm" v-loading="loading">
+			<el-form-item :label="t('marketingConfig.groupingName')" prop="groupName">
+				<el-input v-model="state.ruleForm.groupName" type="text" :placeholder="t('marketingConfig.groupingNameTip')"></el-input>
+			</el-form-item>
+		</el-form>
+		<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="systemMenuDialog">
+import { useI18n } from 'vue-i18n';
+import { addGroup } from '/@/api/marketing/config';
+import { useMessage } from '/@/hooks/message';
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['update:open', 'onsuccess']);
+const { t } = useI18n();
+
+// 定义变量内容
+const loading = ref(false);
+const menuDialogFormRef = ref();
+
+// 定义需要的数据
+const state = reactive({
+	ruleForm: {
+		groupName: '',
+	},
+});
+
+const props = defineProps({
+	open: {
+		type: Boolean,
+		default: false,
+	},
+	type: {
+		type: String,
+		default: 'ip', // 'ip' or 'domain'
+	},
+});
+// // 表单校验规则
+const dataRules = reactive({
+	groupName: [{ required: true, message: '分组名称不能为空', trigger: 'blur' }],
+});
+const onCancel = () => {
+	emit('update:open', false);
+};
+// 保存数据
+const onSubmit = async () => {
+	try {
+		await menuDialogFormRef.value.validateField('groupName');
+	} catch {
+		return;
+	}
+	try {
+		loading.value = true;
+		await addGroup({
+			...state.ruleForm,
+			groupType: state.type === 'ip' ? 1 : 2,
+		});
+		useMessage().success(t('common.addSuccessText'));
+		emit('onsuccess');
+		onCancel();
+	} catch (err) {
+		useMessage().error(err.msg);
+	} finally {
+		loading.value = false;
+	}
+};
+watch(
+	() => props.open,
+	(val) => {
+		if (val) {
+			state.ruleForm = {
+				groupName: '',
+				groupType: '',
+				groupDesc: '',
+				groupStatus: '',
+			};
+		}
+	}
+);
+</script>
+<style>
+.apps-loadmore.el-select-dropdown .el-scrollbar__wrap {
+	height: 330px !important;
+}
+</style>

+ 207 - 0
src/views/marketing/config/components/ipListEdit.vue

@@ -0,0 +1,207 @@
+<template>
+	<el-dialog
+		:title="sign === 'domain' ? '修改域名集合' : '修改ip集合'"
+		width="600"
+		v-model="visible"
+		:close-on-click-modal="false"
+		:destroy-on-close="true"
+		draggable
+	>
+		<el-form ref="menuDialogFormRef" :model="state.ruleForm" :rules="dataRules" label-width="90px" :hide-required-asterisk="true" v-loading="loading">
+			<el-form-item label="名称" prop="groupName">
+				<el-input class="!w-[300px]" v-model="state.ruleForm.groupName" placeholder="请输入名称"></el-input>
+			</el-form-item>
+			<el-form-item
+				v-for="(item, index) in state.ruleForm.list"
+				:key="item.id || index"
+				:prop="'list.' + index + '.val'"
+				:rules="sign === 'domain' ? dataRules.domain : dataRules.ip"
+			>
+				<template #label>
+					<div v-if="index === 0" class="flex items-center">
+						<el-tooltip v-if="sign === 'ip'" effect="light" content="输入127.0.0.1/24格式代表网段" placement="top">
+							<el-icon>
+								<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
+									<path d="M7 14C8.93298 14 10.683 13.2165 11.9497 11.9497C13.2165 10.683 14 8.93298 14 7C14 5.06702 13.2165 3.31702 11.9497 2.05025C10.683 0.783503 8.93298 0 7 0C5.06702 0 3.31702 0.783503 2.05025 2.05025C0.783503 3.31702 0 5.06702 0 7C0 8.93298 0.783503 10.683 2.05025 11.9497C3.31702 13.2165 5.06702 14 7 14Z" fill="#1B4D88" fill-opacity="0.4"/>
+									<path fill-rule="evenodd" clip-rule="evenodd" d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434Z" fill="white"/>
+									<path d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434ZM6.99957 2.33322C6.6774 2.33322 6.41623 2.5944 6.41623 2.91656C6.41623 3.23872 6.6774 3.49989 6.99957 3.49989C7.32173 3.49989 7.5829 3.23872 7.5829 2.91656C7.5829 2.5944 7.32173 2.33322 6.99957 2.33322Z" fill="white"/>
+									<path d="M7.19477 10.8888V5.44434H6.80588H6.41699" fill="white"/>
+									<path d="M6.41645 10.8887V6.22206C5.98689 6.22206 5.63867 5.87384 5.63867 5.44428C5.63867 5.01473 5.98689 4.6665 6.41645 4.6665H7.19423L7.27398 4.6703C7.66608 4.71023 7.97201 5.04164 7.97201 5.44428V10.8887C7.97201 11.3183 7.70354 11.3447 7.27398 11.3447C6.84443 11.3447 6.41645 11.3183 6.41645 10.8887Z" fill="white"/>
+									<path d="M8.55566 10C8.98522 10 9.33344 10.3482 9.33344 10.7778C9.33344 11.2073 8.98522 11.5556 8.55566 11.5556H5.83344C5.40389 11.5556 5.05566 11.2073 5.05566 10.7778C5.05566 10.3482 5.40389 10 5.83344 10H8.55566Z" fill="white"/>
+								</svg>
+							</el-icon>
+						</el-tooltip>
+						&nbsp;{{ sign === 'domain' ? '域名集合' : 'ip集合' }}
+					</div>
+				</template>
+				<!-- <div v-for="(item, index) in state.ruleForm.list" :key="item.index" class="flex items-center mb-2 text-sm font-normal justify-start w-full"> -->
+				<el-input class="!w-[300px] mr-10" v-model="item.val" clearable @blur="item.modify = true" :placeholder="inputTip"></el-input>
+				<div class="config-actions w-[50px] flex items-center justify-between">
+					<svg
+						v-if="index === state.ruleForm.list.length - 1"
+						@click="onAddItem"
+						style="cursor: pointer"
+						width="24"
+						height="24"
+						viewBox="0 0 24 24"
+						fill="none"
+						xmlns="http://www.w3.org/2000/svg"
+					>
+						<path
+							d="M19.5 3H4.5C3.67157 3 3 3.67157 3 4.5V19.5C3 20.3284 3.67157 21 4.5 21H19.5C20.3284 21 21 20.3284 21 19.5V4.5C21 3.67157 20.3284 3 19.5 3Z"
+							stroke="#646464"
+							stroke-linejoin="round"
+						/>
+						<path d="M12 8V16" stroke="#646464" stroke-linecap="round" stroke-linejoin="round" />
+						<path d="M8 12H16" stroke="#646464" stroke-linecap="round" stroke-linejoin="round" />
+					</svg>
+					<svg
+						v-if="state.ruleForm.list.length > 1"
+						style="cursor: pointer"
+						@click="onClickDelete(item, index)"
+						width="24"
+						height="24"
+						viewBox="0 0 24 24"
+						fill="none"
+						xmlns="http://www.w3.org/2000/svg"
+					>
+						<path
+							d="M19.5 3H4.5C3.67157 3 3 3.67157 3 4.5V19.5C3 20.3284 3.67157 21 4.5 21H19.5C20.3284 21 21 20.3284 21 19.5V4.5C21 3.67157 20.3284 3 19.5 3Z"
+							stroke="#646464"
+							stroke-linejoin="round"
+						/>
+						<path d="M8 12H16" stroke="#646464" stroke-linecap="round" stroke-linejoin="round" />
+					</svg>
+					<!-- </div> -->
+				</div>
+			</el-form-item>
+		</el-form>
+		<template #footer>
+			<span class="dialog-footer">
+				<el-button @click="visible = false">{{ 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="systemMenuDialog">
+import { useI18n } from 'vue-i18n';
+import { saveDomains, saveIps } from '/@/api/marketing/config';
+import { useMessage } from '/@/hooks/message';
+import { rule } from '/@/utils/validate';
+import { parseIpRange } from '/@/utils/ipUpdate';
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['onsuccess']);
+const { t } = useI18n();
+
+const inputTip = computed(() => {
+  if (sign.value === 'domain') {
+    return '请输入域名';
+  } else {
+    return t('marketingConfig.inputIPTip');
+  }
+});
+// 定义变量内容
+const visible = ref(false);
+const loading = ref(false);
+const sign = ref('domain');
+const menuDialogFormRef = ref();
+// 定义需要的数据
+const state = reactive({
+	ruleForm: {
+		list: [],
+		delList: [], //删除的ip或者域名的id
+	},
+});
+
+//删除的id存入delList中
+const onClickDelete = (item, index) => {
+	if (item.id) {
+		state.ruleForm.delList.push(item.id);
+	}
+	state.ruleForm.list.splice(index, 1);
+};
+const onAddItem = () => {
+	state.ruleForm.list.push({ val: '' });
+};
+
+// // 表单校验规则
+const dataRules = computed(() => ({
+	ip: [
+		{ required: rule.ip, message: 'IP不能为空', trigger: 'blur' },
+		{ validator: rule.ip, trigger: 'blur' },
+	],
+	domain: [
+		{ required: true, message: '域名不能为空', trigger: 'blur' },
+		{ validator: rule.domain, trigger: 'blur' },
+	],
+}));
+
+// 打开弹窗
+const openDialog = async (type, row) => {
+	visible.value = true;
+	sign.value = type;
+	resetForm();
+	console.log();
+	row = JSON.parse(JSON.stringify(row || {}));
+	state.ruleForm = {
+		...row,
+		list: row.list.length > 0 ? row.list.map((e) => ({ ...e, val: e.value, modify: false })) : [{ val: '' }],
+		delList: [],
+	};
+};
+const resetForm = () => {
+	menuDialogFormRef.value?.resetFields();
+	state.ruleForm.delList = [];
+};
+
+// 保存数据
+const onSubmit = async () => {
+	const valid = await menuDialogFormRef.value.validate().catch(() => {});
+	if (!valid) return false;
+	if (sign.value === 'ip') {
+		state.ruleForm.ips = state.ruleForm.list.map((item) => {
+			const { start, end } = parseIpRange(item.val);
+
+			return {
+				...item,
+				ipMode: end === '' ? 1 : 2,
+				startIp: start,
+				endIp: end,
+			};
+		});
+	} else {
+		state.ruleForm.domains = state.ruleForm.list.map((item) => ({
+			...item,
+			domain: item.val,
+		}));
+	}
+	try {
+		loading.value = true;
+		if (sign.value === 'ip') {
+			await saveIps(state.ruleForm);
+		} else {
+			await saveDomains(state.ruleForm);
+		}
+		useMessage().success(t(state.ruleForm.id ? 'common.editSuccessText' : 'common.addSuccessText'));
+		visible.value = false;
+		emit('onsuccess');
+	} catch (err) {
+		useMessage().error(err.msg);
+	} finally {
+		loading.value = false;
+	}
+};
+
+// 暴露变量 只有暴漏出来的变量 父组件才能使用
+defineExpose({
+	openDialog,
+});
+</script>
+<style scoped>
+.apps-loadmore.el-select-dropdown .el-scrollbar__wrap {
+	height: 330px !important;
+}
+</style>

+ 207 - 0
src/views/marketing/config/components/listEdit.vue

@@ -0,0 +1,207 @@
+<template>
+	<el-dialog
+		:title="type === 'Edit' ? '修改IP' : '添加IP'"
+		width="600"
+		v-model="props.open"
+		:close-on-click-modal="false"
+		:destroy-on-close="true"
+		@close="onCancel"
+		draggable
+	>
+		<el-form ref="menuDialogFormRef" :rules="dataRules" :model="state.ruleForm" v-loading="loading">
+			<el-form-item label="" prop="ipType">
+				<el-radio-group v-model="state.ruleForm.ipType">
+					<el-radio :value="1">白名单</el-radio>
+					<el-radio :value="2">黑名单</el-radio>
+				</el-radio-group>
+			</el-form-item>
+			<div class="custom-style">
+				<el-segmented v-model="state.ruleForm.sourceTypeText" :options="[t('marketingConfig.grouping'), t('marketingConfig.ip')]" size="default">
+					<template #default="scope">
+						<div style="min-width: 50px; line-height: 32px">
+							{{ scope.item }}
+							<el-icon v-if="scope.item === t('marketingConfig.ip')" size="12" style="vertical-align: middle">
+								<el-tooltip effect="light" content="输入127.0.0.1/24格式代表网段" placement="top">
+									<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
+										<path
+											d="M7 14C8.93298 14 10.683 13.2165 11.9497 11.9497C13.2165 10.683 14 8.93298 14 7C14 5.06702 13.2165 3.31702 11.9497 2.05025C10.683 0.783503 8.93298 0 7 0C5.06702 0 3.31702 0.783503 2.05025 2.05025C0.783503 3.31702 0 5.06702 0 7C0 8.93298 0.783503 10.683 2.05025 11.9497C3.31702 13.2165 5.06702 14 7 14Z"
+											fill="#1B4D88"
+											fill-opacity="0.4"
+										/>
+										<path
+											fill-rule="evenodd"
+											clip-rule="evenodd"
+											d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434Z"
+											fill="white"
+										/>
+										<path
+											d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434ZM6.99957 2.33322C6.6774 2.33322 6.41623 2.5944 6.41623 2.91656C6.41623 3.23872 6.6774 3.49989 6.99957 3.49989C7.32173 3.49989 7.5829 3.23872 7.5829 2.91656C7.5829 2.5944 7.32173 2.33322 6.99957 2.33322Z"
+											fill="white"
+										/>
+										<path d="M7.19477 10.8888V5.44434H6.80588H6.41699" fill="white" />
+										<path
+											d="M6.41645 10.8887V6.22206C5.98689 6.22206 5.63867 5.87384 5.63867 5.44428C5.63867 5.01473 5.98689 4.6665 6.41645 4.6665H7.19423L7.27398 4.6703C7.66608 4.71023 7.97201 5.04164 7.97201 5.44428V10.8887C7.97201 11.3183 7.70354 11.3447 7.27398 11.3447C6.84443 11.3447 6.41645 11.3183 6.41645 10.8887Z"
+											fill="white"
+										/>
+										<path
+											d="M8.55566 10C8.98522 10 9.33344 10.3482 9.33344 10.7778C9.33344 11.2073 8.98522 11.5556 8.55566 11.5556H5.83344C5.40389 11.5556 5.05566 11.2073 5.05566 10.7778C5.05566 10.3482 5.40389 10 5.83344 10H8.55566Z"
+											fill="white"
+										/>
+									</svg>
+								</el-tooltip>
+							</el-icon>
+						</div>
+					</template>
+				</el-segmented>
+			</div>
+
+			<el-form-item label="" prop="groupId" v-if="state.ruleForm.sourceTypeText === t('marketingConfig.grouping')">
+				<el-select v-model="state.ruleForm.groupId" @change="state.ruleForm.groupName" :placeholder="t('marketingConfig.groupingTip')">
+					<el-option v-for="item in selectData" :key="item.id" :label="item.groupName" :value="item.id" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="" prop="ip" v-else>
+				<el-input v-model="state.ruleForm.ip" type="text" :placeholder="t('marketingConfig.ipTip')"></el-input>
+			</el-form-item>
+		</el-form>
+		<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="systemMenuDialog">
+import { useI18n } from 'vue-i18n';
+import { saveIpList } from '/@/api/marketing/config';
+import { useMessage } from '/@/hooks/message';
+import { parseIpRange } from '/@/utils/ipUpdate';
+import { rule } from '/@/utils/validate';
+
+// 定义子组件向父组件传值/事件
+const emit = defineEmits(['update:open', 'onsuccess']);
+const { t } = useI18n();
+
+// 定义变量内容
+const loading = ref(false);
+const type = ref('add'); // 'add' or 'edit'
+const menuDialogFormRef = ref();
+
+// 定义需要的数据
+const state = reactive({
+	ruleForm: {
+		ipType: 1, // 1: 白名单, 2: 黑名单
+		sourceType: 1,
+		sourceTypeText: t('marketingConfig.grouping'),
+		groupId: '',
+		ipMode: 1,
+		groupName: '',
+		startIp: '',
+		endIp: '',
+		ip: '',
+	},
+});
+
+const props = defineProps({
+	open: {
+		type: Boolean,
+		default: false,
+	},
+	selectData: {
+		type: Array,
+		default: () => [],
+	},
+});
+// // 表单校验规则
+const dataRules = reactive({
+	groupId: [{ required: true, message: '分组不能为空', trigger: 'blur' }],
+	ip: [
+		{ required: rule.ip, message: 'IP不能为空', trigger: 'blur' },
+		{ validator: rule.ip, trigger: 'blur' },
+	],
+});
+const onCancel = () => {
+	emit('update:open', false);
+};
+// 保存数据
+const onSubmit = async () => {
+	const lis = parseIpRange(state.ruleForm.ip);
+	state.ruleForm.startIp = lis.start;
+	state.ruleForm.endIp = lis.end;
+	if (lis.end !== '') {
+		state.ruleForm.ipMode = 2;
+	}
+
+	state.ruleForm.sourceType = state.ruleForm.sourceTypeText === t('marketingConfig.grouping') ? 1 : 2;
+	if (state.ruleForm.sourceType == 1) {
+		try {
+			await menuDialogFormRef.value.validateField('groupId');
+		} catch {
+			return;
+		}
+	} else if (state.ruleForm.sourceType == 2) {
+		try {
+			await menuDialogFormRef.value.validateField('ip');
+		} catch {
+			return;
+		}
+	}
+
+	try {
+		loading.value = true;
+		await saveIpList(state.ruleForm);
+		useMessage().success(t(state.ruleForm.id ? 'common.editSuccessText' : 'common.addSuccessText'));
+		emit('onsuccess');
+		onCancel();
+	} catch (err) {
+		useMessage().error(err.msg);
+	} finally {
+		loading.value = false;
+	}
+};
+watch(
+	() => props.open,
+	(val) => {
+		if (val) {
+			state.ruleForm = {
+				ipType: 1, // 1: 白名单, 2: 黑名单
+				sourceType: 1,
+				sourceTypeText: t('marketingConfig.grouping'),
+				groupId: '',
+				ipMode: 1,
+				startIp: '',
+				endIp: '',
+				ip: '',
+			};
+		}
+	}
+);
+</script>
+<style lang="scss">
+.apps-loadmore.el-select-dropdown .el-scrollbar__wrap {
+	height: 330px !important;
+}
+.custom-style {
+	margin-top: 14px;
+
+	.el-segmented__item {
+		border-right: 1px solid rgba(230, 230, 230, 1);
+		min-width: 72px;
+		flex: unset;
+	}
+
+	.el-segmented {
+		border: 1px solid rgba(230, 230, 230, 1);
+		border-bottom: unset;
+		--el-segmented-bg-color: #ffffff;
+		--el-segmented-item-hover-bg-color: rgba(22, 122, 240, 0.1);
+		--el-segmented-item-selected-color: rgba(22, 122, 240, 1);
+		--el-segmented-item-hover-color: rgba(22, 122, 240, 1);
+		--el-segmented-item-selected-bg-color: rgba(22, 122, 240, 0.1);
+		--el-border-radius-base: 4px 4px 0 0;
+		--el-segmented-padding: 0;
+	}
+}
+</style>

+ 33 - 1
src/views/marketing/config/i18n/zh-cn.ts

@@ -4,8 +4,40 @@ export default {
 		name: '应用名称',
 		config: '配置方案',
 		inputNameTip: '请输入应用名称名称',
-		inputIPTip: '请输入IP名称',
+		inputIPTip: '请输入IP地址',
 		app: '应用',
 		inputAppSel: '请选择应用',
+		ipList:'IP分组',
+		addIpList:'添加IP分组',
+		domainList: '域名分组',
+		addDomainList: '添加域名分组',
+		deleteListText: '删除分组',
+		updateText: '修改',
+		disposition:'全局配置',
+		deleteText: '删除',
+		cancel: '取消',
+		addDomain: '添加域名',
+		addIp: '添加IP',
+		deleteDomain: '删除域名',
+		deleteIp: '删除IP',
+		grouping: '分组',
+		domain: '域名',
+		ip: 'IP/网段',
+		domainTip: '请输入域名',
+		groupingTip: '请选择分组名称',
+		ipTip: '请输入IP',
+		groupingName: '分组名称',
+		groupingNameTip: '请输入分组名称',
+		triggerType: '触发规格',
+		triggerTypeTip: '请选择触发规格',
+		jumpMode: '触发方式',
+		jumpModeTip: '请选择触发方式',
+		jumpLink: '跳转链接',
+		jumpLinkTip: '请输入跳转链接',
+		triggerFrequency: '触发频率',
+		triggerFrequencyTip: '请输入触发频率',
+		prompt: '提示信息',
+		promptTip: '请输入提示信息',
+		ipSegment: 'IP/网段',
 	},
 };

+ 530 - 131
src/views/marketing/config/index.vue

@@ -1,166 +1,565 @@
 <template>
 	<div class="layout-padding">
-		<div class="layout-padding-auto layout-padding-view">
-			<el-row shadow="hover" v-show="showSearch" class="ml10">
-				<el-form :inline="true" :model="state.queryForm" @keyup.enter="getDataList" ref="queryRef">
-					<el-form-item :label="$t('marketingConfig.name')" prop="domain">
-						<el-input :placeholder="$t('marketingConfig.inputNameTip')" clearable style="max-width: 180px" v-model="state.queryForm.domain" />
-					</el-form-item>
-          <el-form-item>
-            <el-button @click="query" class="ml10" icon="search" type="primary">
-              {{ $t('common.queryBtn') }}
-            </el-button>
-            <el-button @click="resetQuery" icon="Refresh">{{ $t('common.resetBtn') }}</el-button>
-          </el-form-item>
-				</el-form>
-			</el-row>
-			<el-row>
-				<div class="mb8" style="width: 100%">
-					<right-toolbar
-						v-model:showSearch="showSearch"
-						class="ml10"
-						style="float: right; margin-right: 20px"
-						@queryTable="getDataList"
-					></right-toolbar>
+		<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">
+				<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>
+					筛选分组:<el-input placeholder="请输入分组名称" clearable
+						style="display: inline-block; width: 200px; margin-left: 5px;"
+						v-model="queryIPName" />
+					<div v-if="ipData.length > 0" class="overflow-y-auto mt-2" style="height: calc(100vh - 250px)">
+						<JCollapse
+							@update="(item) => onClickEdit(item, 'ip')"
+							@delete="(item) => onOpenDelete(item, 'ip')"
+							:data="showIPData"
+							:activeNames="ipActiveId"
+							:deleteText="t('marketingConfig.deleteListText')"
+							:updateText="t('marketingConfig.updateText')"
+						/>
+					</div>
 				</div>
-			</el-row>
-			<el-table
-				ref="tableRef"
-				:data="state.dataList"
-				row-key="path"
-				style="width: 100%"
-				v-loading="state.loading"
-				border
-				:cell-style="tableStyle.cellStyle"
-				:header-cell-style="tableStyle?.headerCellStyle"
-			>
-				<el-table-column :label="$t('marketingConfig.name')" fixed prop="appName" width="280" show-overflow-tooltip>
-					<template #default="{ row }">
-						<el-link :href="row.url">{{ row.appName }}</el-link>
-					</template>
-				</el-table-column>
-				<el-table-column label="营销投放" fixed prop="isMarketing" width="120">
-					<template #default="{ row }">
-						<el-switch v-model="row.isMarketing" inline-prompt active-text="开启" inactive-text="关闭" :active-value="1" :inactive-value="0" @change="handleChange(row)" />
-					</template>
-				</el-table-column>
-				<el-table-column label="域名限制" fixed prop="isHttp" width="120">
-					<template #default="{ row }">
-						<el-switch v-model="row.isHttp" inline-prompt active-text="开启" inactive-text="关闭" :active-value="1" :inactive-value="0" @change="handleChange(row)" />
-					</template>
-				</el-table-column>
-				<el-table-column label="域名集合" fixed prop="https" show-overflow-tooltip>
-					<template #default="{ row }">
-						<div style="display: flex;flex-wrap: wrap;margin-left: 10%;">
-							<template v-for="(item, index) in row.https" :key="index">
-								<div style="margin-right: 20px;">{{ item }};</div>
-							</template>
+			</el-tab-pane>
+			<el-tab-pane :label="t('marketingConfig.domainList')" name="域名分组" class="layout-padding-auto layout-padding-view">
+				<Title class="ml-4" :title="t('marketingConfig.domainList')" />
+				<div class="p-4 rounded">
+					<el-button style="margin-bottom: 10px;" type="primary" @click="onClickAdd('domain')">{{ t('marketingConfig.addDomainList') }}</el-button><br>
+					筛选分组:<el-input placeholder="请输入分组名称" clearable
+					style="display: inline-block; width: 200px; margin-left: 5px;"
+					v-model="queryDomainName" />
+					<div v-if="domainData.length > 0" class="overflow-y-auto mt-2" style="height: calc(100vh - 250px)">
+						<JCollapse
+							@update="(item) => onClickEdit(item, 'domain')"
+							:data="showDomainData"
+							@delete="(item) => onOpenDelete(item, 'domain')"
+							:activeNames="domainActiveId"
+							:deleteText="t('marketingConfig.deleteListText')"
+							:updateText="t('marketingConfig.updateText')"
+						/>
+					</div>
+					<div v-else class="ml-2">暂无数据</div>
+				</div>
+			</el-tab-pane>
+			<el-tab-pane :label="t('marketingConfig.disposition')" name="全局配置" class="layout-padding-auto layout-padding-view">
+				<Title class="ml-4" :title="t('marketingConfig.disposition')" />
+				<div class="p-4 rounded overflow-y-auto" style="max-height: calc(100vh - 350px)">
+					<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-[66%] 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>
-					</template>
-				</el-table-column>
-				<el-table-column label="ip集合" fixed prop="ips" show-overflow-tooltip>
-					<template #default="{ row }">
-						<div style="display: flex;flex-wrap: wrap; margin-left: 10%;">
-							<template v-for="(item, index) in row.ips" :key="index">
-								<div style="margin-right: 20px;">{{ item }};</div>
-							</template>
+						<div class="w-full">
+							<el-button type="primary" @click="onSubmit(ruleFormRef)" class="w-[80px] ml-5">{{ t('common.saveBtn') }}</el-button>
 						</div>
-					</template>
-				</el-table-column>
-				<el-table-column :label="$t('common.action')" width="250">
-					<template #default="scope">
-						<el-button icon="edit-pen" @click="onOpenEditMenu('edit', scope.row, 'domain')" text type="primary" v-auth="'sys_menu_edit'"
-							>域名
-						</el-button>
-						<el-button icon="edit-pen" @click="onOpenEditMenu('edit', scope.row, 'ip')" text type="primary" v-auth="'sys_menu_edit'"
-							>IP
-						</el-button>
-					</template>
-				</el-table-column>
-			</el-table>
-			<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
-		</div>
-		<MenuDialog @refresh="getDataList()" ref="menuDialogRef" />
+					</el-form>
+				</div>
+			</el-tab-pane>
+		</el-tabs>
+		<DomainEdit :select-data="domainData" v-model:open="domainEditOpen" @onsuccess="getConfig" />
+		<ListEdit :select-data="ipData" v-model:open="listEditOpen" @onsuccess="getConfig" />
+		<GroupingEdit v-model:open="groupingEditOpen" :type="openType" @onsuccess="getData" />
+		<IpListEdit :type="openType" ref="menuDialogRef" @onsuccess="getData" />
+		<el-dialog v-model="delOpen" title="提示" width="500" @close="delOpen = false">
+			<span>确认删除{{ delObj?.title }}吗?</span>
+			<template #footer>
+				<div class="dialog-footer">
+					<el-button @click="delOpen = false">取消</el-button>
+					<el-button type="primary" :disabled="loading" @click="onDel(delObj)"> 确定 </el-button>
+				</div>
+			</template>
+		</el-dialog>
 	</div>
 </template>
 
 <script lang="ts" name="marketingConfig" setup>
-import { delObj, pageList, update } from '/@/api/marketing/config';
-import { BasicTableProps, useTable } from '/@/hooks/table';
-import { useMessage, useMessageBox } from '/@/hooks/message';
+import {
+	delGroup,
+	pageListDomain,
+	pageListIp,
+	getConfigIpList,
+	getConfigDomainList,
+	delIpList,
+	delDomainList,
+	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 MenuDialog = defineAsyncComponent(() => import('./form.vue'));
+const JCollapse = defineAsyncComponent(() => import('/@/components/JCollapse/index.vue'));
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const DomainEdit = defineAsyncComponent(() => import('./components/domainEdit.vue'));
+const ListEdit = defineAsyncComponent(() => import('./components/listEdit.vue'));
+const GroupingEdit = defineAsyncComponent(() => import('./components/ipGroupingEdit.vue'));
+const IpListEdit = defineAsyncComponent(() => import('./components/ipListEdit.vue'));
+const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.vue'));
 const { t } = useI18n();
 // 定义变量内容
-const tableRef = ref();
-const menuDialogRef = ref();
-const queryRef = ref();
-const showSearch = ref(true);
-// 多选rows
-const selectObjs = ref([]) as any;
-// 是否可以多选
-const multiple = ref(true);
-const state: BasicTableProps = reactive<BasicTableProps>({
-	pageList: pageList, // H
-	queryForm: {
-		domain: '',
-	}
+const activeName = ref('IP分组');
+const queryIPName = ref('');
+const queryDomainName = ref('');
+const showIPData = computed(() => {
+	return ipData.value.filter((item) => item.groupName.includes(queryIPName.value));
 });
+const showDomainData = computed(() => {
+	return domainData.value.filter((item) => item.groupName.includes(queryDomainName.value));
+});
+//关闭或打开tabs的关闭按钮
+const closeDomainTags = ref(false);
+const closeIpTags = ref(false);
 
-const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+// 弹窗
+const domainEditOpen = ref(false);
+const listEditOpen = ref(false);
+const groupingEditOpen = ref(false);
+const delOpen = ref(false);
+const loading = ref(false);
+const menuDialogRef = ref();
 
-// 搜索事件
-const query = () => {
-  state.dataList = [];
-  getDataList();
-};
+const domainActiveId = ref([]);
+const ipActiveId = ref([]);
 
-// 修改单条配置信息
-const handleChange = (row: any) => {
-	// update(row).then(() => {
-	// 	useMessage().success(t('common.updateSuccessText'));
-	// 	getDataList();
-	// }).catch((err: any) => {
-	// 	useMessage().error(err.msg);
-	// });
-};
+// const ipListEditOpen = ref(false);
+const domainData = ref([]);
+const ipData = ref([]);
+const delObj = ref({});
+const openType = ref('ip'); // 'ip' or 'domain'
+const ipWhiteList = ref([]); // ip白名单
+const ipBlackList = ref([]); // ip黑名单
+const domainList = ref([]);
+const ruleFormRef = ref();
+const formData = ref({
+	promptMsg: '',
+	triggerMode: '',
+	triggerRule: '',
+	url: '',
+	triggerNum: '',
+});
+const onOpenDelete = (item: any, type: any) => {
+	console.log(item);
 
-// 清空搜索条件
-const resetQuery = () => {
-  queryRef.value.resetFields();
-  state.dataList = [];
-  getDataList();
+	delObj.value = { ...item, type };
+	console.log(delObj.value);
+
+	delOpen.value = true;
 };
-// 打开编辑菜单弹窗
-const onOpenEditMenu = (type: string, row: any) => {
-	menuDialogRef.value.openDialog(type, row);
+const getData = () => {
+	openType.value === 'ip' ? getIpData() : getDomainData();
 };
+const onLoadDetail = async (item: any) => {
+	if (item.list.length !== 0) return;
+	await getGroupDetail({ id: item.groupId }).then((val) => {
+		item.list = val.data.ips.map((item) => {
+			return ipSplicing(item.startIp, item.endIp);
+		});
+	});
+};
+
+// // 表单校验规则
+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',
+		},
+	],
+});
 
-// 删除操作
-const handleDelete = async (ids: string[]) => {
+const onSubmit = async () => {
 	try {
-		await useMessageBox().confirm(t('common.delConfirmText'));
-	} catch {
+		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 handleClick = (data: any) => {
+	if (data.props.label === 'IP分组') {
+		getIpData();
+	} else if (data.props.label === '域名分组') {
+		getDomainData();
+	} else {
+		getConfig();
+	}
+};
+const handleDelete = async (item: any, type: string) => {
+	console.log(item, type);
+	delObj.value = { ...item, delListType: type };
+	delOpen.value = true;
+};
+const onDel = async (data: any) => {
+	if (data?.delListType) {
+		try {
+			if (data?.delListType === 'ip') {
+				await delIpList(data.id);
+				useMessage().success(t('common.delSuccessText'));
+			} else if (data?.delListType === 'domain') {
+				await delDomainList(data.id);
+				useMessage().success(t('common.delSuccessText'));
+			}
+		} catch (err: any) {
+			useMessage().error(err.msg);
+		} finally {
+			loading.value = false;
+			delOpen.value = false;
+			getConfig();
+		}
+
+		return;
+	}
 	try {
-		await delObj(ids);
-		getDataList();
+		loading.value = true;
+		await delGroup({
+			groupType: data.type === 'ip' ? 1 : 2,
+			ids: [data.id],
+		});
 		useMessage().success(t('common.delSuccessText'));
 	} catch (err: any) {
 		useMessage().error(err.msg);
+	} finally {
+		loading.value = false;
+		delOpen.value = false;
+		data.type === 'ip' ? getIpData() : getDomainData();
 	}
 };
+
+const onClickAdd = (type: string) => {
+	openType.value = type;
+	groupingEditOpen.value = true;
+};
+const onClickEdit = (item: any, type: string) => {
+	openType.value = type;
+	// ipListEditOpen.value = true;
+	onOpenEditMenu(type, item);
+};
+
+// 打开编辑菜单弹窗
+const onOpenEditMenu = (type: string, row: any) => {
+	menuDialogRef.value.openDialog(type, row);
+};
+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 getDomainData = async () => {
+	await pageListDomain().then((val) => {
+		domainActiveId.value = [];
+		domainData.value = val.data.map((item: any) => {
+			domainActiveId.value.push(item.id);
+			return {
+				...item,
+				title: item.groupName,
+				id: item.id,
+				list: item.domains.map((items: any) => {
+					return {
+						...items,
+						id: items.id,
+						value: items.domain,
+					};
+				}),
+			};
+		});
+	});
+};
+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: parseFloat(val.data?.triggerNum) < 1 ? parseFloat(val.data?.triggerNum) * 100 + '%' : val.data?.triggerNum,
+		};
+	});
+};
+
+onMounted(() => {
+	//获取IP列表
+	getIpData();
+	getConfig();
+});
 </script>
-<style scoped>
-:deep(.el-link__inner) {
-	display: inline-block;
-	width: 100%;
-	justify-content: start;
+<style  lang="scss">
+.is-top {
+	margin-bottom: 0 !important;
+}
+.el-collapse-item__content {
+	padding-bottom: 0 !important;
+}
+.el-tabs--card > .el-tabs__header .el-tabs__item {
+	background-color: #fff;
 }
-:deep(.el-link.is-underline:hover:after) {
-	display: none;
+.el-tabs--card > .el-tabs__header .el-tabs__item.is-active {
+	background-color: #e8f2fe;
+	border-bottom-color: #e8f2fe;
 }
 </style>

+ 1 - 1
src/views/marketing/statistics/i18n/en.ts

@@ -1,5 +1,5 @@
 export default {
-	systoken: {
+	statistics: {
 		index: '#',
 		userId: 'userId',
 		username: 'username',

+ 11 - 1
src/views/marketing/statistics/i18n/zh-cn.ts

@@ -1,5 +1,5 @@
 export default {
-	systoken: {
+	statistics: {
 		ip: 'IP',
 		domain: '域名',
 		content: '访问量',
@@ -13,5 +13,15 @@ export default {
 		queryBtn: '查询',
 		resetBtn: '重置',
 		fingerprint: '指纹',
+
+		timeRange: '时间范围',
+		firstAccessTime: '首次访问时间',
+		lastAccessTime: '最后访问时间',
+		accessSource: '访问来源',
+		lastVisitedPage: '最后受访页面',
+		IP: 'IP',
+		area: '地区',
+		accessVolume: '访问量',
+		device: '设备',
 	},
 };

+ 99 - 148
src/views/marketing/statistics/index.vue

@@ -1,26 +1,26 @@
 <template>
-  <div class="layout-padding">
+  <div class="layout-padding" style="width: 100%;">
     <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="t('systoken.ip')" prop="ip">
-            <el-input :placeholder="t('systoken.inputIpTip')" v-model="state.queryForm.ip"></el-input>
+        <el-form :inline="true" :model="state.queryForm" @keyup.enter="withCollapsedChildren(getDataList)" ref="queryRef">
+          <el-form-item :label="t('statistics.ip')" prop="ip">
+            <el-input :placeholder="t('statistics.inputIpTip')" v-model="state.queryForm.ip"></el-input>
           </el-form-item>
-          <el-form-item :label="t('systoken.domain')" prop="domain">
-            <el-input :placeholder="t('systoken.inputDomainTip')" v-model="state.queryForm.domain"></el-input>
-          </el-form-item>
-          <el-form-item :label="t('systoken.referrer')" prop="referrer">
-            <el-input :placeholder="t('systoken.inputReferrer')" v-model="state.queryForm.referrer"></el-input>
+          <!-- <el-form-item :label="t('statistics.domain')" prop="domain">
+            <el-input :placeholder="t('statistics.inputDomainTip')" v-model="state.queryForm.domain"></el-input>
           </el-form-item>
+          <el-form-item :label="t('statistics.referrer')" prop="referrer">
+            <el-input :placeholder="t('statistics.inputReferrer')" v-model="state.queryForm.referrer"></el-input>
+          </el-form-item> -->
           <el-form-item>
-            <el-button @click="getDataList" icon="Search" type="primary">{{ t('common.queryBtn') }} </el-button>
+            <el-button @click="withCollapsedChildren(getDataList)" icon="Search" type="primary">{{ t('common.queryBtn') }} </el-button>
             <el-button @click="resetQuery" icon="Refresh">{{ t('common.resetBtn') }}</el-button>
           </el-form-item>
         </el-form>
       </el-row>
-      <el-table class="statistics-table" :data="state.dataList" row-key="ip" @sort-change="sortChangeHandle" style="width: 100%"
+      <el-table class="statistics-table" :data="state.dataList" row-key="ip" @sort-change="withCollapsedChildren(sortChangeHandle)" style="width: 100%"
         v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
-        :header-cell-style="tableStyle.headerCellStyle" @row-click="toggleRowExpansion"
+        :header-cell-style="tableStyle.headerCellStyle"
         :expand-row-keys="expandedRowKeys"
         @expand-change="handleExpandChange"
         >
@@ -30,211 +30,162 @@
             <div class="child-table-container">
               <el-table :data="row.childTableData" v-loading="row.childTableData.childLoading" border :cell-style="tableStyle.cellStyle"
                 :header-cell-style="tableStyle.headerCellStyle">
-                <!-- <el-table-column :label="t('systoken.ip')" prop="ip" show-overflow-tooltip></el-table-column> -->
-                <el-table-column :label="t('systoken.domain')" prop="domain" show-overflow-tooltip></el-table-column>
-                <el-table-column :label="t('systoken.fingerprint')" prop="finger" show-overflow-tooltip></el-table-column>
-                <el-table-column :label="t('systoken.referrer')" prop="referrer" show-overflow-tooltip></el-table-column>
-                <el-table-column :label="t('systoken.content')" prop="total" show-overflow-tooltip></el-table-column>
-                <el-table-column :label="t('systoken.dayActive')" prop="daily" show-overflow-tooltip></el-table-column>
-                <el-table-column :label="t('systoken.hourActive')" prop="hourly" show-overflow-tooltip></el-table-column>
-                <el-table-column :label="t('systoken.fifteenOnline')" prop="online" show-overflow-tooltip></el-table-column>
+                <!-- <el-table-column :label="t('statistics.ip')" prop="ip" show-overflow-tooltip></el-table-column> -->
+                <el-table-column :label="t('statistics.domain')" :formatter="statusFormatter" prop="domain" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('statistics.fingerprint')" :formatter="statusFormatter" prop="fingerprint" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('statistics.referrer')" :formatter="statusFormatter" prop="referrer" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('statistics.content')" :formatter="statusFormatter" prop="total" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('statistics.dayActive')" :formatter="statusFormatter" prop="daily" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('statistics.hourActive')" :formatter="statusFormatter" prop="hourly" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('statistics.fifteenOnline')" :formatter="statusFormatter" prop="online" show-overflow-tooltip></el-table-column>
               </el-table>
               <div class="pagination-container" @click.stop>
-                <pagination v-bind="row.childPagination" @size-change="size => handlePageSizeChange(row, size)"
+                <pagination v-bind="row.childPagination"
+                  :current-page="row.childPagination.page"
+                  :page-size="row.childPagination.size"
+                  :total="row.childPagination.total"
+                  :key="`pagination-${row.ip}-${row.childPagination.page}`"
+                  @size-change="size => handlePageSizeChange(row, size)"
                   @current-change="page => handlePageChange(row, page)" />
               </div>
             </div>
           </template>
         </el-table-column>
-        <el-table-column :label="t('systoken.ip')" prop="ip" show-overflow-tooltip></el-table-column>
-        <!-- <el-table-column :label="t('systoken.domain')" prop="domain" show-overflow-tooltip></el-table-column> -->
-        <!-- <el-table-column :label="t('systoken.fingerprint')" show-overflow-tooltip>--</el-table-column>
-        <el-table-column :label="t('systoken.referrer')" show-overflow-tooltip>--</el-table-column> -->
-        <el-table-column :label="t('systoken.content')" prop="total" show-overflow-tooltip></el-table-column>
-        <el-table-column :label="t('systoken.dayActive')" prop="daily" show-overflow-tooltip></el-table-column>
-        <el-table-column :label="t('systoken.hourActive')" prop="hourly" show-overflow-tooltip></el-table-column>
-        <el-table-column :label="t('systoken.fifteenOnline')" prop="online" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('statistics.ip')" :formatter="statusFormatter" prop="ip" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('statistics.content')" :formatter="statusFormatter" prop="total" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('statistics.dayActive')" :formatter="statusFormatter" prop="daily" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('statistics.hourActive')" :formatter="statusFormatter" prop="hourly" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('statistics.fifteenOnline')" :formatter="statusFormatter" prop="online" show-overflow-tooltip></el-table-column>
       </el-table>
 
-      <pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination">
+      <pagination 
+        @current-change="page => withCollapsedChildren(currentChangeHandle, page)" 
+        @size-change="size => withCollapsedChildren(sizeChangeHandle, size)" 
+        v-bind="state.pagination">
       </pagination>
     </div>
   </div>
 </template>
 
-<script lang="ts" setup>
+<script lang="ts" setup name="StatisticsIndex">
 import { BasicTableProps, useTable } from '/@/hooks/table';
-import { pageList } from '/@/api/marketing/statistics';
+import { pageList, detailList } from '/@/api/marketing/statistics';
 import { useI18n } from 'vue-i18n';
+import { ref, reactive, watchEffect } from 'vue';
 
 const { t } = useI18n();
 // 定义变量内容
 const queryRef = ref();
 const showSearch = ref(true);
+const expandedRowKeys = ref<number[]>([]); // 记录展开的行
+
+// 接受props
+const props = defineProps({
+  row: {
+    type: Object,
+    default: () => ({})
+  },
+  type: {
+    type: String,
+    default: 'index'
+  },
+  appName: {
+    type: String,
+    default: ''
+  }
+});
 
 //  table hook
 const state: BasicTableProps = reactive<BasicTableProps>({
   queryForm: {
     ip: '',
-    domain: ''
+    appId: props.row.appId || '',
   },
   pageList: pageList,
+  pagination: {
+    current: 1,
+    size: 10,
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  }
 });
 const { getDataList, currentChangeHandle, sortChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
 
 // 清空搜索条件
 const resetQuery = () => {
   queryRef.value?.resetFields();
-  getDataList();
+  withCollapsedChildren(getDataList);
 };
 
-// 初始化子表格数据和分页
+const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
+  return cellValue || '--';
+}
+
+// 初始化二级表格数据和分页
 const initChildData = (parentRow) => {
   parentRow.expanded = false;
-  parentRow.childTableData = [
-    {
-      "ip": "",
-      "domain": "192.168.3.9",
-      "referrer": "http://192.168.3.9:5174/",
-      "total": "1",
-      "daily": "0",
-      "hourly": "0",
-      "online": null
-    },
-    {
-      "ip": "",
-      "domain": "192.168.3.9",
-      "referrer": "http://192.168.3.9:5174/www.baidu.com",
-      "total": "2",
-      "daily": "0",
-      "hourly": "0",
-      "online": null
-    },
-    {
-      "ip": "",
-      "domain": "localhost",
-      "referrer": "",
-      "total": "73",
-      "daily": "0",
-      "hourly": "0",
-      "online": null
-    },
-    {
-      "ip": "",
-      "domain": "192.168.3.9",
-      "referrer": "",
-      "total": "75",
-      "daily": "1",
-      "hourly": "0",
-      "online": null
-    },
-    {
-      "ip": "",
-      "domain": "192.168.3.9",
-      "referrer": "",
-      "total": "2",
-      "daily": "0",
-      "hourly": "0",
-      "online": null
-    },
-    {
-      "ip": "",
-      "domain": "192.168.0.77",
-      "referrer": "",
-      "total": "25",
-      "daily": "0",
-      "hourly": "0",
-      "online": null
-    }
-  ];
+  parentRow.childTableData = []
   parentRow.childLoading = false;
-  parentRow.childPagination = {
+  parentRow.childPagination = reactive({
     page: 1,
     size: 5,
-    total: 6,
-    pageSizes: [1, 5, 10, 20]
-  };
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  });
 };
 
-// 初始化所有父行
+// 初始化所有二级表格数据
 watchEffect(() => {
   state?.dataList?.forEach(row => {
     initChildData(row);
   })
-}, [state.pageList])
-
-// 切换行展开状态
-const toggleRowExpansion = (row: any, column: any, event: Event) => {
-  console.log(row);
-  if ((event.target as HTMLElement).closest('.pagination-container')) {
-    return;
-  }
+})
 
-  row.expanded = !row.expanded;
-  if (row.expanded && row.childTableData.length === 0) {
-    loadChildData(row);
-  }
-};
-
-// 加载子表格数据(模拟API请求)
+// 获取二级表格数据
 const loadChildData = async (parentRow) => {
   parentRow.childLoading = true;
   try {
-    // 模拟API请求参数
-    const params = {
-      parentId: parentRow.id,
-      page: parentRow.childPagination.page,
-      pageSize: parentRow.childPagination.size
-    };
-
-    // 模拟API请求延迟
-    await new Promise(resolve => setTimeout(resolve, 800));
-
-    // 生成模拟数据
-    const mockData = [];
-    const startIndex = (params.page - 1) * params.pageSize;
-
-    for (let i = 0; i < params.pageSize; i++) {
-      const index = startIndex + i;
-      if (index >= parentRow.childPagination.total) break;
-
-      mockData.push({
-        "ip": parentRow.ip,
-        "domain": "192.168.3.9",
-        "referrer": "http://192.168.3.9:5174/",
-        "total": "1",
-        "daily": "0",
-        "hourly": "0",
-        "online": Math.random() < 0.5 ? null : Math.floor(Math.random() * 100)
-      });
-    }
-
-    // 更新数据
-    parentRow.childTableData = mockData;
-    parentRow.childPagination.total = parentRow.childPagination.total+=0;
+    const res = await detailList({
+      ip: parentRow.ip,
+      current: parentRow.childPagination.page,
+      size: parentRow.childPagination.size
+    });
+    const data = res.data;
+    parentRow.childTableData = data.records;
+    parentRow.childPagination.total = data.total;
   } finally {
     parentRow.childLoading = false;
   }
 };
 
-// 处理分页大小变化
+// 确保在请求数据前关闭所有二级表格
+const withCollapsedChildren = (callback: Function, ...args: any[]) => {
+  expandedRowKeys.value = [];
+  return callback(...args);
+};
+
+// 处理二级表格分页大小变化
 const handlePageSizeChange = (row: any, size: number) => {
   row.childPagination.size = size;
   row.childPagination.page = 1; // 重置为第一页
   loadChildData(row);
 };
 
-// 处理页码变化
+// 处理二级表格页码变化
 const handlePageChange = (row: any, page: number) => {
   row.childPagination.page = page;
-  console.log('page', page);
-  
   loadChildData(row);
 };
 
-const expandedRowKeys = ref<number[]>([]);
+// 二级表格展开/关闭
 const handleExpandChange = (row: any, expandedRows: any[]) => {
   expandedRowKeys.value = expandedRows.map(item => item.ip);
+  row.expanded = !row.expanded;
+
+  // 加载二级表格第1页数据
+  if (row.expanded && (!row.childTableData || row.childTableData.length === 0)) {
+    loadChildData(row);
+  }
 };
 
 </script>