| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296 |
- <template>
- <div :class="prefixCls" class="face-to-face-page">
- <div class="page-header">
- <div class="page-title">碰面分析</div>
- <div class="page-subtitle">按对象、时间范围和时间间隔汇总碰面组合,联动查看碰面地点和明细记录。</div>
- </div>
- <div class="filter-bar">
- <div class="filter-item">
- <span class="filter-label">选择对象:</span>
- <Select
- v-model:value="filters.target"
- mode="multiple"
- allow-clear
- max-tag-count="responsive"
- class="filter-input"
- :options="targetOptions"
- placeholder="请选择对象"
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">时间:</span>
- <RangePicker v-model:value="filters.dateRange" class="filter-date" />
- </div>
- <div class="filter-item">
- <span class="filter-label">时间间隔:</span>
- <Select v-model:value="filters.intervalOp" class="filter-op" :options="intervalOpOptions" />
- <Input v-model:value="filters.intervalValue" class="filter-micro" :status="intervalValueStatus" />
- <span class="filter-unit">h</span>
- </div>
- <div class="filter-actions">
- <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
- <Button @click="handleReset">重置</Button>
- </div>
- </div>
- <div class="page-content">
- <div class="group-section">
- <div v-if="!hasSearched" class="panel-empty panel-empty--state">
- <EmptyState
- variant="query"
- title="请点击查询"
- description="设置条件精细查询,也可点击查询展示所有信息"
- :min-height="160"
- />
- </div>
- <div v-else-if="queryLoading && !peerGroups.length" class="panel-empty">碰面组合加载中...</div>
- <div v-else-if="peerGroups.length" class="peer-groups">
- <div
- v-for="group in peerGroups"
- :key="group.id"
- class="peer-card"
- :class="{ active: selectedGroup === group.id }"
- @click="handleSelectGroup(group.id)"
- >
- <div class="peer-members">
- <span class="member">{{ group.personName }}</span>
- <span class="member">{{ group.otherName }}</span>
- </div>
- <div class="peer-meta">碰面{{ group.count }}次</div>
- </div>
- </div>
- <div v-else class="panel-empty panel-empty--state">
- <EmptyState description="当前筛选条件下暂无碰面组合" :min-height="160" />
- </div>
- </div>
- <div class="content-panel">
- <div class="left-panel">
- <div class="panel-header">
- <div>
- <div class="panel-title">碰面地点汇总</div>
- <div class="panel-desc">{{ locationPanelDesc }}</div>
- </div>
- </div>
- <div class="location-list-wrap">
- <div v-if="detailLoading && !locationList.length" class="panel-empty">碰面地点加载中...</div>
- <div v-else-if="locationList.length" class="location-list">
- <div
- v-for="location in locationList"
- :key="location.id"
- class="location-item"
- :class="{ active: selectedLocation === location.id }"
- @click="handleSelectLocation(location.id)"
- >
- <div class="location-info">
- <div class="location-name">{{ location.name }}</div>
- <div v-if="location.secondaryLine" class="location-lac">
- {{ location.secondaryLine }}
- </div>
- </div>
- <div class="location-count">碰面{{ location.count }}次</div>
- </div>
- </div>
- <div v-else class="panel-empty panel-empty--state">
- <EmptyState
- variant="compact"
- :title="activeGroup ? '暂无碰面地点' : '请先选择组合'"
- :description="activeGroup ? '当前组合暂无地点明细' : '选择上方碰面组合后查看地点汇总'"
- :min-height="220"
- />
- </div>
- </div>
- </div>
- <div class="right-panel">
- <div class="panel-header">
- <div>
- <div class="panel-title">碰面记录</div>
- <div class="panel-desc">{{ recordPanelDesc }}</div>
- </div>
- </div>
- <div class="record-wrap">
- <div v-if="detailLoading && !tableData.length" class="panel-empty panel-empty--record"
- >碰面记录加载中...</div
- >
- <div v-else-if="tableData.length" class="table-wrapper">
- <BasicTable
- :columns="columns"
- :action-column="actionColumn"
- :data-source="tableData"
- :loading="detailLoading"
- :pagination="false"
- :show-table-setting="false"
- :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
- :can-resize="true"
- :is-can-resize-parent="true"
- :resize-height-offset="32"
- :immediate="false"
- row-key="id"
- >
- <template #action="{ record }">
- <Button type="link" size="small" @click="handleViewDetail(record)">查看详情</Button>
- </template>
- </BasicTable>
- </div>
- <div v-else class="panel-empty panel-empty--state panel-empty--record">
- <EmptyState
- variant="compact"
- :title="activeGroup ? '暂无碰面记录' : '暂无碰面数据'"
- :description="activeGroup ? '当前地点下暂无碰面记录' : '选择上方组合后查看碰面记录'"
- :min-height="240"
- />
- </div>
- </div>
- </div>
- </div>
- </div>
- <CallRecordModal v-model:open="callRecordModalOpen" title="通信记录" :base-query="callRecordBaseQuery" />
- </div>
- </template>
- <script lang="ts" setup name="ViewsFaceToFaceIndex">
- import { computed, onActivated, onMounted, ref, watch } from 'vue';
- import dayjs, { type Dayjs } from 'dayjs';
- import { useRoute } from 'vue-router';
- import { Select, Input, Button, DatePicker } from 'ant-design-vue';
- import { EmptyState } from '@jeesite/core/components/EmptyState';
- import { BasicTable } from '@jeesite/core/components/Table';
- import type { BasicColumn } from '@jeesite/core/components/Table';
- import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
- import { useDesign } from '@jeesite/core/hooks/web/useDesign';
- import { useMessage } from '@jeesite/core/hooks/web/useMessage';
- import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
- import CallRecordModal from '@jeesite/call/views/components/CallRecordModal.vue';
- import type { CallRecordQuery } from '@jeesite/call/api/types/query';
- import { getPersonOptions } from '@jeesite/person/api/personApi';
- import { statFirstTrackMeet, statSecondTrackMeet } from '@jeesite/track/api/trackApi';
- import type { TrackMeetDTO, TrackMeetSummeryDTO } from '@jeesite/track/api/types/dto';
- import type { TrackMeetQuery } from '@jeesite/track/api/types/query';
- type RangeValue = [Dayjs, Dayjs] | undefined;
- const QUERY_LIMIT = 10000;
- const PAGE_PATH = '/faceToFace/index';
- interface MeetRecordItem extends TrackMeetDTO {
- id: string;
- personName: string;
- otherName: string;
- meetPlace: string;
- meetDateTime: string;
- distanceIntervalText: string;
- timeIntervalText: string;
- gapMinutesText: string;
- locationMeta?: string;
- hasExplicitPlace: boolean;
- lac?: string;
- cid?: string;
- meetCount: number;
- }
- interface MeetLocationItem {
- id: string;
- name: string;
- secondaryLine?: string;
- lac?: string;
- cid?: string;
- count: number;
- records: MeetRecordItem[];
- }
- interface MeetGroupItem {
- id: string;
- personName: string;
- otherName: string;
- count: number;
- placeCount: number;
- nestedRecords: MeetRecordItem[];
- raw: TrackMeetSummeryDTO | Record<string, any>;
- }
- const { prefixCls } = useDesign('face-to-face');
- const RangePicker = DatePicker.RangePicker;
- const route = useRoute();
- const { createMessage } = useMessage();
- const { queryLoading, runWithQueryLoading } = useTableSkill();
- const createDefaultFilters = () => ({
- target: [] as string[],
- dateRange: undefined as RangeValue,
- intervalOp: 'le',
- intervalValue: '24',
- });
- const filters = ref(createDefaultFilters());
- const targetOptions = ref<Array<{ label: string; value: string }>>([]);
- const positiveIntegerPattern = /^[1-9]\d*$/;
- const peerGroups = ref<MeetGroupItem[]>([]);
- const hasSearched = ref(false);
- const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
- refresh: () => fetchData(),
- hasData: () => hasSearched.value && peerGroups.value.length > 0,
- });
- const selectedGroup = ref<string | null>(null);
- const selectedLocation = ref<string | null>(null);
- const detailLoading = ref(false);
- const initialized = ref(false);
- const callRecordModalOpen = ref(false);
- const callRecordBaseQuery = ref<Partial<CallRecordQuery>>({});
- const groupDetailMap = ref<Record<string, MeetRecordItem[]>>({});
- const locationDetailMap = ref<Record<string, MeetRecordItem[]>>({});
- const lastExecutedQuery = ref<TrackMeetQuery>({ page: 1, limit: QUERY_LIMIT });
- const routeTargetNames = ref<string[]>([]);
- const routeOtherName = ref('');
- const intervalOpOptions = [{ label: '<=', value: 'le' }];
- const columns: BasicColumn[] = [
- { title: '碰面地点', dataIndex: 'meetPlace', key: 'meetPlace', width: 360, align: 'center' },
- { title: '碰面时间', dataIndex: 'meetDateTime', key: 'meetDateTime', width: 220, align: 'center' },
- { title: '时间间隔(分)', dataIndex: 'timeIntervalText', key: 'timeIntervalText', width: 150, align: 'center' },
- ];
- const actionColumn = {
- title: '操作',
- dataIndex: 'action',
- key: 'action',
- width: 120,
- align: 'center' as const,
- fixed: 'right' as const,
- slot: 'action',
- };
- const activeGroup = computed(() => peerGroups.value.find((item) => item.id === selectedGroup.value) || null);
- const currentGroupRecords = computed(() => {
- if (!selectedGroup.value) return [];
- return groupDetailMap.value[selectedGroup.value] || [];
- });
- const locationList = computed(() => buildLocationList(currentGroupRecords.value));
- const activeLocation = computed(() => locationList.value.find((item) => item.id === selectedLocation.value) || null);
- const activeLocationDetailCacheKey = computed(() => {
- if (!activeGroup.value?.id || !activeLocation.value?.id) return '';
- return `${activeGroup.value.id}::${activeLocation.value.id}`;
- });
- const hasLocationDetailCache = computed(() => {
- if (!activeLocationDetailCacheKey.value) return false;
- return Object.prototype.hasOwnProperty.call(locationDetailMap.value, activeLocationDetailCacheKey.value);
- });
- const currentLocationRecords = computed<MeetRecordItem[] | undefined>(() => {
- if (!activeLocationDetailCacheKey.value) return undefined;
- return locationDetailMap.value[activeLocationDetailCacheKey.value];
- });
- const tableData = computed(() => {
- if (hasLocationDetailCache.value) return currentLocationRecords.value || [];
- return activeLocation.value?.records || currentGroupRecords.value;
- });
- const locationPanelDesc = computed(() => {
- if (!activeGroup.value) return '选择上方碰面组合后查看地点汇总。';
- if (!locationList.value.length)
- return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 暂无地点明细。`;
- return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 共命中 ${locationList.value.length} 个碰面地点。`;
- });
- const recordPanelDesc = computed(() => {
- if (!activeGroup.value) return '选择上方碰面组合后查看碰面记录。';
- if (activeLocation.value) {
- if (hasLocationDetailCache.value) {
- const detailCount = sumMeetCount(currentLocationRecords.value || []);
- if (!detailCount) {
- return `${activeLocation.value.name} 暂无碰面记录。`;
- }
- return `${activeLocation.value.name} 共命中 ${detailCount} 次碰面记录。`;
- }
- return `${activeLocation.value.name} 共命中 ${activeLocation.value.count} 次碰面记录。`;
- }
- if (currentGroupRecords.value.length) {
- return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 共命中 ${sumMeetCount(currentGroupRecords.value)} 次碰面记录。`;
- }
- return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 暂无碰面记录。`;
- });
- const normalizedIntervalValue = computed(() => String(filters.value.intervalValue ?? '').trim());
- const intervalValueStatus = computed<'error' | undefined>(() => {
- if (!normalizedIntervalValue.value) return undefined;
- return positiveIntegerPattern.test(normalizedIntervalValue.value) ? undefined : 'error';
- });
- const parseNumber = (value?: string) => {
- const normalized = String(value ?? '').trim();
- if (!normalized) return undefined;
- if (!positiveIntegerPattern.test(normalized)) return null;
- const numericValue = Number(normalized);
- return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : null;
- };
- const toNumber = (value: unknown, fallback = 0) => {
- const numericValue = Number(value);
- return Number.isFinite(numericValue) ? numericValue : fallback;
- };
- const getDateValue = (value?: string) => {
- if (!value) return 0;
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed.valueOf() : 0;
- };
- const normalizeString = (value: unknown) => {
- if (value === undefined || value === null) return '';
- return String(value).trim();
- };
- const pickRouteQueryValue = (value?: string | string[]) => {
- return Array.isArray(value) ? value[0] : value;
- };
- const normalizeRouteNames = (value?: string | string[] | null) => {
- if (Array.isArray(value)) {
- return Array.from(new Set(value.map((item) => normalizeString(item)).filter(Boolean)));
- }
- const text = normalizeString(value);
- if (!text) return [];
- if (text.startsWith('[') && text.endsWith(']')) {
- try {
- const parsed = JSON.parse(text);
- if (Array.isArray(parsed)) return normalizeRouteNames(parsed as string[]);
- } catch {
- // Ignore invalid JSON and fall back to comma split.
- }
- }
- return Array.from(
- new Set(
- text
- .split(',')
- .map((item) => normalizeString(item))
- .filter(Boolean),
- ),
- );
- };
- const parseRouteDate = (value?: string | string[]) => {
- const text = normalizeString(pickRouteQueryValue(value));
- if (!text) return undefined;
- const parsed = dayjs(text);
- return parsed.isValid() ? parsed : undefined;
- };
- const parseCidValue = (value: unknown) => {
- const normalized = normalizeString(value);
- return normalized || undefined;
- };
- const parseIntervalMinutes = (value?: string) => {
- const hours = parseNumber(value);
- if (hours === undefined) return undefined;
- if (hours === null) return null;
- return hours * 60;
- };
- const parseMeetIds = (value: unknown) => {
- const normalized = normalizeString(value);
- if (!normalized) return [];
- return normalized
- .split('_')
- .map((item) => item.trim())
- .filter(Boolean);
- };
- const formatLocationName = (name?: string, lac?: string, cid?: string) => {
- const normalizedName = normalizeString(name);
- if (normalizedName) return normalizedName;
- const baseStation = [lac ? `LAC: ${lac}` : '', cid ? `CID: ${cid}` : ''].filter(Boolean).join(' ');
- return baseStation || '-';
- };
- const flattenObjectValues = (source: Record<string, any>) => {
- return Object.values(source).reduce<any[]>((acc, value) => {
- if (Array.isArray(value)) {
- acc.push(...value);
- } else if (Array.isArray(value?.list)) {
- acc.push(...value.list);
- } else if (Array.isArray(value?.records)) {
- acc.push(...value.records);
- } else if (Array.isArray(value?.data)) {
- acc.push(...value.data);
- }
- return acc;
- }, []);
- };
- const extractList = (resp: any): any[] => {
- if (Array.isArray(resp)) return resp;
- if (Array.isArray(resp?.list)) return resp.list;
- if (Array.isArray(resp?.records)) return resp.records;
- if (Array.isArray(resp?.data)) return resp.data;
- if (Array.isArray(resp?.data?.list)) return resp.data.list;
- if (Array.isArray(resp?.data?.records)) return resp.data.records;
- if (resp?.data && typeof resp.data === 'object') {
- const flattened = flattenObjectValues(resp.data);
- if (flattened.length) return flattened;
- }
- if (resp && typeof resp === 'object') {
- const flattened = flattenObjectValues(resp);
- if (flattened.length) return flattened;
- }
- return [];
- };
- const parseMeetPlacesCount = (value: unknown) => {
- if (Array.isArray(value)) {
- return value.filter((item) => normalizeString(item)).length;
- }
- if (typeof value === 'string') {
- return value
- .split(/[;,,;\n]/)
- .map((item) => item.trim())
- .filter(Boolean).length;
- }
- return 0;
- };
- const sumMeetCount = (records: MeetRecordItem[]) => {
- return records.reduce((total, record) => total + Math.max(record.meetCount || 0, 1), 0);
- };
- const normalizeRecordItem = (
- record: TrackMeetDTO | Record<string, any>,
- group: Partial<MeetGroupItem>,
- key: string,
- ) => {
- const lac = normalizeString(record?.lac);
- const cid = normalizeString(record?.cid);
- const explicitPlace = normalizeString(
- record?.meetPlace || (record as any)?.stationName || (record as any)?.meetAddress || (record as any)?.location,
- );
- const locationMeta = [lac ? `LAC: ${lac}` : '', cid ? `CID: ${cid}` : ''].filter(Boolean).join(' ');
- const meetPlace = formatLocationName(explicitPlace || undefined, lac || undefined, cid || undefined);
- const meetDateTime =
- normalizeString(
- (record as any)?.meetDateTime || record?.meetDate || (record as any)?.dateTime || (record as any)?.date,
- ) || '-';
- const meetCount = Math.max(toNumber(record?.meetCount, 1), 1);
- const gapMinutes = record?.gapMinutes ?? (record as any)?.timeInterval ?? (record as any)?.gapMinute;
- const distanceInterval = (record as any)?.distanceInterval ?? (record as any)?.distanceGap;
- return {
- ...record,
- id: normalizeString((record as any)?.meetId || (record as any)?.id || key) || key,
- personName: normalizeString(record?.personName || (record as any)?.persona || group.personName) || '-',
- otherName: normalizeString(record?.otherName || (record as any)?.personb || group.otherName) || '-',
- meetPlace,
- meetDateTime,
- distanceIntervalText: normalizeString(distanceInterval) || '-',
- timeIntervalText: normalizeString((record as any)?.timeInterval ?? gapMinutes) || '-',
- gapMinutesText: normalizeString(gapMinutes) || '-',
- locationMeta: locationMeta || undefined,
- hasExplicitPlace: Boolean(explicitPlace),
- lac: lac || undefined,
- cid: cid || undefined,
- meetCount,
- } as MeetRecordItem;
- };
- const matchesLocationKeyword = (record: Partial<MeetRecordItem> | Record<string, any>, keyword: string) => {
- const normalizedKeyword = normalizeString(keyword).toLowerCase();
- if (!normalizedKeyword) return true;
- return [
- record?.meetPlace,
- record?.stationName,
- (record as any)?.meetAddress,
- (record as any)?.location,
- record?.lac,
- record?.cid,
- ].some((value) => normalizeString(value).toLowerCase().includes(normalizedKeyword));
- };
- const filterRecordsByLocationKeyword = (records: MeetRecordItem[], keyword: string) => {
- const normalizedKeyword = normalizeString(keyword);
- if (!normalizedKeyword) return records;
- return records.filter((record) => matchesLocationKeyword(record, normalizedKeyword));
- };
- const buildLocationList = (records: MeetRecordItem[]) => {
- const locationMap = new Map<string, MeetLocationItem>();
- records.forEach((record, index) => {
- const key =
- [record.meetPlace, record.lac, record.cid].filter(Boolean).join('||') || `${record.meetPlace}-${index}`;
- const current = locationMap.get(key);
- if (!current) {
- locationMap.set(key, {
- id: key,
- name: record.meetPlace,
- secondaryLine: record.hasExplicitPlace ? record.locationMeta : undefined,
- lac: record.lac,
- cid: record.cid,
- count: Math.max(record.meetCount || 0, 1),
- records: [record],
- });
- return;
- }
- current.records.push(record);
- current.count += Math.max(record.meetCount || 0, 1);
- });
- return Array.from(locationMap.values())
- .map((item) => ({
- ...item,
- records: [...item.records].sort(
- (prev, next) => getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
- ),
- }))
- .sort((prev, next) => {
- if (next.count !== prev.count) return next.count - prev.count;
- return prev.name.localeCompare(next.name, 'zh-CN');
- });
- };
- const normalizeSummaryGroups = (resp: any) => {
- return extractList(resp)
- .map((item: TrackMeetSummeryDTO | Record<string, any>, index: number) => {
- const meetPersonNames = Array.isArray((item as any)?.meetPersonNames) ? (item as any).meetPersonNames : [];
- const personName = normalizeString(item.personName || (item as any)?.persona || meetPersonNames[0]) || '-';
- const otherName = normalizeString(item.otherName || (item as any)?.personb || meetPersonNames[1]) || '-';
- const nestedRecords = extractList((item as any)?.meets)
- .map((record: TrackMeetDTO | Record<string, any>, recordIndex: number) =>
- normalizeRecordItem(record, { personName, otherName }, `${personName}-${otherName}-${recordIndex}`),
- )
- .sort(
- (prev: MeetRecordItem, next: MeetRecordItem) =>
- getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
- );
- const count = Math.max(
- toNumber((item as any)?.totalMeetCount ?? item.meetCount, sumMeetCount(nestedRecords)),
- nestedRecords.length ? 1 : 0,
- );
- const placeCount =
- toNumber((item as any)?.meetAddressCount, 0) ||
- (nestedRecords.length
- ? buildLocationList(nestedRecords).length
- : parseMeetPlacesCount((item as any)?.meetPlaces));
- return {
- id: `${personName}-${otherName}-${index}`,
- personName,
- otherName,
- count,
- placeCount,
- nestedRecords,
- raw: item,
- } as MeetGroupItem;
- })
- .filter((item: MeetGroupItem) => item.personName !== '-' || item.otherName !== '-' || item.count > 0)
- .sort((prev: MeetGroupItem, next: MeetGroupItem) => {
- if (next.count !== prev.count) return next.count - prev.count;
- const prevText = `${prev.personName}-${prev.otherName}`;
- const nextText = `${next.personName}-${next.otherName}`;
- return prevText.localeCompare(nextText, 'zh-CN');
- });
- };
- const hasGroupDetail = (groupId: string) => {
- return Object.prototype.hasOwnProperty.call(groupDetailMap.value, groupId);
- };
- const resolveRouteMatchedGroupId = () => {
- if (!peerGroups.value.length) return null;
- const primaryTargets = routeTargetNames.value;
- const companionName = routeOtherName.value;
- if (!primaryTargets.length && !companionName) {
- return peerGroups.value[0]?.id || null;
- }
- const directMatched = peerGroups.value.find((item) => {
- const matchesPrimary = !primaryTargets.length || primaryTargets.includes(item.personName);
- const matchesCompanion = !companionName || item.otherName === companionName;
- return matchesPrimary && matchesCompanion;
- });
- if (directMatched) return directMatched.id;
- const reverseMatched = peerGroups.value.find((item) => {
- if (!companionName) return false;
- const matchesPrimary = primaryTargets.includes(item.otherName);
- return matchesPrimary && item.personName === companionName;
- });
- if (reverseMatched) return reverseMatched.id;
- return peerGroups.value[0]?.id || null;
- };
- const buildQuery = (): TrackMeetQuery | null => {
- const startDate =
- Array.isArray(filters.value.dateRange) && filters.value.dateRange[0]
- ? dayjs(filters.value.dateRange[0]).format('YYYY-MM-DD')
- : undefined;
- const endDate =
- Array.isArray(filters.value.dateRange) && filters.value.dateRange[1]
- ? dayjs(filters.value.dateRange[1]).format('YYYY-MM-DD')
- : undefined;
- const timeInterval = parseIntervalMinutes(filters.value.intervalValue);
- if (timeInterval === null || timeInterval === undefined) {
- createMessage.warning('时间间隔请输入大于 0 的整数');
- return null;
- }
- return {
- page: 1,
- limit: QUERY_LIMIT,
- startDate,
- endDate,
- personNames: filters.value.target.length ? filters.value.target : [],
- timeInterval,
- };
- };
- const buildDetailQuery = (group: MeetGroupItem, location?: MeetLocationItem): TrackMeetQuery => {
- const baseQuery = lastExecutedQuery.value;
- return {
- ...baseQuery,
- personName: group.personName !== '-' ? group.personName : baseQuery.personName,
- persona: group.personName !== '-' ? group.personName : undefined,
- personb: group.otherName !== '-' ? group.otherName : undefined,
- lac: location?.lac || undefined,
- cid: parseCidValue(location?.cid),
- };
- };
- const syncGroupSelection = () => {
- if (!peerGroups.value.length) {
- selectedGroup.value = null;
- selectedLocation.value = null;
- return;
- }
- if (!peerGroups.value.some((item) => item.id === selectedGroup.value)) {
- selectedGroup.value = peerGroups.value[0].id;
- }
- };
- const fetchDetailData = async (group: MeetGroupItem) => {
- if (!group?.id || hasGroupDetail(group.id)) return;
- detailLoading.value = true;
- try {
- const resp = await statSecondTrackMeet(buildDetailQuery(group));
- const records = filterRecordsByLocationKeyword(
- extractList(resp)
- .map((record: TrackMeetDTO | Record<string, any>, index: number) =>
- normalizeRecordItem(record, group, `${group.id}-${index}`),
- )
- .sort(
- (prev: MeetRecordItem, next: MeetRecordItem) =>
- getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
- ),
- normalizeString(lastExecutedQuery.value.keyword),
- );
- groupDetailMap.value = {
- ...groupDetailMap.value,
- [group.id]: records,
- };
- } catch (error) {
- groupDetailMap.value = {
- ...groupDetailMap.value,
- [group.id]: group.nestedRecords || [],
- };
- createMessage.error('碰面记录加载失败');
- } finally {
- detailLoading.value = false;
- }
- };
- const fetchLocationDetailData = async (group: MeetGroupItem, location: MeetLocationItem) => {
- if (!group?.id || !location?.id) return;
- const cacheKey = `${group.id}::${location.id}`;
- if (Object.prototype.hasOwnProperty.call(locationDetailMap.value, cacheKey)) return;
- detailLoading.value = true;
- try {
- const resp = await statSecondTrackMeet(buildDetailQuery(group, location));
- const records = filterRecordsByLocationKeyword(
- extractList(resp)
- .map((record: TrackMeetDTO | Record<string, any>, index: number) =>
- normalizeRecordItem(record, group, `${cacheKey}-${index}`),
- )
- .sort(
- (prev: MeetRecordItem, next: MeetRecordItem) =>
- getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
- ),
- normalizeString(lastExecutedQuery.value.keyword),
- );
- locationDetailMap.value = {
- ...locationDetailMap.value,
- [cacheKey]: records,
- };
- } catch (error) {
- locationDetailMap.value = {
- ...locationDetailMap.value,
- [cacheKey]: location.records,
- };
- createMessage.warning('碰面地点明细加载失败,已展示当前缓存记录');
- } finally {
- detailLoading.value = false;
- }
- };
- const fetchData = async () => {
- const query = buildQuery();
- if (!query) return;
- await runWithQueryLoading(async () => {
- const resp = await statFirstTrackMeet(query);
- const groups = normalizeSummaryGroups(resp);
- const nextDetailMap: Record<string, MeetRecordItem[]> = {};
- groups.forEach((group) => {
- if (group.nestedRecords.length) {
- nextDetailMap[group.id] = group.nestedRecords;
- }
- });
- peerGroups.value = groups;
- hasSearched.value = true;
- syncAnalysisDataRefreshState(groups.length > 0);
- groupDetailMap.value = nextDetailMap;
- locationDetailMap.value = {};
- lastExecutedQuery.value = { ...query };
- const matchedGroupId = resolveRouteMatchedGroupId();
- selectedGroup.value = matchedGroupId;
- selectedLocation.value = null;
- syncGroupSelection();
- });
- };
- const handleSelectGroup = (id: string) => {
- selectedGroup.value = id;
- };
- const handleSelectLocation = (id: string) => {
- selectedLocation.value = id;
- };
- const handleSearch = async () => {
- await fetchData();
- };
- const handleReset = async () => {
- routeTargetNames.value = [];
- routeOtherName.value = '';
- filters.value = createDefaultFilters();
- await fetchData();
- };
- const handleViewDetail = (record: MeetRecordItem) => {
- const ids = parseMeetIds(record.meetId);
- if (!ids.length) {
- createMessage.warning('当前记录缺少 meetId,无法打开通信详情');
- return;
- }
- callRecordBaseQuery.value = {
- ids,
- };
- callRecordModalOpen.value = true;
- };
- const fetchTargetOptions = async () => {
- try {
- targetOptions.value = await getPersonOptions(false);
- } catch (error) {
- targetOptions.value = [];
- }
- };
- const applyRouteQuery = (options: Array<{ label: string; value: string }>) => {
- filters.value = createDefaultFilters();
- routeTargetNames.value = [];
- routeOtherName.value = '';
- const rawPersonNames = normalizeRouteNames(
- (route.query.personNames as string | string[] | null | undefined) ??
- (route.query.personName as string | string[] | null | undefined),
- );
- const otherName = normalizeString(pickRouteQueryValue(route.query.otherName as string | string[] | undefined));
- const filteredPersonNames = otherName
- ? rawPersonNames.filter((personName) => personName !== otherName)
- : rawPersonNames;
- const personNames = filteredPersonNames.length
- ? [filteredPersonNames[0]]
- : rawPersonNames.length
- ? [rawPersonNames[0]]
- : [];
- const startDate = parseRouteDate(route.query.startDate as string | string[] | undefined);
- const endDate = parseRouteDate(
- (route.query.endDate as string | string[] | undefined) ??
- (route.query.seriesDateTime as string | string[] | undefined),
- );
- routeTargetNames.value = personNames;
- routeOtherName.value = otherName;
- if (personNames.length) {
- filters.value.target = personNames.map((personName) => {
- const matched = options.find((item) => normalizeString(item.label) === personName);
- return matched ? matched.value : personName;
- });
- }
- if (startDate || endDate) {
- const rangeStart = startDate || endDate;
- const rangeEnd = endDate || startDate;
- if (rangeStart && rangeEnd) {
- filters.value.dateRange = [rangeStart, rangeEnd];
- }
- }
- return Boolean(personNames.length || otherName || startDate || endDate);
- };
- const syncFromRoute = async () => {
- if (route.path !== PAGE_PATH) return;
- if (applyRouteQuery(targetOptions.value)) {
- await fetchData();
- }
- };
- const initializePage = async () => {
- if (initialized.value) return;
- initialized.value = true;
- await fetchTargetOptions();
- await syncFromRoute();
- };
- watch(
- () => activeGroup.value?.id,
- (groupId) => {
- if (!groupId || !activeGroup.value) {
- selectedLocation.value = null;
- return;
- }
- if (!hasGroupDetail(groupId)) {
- void fetchDetailData(activeGroup.value);
- return;
- }
- if (!locationList.value.some((item) => item.id === selectedLocation.value)) {
- selectedLocation.value = locationList.value[0]?.id || null;
- }
- },
- );
- watch(
- () => locationList.value,
- (list) => {
- if (!list.length) {
- selectedLocation.value = null;
- return;
- }
- if (!list.some((item) => item.id === selectedLocation.value)) {
- selectedLocation.value = list[0].id;
- }
- },
- );
- watch(
- () => [activeGroup.value?.id, activeLocation.value?.id],
- ([groupId, locationId]) => {
- if (!groupId || !locationId || !activeGroup.value || !activeLocation.value) return;
- void fetchLocationDetailData(activeGroup.value, activeLocation.value);
- },
- );
- onMounted(() => {
- void initializePage();
- });
- onActivated(() => {
- if (!initialized.value) {
- void initializePage();
- return;
- }
- void syncFromRoute();
- });
- watch(
- () => route.fullPath,
- async (next, prev) => {
- if (next === prev) return;
- if (!initialized.value) return;
- await syncFromRoute();
- },
- );
- </script>
- <style lang="less">
- @import '../styles/trans-analysis-table-ui.less';
- @prefix-cls: ~'jeesite-face-to-face';
- .@{prefix-cls}.face-to-face-page {
- .analysis-page-frame();
- .page-content {
- display: flex;
- flex-direction: column;
- gap: 2px;
- flex: 1 1 auto;
- min-height: 0;
- }
- .filter-input {
- width: 180px;
- }
- .filter-op {
- width: 84px;
- min-width: 84px;
- }
- .filter-micro {
- width: 84px;
- }
- .filter-unit {
- color: #6b7280;
- font-size: 12px;
- white-space: nowrap;
- }
- .filter-date {
- width: 360px;
- }
- .group-section {
- .analysis-card-shell();
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- overflow: hidden;
- min-height: 0;
- }
- .peer-groups {
- display: flex;
- gap: 8px;
- padding: 0;
- overflow: auto;
- }
- .peer-card {
- min-width: 146px;
- padding: 10px 12px 9px;
- border-radius: 6px;
- border: 1px solid #e5e7eb;
- background: transparent;
- cursor: pointer;
- transition: all 0.2s ease;
- flex: 0 0 auto;
- &:hover {
- border-color: #91caff;
- }
- &.active {
- background: rgba(247, 251, 255, 0.72);
- border-color: #1677ff;
- box-shadow: none;
- }
- }
- .peer-members {
- display: flex;
- align-items: center;
- gap: 10px;
- flex-wrap: wrap;
- .member {
- font-size: 13px;
- font-weight: 600;
- color: #111827;
- }
- }
- .peer-meta {
- margin-top: 8px;
- font-size: 12px;
- line-height: 18px;
- color: #374151;
- font-weight: 600;
- }
- .content-panel {
- display: flex;
- gap: 2px;
- flex: 1 1 auto;
- min-height: 0;
- padding: 0;
- background: transparent;
- }
- .left-panel {
- width: 392px;
- flex: 0 0 auto;
- .analysis-card-shell();
- }
- .right-panel {
- .analysis-card-shell();
- flex: 1;
- min-width: 0;
- height: 100%;
- box-sizing: border-box;
- }
- .panel-header {
- padding: 0 0 8px;
- border-bottom: 0;
- flex: 0 0 auto;
- width: 100%;
- box-sizing: border-box;
- }
- .panel-title {
- font-size: 14px;
- font-weight: 600;
- color: #111827;
- line-height: 22px;
- }
- .panel-desc {
- margin-top: 4px;
- color: #6b7280;
- font-size: 12px;
- line-height: 18px;
- }
- .location-list-wrap,
- .record-wrap {
- flex: 1 1 auto;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- width: 100%;
- }
- .location-list {
- display: flex;
- flex-direction: column;
- padding: 0;
- gap: 0;
- overflow: auto;
- width: 100%;
- }
- .location-item {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 12px;
- padding: 10px 0;
- cursor: pointer;
- transition: all 0.2s ease;
- border: 0;
- border-bottom: 1px solid #eef2f7;
- border-radius: 0;
- background: transparent;
- &:hover {
- border-color: #91caff;
- }
- &.active {
- background: rgba(247, 251, 255, 0.72);
- .location-count {
- color: #1677ff;
- }
- }
- }
- .location-info {
- flex: 1;
- min-width: 0;
- .location-name {
- font-size: 12px;
- color: #374151;
- line-height: 20px;
- word-break: break-all;
- font-weight: 500;
- }
- .location-lac {
- margin-top: 4px;
- font-size: 11px;
- color: #6b7280;
- }
- }
- .location-count {
- font-size: 12px;
- color: #374151;
- white-space: nowrap;
- font-weight: 600;
- }
- .table-panel {
- display: none;
- }
- .table-wrapper {
- flex: 1 1 auto;
- min-height: 0;
- border-top: 1px solid #d1d5db;
- border-radius: 0;
- overflow: hidden;
- margin: 0;
- width: 100%;
- }
- .table-wrapper .jeesite-basic-table {
- height: 100%;
- display: flex;
- flex-direction: column;
- }
- .table-wrapper .ant-table-wrapper {
- height: 100%;
- min-height: 0;
- }
- .table-wrapper .ant-spin-nested-loading,
- .table-wrapper .ant-spin-container {
- height: 100%;
- min-height: 0;
- }
- .table-wrapper .ant-spin-container,
- .table-wrapper .ant-table,
- .table-wrapper .ant-table-container {
- height: 100%;
- min-height: 0;
- }
- .table-wrapper .ant-spin-container,
- .table-wrapper .ant-table,
- .table-wrapper .ant-table-container {
- display: flex;
- flex-direction: column;
- }
- .table-wrapper .ant-table-body {
- max-height: 100% !important;
- }
- .table-wrapper .ant-table-thead > tr > th {
- background: #f8fafc;
- font-weight: 600;
- color: #374151;
- border-bottom: 1px solid #e5e7eb;
- }
- .table-wrapper .ant-table-tbody > tr > td {
- color: #374151;
- font-size: 12px;
- border-bottom: 1px solid #e5e7eb;
- }
- .table-wrapper .ant-table-tbody > tr:hover > td {
- background: #f7fbff;
- }
- .table-wrapper .ant-table-body::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
- .table-wrapper .ant-table-body::-webkit-scrollbar-thumb {
- background: rgba(148, 163, 184, 0.6);
- border-radius: 999px;
- }
- .table-wrapper .ant-table-body::-webkit-scrollbar-track {
- background: transparent;
- }
- .jeesite-basic-table-header__toolbar {
- gap: 6px;
- }
- .panel-empty {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #94a3b8;
- font-size: 13px;
- padding: 16px;
- &--state {
- padding: 20px;
- }
- &--record {
- min-height: 240px;
- }
- }
- :deep(.ant-select-selector),
- :deep(.ant-input),
- :deep(.ant-picker),
- :deep(.ant-btn) {
- border-radius: 8px;
- }
- @media screen and (max-width: 900px) {
- .content-panel {
- flex-direction: column;
- }
- .left-panel {
- width: 100%;
- }
- }
- @media screen and (max-width: 992px) {
- .filter-actions {
- width: 100%;
- margin-left: 0;
- }
- .filter-input,
- .filter-date {
- width: 100%;
- }
- .filter-micro {
- width: 100%;
- }
- .peer-card {
- min-width: 100%;
- }
- }
- }
- </style>
|