index.vue 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
  1. <template>
  2. <div :class="prefixCls" class="face-to-face-page">
  3. <div class="page-header">
  4. <div class="page-title">碰面分析</div>
  5. <div class="page-subtitle">按对象、时间范围和时间间隔汇总碰面组合,联动查看碰面地点和明细记录。</div>
  6. </div>
  7. <div class="filter-bar">
  8. <div class="filter-item">
  9. <span class="filter-label">选择对象:</span>
  10. <Select
  11. v-model:value="filters.target"
  12. mode="multiple"
  13. allow-clear
  14. max-tag-count="responsive"
  15. class="filter-input"
  16. :options="targetOptions"
  17. placeholder="请选择对象"
  18. />
  19. </div>
  20. <div class="filter-item">
  21. <span class="filter-label">时间:</span>
  22. <RangePicker v-model:value="filters.dateRange" class="filter-date" />
  23. </div>
  24. <div class="filter-item">
  25. <span class="filter-label">时间间隔:</span>
  26. <Select v-model:value="filters.intervalOp" class="filter-op" :options="intervalOpOptions" />
  27. <Input v-model:value="filters.intervalValue" class="filter-micro" :status="intervalValueStatus" />
  28. <span class="filter-unit">h</span>
  29. </div>
  30. <div class="filter-actions">
  31. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  32. <Button @click="handleReset">重置</Button>
  33. </div>
  34. </div>
  35. <div class="page-content">
  36. <div class="group-section">
  37. <div v-if="!hasSearched" class="panel-empty panel-empty--state">
  38. <EmptyState
  39. variant="query"
  40. title="请点击查询"
  41. description="设置条件精细查询,也可点击查询展示所有信息"
  42. :min-height="160"
  43. />
  44. </div>
  45. <div v-else-if="queryLoading && !peerGroups.length" class="panel-empty">碰面组合加载中...</div>
  46. <div v-else-if="peerGroups.length" class="peer-groups">
  47. <div
  48. v-for="group in peerGroups"
  49. :key="group.id"
  50. class="peer-card"
  51. :class="{ active: selectedGroup === group.id }"
  52. @click="handleSelectGroup(group.id)"
  53. >
  54. <div class="peer-members">
  55. <span class="member">{{ group.personName }}</span>
  56. <span class="member">{{ group.otherName }}</span>
  57. </div>
  58. <div class="peer-meta">碰面{{ group.count }}次</div>
  59. </div>
  60. </div>
  61. <div v-else class="panel-empty panel-empty--state">
  62. <EmptyState description="当前筛选条件下暂无碰面组合" :min-height="160" />
  63. </div>
  64. </div>
  65. <div class="content-panel">
  66. <div class="left-panel">
  67. <div class="panel-header">
  68. <div>
  69. <div class="panel-title">碰面地点汇总</div>
  70. <div class="panel-desc">{{ locationPanelDesc }}</div>
  71. </div>
  72. </div>
  73. <div class="location-list-wrap">
  74. <div v-if="detailLoading && !locationList.length" class="panel-empty">碰面地点加载中...</div>
  75. <div v-else-if="locationList.length" class="location-list">
  76. <div
  77. v-for="location in locationList"
  78. :key="location.id"
  79. class="location-item"
  80. :class="{ active: selectedLocation === location.id }"
  81. @click="handleSelectLocation(location.id)"
  82. >
  83. <div class="location-info">
  84. <div class="location-name">{{ location.name }}</div>
  85. <div v-if="location.secondaryLine" class="location-lac">
  86. {{ location.secondaryLine }}
  87. </div>
  88. </div>
  89. <div class="location-count">碰面{{ location.count }}次</div>
  90. </div>
  91. </div>
  92. <div v-else class="panel-empty panel-empty--state">
  93. <EmptyState
  94. variant="compact"
  95. :title="activeGroup ? '暂无碰面地点' : '请先选择组合'"
  96. :description="activeGroup ? '当前组合暂无地点明细' : '选择上方碰面组合后查看地点汇总'"
  97. :min-height="220"
  98. />
  99. </div>
  100. </div>
  101. </div>
  102. <div class="right-panel">
  103. <div class="panel-header">
  104. <div>
  105. <div class="panel-title">碰面记录</div>
  106. <div class="panel-desc">{{ recordPanelDesc }}</div>
  107. </div>
  108. </div>
  109. <div class="record-wrap">
  110. <div v-if="detailLoading && !tableData.length" class="panel-empty panel-empty--record"
  111. >碰面记录加载中...</div
  112. >
  113. <div v-else-if="tableData.length" class="table-wrapper">
  114. <BasicTable
  115. :columns="columns"
  116. :action-column="actionColumn"
  117. :data-source="tableData"
  118. :loading="detailLoading"
  119. :pagination="false"
  120. :show-table-setting="false"
  121. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  122. :can-resize="true"
  123. :is-can-resize-parent="true"
  124. :resize-height-offset="32"
  125. :immediate="false"
  126. row-key="id"
  127. >
  128. <template #action="{ record }">
  129. <Button type="link" size="small" @click="handleViewDetail(record)">查看详情</Button>
  130. </template>
  131. </BasicTable>
  132. </div>
  133. <div v-else class="panel-empty panel-empty--state panel-empty--record">
  134. <EmptyState
  135. variant="compact"
  136. :title="activeGroup ? '暂无碰面记录' : '暂无碰面数据'"
  137. :description="activeGroup ? '当前地点下暂无碰面记录' : '选择上方组合后查看碰面记录'"
  138. :min-height="240"
  139. />
  140. </div>
  141. </div>
  142. </div>
  143. </div>
  144. </div>
  145. <CallRecordModal v-model:open="callRecordModalOpen" title="通信记录" :base-query="callRecordBaseQuery" />
  146. </div>
  147. </template>
  148. <script lang="ts" setup name="ViewsFaceToFaceIndex">
  149. import { computed, onActivated, onMounted, ref, watch } from 'vue';
  150. import dayjs, { type Dayjs } from 'dayjs';
  151. import { useRoute } from 'vue-router';
  152. import { Select, Input, Button, DatePicker } from 'ant-design-vue';
  153. import { EmptyState } from '@jeesite/core/components/EmptyState';
  154. import { BasicTable } from '@jeesite/core/components/Table';
  155. import type { BasicColumn } from '@jeesite/core/components/Table';
  156. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  157. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  158. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  159. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  160. import CallRecordModal from '@jeesite/call/views/components/CallRecordModal.vue';
  161. import type { CallRecordQuery } from '@jeesite/call/api/types/query';
  162. import { getPersonOptions } from '@jeesite/person/api/personApi';
  163. import { statFirstTrackMeet, statSecondTrackMeet } from '@jeesite/track/api/trackApi';
  164. import type { TrackMeetDTO, TrackMeetSummeryDTO } from '@jeesite/track/api/types/dto';
  165. import type { TrackMeetQuery } from '@jeesite/track/api/types/query';
  166. type RangeValue = [Dayjs, Dayjs] | undefined;
  167. const QUERY_LIMIT = 10000;
  168. const PAGE_PATH = '/faceToFace/index';
  169. interface MeetRecordItem extends TrackMeetDTO {
  170. id: string;
  171. personName: string;
  172. otherName: string;
  173. meetPlace: string;
  174. meetDateTime: string;
  175. distanceIntervalText: string;
  176. timeIntervalText: string;
  177. gapMinutesText: string;
  178. locationMeta?: string;
  179. hasExplicitPlace: boolean;
  180. lac?: string;
  181. cid?: string;
  182. meetCount: number;
  183. }
  184. interface MeetLocationItem {
  185. id: string;
  186. name: string;
  187. secondaryLine?: string;
  188. lac?: string;
  189. cid?: string;
  190. count: number;
  191. records: MeetRecordItem[];
  192. }
  193. interface MeetGroupItem {
  194. id: string;
  195. personName: string;
  196. otherName: string;
  197. count: number;
  198. placeCount: number;
  199. nestedRecords: MeetRecordItem[];
  200. raw: TrackMeetSummeryDTO | Record<string, any>;
  201. }
  202. const { prefixCls } = useDesign('face-to-face');
  203. const RangePicker = DatePicker.RangePicker;
  204. const route = useRoute();
  205. const { createMessage } = useMessage();
  206. const { queryLoading, runWithQueryLoading } = useTableSkill();
  207. const createDefaultFilters = () => ({
  208. target: [] as string[],
  209. dateRange: undefined as RangeValue,
  210. intervalOp: 'le',
  211. intervalValue: '24',
  212. });
  213. const filters = ref(createDefaultFilters());
  214. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  215. const positiveIntegerPattern = /^[1-9]\d*$/;
  216. const peerGroups = ref<MeetGroupItem[]>([]);
  217. const hasSearched = ref(false);
  218. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  219. refresh: () => fetchData(),
  220. hasData: () => hasSearched.value && peerGroups.value.length > 0,
  221. });
  222. const selectedGroup = ref<string | null>(null);
  223. const selectedLocation = ref<string | null>(null);
  224. const detailLoading = ref(false);
  225. const initialized = ref(false);
  226. const callRecordModalOpen = ref(false);
  227. const callRecordBaseQuery = ref<Partial<CallRecordQuery>>({});
  228. const groupDetailMap = ref<Record<string, MeetRecordItem[]>>({});
  229. const locationDetailMap = ref<Record<string, MeetRecordItem[]>>({});
  230. const lastExecutedQuery = ref<TrackMeetQuery>({ page: 1, limit: QUERY_LIMIT });
  231. const routeTargetNames = ref<string[]>([]);
  232. const routeOtherName = ref('');
  233. const intervalOpOptions = [{ label: '<=', value: 'le' }];
  234. const columns: BasicColumn[] = [
  235. { title: '碰面地点', dataIndex: 'meetPlace', key: 'meetPlace', width: 360, align: 'center' },
  236. { title: '碰面时间', dataIndex: 'meetDateTime', key: 'meetDateTime', width: 220, align: 'center' },
  237. { title: '时间间隔(分)', dataIndex: 'timeIntervalText', key: 'timeIntervalText', width: 150, align: 'center' },
  238. ];
  239. const actionColumn = {
  240. title: '操作',
  241. dataIndex: 'action',
  242. key: 'action',
  243. width: 120,
  244. align: 'center' as const,
  245. fixed: 'right' as const,
  246. slot: 'action',
  247. };
  248. const activeGroup = computed(() => peerGroups.value.find((item) => item.id === selectedGroup.value) || null);
  249. const currentGroupRecords = computed(() => {
  250. if (!selectedGroup.value) return [];
  251. return groupDetailMap.value[selectedGroup.value] || [];
  252. });
  253. const locationList = computed(() => buildLocationList(currentGroupRecords.value));
  254. const activeLocation = computed(() => locationList.value.find((item) => item.id === selectedLocation.value) || null);
  255. const activeLocationDetailCacheKey = computed(() => {
  256. if (!activeGroup.value?.id || !activeLocation.value?.id) return '';
  257. return `${activeGroup.value.id}::${activeLocation.value.id}`;
  258. });
  259. const hasLocationDetailCache = computed(() => {
  260. if (!activeLocationDetailCacheKey.value) return false;
  261. return Object.prototype.hasOwnProperty.call(locationDetailMap.value, activeLocationDetailCacheKey.value);
  262. });
  263. const currentLocationRecords = computed<MeetRecordItem[] | undefined>(() => {
  264. if (!activeLocationDetailCacheKey.value) return undefined;
  265. return locationDetailMap.value[activeLocationDetailCacheKey.value];
  266. });
  267. const tableData = computed(() => {
  268. if (hasLocationDetailCache.value) return currentLocationRecords.value || [];
  269. return activeLocation.value?.records || currentGroupRecords.value;
  270. });
  271. const locationPanelDesc = computed(() => {
  272. if (!activeGroup.value) return '选择上方碰面组合后查看地点汇总。';
  273. if (!locationList.value.length)
  274. return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 暂无地点明细。`;
  275. return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 共命中 ${locationList.value.length} 个碰面地点。`;
  276. });
  277. const recordPanelDesc = computed(() => {
  278. if (!activeGroup.value) return '选择上方碰面组合后查看碰面记录。';
  279. if (activeLocation.value) {
  280. if (hasLocationDetailCache.value) {
  281. const detailCount = sumMeetCount(currentLocationRecords.value || []);
  282. if (!detailCount) {
  283. return `${activeLocation.value.name} 暂无碰面记录。`;
  284. }
  285. return `${activeLocation.value.name} 共命中 ${detailCount} 次碰面记录。`;
  286. }
  287. return `${activeLocation.value.name} 共命中 ${activeLocation.value.count} 次碰面记录。`;
  288. }
  289. if (currentGroupRecords.value.length) {
  290. return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 共命中 ${sumMeetCount(currentGroupRecords.value)} 次碰面记录。`;
  291. }
  292. return `${activeGroup.value.personName} 与 ${activeGroup.value.otherName} 暂无碰面记录。`;
  293. });
  294. const normalizedIntervalValue = computed(() => String(filters.value.intervalValue ?? '').trim());
  295. const intervalValueStatus = computed<'error' | undefined>(() => {
  296. if (!normalizedIntervalValue.value) return undefined;
  297. return positiveIntegerPattern.test(normalizedIntervalValue.value) ? undefined : 'error';
  298. });
  299. const parseNumber = (value?: string) => {
  300. const normalized = String(value ?? '').trim();
  301. if (!normalized) return undefined;
  302. if (!positiveIntegerPattern.test(normalized)) return null;
  303. const numericValue = Number(normalized);
  304. return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : null;
  305. };
  306. const toNumber = (value: unknown, fallback = 0) => {
  307. const numericValue = Number(value);
  308. return Number.isFinite(numericValue) ? numericValue : fallback;
  309. };
  310. const getDateValue = (value?: string) => {
  311. if (!value) return 0;
  312. const parsed = dayjs(value);
  313. return parsed.isValid() ? parsed.valueOf() : 0;
  314. };
  315. const normalizeString = (value: unknown) => {
  316. if (value === undefined || value === null) return '';
  317. return String(value).trim();
  318. };
  319. const pickRouteQueryValue = (value?: string | string[]) => {
  320. return Array.isArray(value) ? value[0] : value;
  321. };
  322. const normalizeRouteNames = (value?: string | string[] | null) => {
  323. if (Array.isArray(value)) {
  324. return Array.from(new Set(value.map((item) => normalizeString(item)).filter(Boolean)));
  325. }
  326. const text = normalizeString(value);
  327. if (!text) return [];
  328. if (text.startsWith('[') && text.endsWith(']')) {
  329. try {
  330. const parsed = JSON.parse(text);
  331. if (Array.isArray(parsed)) return normalizeRouteNames(parsed as string[]);
  332. } catch {
  333. // Ignore invalid JSON and fall back to comma split.
  334. }
  335. }
  336. return Array.from(
  337. new Set(
  338. text
  339. .split(',')
  340. .map((item) => normalizeString(item))
  341. .filter(Boolean),
  342. ),
  343. );
  344. };
  345. const parseRouteDate = (value?: string | string[]) => {
  346. const text = normalizeString(pickRouteQueryValue(value));
  347. if (!text) return undefined;
  348. const parsed = dayjs(text);
  349. return parsed.isValid() ? parsed : undefined;
  350. };
  351. const parseCidValue = (value: unknown) => {
  352. const normalized = normalizeString(value);
  353. return normalized || undefined;
  354. };
  355. const parseIntervalMinutes = (value?: string) => {
  356. const hours = parseNumber(value);
  357. if (hours === undefined) return undefined;
  358. if (hours === null) return null;
  359. return hours * 60;
  360. };
  361. const parseMeetIds = (value: unknown) => {
  362. const normalized = normalizeString(value);
  363. if (!normalized) return [];
  364. return normalized
  365. .split('_')
  366. .map((item) => item.trim())
  367. .filter(Boolean);
  368. };
  369. const formatLocationName = (name?: string, lac?: string, cid?: string) => {
  370. const normalizedName = normalizeString(name);
  371. if (normalizedName) return normalizedName;
  372. const baseStation = [lac ? `LAC: ${lac}` : '', cid ? `CID: ${cid}` : ''].filter(Boolean).join(' ');
  373. return baseStation || '-';
  374. };
  375. const flattenObjectValues = (source: Record<string, any>) => {
  376. return Object.values(source).reduce<any[]>((acc, value) => {
  377. if (Array.isArray(value)) {
  378. acc.push(...value);
  379. } else if (Array.isArray(value?.list)) {
  380. acc.push(...value.list);
  381. } else if (Array.isArray(value?.records)) {
  382. acc.push(...value.records);
  383. } else if (Array.isArray(value?.data)) {
  384. acc.push(...value.data);
  385. }
  386. return acc;
  387. }, []);
  388. };
  389. const extractList = (resp: any): any[] => {
  390. if (Array.isArray(resp)) return resp;
  391. if (Array.isArray(resp?.list)) return resp.list;
  392. if (Array.isArray(resp?.records)) return resp.records;
  393. if (Array.isArray(resp?.data)) return resp.data;
  394. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  395. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  396. if (resp?.data && typeof resp.data === 'object') {
  397. const flattened = flattenObjectValues(resp.data);
  398. if (flattened.length) return flattened;
  399. }
  400. if (resp && typeof resp === 'object') {
  401. const flattened = flattenObjectValues(resp);
  402. if (flattened.length) return flattened;
  403. }
  404. return [];
  405. };
  406. const parseMeetPlacesCount = (value: unknown) => {
  407. if (Array.isArray(value)) {
  408. return value.filter((item) => normalizeString(item)).length;
  409. }
  410. if (typeof value === 'string') {
  411. return value
  412. .split(/[;,,;\n]/)
  413. .map((item) => item.trim())
  414. .filter(Boolean).length;
  415. }
  416. return 0;
  417. };
  418. const sumMeetCount = (records: MeetRecordItem[]) => {
  419. return records.reduce((total, record) => total + Math.max(record.meetCount || 0, 1), 0);
  420. };
  421. const normalizeRecordItem = (
  422. record: TrackMeetDTO | Record<string, any>,
  423. group: Partial<MeetGroupItem>,
  424. key: string,
  425. ) => {
  426. const lac = normalizeString(record?.lac);
  427. const cid = normalizeString(record?.cid);
  428. const explicitPlace = normalizeString(
  429. record?.meetPlace || (record as any)?.stationName || (record as any)?.meetAddress || (record as any)?.location,
  430. );
  431. const locationMeta = [lac ? `LAC: ${lac}` : '', cid ? `CID: ${cid}` : ''].filter(Boolean).join(' ');
  432. const meetPlace = formatLocationName(explicitPlace || undefined, lac || undefined, cid || undefined);
  433. const meetDateTime =
  434. normalizeString(
  435. (record as any)?.meetDateTime || record?.meetDate || (record as any)?.dateTime || (record as any)?.date,
  436. ) || '-';
  437. const meetCount = Math.max(toNumber(record?.meetCount, 1), 1);
  438. const gapMinutes = record?.gapMinutes ?? (record as any)?.timeInterval ?? (record as any)?.gapMinute;
  439. const distanceInterval = (record as any)?.distanceInterval ?? (record as any)?.distanceGap;
  440. return {
  441. ...record,
  442. id: normalizeString((record as any)?.meetId || (record as any)?.id || key) || key,
  443. personName: normalizeString(record?.personName || (record as any)?.persona || group.personName) || '-',
  444. otherName: normalizeString(record?.otherName || (record as any)?.personb || group.otherName) || '-',
  445. meetPlace,
  446. meetDateTime,
  447. distanceIntervalText: normalizeString(distanceInterval) || '-',
  448. timeIntervalText: normalizeString((record as any)?.timeInterval ?? gapMinutes) || '-',
  449. gapMinutesText: normalizeString(gapMinutes) || '-',
  450. locationMeta: locationMeta || undefined,
  451. hasExplicitPlace: Boolean(explicitPlace),
  452. lac: lac || undefined,
  453. cid: cid || undefined,
  454. meetCount,
  455. } as MeetRecordItem;
  456. };
  457. const matchesLocationKeyword = (record: Partial<MeetRecordItem> | Record<string, any>, keyword: string) => {
  458. const normalizedKeyword = normalizeString(keyword).toLowerCase();
  459. if (!normalizedKeyword) return true;
  460. return [
  461. record?.meetPlace,
  462. record?.stationName,
  463. (record as any)?.meetAddress,
  464. (record as any)?.location,
  465. record?.lac,
  466. record?.cid,
  467. ].some((value) => normalizeString(value).toLowerCase().includes(normalizedKeyword));
  468. };
  469. const filterRecordsByLocationKeyword = (records: MeetRecordItem[], keyword: string) => {
  470. const normalizedKeyword = normalizeString(keyword);
  471. if (!normalizedKeyword) return records;
  472. return records.filter((record) => matchesLocationKeyword(record, normalizedKeyword));
  473. };
  474. const buildLocationList = (records: MeetRecordItem[]) => {
  475. const locationMap = new Map<string, MeetLocationItem>();
  476. records.forEach((record, index) => {
  477. const key =
  478. [record.meetPlace, record.lac, record.cid].filter(Boolean).join('||') || `${record.meetPlace}-${index}`;
  479. const current = locationMap.get(key);
  480. if (!current) {
  481. locationMap.set(key, {
  482. id: key,
  483. name: record.meetPlace,
  484. secondaryLine: record.hasExplicitPlace ? record.locationMeta : undefined,
  485. lac: record.lac,
  486. cid: record.cid,
  487. count: Math.max(record.meetCount || 0, 1),
  488. records: [record],
  489. });
  490. return;
  491. }
  492. current.records.push(record);
  493. current.count += Math.max(record.meetCount || 0, 1);
  494. });
  495. return Array.from(locationMap.values())
  496. .map((item) => ({
  497. ...item,
  498. records: [...item.records].sort(
  499. (prev, next) => getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
  500. ),
  501. }))
  502. .sort((prev, next) => {
  503. if (next.count !== prev.count) return next.count - prev.count;
  504. return prev.name.localeCompare(next.name, 'zh-CN');
  505. });
  506. };
  507. const normalizeSummaryGroups = (resp: any) => {
  508. return extractList(resp)
  509. .map((item: TrackMeetSummeryDTO | Record<string, any>, index: number) => {
  510. const meetPersonNames = Array.isArray((item as any)?.meetPersonNames) ? (item as any).meetPersonNames : [];
  511. const personName = normalizeString(item.personName || (item as any)?.persona || meetPersonNames[0]) || '-';
  512. const otherName = normalizeString(item.otherName || (item as any)?.personb || meetPersonNames[1]) || '-';
  513. const nestedRecords = extractList((item as any)?.meets)
  514. .map((record: TrackMeetDTO | Record<string, any>, recordIndex: number) =>
  515. normalizeRecordItem(record, { personName, otherName }, `${personName}-${otherName}-${recordIndex}`),
  516. )
  517. .sort(
  518. (prev: MeetRecordItem, next: MeetRecordItem) =>
  519. getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
  520. );
  521. const count = Math.max(
  522. toNumber((item as any)?.totalMeetCount ?? item.meetCount, sumMeetCount(nestedRecords)),
  523. nestedRecords.length ? 1 : 0,
  524. );
  525. const placeCount =
  526. toNumber((item as any)?.meetAddressCount, 0) ||
  527. (nestedRecords.length
  528. ? buildLocationList(nestedRecords).length
  529. : parseMeetPlacesCount((item as any)?.meetPlaces));
  530. return {
  531. id: `${personName}-${otherName}-${index}`,
  532. personName,
  533. otherName,
  534. count,
  535. placeCount,
  536. nestedRecords,
  537. raw: item,
  538. } as MeetGroupItem;
  539. })
  540. .filter((item: MeetGroupItem) => item.personName !== '-' || item.otherName !== '-' || item.count > 0)
  541. .sort((prev: MeetGroupItem, next: MeetGroupItem) => {
  542. if (next.count !== prev.count) return next.count - prev.count;
  543. const prevText = `${prev.personName}-${prev.otherName}`;
  544. const nextText = `${next.personName}-${next.otherName}`;
  545. return prevText.localeCompare(nextText, 'zh-CN');
  546. });
  547. };
  548. const hasGroupDetail = (groupId: string) => {
  549. return Object.prototype.hasOwnProperty.call(groupDetailMap.value, groupId);
  550. };
  551. const resolveRouteMatchedGroupId = () => {
  552. if (!peerGroups.value.length) return null;
  553. const primaryTargets = routeTargetNames.value;
  554. const companionName = routeOtherName.value;
  555. if (!primaryTargets.length && !companionName) {
  556. return peerGroups.value[0]?.id || null;
  557. }
  558. const directMatched = peerGroups.value.find((item) => {
  559. const matchesPrimary = !primaryTargets.length || primaryTargets.includes(item.personName);
  560. const matchesCompanion = !companionName || item.otherName === companionName;
  561. return matchesPrimary && matchesCompanion;
  562. });
  563. if (directMatched) return directMatched.id;
  564. const reverseMatched = peerGroups.value.find((item) => {
  565. if (!companionName) return false;
  566. const matchesPrimary = primaryTargets.includes(item.otherName);
  567. return matchesPrimary && item.personName === companionName;
  568. });
  569. if (reverseMatched) return reverseMatched.id;
  570. return peerGroups.value[0]?.id || null;
  571. };
  572. const buildQuery = (): TrackMeetQuery | null => {
  573. const startDate =
  574. Array.isArray(filters.value.dateRange) && filters.value.dateRange[0]
  575. ? dayjs(filters.value.dateRange[0]).format('YYYY-MM-DD')
  576. : undefined;
  577. const endDate =
  578. Array.isArray(filters.value.dateRange) && filters.value.dateRange[1]
  579. ? dayjs(filters.value.dateRange[1]).format('YYYY-MM-DD')
  580. : undefined;
  581. const timeInterval = parseIntervalMinutes(filters.value.intervalValue);
  582. if (timeInterval === null || timeInterval === undefined) {
  583. createMessage.warning('时间间隔请输入大于 0 的整数');
  584. return null;
  585. }
  586. return {
  587. page: 1,
  588. limit: QUERY_LIMIT,
  589. startDate,
  590. endDate,
  591. personNames: filters.value.target.length ? filters.value.target : [],
  592. timeInterval,
  593. };
  594. };
  595. const buildDetailQuery = (group: MeetGroupItem, location?: MeetLocationItem): TrackMeetQuery => {
  596. const baseQuery = lastExecutedQuery.value;
  597. return {
  598. ...baseQuery,
  599. personName: group.personName !== '-' ? group.personName : baseQuery.personName,
  600. persona: group.personName !== '-' ? group.personName : undefined,
  601. personb: group.otherName !== '-' ? group.otherName : undefined,
  602. lac: location?.lac || undefined,
  603. cid: parseCidValue(location?.cid),
  604. };
  605. };
  606. const syncGroupSelection = () => {
  607. if (!peerGroups.value.length) {
  608. selectedGroup.value = null;
  609. selectedLocation.value = null;
  610. return;
  611. }
  612. if (!peerGroups.value.some((item) => item.id === selectedGroup.value)) {
  613. selectedGroup.value = peerGroups.value[0].id;
  614. }
  615. };
  616. const fetchDetailData = async (group: MeetGroupItem) => {
  617. if (!group?.id || hasGroupDetail(group.id)) return;
  618. detailLoading.value = true;
  619. try {
  620. const resp = await statSecondTrackMeet(buildDetailQuery(group));
  621. const records = filterRecordsByLocationKeyword(
  622. extractList(resp)
  623. .map((record: TrackMeetDTO | Record<string, any>, index: number) =>
  624. normalizeRecordItem(record, group, `${group.id}-${index}`),
  625. )
  626. .sort(
  627. (prev: MeetRecordItem, next: MeetRecordItem) =>
  628. getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
  629. ),
  630. normalizeString(lastExecutedQuery.value.keyword),
  631. );
  632. groupDetailMap.value = {
  633. ...groupDetailMap.value,
  634. [group.id]: records,
  635. };
  636. } catch (error) {
  637. groupDetailMap.value = {
  638. ...groupDetailMap.value,
  639. [group.id]: group.nestedRecords || [],
  640. };
  641. createMessage.error('碰面记录加载失败');
  642. } finally {
  643. detailLoading.value = false;
  644. }
  645. };
  646. const fetchLocationDetailData = async (group: MeetGroupItem, location: MeetLocationItem) => {
  647. if (!group?.id || !location?.id) return;
  648. const cacheKey = `${group.id}::${location.id}`;
  649. if (Object.prototype.hasOwnProperty.call(locationDetailMap.value, cacheKey)) return;
  650. detailLoading.value = true;
  651. try {
  652. const resp = await statSecondTrackMeet(buildDetailQuery(group, location));
  653. const records = filterRecordsByLocationKeyword(
  654. extractList(resp)
  655. .map((record: TrackMeetDTO | Record<string, any>, index: number) =>
  656. normalizeRecordItem(record, group, `${cacheKey}-${index}`),
  657. )
  658. .sort(
  659. (prev: MeetRecordItem, next: MeetRecordItem) =>
  660. getDateValue(next.meetDateTime) - getDateValue(prev.meetDateTime),
  661. ),
  662. normalizeString(lastExecutedQuery.value.keyword),
  663. );
  664. locationDetailMap.value = {
  665. ...locationDetailMap.value,
  666. [cacheKey]: records,
  667. };
  668. } catch (error) {
  669. locationDetailMap.value = {
  670. ...locationDetailMap.value,
  671. [cacheKey]: location.records,
  672. };
  673. createMessage.warning('碰面地点明细加载失败,已展示当前缓存记录');
  674. } finally {
  675. detailLoading.value = false;
  676. }
  677. };
  678. const fetchData = async () => {
  679. const query = buildQuery();
  680. if (!query) return;
  681. await runWithQueryLoading(async () => {
  682. const resp = await statFirstTrackMeet(query);
  683. const groups = normalizeSummaryGroups(resp);
  684. const nextDetailMap: Record<string, MeetRecordItem[]> = {};
  685. groups.forEach((group) => {
  686. if (group.nestedRecords.length) {
  687. nextDetailMap[group.id] = group.nestedRecords;
  688. }
  689. });
  690. peerGroups.value = groups;
  691. hasSearched.value = true;
  692. syncAnalysisDataRefreshState(groups.length > 0);
  693. groupDetailMap.value = nextDetailMap;
  694. locationDetailMap.value = {};
  695. lastExecutedQuery.value = { ...query };
  696. const matchedGroupId = resolveRouteMatchedGroupId();
  697. selectedGroup.value = matchedGroupId;
  698. selectedLocation.value = null;
  699. syncGroupSelection();
  700. });
  701. };
  702. const handleSelectGroup = (id: string) => {
  703. selectedGroup.value = id;
  704. };
  705. const handleSelectLocation = (id: string) => {
  706. selectedLocation.value = id;
  707. };
  708. const handleSearch = async () => {
  709. await fetchData();
  710. };
  711. const handleReset = async () => {
  712. routeTargetNames.value = [];
  713. routeOtherName.value = '';
  714. filters.value = createDefaultFilters();
  715. await fetchData();
  716. };
  717. const handleViewDetail = (record: MeetRecordItem) => {
  718. const ids = parseMeetIds(record.meetId);
  719. if (!ids.length) {
  720. createMessage.warning('当前记录缺少 meetId,无法打开通信详情');
  721. return;
  722. }
  723. callRecordBaseQuery.value = {
  724. ids,
  725. };
  726. callRecordModalOpen.value = true;
  727. };
  728. const fetchTargetOptions = async () => {
  729. try {
  730. targetOptions.value = await getPersonOptions(false);
  731. } catch (error) {
  732. targetOptions.value = [];
  733. }
  734. };
  735. const applyRouteQuery = (options: Array<{ label: string; value: string }>) => {
  736. filters.value = createDefaultFilters();
  737. routeTargetNames.value = [];
  738. routeOtherName.value = '';
  739. const rawPersonNames = normalizeRouteNames(
  740. (route.query.personNames as string | string[] | null | undefined) ??
  741. (route.query.personName as string | string[] | null | undefined),
  742. );
  743. const otherName = normalizeString(pickRouteQueryValue(route.query.otherName as string | string[] | undefined));
  744. const filteredPersonNames = otherName
  745. ? rawPersonNames.filter((personName) => personName !== otherName)
  746. : rawPersonNames;
  747. const personNames = filteredPersonNames.length
  748. ? [filteredPersonNames[0]]
  749. : rawPersonNames.length
  750. ? [rawPersonNames[0]]
  751. : [];
  752. const startDate = parseRouteDate(route.query.startDate as string | string[] | undefined);
  753. const endDate = parseRouteDate(
  754. (route.query.endDate as string | string[] | undefined) ??
  755. (route.query.seriesDateTime as string | string[] | undefined),
  756. );
  757. routeTargetNames.value = personNames;
  758. routeOtherName.value = otherName;
  759. if (personNames.length) {
  760. filters.value.target = personNames.map((personName) => {
  761. const matched = options.find((item) => normalizeString(item.label) === personName);
  762. return matched ? matched.value : personName;
  763. });
  764. }
  765. if (startDate || endDate) {
  766. const rangeStart = startDate || endDate;
  767. const rangeEnd = endDate || startDate;
  768. if (rangeStart && rangeEnd) {
  769. filters.value.dateRange = [rangeStart, rangeEnd];
  770. }
  771. }
  772. return Boolean(personNames.length || otherName || startDate || endDate);
  773. };
  774. const syncFromRoute = async () => {
  775. if (route.path !== PAGE_PATH) return;
  776. if (applyRouteQuery(targetOptions.value)) {
  777. await fetchData();
  778. }
  779. };
  780. const initializePage = async () => {
  781. if (initialized.value) return;
  782. initialized.value = true;
  783. await fetchTargetOptions();
  784. await syncFromRoute();
  785. };
  786. watch(
  787. () => activeGroup.value?.id,
  788. (groupId) => {
  789. if (!groupId || !activeGroup.value) {
  790. selectedLocation.value = null;
  791. return;
  792. }
  793. if (!hasGroupDetail(groupId)) {
  794. void fetchDetailData(activeGroup.value);
  795. return;
  796. }
  797. if (!locationList.value.some((item) => item.id === selectedLocation.value)) {
  798. selectedLocation.value = locationList.value[0]?.id || null;
  799. }
  800. },
  801. );
  802. watch(
  803. () => locationList.value,
  804. (list) => {
  805. if (!list.length) {
  806. selectedLocation.value = null;
  807. return;
  808. }
  809. if (!list.some((item) => item.id === selectedLocation.value)) {
  810. selectedLocation.value = list[0].id;
  811. }
  812. },
  813. );
  814. watch(
  815. () => [activeGroup.value?.id, activeLocation.value?.id],
  816. ([groupId, locationId]) => {
  817. if (!groupId || !locationId || !activeGroup.value || !activeLocation.value) return;
  818. void fetchLocationDetailData(activeGroup.value, activeLocation.value);
  819. },
  820. );
  821. onMounted(() => {
  822. void initializePage();
  823. });
  824. onActivated(() => {
  825. if (!initialized.value) {
  826. void initializePage();
  827. return;
  828. }
  829. void syncFromRoute();
  830. });
  831. watch(
  832. () => route.fullPath,
  833. async (next, prev) => {
  834. if (next === prev) return;
  835. if (!initialized.value) return;
  836. await syncFromRoute();
  837. },
  838. );
  839. </script>
  840. <style lang="less">
  841. @import '../styles/trans-analysis-table-ui.less';
  842. @prefix-cls: ~'jeesite-face-to-face';
  843. .@{prefix-cls}.face-to-face-page {
  844. .analysis-page-frame();
  845. .page-content {
  846. display: flex;
  847. flex-direction: column;
  848. gap: 2px;
  849. flex: 1 1 auto;
  850. min-height: 0;
  851. }
  852. .filter-input {
  853. width: 180px;
  854. }
  855. .filter-op {
  856. width: 84px;
  857. min-width: 84px;
  858. }
  859. .filter-micro {
  860. width: 84px;
  861. }
  862. .filter-unit {
  863. color: #6b7280;
  864. font-size: 12px;
  865. white-space: nowrap;
  866. }
  867. .filter-date {
  868. width: 360px;
  869. }
  870. .group-section {
  871. .analysis-card-shell();
  872. display: flex;
  873. flex-direction: column;
  874. flex-shrink: 0;
  875. overflow: hidden;
  876. min-height: 0;
  877. }
  878. .peer-groups {
  879. display: flex;
  880. gap: 8px;
  881. padding: 0;
  882. overflow: auto;
  883. }
  884. .peer-card {
  885. min-width: 146px;
  886. padding: 10px 12px 9px;
  887. border-radius: 6px;
  888. border: 1px solid #e5e7eb;
  889. background: transparent;
  890. cursor: pointer;
  891. transition: all 0.2s ease;
  892. flex: 0 0 auto;
  893. &:hover {
  894. border-color: #91caff;
  895. }
  896. &.active {
  897. background: rgba(247, 251, 255, 0.72);
  898. border-color: #1677ff;
  899. box-shadow: none;
  900. }
  901. }
  902. .peer-members {
  903. display: flex;
  904. align-items: center;
  905. gap: 10px;
  906. flex-wrap: wrap;
  907. .member {
  908. font-size: 13px;
  909. font-weight: 600;
  910. color: #111827;
  911. }
  912. }
  913. .peer-meta {
  914. margin-top: 8px;
  915. font-size: 12px;
  916. line-height: 18px;
  917. color: #374151;
  918. font-weight: 600;
  919. }
  920. .content-panel {
  921. display: flex;
  922. gap: 2px;
  923. flex: 1 1 auto;
  924. min-height: 0;
  925. padding: 0;
  926. background: transparent;
  927. }
  928. .left-panel {
  929. width: 392px;
  930. flex: 0 0 auto;
  931. .analysis-card-shell();
  932. }
  933. .right-panel {
  934. .analysis-card-shell();
  935. flex: 1;
  936. min-width: 0;
  937. height: 100%;
  938. box-sizing: border-box;
  939. }
  940. .panel-header {
  941. padding: 0 0 8px;
  942. border-bottom: 0;
  943. flex: 0 0 auto;
  944. width: 100%;
  945. box-sizing: border-box;
  946. }
  947. .panel-title {
  948. font-size: 14px;
  949. font-weight: 600;
  950. color: #111827;
  951. line-height: 22px;
  952. }
  953. .panel-desc {
  954. margin-top: 4px;
  955. color: #6b7280;
  956. font-size: 12px;
  957. line-height: 18px;
  958. }
  959. .location-list-wrap,
  960. .record-wrap {
  961. flex: 1 1 auto;
  962. min-height: 0;
  963. overflow: hidden;
  964. display: flex;
  965. flex-direction: column;
  966. width: 100%;
  967. }
  968. .location-list {
  969. display: flex;
  970. flex-direction: column;
  971. padding: 0;
  972. gap: 0;
  973. overflow: auto;
  974. width: 100%;
  975. }
  976. .location-item {
  977. display: flex;
  978. justify-content: space-between;
  979. align-items: flex-start;
  980. gap: 12px;
  981. padding: 10px 0;
  982. cursor: pointer;
  983. transition: all 0.2s ease;
  984. border: 0;
  985. border-bottom: 1px solid #eef2f7;
  986. border-radius: 0;
  987. background: transparent;
  988. &:hover {
  989. border-color: #91caff;
  990. }
  991. &.active {
  992. background: rgba(247, 251, 255, 0.72);
  993. .location-count {
  994. color: #1677ff;
  995. }
  996. }
  997. }
  998. .location-info {
  999. flex: 1;
  1000. min-width: 0;
  1001. .location-name {
  1002. font-size: 12px;
  1003. color: #374151;
  1004. line-height: 20px;
  1005. word-break: break-all;
  1006. font-weight: 500;
  1007. }
  1008. .location-lac {
  1009. margin-top: 4px;
  1010. font-size: 11px;
  1011. color: #6b7280;
  1012. }
  1013. }
  1014. .location-count {
  1015. font-size: 12px;
  1016. color: #374151;
  1017. white-space: nowrap;
  1018. font-weight: 600;
  1019. }
  1020. .table-panel {
  1021. display: none;
  1022. }
  1023. .table-wrapper {
  1024. flex: 1 1 auto;
  1025. min-height: 0;
  1026. border-top: 1px solid #d1d5db;
  1027. border-radius: 0;
  1028. overflow: hidden;
  1029. margin: 0;
  1030. width: 100%;
  1031. }
  1032. .table-wrapper .jeesite-basic-table {
  1033. height: 100%;
  1034. display: flex;
  1035. flex-direction: column;
  1036. }
  1037. .table-wrapper .ant-table-wrapper {
  1038. height: 100%;
  1039. min-height: 0;
  1040. }
  1041. .table-wrapper .ant-spin-nested-loading,
  1042. .table-wrapper .ant-spin-container {
  1043. height: 100%;
  1044. min-height: 0;
  1045. }
  1046. .table-wrapper .ant-spin-container,
  1047. .table-wrapper .ant-table,
  1048. .table-wrapper .ant-table-container {
  1049. height: 100%;
  1050. min-height: 0;
  1051. }
  1052. .table-wrapper .ant-spin-container,
  1053. .table-wrapper .ant-table,
  1054. .table-wrapper .ant-table-container {
  1055. display: flex;
  1056. flex-direction: column;
  1057. }
  1058. .table-wrapper .ant-table-body {
  1059. max-height: 100% !important;
  1060. }
  1061. .table-wrapper .ant-table-thead > tr > th {
  1062. background: #f8fafc;
  1063. font-weight: 600;
  1064. color: #374151;
  1065. border-bottom: 1px solid #e5e7eb;
  1066. }
  1067. .table-wrapper .ant-table-tbody > tr > td {
  1068. color: #374151;
  1069. font-size: 12px;
  1070. border-bottom: 1px solid #e5e7eb;
  1071. }
  1072. .table-wrapper .ant-table-tbody > tr:hover > td {
  1073. background: #f7fbff;
  1074. }
  1075. .table-wrapper .ant-table-body::-webkit-scrollbar {
  1076. width: 8px;
  1077. height: 8px;
  1078. }
  1079. .table-wrapper .ant-table-body::-webkit-scrollbar-thumb {
  1080. background: rgba(148, 163, 184, 0.6);
  1081. border-radius: 999px;
  1082. }
  1083. .table-wrapper .ant-table-body::-webkit-scrollbar-track {
  1084. background: transparent;
  1085. }
  1086. .jeesite-basic-table-header__toolbar {
  1087. gap: 6px;
  1088. }
  1089. .panel-empty {
  1090. flex: 1 1 auto;
  1091. min-height: 0;
  1092. display: flex;
  1093. align-items: center;
  1094. justify-content: center;
  1095. color: #94a3b8;
  1096. font-size: 13px;
  1097. padding: 16px;
  1098. &--state {
  1099. padding: 20px;
  1100. }
  1101. &--record {
  1102. min-height: 240px;
  1103. }
  1104. }
  1105. :deep(.ant-select-selector),
  1106. :deep(.ant-input),
  1107. :deep(.ant-picker),
  1108. :deep(.ant-btn) {
  1109. border-radius: 8px;
  1110. }
  1111. @media screen and (max-width: 900px) {
  1112. .content-panel {
  1113. flex-direction: column;
  1114. }
  1115. .left-panel {
  1116. width: 100%;
  1117. }
  1118. }
  1119. @media screen and (max-width: 992px) {
  1120. .filter-actions {
  1121. width: 100%;
  1122. margin-left: 0;
  1123. }
  1124. .filter-input,
  1125. .filter-date {
  1126. width: 100%;
  1127. }
  1128. .filter-micro {
  1129. width: 100%;
  1130. }
  1131. .peer-card {
  1132. min-width: 100%;
  1133. }
  1134. }
  1135. }
  1136. </style>