Explorar el Código

feat:营销管理-应用列表-应用统计,表格修改

cmy hace 1 semana
padre
commit
3e9354f5c5

+ 5 - 5
src/views/marketing/apps/components/domainCell.vue

@@ -11,13 +11,14 @@
   </div>
   <DomainForm ref="DomainFormRef" @refresh="handleUpdate()" />
 </template>
-<script setup>
+<script setup lang="ts">
 import { ref, watch } from 'vue';
 const DomainForm = defineAsyncComponent(() => import('./domainForm.vue'));
+import { SourceType  } from '../types';
 
 const props = defineProps(['domainList', 'rowData', 'state']);
 const emit = defineEmits(['refresh']);
-const list = ref([]);
+const list = ref<string[]>([]);
 const DomainFormRef = ref();
 
 watch(
@@ -25,9 +26,8 @@ watch(
   (newVal) => {
     list.value = [];
     let temp = '';
-    (newVal || []).forEach(item => {
-      // sourceType 1:分组 2: 具体域名
-      if (item.sourceType == '2') {
+    (newVal || []).forEach((item: { sourceType: SourceType; domain: string; groupName: string; }) => {
+      if (item.sourceType == SourceType.DOMAIN) {
         temp = item.domain;
       } else {
         temp = item.groupName;

+ 15 - 27
src/views/marketing/apps/components/domainCollapse.vue

@@ -7,19 +7,19 @@
       <div class="p-2 items-center flex flex-wrap">
         <template v-for="item in domains" >
           <!-- 具体域名 -->
-            <el-tag v-if="item.sourceType == 2" effect="light" :closable="domainDeletable"
-            @close="handleDeleteDomain(item, 'domain')" color="#f4f4f4" round class="ml-1 cursor-pointer"
+            <el-tag v-if="item.sourceType == SourceType.DOMAIN" effect="light" :closable="domainDeletable"
+            @close="handleDeleteDomain(item, SourceType.DOMAIN)" color="#f4f4f4" round class="ml-1 cursor-pointer"
             style="margin-bottom: 4px;">
               <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">
+            <div class="flex flex-wrap break-all">
               {{ item.domains && item.domains.length ? item.domains.map(i=>i.domain).join(',') : '暂无数据' }}
             </div>
             <template #reference>
               <el-tag effect="light" :closable="domainDeletable"
-                @close="handleDeleteDomain(item, 'group')" color="#f4f4f4" round class="ml-1 cursor-pointer"
+                @close="handleDeleteDomain(item, SourceType.GROUP)" color="#f4f4f4" round class="ml-1 cursor-pointer"
                 style="margin-bottom: 4px;">
                 <span class="ellipsis">{{ item.groupName }}</span>
               </el-tag>
@@ -41,19 +41,7 @@ const DomainEdit = defineAsyncComponent(() => import('./domainEdit.vue'));
 import { useI18n } from 'vue-i18n';
 import { useMessage } from '/@/hooks/message';
 import { getGroupDetail } from '/@/api/marketing/config';
-
-interface DomianItem {
-  id?: string;
-  domain: string;
-  sourceType: number; // 1:分组 2:具体域名
-  groupId: string;
-  groupName: string;
-  modify: boolean; // 是否被修改/新增
-  domains: {
-    domain: string;
-    id: string;
-  }[];
-}
+import { SourceType, DomainItem } from '../types'
 
 const props = defineProps(['data']);
 
@@ -65,11 +53,11 @@ const { t } = useI18n();
 const domainDeletable = ref(false);// 控制域名列表项是否可删除
 const domainEditOpen = ref(false);
 const delDomains = ref<string[]>([]);
-const domains = ref<DomianItem[]>([]);
+const domains = ref<DomainItem[]>([]);
 
 watch(() => props.data, (newVal = []) => {
   domains.value = [];
-  newVal.forEach((item: DomianItem) => {
+  newVal.forEach((item: DomainItem) => {
     item.modify = false;
     domains.value = [...domains.value, item];
   })
@@ -84,12 +72,12 @@ watch(delDomains, (newVal) => {
 }, { deep: true, immediate: true });
 
 // 删除域名
-const handleDeleteDomain = (deleteItem: DomianItem, type: 'group' | 'domain') => {
+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(type == 'group') {
+    if(sourceType == SourceType.GROUP) {
       return i.groupId != deleteItem.groupId
     }else{
       return i.domain != deleteItem.domain
@@ -98,14 +86,14 @@ const handleDeleteDomain = (deleteItem: DomianItem, type: 'group' | 'domain') =>
 }
 
 // 分组去重
-function addGroupsUnique(newGroups: DomianItem[]) {
+function addGroupsUnique(newGroups: DomainItem[]) {
   const existIds = new Set([
     ...domains.value.map(item => item.groupId),
   ]);
-  const filtered:DomianItem[] = newGroups.filter(item => !existIds.has(item.id as string))
+  const filtered:DomainItem[] = newGroups.filter(item => !existIds.has(item.id as string))
     .map(item=>{
       return {
-        "sourceType": 1,
+        "sourceType": SourceType.GROUP,
         "modify": true,
         "groupId": item.id?.toString() || '',
         "groupName": item.groupName,
@@ -123,7 +111,7 @@ function addGroupsUnique(newGroups: DomianItem[]) {
 }
 
 // 域名去重
-function addSinglesUnique(newSingles: DomianItem[]) {
+function addSinglesUnique(newSingles: DomainItem[]) {
   const existDomains = new Set([
     ...domains.value.map(item => item.domain),
   ]);
@@ -135,7 +123,7 @@ function addSinglesUnique(newSingles: DomianItem[]) {
         "groupName": "",
         "domains": [],
         "domain": item.domain,
-        "sourceType": 2,
+        "sourceType": SourceType.DOMAIN,
         "modify": true,
       })
     })
@@ -147,7 +135,7 @@ function addSinglesUnique(newSingles: DomianItem[]) {
   }
 }
 
-const addDomain = (data: DomianItem[], type: 'group' | 'domain') => {
+const addDomain = (data: DomainItem[], type: 'group' | 'domain') => {
   if (type === 'domain') {
     data.length && addSinglesUnique(data);
   } else {

+ 1 - 6
src/views/marketing/apps/components/domainEdit.vue

@@ -37,17 +37,12 @@ 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();
 
-interface DomainItem {
-	domains: string[];
-	groupName: string;
-	id: string;
-}
-
 // 定义变量内容
 const loading = ref(false);
 const type = ref('add'); // 'add' or 'edit'

+ 4 - 10
src/views/marketing/apps/components/domainForm.vue

@@ -20,15 +20,9 @@
 import { ElMessage } from 'element-plus';
 import { useI18n } from 'vue-i18n';
 import { updateModDomains } from '/@/api/marketing/apps';
-const DomainCollapse = defineAsyncComponent(() => import('./domainCollapse.vue'));
+import { DomainItem } from '../types';
 
-interface DomianItem {
-  id: string;
-  domain: string;
-  sourceType: number; // 1:分组 2:具体域名
-  groupId: string;
-  groupName: string;
-}
+const DomainCollapse = defineAsyncComponent(() => import('./domainCollapse.vue'));
 
 // 定义子组件向父组件传值/事件
 const emit = defineEmits(['refresh']);
@@ -37,7 +31,7 @@ const { t } = useI18n();
 // 定义变量内容
 const visible = ref(false);
 const loading = ref(false);
-const domains = ref<DomianItem[]>([]);
+const domains = ref<DomainItem[]>([]);
 const childDomains = ref();
 const delDomains = ref<string[]>([]);
 const rowData = ref();
@@ -51,7 +45,7 @@ const openDialog = async (data: any, row) => {
   rowData.value = row;
 };
 
-const updateDomains = (data: DomianItem[])=>{
+const updateDomains = (data: DomainItem[])=>{
   childDomains.value = data;
 }
 const updateDelDomains = (data: string[]) => {

+ 3 - 40
src/views/marketing/apps/components/form.vue

@@ -64,44 +64,7 @@ const IpCollapse = defineAsyncComponent(() => import('./ipCollapse.vue'));
 import { ElMessage } from 'element-plus';
 import { useI18n } from 'vue-i18n';
 import { appUpdate } from '/@/api/marketing/apps';
-
-interface IpItem {
-  id: string;
-  ipMode: number;
-  ipType: number;
-  sourceType: number; // 1:分组 2:具体Ip(段)
-  startIp: string;
-  endIp: string;
-  groupId: string;
-  groupName: string;
-}
-
-interface DomianItem {
-  id: string;
-  domain: string;
-  sourceType: number; // 1:分组 2:具体域名
-  groupId: string;
-  groupName: string;
-}
-
-interface Form {
-  id?: string;
-  appId?: string;
-  appName?: string;
-  appImg?: string;
-  appUrl?: string;
-  backUpUrl?: string;
-  domainType?: string;
-  domainLimit: boolean;
-  launch: boolean;
-  triggerRule: number;
-  triggerNum: string;
-  remark: string;
-  ips?: IpItem[];
-  domains?: DomianItem[];
-  delDomains?: string[];
-  delIps?: string[];
-}
+import { Form, DomainItem, IpItem } from '../types';
 
 // 定义子组件向父组件传值/事件
 const emit = defineEmits(['refresh']);
@@ -151,7 +114,7 @@ const updateDelIps = (data: string[]) => {
   delIps.value = data;
 }
 
-const updateDomains = (data: DomianItem[]) => {
+const updateDomains = (data: DomainItem[]) => {
   domains.value = data;
 }
 const updateDelDomains = (data: string[]) => {
@@ -245,7 +208,7 @@ const onSubmit = async () => {
       data.push({
         ...item,
         domains: domains.value,
-        delDomains: state.ruleForm.id ? delDomains.value : item.domains.map((item: DomianItem) => item.id),
+        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,

+ 4 - 3
src/views/marketing/apps/components/ipCell.vue

@@ -13,10 +13,11 @@
   </div>
   <IpForm ref="IpFormRef" @refresh="handleUpdate()" />
 </template>
-<script setup>
+<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';
 
 const props = defineProps(['ipList', 'rowData', 'state']);
 const emit = defineEmits(['refresh']);
@@ -32,12 +33,12 @@ watch(
     whitelist.value = [];
     let temp = '';
     (newVal || []).forEach(item => {
-      if (item.sourceType == 2) {
+      if (item.sourceType == SourceType.DOMAIN) {
         temp = ipSplicing(item.startIp, item.endIp);
       } else {
         temp = item.groupName;
       }
-      item.ipType == 1 ? whitelist.value.push(temp) : blacklist.value.push(temp);
+      item.ipType == IpType.WHITE ? whitelist.value.push(temp) : blacklist.value.push(temp);
     });
   },
   { immediate: true }

+ 18 - 30
src/views/marketing/apps/components/ipCollapse.vue

@@ -3,9 +3,9 @@
     @delete="(item: any) => ipDeletable = !ipDeletable"
     :deleteText="ipDeletable ? '取消' : t('marketingConfig.deleteText')" :updateText="'新增'" :activeNames="['1']">
     <template #default>
-      <div class="border-b p-2 items-center flex flex-wrap collapse-group min-h-[40px]" v-for="type in [1, 2]"
+      <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 === 1 ? '白名单' : '黑名单' }}:</div>
+        <div class="collapse-group-name">{{ type === IpType.WHITE ? '白名单' : '黑名单' }}:</div>
         <div class="tag-content" v-if="getTargetList(type).length > 0">
           <template v-for="item in getTargetList(type)" :key="item.id">
             <!-- 单个 -->
@@ -42,19 +42,7 @@ import { useI18n } from 'vue-i18n';
 import { getGroupDetail } from '/@/api/marketing/config';
 import { ipSplicing } from '/@/utils/ipUpdate';
 import { useMessage } from '/@/hooks/message';
-
-interface IpItem {
-  id?: string;
-  ipMode: number;
-  ipType: number; // 1: 白名单, 2: 黑名单
-  sourceType: number;
-  startIp: string;
-  endIp: string;
-  groupId: string;
-  groupName: string;
-  list: string[];
-  modify?: boolean;
-}
+import { SourceType, IpType, IpMode, IpItem } from '../types';
 
 const props = defineProps(['data']);
 const emit = defineEmits(['refresh', 'ips', 'delIps']);
@@ -89,7 +77,7 @@ const handleDeleteIp = (deleteItem: IpItem) => {
   }
   ips.value = ips.value.filter(i => {
     if (i.id !== deleteItem.id) return true;
-    if (deleteItem.sourceType === 1) return i.groupId !== deleteItem.groupId;
+    if (deleteItem.sourceType === SourceType.GROUP) return i.groupId !== deleteItem.groupId;
     return i.startIp !== deleteItem.startIp || i.endIp !== deleteItem.endIp;
   });
 };
@@ -102,14 +90,14 @@ const onLoadDetail = async (item: any) => {
 
 function addGroupsUnique(newGroups: IpItem[], ipType: number) {
   const targetList = getTargetList(ipType);
-  const conflictType = ipType === 1 ? 2 : 1;
+  const conflictType = ipType === IpType.WHITE ? IpType.BLACK : IpType.WHITE;
   const conflictList = getTargetList(conflictType);
-  const existIds = new Set(targetList.filter(i => i.sourceType === 1).map(i => i.groupId));
-  const conflictIds = new Set(conflictList.filter(i => i.sourceType === 1).map(i => i.groupId));
+  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 === 1 ? '黑名单' : '白名单'} 中`);
+    useMessage().warning(`分组 ${conflict.groupName} 已存在于 ${ipType === IpType.WHITE ? '黑名单' : '白名单'} 中`);
     return;
   }
 
@@ -118,10 +106,10 @@ function addGroupsUnique(newGroups: IpItem[], ipType: number) {
   if (filtered.length) {
     const processedGroups = filtered.map(item => ({
       ipType,
-      sourceType: 1,
+      sourceType: SourceType.GROUP,
       groupId: item.id as string,
       groupName: item.groupName,
-      ipMode: 1,
+      ipMode: IpMode.IP,
       startIp: '',
       endIp: '',
       modify: true,
@@ -135,16 +123,16 @@ function addGroupsUnique(newGroups: IpItem[], ipType: number) {
 
 function addSinglesUnique(newSingles: IpItem[], ipType: number) {
   const targetList = getTargetList(ipType);
-  const conflictType = ipType === 1 ? 2 : 1;
+  const conflictType = ipType === IpType.WHITE ? IpType.BLACK : IpType.WHITE;
   const conflictList = getTargetList(conflictType);
 
-  const existIps = new Set(targetList.filter(i => i.sourceType === 2).map(i => i.startIp));
-  const conflictIps = new Set(conflictList.filter(i => i.sourceType === 2).map(i => i.startIp));
+  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 === 1 ? '黑名单' : '白名单'} 中`);
+    useMessage().warning(`IP ${conflict.startIp} 已存在于 ${ipType === IpType.WHITE ? '黑名单' : '白名单'} 中`);
     return;
   }
 
@@ -155,9 +143,9 @@ function addSinglesUnique(newSingles: IpItem[], ipType: number) {
       endIp: i.endIp,
       groupId: '',
       groupName: '',
-      ipMode: i.endIp ? 2 : 1,
+      ipMode: i.endIp ? IpMode.IPRange : IpMode.IP,
       startIp: i.startIp,
-      sourceType: 2,
+      sourceType: SourceType.DOMAIN,
       modify: true,
       ipType,
       list: []
@@ -168,9 +156,9 @@ function addSinglesUnique(newSingles: IpItem[], ipType: number) {
   }
 }
 
-const addIp = (data: IpItem[], sourceType: 1 | 2, ipType: '1' | '2') => {
+const addIp = (data: IpItem[], sourceType: SourceType.GROUP | SourceType.DOMAIN, ipType: IpType.WHITE | IpType.BLACK) => {
   const ipTypeNum = Number(ipType);
-  if (sourceType === 2) {
+  if (sourceType === SourceType.DOMAIN) {
     data.length && addSinglesUnique(data, ipTypeNum);
   } else {
     data.length && addGroupsUnique(data, ipTypeNum);

+ 12 - 28
src/views/marketing/apps/components/ipEdit.vue

@@ -4,8 +4,8 @@
 		<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="1">白名单</el-radio>
-					<el-radio :value="2">黑名单</el-radio>
+					<el-radio :value="IpType.WHITE">白名单</el-radio>
+					<el-radio :value="IpType.BLACK">黑名单</el-radio>
 				</el-radio-group>
 			</el-form-item>
 			<div class="custom-style">
@@ -53,27 +53,11 @@
 <script setup name="systemMenuDialog" lang="ts">
 import { useI18n } from 'vue-i18n';
 import { useMessage } from '/@/hooks/message';
-import {
-	pageListIp,
-} from '/@/api/marketing/config';
+import { pageListIp } from '/@/api/marketing/config';
 import { ipSplicing } from '/@/utils/ipUpdate';
 import { rule } from '/@/utils/validate';
 import { parseIpRange } from '/@/utils/ipUpdate';
-
-interface IpItem {
-	id: number;
-	groupId: number;
-	groupName: string;
-	ipMode: number;
-	ip: string;
-	startIp: string;
-	endIp: string;
-	sourceType: number;
-	list: {
-		id: number;
-		value: string;
-	}[];
-}
+import { SourceType, IpType, IpMode, IpItem } from '../types';
 
 // 定义子组件向父组件传值/事件
 const emit = defineEmits(['update:open', 'onsuccess']);
@@ -89,11 +73,11 @@ const selectedObjects = ref<IpItem[]>([]);
 // 定义需要的数据
 const state = reactive({
 	ruleForm: {
-		ipType: 1, // 1: 白名单, 2: 黑名单
-		sourceType: 1,
+		ipType: IpType.WHITE,
+		sourceType: SourceType.GROUP,
 		sourceTypeText: t('marketingConfig.grouping'),
 		groupId: [] as IpItem[],
-		ipMode: 1,
+		ipMode: IpMode.IP,
 		groupName:'',
 		startIp: '',
 		endIp: '',
@@ -141,7 +125,7 @@ watchEffect(() => {
   if (props.open) {
 	state.ruleForm.groupId = [];
 	state.ruleForm.ip = '';
-	state.ruleForm.ipType = 1;
+	state.ruleForm.ipType = IpType.WHITE;
 	getIpData();
   }
 })
@@ -160,13 +144,13 @@ const onSubmit = async () => {
 	state.ruleForm.startIp = lis.start;
 	state.ruleForm.endIp = lis.end;
 	if (lis.end !== '') {
-		state.ruleForm.ipMode = 2;
+		state.ruleForm.ipMode = IpMode.IPRange;
 	}
 	
-	state.ruleForm.sourceType = state.ruleForm.sourceTypeText === t('marketingConfig.grouping') ? 1 : 2;
-	if (state.ruleForm.sourceType == 1 && state.ruleForm.groupId.length == 0) {
+	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 == 2 && state.ruleForm.ip == '') {
+	} else if (state.ruleForm.sourceType == SourceType.DOMAIN && state.ruleForm.ip == '') {
 		return useMessage().error('ip不能为空');
 	}
 	

+ 1 - 8
src/views/marketing/apps/components/ipForm.vue

@@ -20,14 +20,7 @@ import { ElMessage } from 'element-plus';
 import { useI18n } from 'vue-i18n';
 import { updateModIp } from '/@/api/marketing/apps';
 const IpCollapse = defineAsyncComponent(() => import('./ipCollapse.vue'));
-
-interface IpItem {
-  id: string;
-  ip: string;
-  sourceType: number; // 1:分组 2:具体域名
-  groupId: string;
-  groupName: string;
-}
+import { IpItem } from '../types';
 
 // 定义子组件向父组件传值/事件
 const emit = defineEmits(['refresh']);

+ 220 - 38
src/views/marketing/apps/components/statistical.vue

@@ -1,58 +1,240 @@
 <template>
-  <el-dialog
-    :title="'统计'"
-    width="80%"
-    v-model="visible"
-    :destroy-on-close="true"
-    :close-on-click-modal="false" 
-    draggable>
-    <div class="statistics-table-wrapper">
-      <StatisticsIndex :row="row" />
-    </div>
-  </el-dialog>
+  <div class="dialog">
+    <el-dialog
+      :title="'统计'"
+      width="80%"
+      v-model="visible"
+      :destroy-on-close="true"
+      :close-on-click-modal="false" 
+      :before-close="done=>handleClose(done)"
+      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="'时间范围'" prop="timeRange">
+              <el-select v-model="state.queryForm.timeRange" @change="handleTimeChange()">
+                  <el-option :value="TimeRange.ALL" :label="'全部'">全部</el-option>
+                  <el-option :value="TimeRange.TWENTY_FOUR_HOURS" :label="'24小时'">24小时</el-option>
+                  <el-option :value="TimeRange.FIFTEEN_MINUTES" :label="'15分钟'">15分钟</el-option>
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </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 align="center" type="selection" width="40" /> -->
+          <el-table-column 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 :label="t('apps.ip')" prop="ip" show-overflow-tooltip></el-table-column> -->
+                  <el-table-column :label="t('apps.fingerprint')" prop="fingerprint" show-overflow-tooltip></el-table-column>
+                  <el-table-column :label="t('apps.device')" prop="device" show-overflow-tooltip></el-table-column>
+                  <el-table-column :label="t('apps.firstAccessTime')" prop="firstAccessTime" show-overflow-tooltip></el-table-column>
+                  <el-table-column :label="t('apps.lastAccessTime')" prop="lastAccessTime" show-overflow-tooltip></el-table-column>
+                  <el-table-column :label="t('apps.lastVisitedPage')" prop="lastVisitedPage" show-overflow-tooltip></el-table-column>
+                  <el-table-column :label="t('apps.accessVolume')" prop="accessVolume" 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 :label="t('apps.firstAccessTime')" prop="firstAccessTime" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="t('apps.lastAccessTime')" prop="lastAccessTime" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="t('apps.accessSource')" prop="accessSource" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="t('apps.lastVisitedPage')" prop="lastVisitedPage" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="t('apps.IP')" prop="IP" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="t('apps.area')" prop="area" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="t('apps.accessVolume')" prop="accessVolume" 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 } from 'vue';
-const StatisticsIndex = defineAsyncComponent(() => import('/@/views/marketing/statistics/index.vue'));
+import { ref, reactive, watchEffect } from 'vue';
+import { BasicTableProps, useTable } from '/@/hooks/table';
+import { pageList, detailList } from '/@/api/marketing/statistics';
+import { useI18n } from 'vue-i18n';
+
+enum TimeRange {
+  ALL = 0,
+  TWENTY_FOUR_HOURS = 1,
+  FIFTEEN_MINUTES = 2,
+}
 
 const visible = ref(false);
 const row = ref({});
+const { t } = useI18n();
+// 定义变量内容
+const queryRef = ref();
+const showSearch = ref(true);
+const expandedRowKeys = ref<number[]>([]); // 记录展开的行
+const setIntervalTime = ref(0);
+
+//  table hook
+const state: BasicTableProps = reactive<BasicTableProps>({
+  queryForm: {
+    timeRange: TimeRange.ALL,
+    appId: '',
+  },
+  pageList: pageList,
+  dataListLoading: true,
+  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;
-  row.value = _row;
+  getDataList();
+  state.queryForm.timeRange = TimeRange.ALL;
+  state.queryForm.appId = _row.id;
+};
+
+const handleClose = (done) => {
+  clearInterval(setIntervalTime.value);
+  done();
+}
+
+// // 清空搜索条件
+// const resetQuery = () => {
+//   queryRef.value?.resetFields();
+//   withCollapsedChildren(getDataList);
+// };
+
+const handleTimeChange = () => {
+  withCollapsedChildren(getDataList);
+  if(state.queryForm.timeRange === TimeRange.FIFTEEN_MINUTES) {
+    setIntervalTime.value = setInterval(() => {
+      getDataList();
+    }, 4000);
+  }else{
+    clearInterval(setIntervalTime.value);
+  }
+}
+
+// 初始化二级表格数据和分页
+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 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;
+  }
+};
+
+/**
+ * 关闭所有二级表格,后执行对应函数
+ * @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);
+  }
 };
 
 // 暴露变量 只有暴漏出来的变量 父组件才能使用
 defineExpose({
   openDialog,
 });
-</script>
-<style>
-.config-container {
-  display: flex;
-  align-items: center;
-  margin-bottom: 10px;
-  font-size: 14px;
-  font-weight: 400;
-  width: 100%;
-  justify-content: start;
-}
-.config-actions {
-  width: 50px;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-}
 
-.apps-loadmore.el-select-dropdown .el-scrollbar__wrap {
-  height: 330px !important;
-}
-
-.statistics-table-wrapper {
-  height: 70vh;
-  position: relative;
+// 页面卸载时
+onUnmounted(() => {
+	clearInterval(setIntervalTime.value);
+});
+</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>

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

@@ -0,0 +1,63 @@
+export interface DomainItem {
+  id?: string;
+  domain: string;
+  sourceType: number; // 1:分组 2:具体域名
+  groupId: string;
+  groupName: string;
+  modify: boolean; // 是否被修改/新增
+  domains: {
+    domain: string;
+    id: string;
+  }[];
+}
+
+export enum SourceType {
+  GROUP = 1,
+  DOMAIN = 2
+}
+
+export enum IpType {
+  WHITE = 1,
+  BLACK = 2
+}
+
+export enum IpMode {
+  IP = 1,
+  IPRange = 2
+}
+
+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 Form {
+  id?: string;
+  appId?: string;
+  appName?: string;
+  appImg?: string;
+  appUrl?: string;
+  backUpUrl?: string;
+  domainType?: string;
+  domainLimit: boolean;
+  launch: boolean;
+  triggerRule: number;
+  triggerNum: string;
+  remark: string;
+  ips?: IpItem[];
+  domains?: DomainItem[];
+  delDomains?: string[];
+  delIps?: string[];
+}

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

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

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

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

+ 20 - 20
src/views/marketing/statistics/index.vue

@@ -3,14 +3,14 @@
     <div class="layout-padding-auto layout-padding-view">
       <el-row class="ml10" v-show="showSearch">
         <el-form :inline="true" :model="state.queryForm" @keyup.enter="withCollapsedChildren(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-item :label="t('apps.ip')" prop="ip">
+            <el-input :placeholder="t('apps.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 :label="t('apps.domain')" prop="domain">
+            <el-input :placeholder="t('apps.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('apps.referrer')" prop="referrer">
+            <el-input :placeholder="t('apps.inputReferrer')" v-model="state.queryForm.referrer"></el-input>
           </el-form-item> -->
           <el-form-item>
             <el-button @click="withCollapsedChildren(getDataList)" icon="Search" type="primary">{{ t('common.queryBtn') }} </el-button>
@@ -30,14 +30,14 @@
             <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="fingerprint" 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('apps.ip')" prop="ip" show-overflow-tooltip></el-table-column> -->
+                <el-table-column :label="t('apps.domain')" prop="domain" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('apps.fingerprint')" prop="fingerprint" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('apps.referrer')" prop="referrer" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('apps.content')" prop="total" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('apps.dayActive')" prop="daily" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('apps.hourActive')" prop="hourly" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="t('apps.fifteenOnline')" prop="online" show-overflow-tooltip></el-table-column>
               </el-table>
               <div class="pagination-container" @click.stop>
                 <pagination v-bind="row.childPagination"
@@ -51,11 +51,11 @@
             </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.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('apps.ip')" prop="ip" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('apps.content')" prop="total" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('apps.dayActive')" prop="daily" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('apps.hourActive')" prop="hourly" show-overflow-tooltip></el-table-column>
+        <el-table-column :label="t('apps.fifteenOnline')" prop="online" show-overflow-tooltip></el-table-column>
       </el-table>
 
       <pagination 
@@ -135,7 +135,7 @@ watchEffect(() => {
   state?.dataList?.forEach(row => {
     initChildData(row);
   })
-}, [state.pageList])
+})
 
 // 获取二级表格数据
 const loadChildData = async (parentRow) => {