index.vue 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348
  1. <template>
  2. <div :class="prefixCls" class="co-residence-page">
  3. <div class="page-header">
  4. <div class="page-title">同住分析</div>
  5. <div class="page-subtitle"
  6. >围绕对象、酒店和时间窗口识别疑似同住记录,并联动查看当前对象下的同住人及酒店时间明细。</div
  7. >
  8. </div>
  9. <div class="filter-bar">
  10. <div class="filter-item">
  11. <span class="filter-label">选择对象:</span>
  12. <Select
  13. v-model:value="filters.target"
  14. mode="multiple"
  15. allow-clear
  16. max-tag-count="responsive"
  17. class="filter-input"
  18. :options="targetOptions"
  19. placeholder="请选择对象"
  20. />
  21. </div>
  22. <div class="filter-item">
  23. <span class="filter-label">同住时间:</span>
  24. <RangePicker v-model:value="filters.dateRange" class="filter-date" />
  25. </div>
  26. <div class="filter-item">
  27. <span class="filter-label">同住酒店:</span>
  28. <Input v-model:value="filters.hotel" class="filter-input" />
  29. </div>
  30. <div class="filter-item">
  31. <span class="filter-label">同住次数:</span>
  32. <Select v-model:value="filters.countOp" class="filter-op" :options="countOpOptions" />
  33. <Input v-model:value="filters.countValue" class="filter-micro" :status="countValueStatus" />
  34. </div>
  35. <div class="filter-item">
  36. <span class="filter-label">疑似同住时间间隔:</span>
  37. <span class="filter-fixed-op">&lt;=</span>
  38. <Input v-model:value="filters.timeGapValue" class="filter-micro" :status="timeGapValueStatus" />
  39. <span class="filter-unit">h</span>
  40. </div>
  41. <div class="filter-actions">
  42. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  43. <Button @click="handleReset">重置</Button>
  44. </div>
  45. </div>
  46. <div v-if="targetGroups.length" class="target-tags">
  47. <div
  48. v-for="target in targetGroups"
  49. :key="target.id"
  50. class="target-tag"
  51. :class="{ active: selectedTarget === target.id }"
  52. @click="handleSelectTarget(target.id)"
  53. >
  54. {{ target.name }}
  55. </div>
  56. </div>
  57. <div class="content-panel">
  58. <div class="left-sidebar">
  59. <div class="panel-header panel-header--sidebar">
  60. <div>
  61. <div class="panel-title">同住对象</div>
  62. <div class="panel-desc">{{ personPanelDesc }}</div>
  63. </div>
  64. </div>
  65. <div class="person-list-wrap">
  66. <div v-if="!hasSearched" class="panel-empty panel-empty--state">
  67. <EmptyState
  68. variant="query"
  69. title="请点击查询"
  70. description="设置条件精细查询,也可点击查询展示所有信息"
  71. :min-height="220"
  72. />
  73. </div>
  74. <div v-else-if="queryLoading && !personList.length" class="panel-empty">同住对象加载中...</div>
  75. <div v-else-if="personList.length" class="person-list">
  76. <div
  77. v-for="person in personList"
  78. :key="person.id"
  79. class="person-item"
  80. :class="{ active: selectedPerson === person.id }"
  81. @click="handleSelectPerson(person.id)"
  82. >
  83. <div class="person-main">
  84. <span class="person-name">{{ person.name }}</span>
  85. <span class="person-count">同住{{ person.count }}次</span>
  86. </div>
  87. <div v-if="person.suspectedTogether > 0" class="person-meta">
  88. <span v-if="person.suspectedTogether > 0" class="person-badge">疑似同住</span>
  89. </div>
  90. </div>
  91. </div>
  92. <div v-else class="panel-empty panel-empty--state">
  93. <EmptyState
  94. variant="compact"
  95. :title="targetGroups.length ? '暂无同住对象' : '暂无同住数据'"
  96. :description="targetGroups.length ? '当前对象下暂无同住人信息' : '当前筛选条件下暂无同住分析结果'"
  97. :min-height="220"
  98. />
  99. </div>
  100. </div>
  101. </div>
  102. <div class="right-content">
  103. <div class="panel-header panel-header--record">
  104. <div>
  105. <div class="panel-title">同住记录</div>
  106. <div class="panel-desc">{{ recordPanelDesc }}</div>
  107. </div>
  108. </div>
  109. <div class="record-content">
  110. <div v-if="(queryLoading || recordLoading) && !recordList.length" class="panel-empty panel-empty--record">
  111. 同住记录加载中...
  112. </div>
  113. <div v-else-if="recordList.length" class="record-list">
  114. <div v-for="record in recordList" :key="record.id" class="record-item">
  115. <div class="record-timeline">
  116. <span class="time-dot" :class="{ suspected: record.suspectedTogether > 0 }"></span>
  117. </div>
  118. <div class="record-main">
  119. <div class="record-grid">
  120. <div class="record-cell record-cell--time">
  121. <button type="button" class="record-time-button" @click.stop="handleViewDetail(record)">
  122. {{ formatDateOnly(record.inDateTime) }}
  123. </button>
  124. </div>
  125. <div class="record-cell">
  126. <span class="record-label">同住酒店:</span>
  127. <span class="record-value">{{ record.hotelName || '-' }}</span>
  128. </div>
  129. <div class="record-cell">
  130. <span class="record-label">同住次数:</span>
  131. <span class="record-value">{{ record.liveCount || 0 }} 次</span>
  132. </div>
  133. <div class="record-cell">
  134. <span class="record-label">研判结果:</span>
  135. <span class="record-value">{{ record.suspectedTogether > 0 ? '疑似同住' : '同住' }}</span>
  136. </div>
  137. </div>
  138. <div v-if="record.suspectedTogether > 0 || record.coType || record.hotelAddress" class="record-extra">
  139. <span v-if="record.suspectedTogether > 0" class="record-badge">疑似同住</span>
  140. <span v-if="record.coType" class="record-meta">类型:{{ record.coType }}</span>
  141. <span v-if="record.hotelAddress" class="record-meta">地址:{{ record.hotelAddress }}</span>
  142. </div>
  143. </div>
  144. </div>
  145. </div>
  146. <div v-else class="panel-empty panel-empty--state panel-empty--record">
  147. <EmptyState
  148. variant="compact"
  149. :title="activeTargetGroup ? '暂无记录' : '暂无同住数据'"
  150. :description="
  151. activeTargetGroup
  152. ? '请选择左侧同住对象后查看明细,或当前对象暂无对应记录。'
  153. : '当前筛选条件下暂无同住分析结果'
  154. "
  155. :min-height="240"
  156. />
  157. </div>
  158. </div>
  159. </div>
  160. </div>
  161. <CoResidenceDetailModal
  162. v-model:open="detailModalOpen"
  163. :base-query="detailModalBaseQuery"
  164. :mode-options="detailModeOptions"
  165. />
  166. </div>
  167. </template>
  168. <script lang="ts" setup name="ViewsCoResidenceIndex">
  169. import { computed, onActivated, onMounted, ref, watch } from 'vue';
  170. import dayjs, { type Dayjs } from 'dayjs';
  171. import { Select, Input, Button, DatePicker } from 'ant-design-vue';
  172. import { EmptyState } from '@jeesite/core/components/EmptyState';
  173. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  174. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  175. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  176. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  177. import {
  178. COMPARE_OPERATOR_OPTIONS,
  179. DEFAULT_COMPARE_OPERATOR,
  180. normalizeCompareOperator,
  181. } from '@jeesite/core/utils/compareOperator';
  182. import { getPersonOptions } from '@jeesite/person/api/personApi';
  183. import { statFirstTrackTogetherLive, statSecondTrackTogetherLive } from '@jeesite/track/api/trackApi';
  184. import type { TrackTogetherLiveDTO, TrackPersonTogetherLiveDTO } from '@jeesite/track/api/types/dto';
  185. import type { TogetherLiveDetailQuery, TrackTogetherLiveQuery } from '@jeesite/track/api/types/query';
  186. import CoResidenceDetailModal from '../components/CoResidenceDetailModal.vue';
  187. type RangeValue = [Dayjs, Dayjs] | [string, string] | undefined;
  188. interface TogetherLiveRecordItem extends Omit<TrackPersonTogetherLiveDTO, 'id'> {
  189. id: string;
  190. raw: Record<string, any>;
  191. objId?: string;
  192. objName?: string;
  193. objCertNo?: string;
  194. liveWithName: string;
  195. liveWithCertNo?: string;
  196. hotelName: string;
  197. hotelAddress?: string;
  198. roomNo?: string;
  199. inDateTime: string;
  200. outDateTime?: string;
  201. liveCount: number;
  202. suspectedTogether: number;
  203. coType?: string;
  204. remark?: string;
  205. }
  206. interface TogetherLivePersonItem {
  207. id: string;
  208. name: string;
  209. certNo?: string;
  210. count: number;
  211. suspectedTogether: number;
  212. records: TogetherLiveRecordItem[];
  213. }
  214. interface TogetherLiveTargetItem {
  215. id: string;
  216. name: string;
  217. idCard?: string;
  218. persons: TogetherLivePersonItem[];
  219. raw: TrackTogetherLiveDTO | Record<string, any>;
  220. }
  221. const { prefixCls } = useDesign('co-residence');
  222. const RangePicker = DatePicker.RangePicker;
  223. const { queryLoading, runWithQueryLoading } = useTableSkill();
  224. const { createMessage } = useMessage();
  225. const filters = ref({
  226. target: [] as string[],
  227. dateRange: undefined as RangeValue,
  228. hotel: '',
  229. countOp: DEFAULT_COMPARE_OPERATOR,
  230. countValue: '',
  231. timeGapValue: '12',
  232. });
  233. const countOpOptions = COMPARE_OPERATOR_OPTIONS.filter((o) => o.value === 'GE' || o.value === 'LE');
  234. const detailModeOptions = [
  235. { label: '旅馆住宿信息', value: 'hotelStay' },
  236. { label: '同住服务', value: 'togetherLive' },
  237. ] as const;
  238. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  239. const targetGroups = ref<TogetherLiveTargetItem[]>([]);
  240. const hasSearched = ref(false);
  241. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  242. refresh: () => fetchData(),
  243. hasData: () => hasSearched.value && targetGroups.value.length > 0,
  244. });
  245. const selectedTarget = ref<string | null>(null);
  246. const selectedPerson = ref<string | null>(null);
  247. const initialized = ref(false);
  248. const detailModalOpen = ref(false);
  249. const detailModalBaseQuery = ref<Partial<TogetherLiveDetailQuery>>({});
  250. const recordLoading = ref(false);
  251. const detailSessionId = ref(0);
  252. const recordDetailMap = ref<Record<string, TogetherLiveRecordItem[]>>({});
  253. const syncingRecordSelection = ref(false);
  254. const positiveIntegerPattern = /^[1-9]\d*$/;
  255. const activeTargetGroup = computed(() => targetGroups.value.find((item) => item.id === selectedTarget.value) || null);
  256. const personList = computed(() => activeTargetGroup.value?.persons || []);
  257. const activePerson = computed(() => personList.value.find((item) => item.id === selectedPerson.value) || null);
  258. const activeRecordCacheKey = computed(() => {
  259. if (!activeTargetGroup.value?.id || !activePerson.value?.id) return '';
  260. return `${activeTargetGroup.value.id}::${activePerson.value.id}`;
  261. });
  262. const recordList = computed(() => {
  263. if (!activeRecordCacheKey.value) return [];
  264. return recordDetailMap.value[activeRecordCacheKey.value] || [];
  265. });
  266. const normalizedCountValue = computed(() => String(filters.value.countValue ?? '').trim());
  267. const normalizedTimeGapValue = computed(() =>
  268. String(filters.value.timeGapValue ?? '')
  269. .replace(/,/g, '')
  270. .trim(),
  271. );
  272. const countValueStatus = computed<'error' | undefined>(() => {
  273. if (!normalizedCountValue.value) return undefined;
  274. return positiveIntegerPattern.test(normalizedCountValue.value) ? undefined : 'error';
  275. });
  276. const timeGapValueStatus = computed<'error' | undefined>(() => {
  277. if (!normalizedTimeGapValue.value) return undefined;
  278. const parsed = Number(normalizedTimeGapValue.value);
  279. return Number.isFinite(parsed) && parsed > 0 ? undefined : 'error';
  280. });
  281. const personPanelDesc = computed(() => {
  282. if (!activeTargetGroup.value) {
  283. return '按当前对象查看同住人和同住次数,点击左侧对象可联动右侧酒店时间明细。';
  284. }
  285. return `当前对象 ${activeTargetGroup.value.name} 下共 ${personList.value.length} 个同住对象。`;
  286. });
  287. const recordPanelDesc = computed(() => {
  288. if (!activeTargetGroup.value) {
  289. return '暂无可展示的同住记录。';
  290. }
  291. if (!activePerson.value) {
  292. return '请选择左侧同住对象后查看酒店与时间明细。';
  293. }
  294. if (recordLoading.value) {
  295. return `${activeTargetGroup.value.name} 与 ${activePerson.value.name} 的同住明细加载中...`;
  296. }
  297. const suspectedCount = recordList.value.filter((item) => item.suspectedTogether > 0).length;
  298. return `${activeTargetGroup.value.name} 与 ${activePerson.value.name} 共命中 ${recordList.value.length} 条记录${
  299. suspectedCount ? `,其中疑似同住 ${suspectedCount} 条` : ''
  300. }。`;
  301. });
  302. const resolveOutCount = (showMessage = false) => {
  303. if (!normalizedCountValue.value) return undefined;
  304. if (positiveIntegerPattern.test(normalizedCountValue.value)) {
  305. return Number(normalizedCountValue.value);
  306. }
  307. if (showMessage) {
  308. createMessage.warning('同住次数请输入大于 0 的整数');
  309. }
  310. return null;
  311. };
  312. const resolveTimeGap = (showMessage = false) => {
  313. if (!normalizedTimeGapValue.value) return undefined;
  314. const parsed = Number(normalizedTimeGapValue.value);
  315. if (Number.isFinite(parsed) && parsed > 0) {
  316. return parsed;
  317. }
  318. if (showMessage) {
  319. createMessage.warning('疑似同住时间间隔请输入大于 0 的数字');
  320. }
  321. return null;
  322. };
  323. const formatDateOnly = (value?: string) => {
  324. const text = String(value || '').trim();
  325. if (!text || text === '-') return '-';
  326. const parsed = dayjs(text);
  327. if (parsed.isValid()) {
  328. return parsed.format('YYYY-MM-DD');
  329. }
  330. return text;
  331. };
  332. const getTimeValue = (value?: string) => {
  333. if (!value) return 0;
  334. const parsed = dayjs(value);
  335. return parsed.isValid() ? parsed.valueOf() : 0;
  336. };
  337. const isPresent = (value: unknown) => {
  338. if (value === null || value === undefined) return false;
  339. if (typeof value === 'string') return value.trim().length > 0;
  340. return true;
  341. };
  342. const pickFirstText = (...values: unknown[]) => {
  343. for (const value of values) {
  344. if (!isPresent(value)) continue;
  345. return String(value).trim();
  346. }
  347. return undefined;
  348. };
  349. const pickFirstNumber = (...values: unknown[]) => {
  350. for (const value of values) {
  351. const parsed = Number(value);
  352. if (Number.isFinite(parsed)) return parsed;
  353. }
  354. return 0;
  355. };
  356. const flattenObjectValues = (source: Record<string, any>) => {
  357. return Object.values(source).reduce<any[]>((acc, value) => {
  358. if (Array.isArray(value)) {
  359. acc.push(...value);
  360. } else if (Array.isArray(value?.list)) {
  361. acc.push(...value.list);
  362. } else if (value && typeof value === 'object') {
  363. acc.push(value);
  364. }
  365. return acc;
  366. }, []);
  367. };
  368. const extractRootItems = (resp: any): Array<TrackTogetherLiveDTO | Record<string, any>> => {
  369. if (Array.isArray(resp)) return resp;
  370. if (Array.isArray(resp?.list)) return resp.list;
  371. if (Array.isArray(resp?.data)) return resp.data;
  372. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  373. if (Array.isArray(resp?.records)) return resp.records;
  374. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  375. if (resp?.data && typeof resp.data === 'object') {
  376. const flattened = flattenObjectValues(resp.data);
  377. if (flattened.length) return flattened;
  378. }
  379. if (resp && typeof resp === 'object') {
  380. const flattened = flattenObjectValues(resp);
  381. if (flattened.length) return flattened;
  382. }
  383. return [];
  384. };
  385. const extractChildItems = (item: TrackTogetherLiveDTO | Record<string, any>) => {
  386. if (Array.isArray((item as TrackTogetherLiveDTO)?.list) && (item as TrackTogetherLiveDTO).list?.length) {
  387. return (item as TrackTogetherLiveDTO).list || [];
  388. }
  389. if ((item as TrackTogetherLiveDTO)?.map && typeof (item as TrackTogetherLiveDTO).map === 'object') {
  390. return Object.values((item as TrackTogetherLiveDTO).map || {});
  391. }
  392. if (
  393. (item as Record<string, any>)?.liveWithName ||
  394. (item as Record<string, any>)?.togetherName ||
  395. (item as Record<string, any>)?.hotelName
  396. ) {
  397. return [item as TrackPersonTogetherLiveDTO];
  398. }
  399. return [];
  400. };
  401. const normalizeRecordItem = (
  402. record: TrackPersonTogetherLiveDTO | Record<string, any>,
  403. target: TrackTogetherLiveDTO | Record<string, any>,
  404. key: string,
  405. ): TogetherLiveRecordItem => {
  406. const liveWithName =
  407. pickFirstText(
  408. record?.liveWithName,
  409. (record as any)?.togetherName,
  410. record?.personName,
  411. (record as any)?.personName,
  412. ) || '-';
  413. return {
  414. ...(record as Record<string, any>),
  415. id: key,
  416. raw: record as Record<string, any>,
  417. objId: pickFirstText((target as any)?.objId, (record as any)?.objId),
  418. objName: pickFirstText((target as any)?.objName, (target as any)?.personName, (target as any)?.name),
  419. objCertNo: pickFirstText((target as any)?.idCard, (target as any)?.objIdCard, (target as any)?.personCertNo),
  420. liveWithName,
  421. liveWithCertNo: pickFirstText(
  422. (record as any)?.liveWithCertNo,
  423. (record as any)?.togetherCertNo,
  424. (record as any)?.personCertNo,
  425. ),
  426. hotelName:
  427. pickFirstText(record?.hotelName, (record as any)?.togHotel, (record as any)?.tzjd, (record as any)?.ldmc) ||
  428. '-',
  429. hotelAddress: pickFirstText((record as any)?.hotelAddress, (record as any)?.address, (record as any)?.hotelAddr),
  430. roomNo: pickFirstText((record as any)?.roomNo, (record as any)?.roomNum, (record as any)?.fjh),
  431. inDateTime:
  432. pickFirstText(
  433. record?.inDateTime,
  434. (record as any)?.checkinTime,
  435. (record as any)?.checkinDate,
  436. (record as any)?.time,
  437. ) || '-',
  438. outDateTime: pickFirstText(
  439. (record as any)?.outDateTime,
  440. (record as any)?.checkoutTime,
  441. (record as any)?.checkoutDate,
  442. (record as any)?.outTime,
  443. (record as any)?.checkOutTime,
  444. ),
  445. liveCount: pickFirstNumber(record?.liveCount, (record as any)?.count, (record as any)?.stayCount),
  446. suspectedTogether: pickFirstNumber(
  447. record?.suspectedTogether,
  448. (record as any)?.ystz,
  449. (record as any)?.hasTogether,
  450. ),
  451. coType: pickFirstText((record as any)?.coType, (record as any)?.relationType),
  452. remark: pickFirstText((record as any)?.remark),
  453. };
  454. };
  455. const buildTargetGroup = (
  456. item: TrackTogetherLiveDTO | Record<string, any>,
  457. index: number,
  458. fallbackRecords?: Array<TrackPersonTogetherLiveDTO | Record<string, any>>,
  459. ): TogetherLiveTargetItem | null => {
  460. const targetName = pickFirstText((item as any)?.objName, (item as any)?.personName, (item as any)?.name) || '-';
  461. const targetIdCard =
  462. pickFirstText((item as any)?.idCard, (item as any)?.objIdCard, (item as any)?.personCertNo) || '';
  463. const rawRecords = (fallbackRecords && fallbackRecords.length ? fallbackRecords : extractChildItems(item)).filter(
  464. Boolean,
  465. );
  466. const normalizedRecords = rawRecords
  467. .map((record, recordIndex) => normalizeRecordItem(record, item, `${targetName}-${index}-${recordIndex}`))
  468. .sort((prev, next) => getTimeValue(next.inDateTime) - getTimeValue(prev.inDateTime));
  469. const personMap = new Map<string, TogetherLivePersonItem>();
  470. normalizedRecords.forEach((record) => {
  471. const personKey = record.liveWithCertNo ? `${record.liveWithName}-${record.liveWithCertNo}` : record.liveWithName;
  472. const current = personMap.get(personKey);
  473. if (!current) {
  474. personMap.set(personKey, {
  475. id: personKey,
  476. name: record.liveWithName,
  477. certNo: record.liveWithCertNo,
  478. count: record.liveCount || 1,
  479. suspectedTogether: record.suspectedTogether || 0,
  480. records: [record],
  481. });
  482. return;
  483. }
  484. current.records.push(record);
  485. current.count = Math.max(current.count, record.liveCount || current.records.length);
  486. current.suspectedTogether = Math.max(current.suspectedTogether, record.suspectedTogether || 0);
  487. current.certNo = current.certNo || record.liveWithCertNo;
  488. });
  489. const persons = Array.from(personMap.values())
  490. .map((person) => ({
  491. ...person,
  492. records: [...person.records].sort(
  493. (prev, next) => getTimeValue(next.inDateTime) - getTimeValue(prev.inDateTime),
  494. ),
  495. }))
  496. .sort((prev, next) => {
  497. if (next.count !== prev.count) return next.count - prev.count;
  498. return prev.name.localeCompare(next.name, 'zh-CN');
  499. });
  500. if (!persons.length && targetName === '-') return null;
  501. return {
  502. id: `${targetName}-${targetIdCard || index}`,
  503. name: targetName,
  504. idCard: targetIdCard || undefined,
  505. persons,
  506. raw: item,
  507. };
  508. };
  509. const normalizeTargetGroups = (resp: any) => {
  510. const rootItems = extractRootItems(resp);
  511. const directGroups: TogetherLiveTargetItem[] = [];
  512. const groupedRecords = new Map<string, { header: Record<string, any>; records: Record<string, any>[] }>();
  513. rootItems.forEach((item, index) => {
  514. const hasNestedList =
  515. Array.isArray((item as any)?.list) || ((item as any)?.map && typeof (item as any).map === 'object');
  516. if (hasNestedList) {
  517. const group = buildTargetGroup(item, index);
  518. if (group) directGroups.push(group);
  519. return;
  520. }
  521. const targetName =
  522. pickFirstText((item as any)?.objName, (item as any)?.personName) ||
  523. (filters.value.target !== 'all' ? filters.value.target : '-');
  524. const targetIdCard = pickFirstText((item as any)?.idCard, (item as any)?.objIdCard) || '';
  525. const key = `${targetName}-${targetIdCard}`;
  526. const current = groupedRecords.get(key);
  527. if (!current) {
  528. groupedRecords.set(key, {
  529. header: {
  530. objName: targetName,
  531. idCard: targetIdCard || undefined,
  532. },
  533. records: [item as Record<string, any>],
  534. });
  535. return;
  536. }
  537. current.records.push(item as Record<string, any>);
  538. });
  539. const groupedTargets = Array.from(groupedRecords.values())
  540. .map((item, index) => buildTargetGroup(item.header, directGroups.length + index, item.records))
  541. .filter(Boolean) as TogetherLiveTargetItem[];
  542. return [...directGroups, ...groupedTargets].sort((prev, next) => prev.name.localeCompare(next.name, 'zh-CN'));
  543. };
  544. const buildQuery = (outCount?: number, timeGap?: number): TrackTogetherLiveQuery => {
  545. const startDate =
  546. Array.isArray(filters.value.dateRange) && filters.value.dateRange[0]
  547. ? dayjs(filters.value.dateRange[0]).format('YYYY-MM-DD')
  548. : undefined;
  549. const endDate =
  550. Array.isArray(filters.value.dateRange) && filters.value.dateRange[1]
  551. ? dayjs(filters.value.dateRange[1]).format('YYYY-MM-DD')
  552. : undefined;
  553. const hotelName = filters.value.hotel.trim() || undefined;
  554. const personNames = filters.value.target.length ? [...filters.value.target] : undefined;
  555. return {
  556. page: 1,
  557. limit: 200,
  558. startDate,
  559. endDate,
  560. personNames,
  561. search: personNames,
  562. hotelName,
  563. togHotel: hotelName,
  564. outCount,
  565. outCountSymbol:
  566. outCount !== undefined ? normalizeCompareOperator(filters.value.countOp, DEFAULT_COMPARE_OPERATOR) : undefined,
  567. timeGap,
  568. };
  569. };
  570. const buildSecondQuery = (
  571. target: TogetherLiveTargetItem,
  572. person: TogetherLivePersonItem,
  573. outCount?: number,
  574. timeGap?: number,
  575. ): TrackTogetherLiveQuery => {
  576. const hotelName = filters.value.hotel.trim() || undefined;
  577. const startDate =
  578. Array.isArray(filters.value.dateRange) && filters.value.dateRange[0]
  579. ? dayjs(filters.value.dateRange[0]).format('YYYY-MM-DD')
  580. : undefined;
  581. const endDate =
  582. Array.isArray(filters.value.dateRange) && filters.value.dateRange[1]
  583. ? dayjs(filters.value.dateRange[1]).format('YYYY-MM-DD')
  584. : undefined;
  585. return {
  586. page: 1,
  587. limit: 200,
  588. startDate,
  589. endDate,
  590. hotelName,
  591. search: [target.name],
  592. togHotel: hotelName,
  593. outCount,
  594. outCountSymbol:
  595. outCount !== undefined ? normalizeCompareOperator(filters.value.countOp, DEFAULT_COMPARE_OPERATOR) : undefined,
  596. timeGap,
  597. personName: target.name,
  598. liveWithName: person.name,
  599. };
  600. };
  601. const buildDetailBaseQuery = (record: TogetherLiveRecordItem): Partial<TogetherLiveDetailQuery> => {
  602. const timeGap = resolveTimeGap(true);
  603. if (timeGap === null) return {};
  604. const target = activeTargetGroup.value;
  605. const person = activePerson.value;
  606. const objId = pickFirstText((target?.raw as any)?.objId, record.objId, (record.raw as any)?.objId);
  607. const objName = pickFirstText(target?.name, record.objName, (target?.raw as any)?.objName, record.personName);
  608. const objIdCard = pickFirstText(
  609. target?.idCard,
  610. record.objCertNo,
  611. (target?.raw as any)?.idCard,
  612. (target?.raw as any)?.personCertNo,
  613. (record.raw as any)?.personCertNo,
  614. );
  615. const liveWithName = pickFirstText(
  616. person?.name,
  617. record.liveWithName,
  618. (record.raw as any)?.liveWithName,
  619. (record.raw as any)?.togetherName,
  620. );
  621. const liveWithIdCard = pickFirstText(
  622. person?.certNo,
  623. record.liveWithCertNo,
  624. (record.raw as any)?.liveWithCertNo,
  625. (record.raw as any)?.togetherCertNo,
  626. );
  627. const hotelName = pickFirstText(
  628. record.hotelName,
  629. (record.raw as any)?.hotelName,
  630. (record.raw as any)?.togHotel,
  631. (record.raw as any)?.tzjd,
  632. );
  633. const hasTogether = record.suspectedTogether > 0 ? record.suspectedTogether : undefined;
  634. return {
  635. objId,
  636. objName,
  637. objIdCard,
  638. hotelName,
  639. personName: objName,
  640. personCertNo: objIdCard,
  641. liveWithName,
  642. liveWithIdCard,
  643. liveWithCertNo: liveWithIdCard,
  644. search: hotelName,
  645. timeGap,
  646. ystz: hasTogether !== undefined ? String(hasTogether) : undefined,
  647. hasTogether,
  648. };
  649. };
  650. const syncSelection = () => {
  651. const nextTarget =
  652. targetGroups.value.find((item) => item.id === selectedTarget.value) || targetGroups.value[0] || null;
  653. selectedTarget.value = nextTarget?.id || null;
  654. const nextPersonList = nextTarget?.persons || [];
  655. const nextPerson = nextPersonList.find((item) => item.id === selectedPerson.value) || nextPersonList[0] || null;
  656. selectedPerson.value = nextPerson?.id || null;
  657. };
  658. const fetchRecordDetail = async (
  659. target: TogetherLiveTargetItem | null = activeTargetGroup.value,
  660. person: TogetherLivePersonItem | null = activePerson.value,
  661. ) => {
  662. if (!target || !person) {
  663. recordLoading.value = false;
  664. return;
  665. }
  666. const outCount = resolveOutCount(true);
  667. if (outCount === null) return;
  668. const timeGap = resolveTimeGap(true);
  669. if (timeGap === null) return;
  670. const cacheKey = `${target.id}::${person.id}`;
  671. if (Object.prototype.hasOwnProperty.call(recordDetailMap.value, cacheKey)) {
  672. if (activeRecordCacheKey.value === cacheKey) {
  673. recordLoading.value = false;
  674. }
  675. return;
  676. }
  677. const sessionId = detailSessionId.value;
  678. recordLoading.value = true;
  679. detailModalOpen.value = false;
  680. try {
  681. const resp = await statSecondTrackTogetherLive(buildSecondQuery(target, person, outCount, timeGap));
  682. if (sessionId !== detailSessionId.value) return;
  683. const rootItems = extractRootItems(resp);
  684. const records = rootItems
  685. .flatMap((item, index) => {
  686. const childItems = extractChildItems(item);
  687. if (childItems.length) {
  688. return childItems.map((child, childIndex) =>
  689. normalizeRecordItem(child, item, `${cacheKey}-${index}-${childIndex}`),
  690. );
  691. }
  692. return [normalizeRecordItem(item as Record<string, any>, target.raw, `${cacheKey}-${index}`)];
  693. })
  694. .filter((item) => !person.name || item.liveWithName === person.name)
  695. .sort((prev, next) => getTimeValue(next.inDateTime) - getTimeValue(prev.inDateTime));
  696. recordDetailMap.value = {
  697. ...recordDetailMap.value,
  698. [cacheKey]: records,
  699. };
  700. } catch (error) {
  701. if (sessionId !== detailSessionId.value) return;
  702. createMessage.warning('二层同住信息接口调用失败');
  703. } finally {
  704. if (sessionId === detailSessionId.value && activeRecordCacheKey.value === cacheKey) {
  705. recordLoading.value = false;
  706. }
  707. }
  708. };
  709. const fetchData = async () => {
  710. const outCount = resolveOutCount(true);
  711. if (outCount === null) return;
  712. const timeGap = resolveTimeGap(true);
  713. if (timeGap === null) return;
  714. let shouldSyncRecord = false;
  715. try {
  716. syncingRecordSelection.value = true;
  717. await runWithQueryLoading(async () => {
  718. detailSessionId.value += 1;
  719. recordLoading.value = false;
  720. recordDetailMap.value = {};
  721. detailModalOpen.value = false;
  722. detailModalBaseQuery.value = {};
  723. const resp = await statFirstTrackTogetherLive(buildQuery(outCount, timeGap));
  724. targetGroups.value = normalizeTargetGroups(resp);
  725. hasSearched.value = true;
  726. syncAnalysisDataRefreshState(targetGroups.value.length > 0);
  727. syncSelection();
  728. shouldSyncRecord = Boolean(activeTargetGroup.value && activePerson.value);
  729. });
  730. } finally {
  731. syncingRecordSelection.value = false;
  732. }
  733. if (shouldSyncRecord) {
  734. void fetchRecordDetail(activeTargetGroup.value, activePerson.value);
  735. }
  736. };
  737. const handleSelectTarget = (id: string) => {
  738. detailModalOpen.value = false;
  739. selectedTarget.value = id;
  740. };
  741. const handleSelectPerson = (id: string) => {
  742. detailModalOpen.value = false;
  743. if (selectedPerson.value === id) {
  744. void fetchRecordDetail(activeTargetGroup.value, activePerson.value);
  745. return;
  746. }
  747. selectedPerson.value = id;
  748. };
  749. const handleViewDetail = (record: TogetherLiveRecordItem) => {
  750. detailModalBaseQuery.value = buildDetailBaseQuery(record);
  751. detailModalOpen.value = true;
  752. };
  753. const handleSearch = async () => {
  754. await fetchData();
  755. };
  756. const handleReset = async () => {
  757. filters.value = {
  758. target: [],
  759. dateRange: undefined,
  760. hotel: '',
  761. countOp: DEFAULT_COMPARE_OPERATOR,
  762. countValue: '',
  763. timeGapValue: '12',
  764. };
  765. await fetchData();
  766. };
  767. const fetchTargetOptions = async () => {
  768. try {
  769. targetOptions.value = await getPersonOptions(false);
  770. } catch (error) {
  771. targetOptions.value = [];
  772. }
  773. };
  774. const initializePage = async () => {
  775. if (initialized.value) return;
  776. initialized.value = true;
  777. await fetchTargetOptions();
  778. };
  779. watch(
  780. () => activeTargetGroup.value?.id,
  781. () => {
  782. const list = activeTargetGroup.value?.persons || [];
  783. if (!list.length) {
  784. selectedPerson.value = null;
  785. return;
  786. }
  787. if (!list.some((item) => item.id === selectedPerson.value)) {
  788. selectedPerson.value = list[0].id;
  789. }
  790. },
  791. );
  792. watch(
  793. () => activeRecordCacheKey.value,
  794. (cacheKey) => {
  795. if (syncingRecordSelection.value) {
  796. return;
  797. }
  798. if (!cacheKey) {
  799. recordLoading.value = false;
  800. return;
  801. }
  802. void fetchRecordDetail();
  803. },
  804. );
  805. onMounted(() => {
  806. void initializePage();
  807. });
  808. onActivated(() => {
  809. if (!initialized.value) {
  810. void initializePage();
  811. }
  812. });
  813. </script>
  814. <style lang="less">
  815. @import '../styles/trans-analysis-table-ui.less';
  816. @prefix-cls: ~'jeesite-co-residence';
  817. .@{prefix-cls}.co-residence-page {
  818. .analysis-page-frame();
  819. .filter-input {
  820. width: 180px;
  821. }
  822. .filter-micro {
  823. width: 72px;
  824. }
  825. .filter-op {
  826. width: 84px;
  827. min-width: 84px;
  828. }
  829. .filter-unit {
  830. color: #6b7280;
  831. font-size: 12px;
  832. }
  833. .filter-date {
  834. width: 360px;
  835. }
  836. .target-tags {
  837. display: flex;
  838. gap: 10px;
  839. margin-top: 0;
  840. flex-shrink: 0;
  841. flex-wrap: nowrap;
  842. padding: 8px;
  843. border: 1px solid #e2e8f0;
  844. border-radius: 12px;
  845. background: #fff;
  846. box-sizing: border-box;
  847. overflow-x: auto;
  848. overflow-y: hidden;
  849. &::-webkit-scrollbar {
  850. height: 8px;
  851. }
  852. &::-webkit-scrollbar-thumb {
  853. background: rgba(148, 163, 184, 0.6);
  854. border-radius: 999px;
  855. }
  856. &::-webkit-scrollbar-track {
  857. background: transparent;
  858. }
  859. }
  860. .target-tag {
  861. flex: 0 0 auto;
  862. padding: 7px 14px;
  863. background: #fff;
  864. border: 1px solid #e5e7eb;
  865. border-radius: 999px;
  866. font-size: 13px;
  867. color: #4b5563;
  868. cursor: pointer;
  869. transition: all 0.2s ease;
  870. &:hover {
  871. border-color: #bfd4f5;
  872. color: #1677ff;
  873. }
  874. &.active {
  875. background: #f0f6ff;
  876. border-color: #bfd4f5;
  877. color: #1677ff;
  878. }
  879. }
  880. .content-panel {
  881. display: flex;
  882. gap: 2px;
  883. }
  884. .left-sidebar {
  885. width: 236px;
  886. flex: 0 0 236px;
  887. .analysis-card-shell();
  888. }
  889. .right-content {
  890. .analysis-card-shell();
  891. flex: 1 1 auto;
  892. min-width: 0;
  893. }
  894. .panel-header {
  895. padding: 0 0 8px;
  896. border-bottom: 0;
  897. flex: 0 0 auto;
  898. width: 100%;
  899. box-sizing: border-box;
  900. }
  901. .panel-title {
  902. font-size: 14px;
  903. font-weight: 600;
  904. color: #111827;
  905. line-height: 22px;
  906. }
  907. .panel-desc {
  908. margin-top: 4px;
  909. color: #6b7280;
  910. font-size: 12px;
  911. line-height: 20px;
  912. }
  913. .person-list-wrap,
  914. .record-content {
  915. flex: 1 1 auto;
  916. min-height: 0;
  917. overflow: hidden;
  918. display: flex;
  919. flex-direction: column;
  920. width: 100%;
  921. }
  922. .person-list {
  923. display: flex;
  924. flex-direction: column;
  925. overflow-x: hidden;
  926. overflow-y: auto;
  927. padding: 6px 0;
  928. width: 100%;
  929. &::-webkit-scrollbar {
  930. width: 8px;
  931. }
  932. &::-webkit-scrollbar-thumb {
  933. background: rgba(148, 163, 184, 0.6);
  934. border-radius: 999px;
  935. }
  936. &::-webkit-scrollbar-track {
  937. background: transparent;
  938. }
  939. }
  940. .person-item {
  941. display: flex;
  942. flex-direction: column;
  943. gap: 4px;
  944. padding: 12px 14px;
  945. cursor: pointer;
  946. transition: all 0.2s ease;
  947. border-left: 3px solid transparent;
  948. &:hover {
  949. background: #f5f7ff;
  950. }
  951. &.active {
  952. background: #f0f6ff;
  953. border-left-color: #1677ff;
  954. .person-name,
  955. .person-count {
  956. color: #1677ff;
  957. }
  958. }
  959. }
  960. .person-main,
  961. .person-meta {
  962. display: flex;
  963. align-items: center;
  964. justify-content: flex-start;
  965. gap: 8px;
  966. }
  967. .person-name {
  968. min-width: 0;
  969. color: #374151;
  970. font-size: 14px;
  971. font-weight: 500;
  972. word-break: break-all;
  973. }
  974. .person-count {
  975. flex: 0 0 auto;
  976. margin-left: auto;
  977. color: #6b7280;
  978. font-size: 13px;
  979. white-space: nowrap;
  980. }
  981. .person-badge,
  982. .record-badge {
  983. display: inline-flex;
  984. align-items: center;
  985. padding: 2px 8px;
  986. border-radius: 999px;
  987. background: #fff7e6;
  988. color: #d46b08;
  989. border: 1px solid #ffd591;
  990. font-size: 12px;
  991. line-height: 18px;
  992. }
  993. .record-content {
  994. background: transparent;
  995. }
  996. .record-list {
  997. display: flex;
  998. flex-direction: column;
  999. gap: 0;
  1000. padding: 0;
  1001. overflow: auto;
  1002. width: 100%;
  1003. }
  1004. .record-item {
  1005. display: grid;
  1006. grid-template-columns: 26px minmax(0, 1fr);
  1007. gap: 10px;
  1008. padding: 12px 0;
  1009. min-height: 64px;
  1010. & + .record-item {
  1011. border-top: 1px dashed #eef2f7;
  1012. }
  1013. }
  1014. .record-timeline {
  1015. position: relative;
  1016. display: flex;
  1017. justify-content: center;
  1018. padding-top: 4px;
  1019. &::after {
  1020. content: '';
  1021. position: absolute;
  1022. top: 18px;
  1023. bottom: -16px;
  1024. left: 50%;
  1025. width: 1px;
  1026. background: #e5e7eb;
  1027. transform: translateX(-50%);
  1028. }
  1029. }
  1030. .record-item:last-child {
  1031. .record-timeline::after {
  1032. display: none;
  1033. }
  1034. }
  1035. .time-dot {
  1036. position: relative;
  1037. z-index: 1;
  1038. width: 12px;
  1039. height: 12px;
  1040. background: #1677ff;
  1041. border-radius: 50%;
  1042. border: 2px solid #fff;
  1043. box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
  1044. &.suspected {
  1045. background: #faad14;
  1046. box-shadow: 0 0 0 3px rgba(250, 173, 20, 0.16);
  1047. }
  1048. }
  1049. .record-main {
  1050. min-width: 0;
  1051. display: flex;
  1052. flex-direction: column;
  1053. gap: 6px;
  1054. }
  1055. .record-grid {
  1056. display: grid;
  1057. grid-template-columns: 196px minmax(220px, 1.3fr) 110px 110px;
  1058. gap: 10px;
  1059. align-items: center;
  1060. min-width: 0;
  1061. }
  1062. .record-cell {
  1063. display: flex;
  1064. align-items: center;
  1065. gap: 6px;
  1066. min-width: 0;
  1067. }
  1068. .record-cell--time {
  1069. min-width: 0;
  1070. }
  1071. .record-time-button {
  1072. min-width: 0;
  1073. padding: 0;
  1074. border: none;
  1075. background: transparent;
  1076. color: #1677ff;
  1077. font-size: 14px;
  1078. font-weight: 500;
  1079. line-height: 20px;
  1080. text-align: left;
  1081. cursor: pointer;
  1082. overflow: hidden;
  1083. text-overflow: ellipsis;
  1084. white-space: nowrap;
  1085. &:hover {
  1086. color: #4096ff;
  1087. }
  1088. }
  1089. .record-label {
  1090. flex: 0 0 auto;
  1091. color: #6b7280;
  1092. font-size: 13px;
  1093. white-space: nowrap;
  1094. }
  1095. .record-value,
  1096. .record-time-text {
  1097. min-width: 0;
  1098. color: #374151;
  1099. font-size: 13px;
  1100. line-height: 20px;
  1101. overflow: hidden;
  1102. text-overflow: ellipsis;
  1103. white-space: nowrap;
  1104. }
  1105. .record-extra {
  1106. display: flex;
  1107. align-items: center;
  1108. gap: 12px;
  1109. min-width: 0;
  1110. flex-wrap: wrap;
  1111. }
  1112. .record-meta {
  1113. min-width: 0;
  1114. color: #6b7280;
  1115. font-size: 12px;
  1116. line-height: 18px;
  1117. overflow: hidden;
  1118. text-overflow: ellipsis;
  1119. white-space: nowrap;
  1120. }
  1121. .panel-empty {
  1122. flex: 1 1 auto;
  1123. min-height: 0;
  1124. display: flex;
  1125. align-items: center;
  1126. justify-content: center;
  1127. color: #9ca3af;
  1128. font-size: 13px;
  1129. padding: 16px;
  1130. &--state {
  1131. padding: 20px;
  1132. }
  1133. &--record {
  1134. min-height: 240px;
  1135. }
  1136. &--compact {
  1137. min-height: 160px;
  1138. }
  1139. }
  1140. :deep(.ant-select-selector),
  1141. :deep(.ant-input),
  1142. :deep(.ant-picker),
  1143. :deep(.ant-btn) {
  1144. border-radius: 8px;
  1145. }
  1146. @media screen and (max-width: 1360px) {
  1147. .record-grid {
  1148. grid-template-columns: 188px minmax(170px, 1fr) 100px 100px;
  1149. }
  1150. }
  1151. @media screen and (max-width: 900px) {
  1152. .content-panel {
  1153. flex-direction: column;
  1154. }
  1155. .left-sidebar {
  1156. width: 100%;
  1157. flex-basis: auto;
  1158. border-bottom: 1px solid #eef2f7;
  1159. }
  1160. }
  1161. @media screen and (max-width: 992px) {
  1162. .filter-actions {
  1163. width: 100%;
  1164. margin-left: 0;
  1165. }
  1166. .filter-input,
  1167. .filter-date {
  1168. width: 100%;
  1169. }
  1170. .record-list {
  1171. padding-right: 18px;
  1172. }
  1173. .record-grid {
  1174. grid-template-columns: 1fr;
  1175. gap: 8px;
  1176. }
  1177. .record-cell,
  1178. .record-extra {
  1179. flex-wrap: wrap;
  1180. }
  1181. .record-value,
  1182. .record-time-text,
  1183. .record-meta {
  1184. white-space: normal;
  1185. }
  1186. }
  1187. }
  1188. </style>