| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348 |
- <template>
- <div :class="prefixCls" class="co-residence-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>
- <Input v-model:value="filters.hotel" class="filter-input" />
- </div>
- <div class="filter-item">
- <span class="filter-label">同住次数:</span>
- <Select v-model:value="filters.countOp" class="filter-op" :options="countOpOptions" />
- <Input v-model:value="filters.countValue" class="filter-micro" :status="countValueStatus" />
- </div>
- <div class="filter-item">
- <span class="filter-label">疑似同住时间间隔:</span>
- <span class="filter-fixed-op"><=</span>
- <Input v-model:value="filters.timeGapValue" class="filter-micro" :status="timeGapValueStatus" />
- <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 v-if="targetGroups.length" class="target-tags">
- <div
- v-for="target in targetGroups"
- :key="target.id"
- class="target-tag"
- :class="{ active: selectedTarget === target.id }"
- @click="handleSelectTarget(target.id)"
- >
- {{ target.name }}
- </div>
- </div>
- <div class="content-panel">
- <div class="left-sidebar">
- <div class="panel-header panel-header--sidebar">
- <div>
- <div class="panel-title">同住对象</div>
- <div class="panel-desc">{{ personPanelDesc }}</div>
- </div>
- </div>
- <div class="person-list-wrap">
- <div v-if="!hasSearched" class="panel-empty panel-empty--state">
- <EmptyState
- variant="query"
- title="请点击查询"
- description="设置条件精细查询,也可点击查询展示所有信息"
- :min-height="220"
- />
- </div>
- <div v-else-if="queryLoading && !personList.length" class="panel-empty">同住对象加载中...</div>
- <div v-else-if="personList.length" class="person-list">
- <div
- v-for="person in personList"
- :key="person.id"
- class="person-item"
- :class="{ active: selectedPerson === person.id }"
- @click="handleSelectPerson(person.id)"
- >
- <div class="person-main">
- <span class="person-name">{{ person.name }}</span>
- <span class="person-count">同住{{ person.count }}次</span>
- </div>
- <div v-if="person.suspectedTogether > 0" class="person-meta">
- <span v-if="person.suspectedTogether > 0" class="person-badge">疑似同住</span>
- </div>
- </div>
- </div>
- <div v-else class="panel-empty panel-empty--state">
- <EmptyState
- variant="compact"
- :title="targetGroups.length ? '暂无同住对象' : '暂无同住数据'"
- :description="targetGroups.length ? '当前对象下暂无同住人信息' : '当前筛选条件下暂无同住分析结果'"
- :min-height="220"
- />
- </div>
- </div>
- </div>
- <div class="right-content">
- <div class="panel-header panel-header--record">
- <div>
- <div class="panel-title">同住记录</div>
- <div class="panel-desc">{{ recordPanelDesc }}</div>
- </div>
- </div>
- <div class="record-content">
- <div v-if="(queryLoading || recordLoading) && !recordList.length" class="panel-empty panel-empty--record">
- 同住记录加载中...
- </div>
- <div v-else-if="recordList.length" class="record-list">
- <div v-for="record in recordList" :key="record.id" class="record-item">
- <div class="record-timeline">
- <span class="time-dot" :class="{ suspected: record.suspectedTogether > 0 }"></span>
- </div>
- <div class="record-main">
- <div class="record-grid">
- <div class="record-cell record-cell--time">
- <button type="button" class="record-time-button" @click.stop="handleViewDetail(record)">
- {{ formatDateOnly(record.inDateTime) }}
- </button>
- </div>
- <div class="record-cell">
- <span class="record-label">同住酒店:</span>
- <span class="record-value">{{ record.hotelName || '-' }}</span>
- </div>
- <div class="record-cell">
- <span class="record-label">同住次数:</span>
- <span class="record-value">{{ record.liveCount || 0 }} 次</span>
- </div>
- <div class="record-cell">
- <span class="record-label">研判结果:</span>
- <span class="record-value">{{ record.suspectedTogether > 0 ? '疑似同住' : '同住' }}</span>
- </div>
- </div>
- <div v-if="record.suspectedTogether > 0 || record.coType || record.hotelAddress" class="record-extra">
- <span v-if="record.suspectedTogether > 0" class="record-badge">疑似同住</span>
- <span v-if="record.coType" class="record-meta">类型:{{ record.coType }}</span>
- <span v-if="record.hotelAddress" class="record-meta">地址:{{ record.hotelAddress }}</span>
- </div>
- </div>
- </div>
- </div>
- <div v-else class="panel-empty panel-empty--state panel-empty--record">
- <EmptyState
- variant="compact"
- :title="activeTargetGroup ? '暂无记录' : '暂无同住数据'"
- :description="
- activeTargetGroup
- ? '请选择左侧同住对象后查看明细,或当前对象暂无对应记录。'
- : '当前筛选条件下暂无同住分析结果'
- "
- :min-height="240"
- />
- </div>
- </div>
- </div>
- </div>
- <CoResidenceDetailModal
- v-model:open="detailModalOpen"
- :base-query="detailModalBaseQuery"
- :mode-options="detailModeOptions"
- />
- </div>
- </template>
- <script lang="ts" setup name="ViewsCoResidenceIndex">
- import { computed, onActivated, onMounted, ref, watch } from 'vue';
- import dayjs, { type Dayjs } from 'dayjs';
- import { Select, Input, Button, DatePicker } from 'ant-design-vue';
- import { EmptyState } from '@jeesite/core/components/EmptyState';
- import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
- import { useDesign } from '@jeesite/core/hooks/web/useDesign';
- import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
- import { useMessage } from '@jeesite/core/hooks/web/useMessage';
- import {
- COMPARE_OPERATOR_OPTIONS,
- DEFAULT_COMPARE_OPERATOR,
- normalizeCompareOperator,
- } from '@jeesite/core/utils/compareOperator';
- import { getPersonOptions } from '@jeesite/person/api/personApi';
- import { statFirstTrackTogetherLive, statSecondTrackTogetherLive } from '@jeesite/track/api/trackApi';
- import type { TrackTogetherLiveDTO, TrackPersonTogetherLiveDTO } from '@jeesite/track/api/types/dto';
- import type { TogetherLiveDetailQuery, TrackTogetherLiveQuery } from '@jeesite/track/api/types/query';
- import CoResidenceDetailModal from '../components/CoResidenceDetailModal.vue';
- type RangeValue = [Dayjs, Dayjs] | [string, string] | undefined;
- interface TogetherLiveRecordItem extends Omit<TrackPersonTogetherLiveDTO, 'id'> {
- id: string;
- raw: Record<string, any>;
- objId?: string;
- objName?: string;
- objCertNo?: string;
- liveWithName: string;
- liveWithCertNo?: string;
- hotelName: string;
- hotelAddress?: string;
- roomNo?: string;
- inDateTime: string;
- outDateTime?: string;
- liveCount: number;
- suspectedTogether: number;
- coType?: string;
- remark?: string;
- }
- interface TogetherLivePersonItem {
- id: string;
- name: string;
- certNo?: string;
- count: number;
- suspectedTogether: number;
- records: TogetherLiveRecordItem[];
- }
- interface TogetherLiveTargetItem {
- id: string;
- name: string;
- idCard?: string;
- persons: TogetherLivePersonItem[];
- raw: TrackTogetherLiveDTO | Record<string, any>;
- }
- const { prefixCls } = useDesign('co-residence');
- const RangePicker = DatePicker.RangePicker;
- const { queryLoading, runWithQueryLoading } = useTableSkill();
- const { createMessage } = useMessage();
- const filters = ref({
- target: [] as string[],
- dateRange: undefined as RangeValue,
- hotel: '',
- countOp: DEFAULT_COMPARE_OPERATOR,
- countValue: '',
- timeGapValue: '12',
- });
- const countOpOptions = COMPARE_OPERATOR_OPTIONS.filter((o) => o.value === 'GE' || o.value === 'LE');
- const detailModeOptions = [
- { label: '旅馆住宿信息', value: 'hotelStay' },
- { label: '同住服务', value: 'togetherLive' },
- ] as const;
- const targetOptions = ref<Array<{ label: string; value: string }>>([]);
- const targetGroups = ref<TogetherLiveTargetItem[]>([]);
- const hasSearched = ref(false);
- const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
- refresh: () => fetchData(),
- hasData: () => hasSearched.value && targetGroups.value.length > 0,
- });
- const selectedTarget = ref<string | null>(null);
- const selectedPerson = ref<string | null>(null);
- const initialized = ref(false);
- const detailModalOpen = ref(false);
- const detailModalBaseQuery = ref<Partial<TogetherLiveDetailQuery>>({});
- const recordLoading = ref(false);
- const detailSessionId = ref(0);
- const recordDetailMap = ref<Record<string, TogetherLiveRecordItem[]>>({});
- const syncingRecordSelection = ref(false);
- const positiveIntegerPattern = /^[1-9]\d*$/;
- const activeTargetGroup = computed(() => targetGroups.value.find((item) => item.id === selectedTarget.value) || null);
- const personList = computed(() => activeTargetGroup.value?.persons || []);
- const activePerson = computed(() => personList.value.find((item) => item.id === selectedPerson.value) || null);
- const activeRecordCacheKey = computed(() => {
- if (!activeTargetGroup.value?.id || !activePerson.value?.id) return '';
- return `${activeTargetGroup.value.id}::${activePerson.value.id}`;
- });
- const recordList = computed(() => {
- if (!activeRecordCacheKey.value) return [];
- return recordDetailMap.value[activeRecordCacheKey.value] || [];
- });
- const normalizedCountValue = computed(() => String(filters.value.countValue ?? '').trim());
- const normalizedTimeGapValue = computed(() =>
- String(filters.value.timeGapValue ?? '')
- .replace(/,/g, '')
- .trim(),
- );
- const countValueStatus = computed<'error' | undefined>(() => {
- if (!normalizedCountValue.value) return undefined;
- return positiveIntegerPattern.test(normalizedCountValue.value) ? undefined : 'error';
- });
- const timeGapValueStatus = computed<'error' | undefined>(() => {
- if (!normalizedTimeGapValue.value) return undefined;
- const parsed = Number(normalizedTimeGapValue.value);
- return Number.isFinite(parsed) && parsed > 0 ? undefined : 'error';
- });
- const personPanelDesc = computed(() => {
- if (!activeTargetGroup.value) {
- return '按当前对象查看同住人和同住次数,点击左侧对象可联动右侧酒店时间明细。';
- }
- return `当前对象 ${activeTargetGroup.value.name} 下共 ${personList.value.length} 个同住对象。`;
- });
- const recordPanelDesc = computed(() => {
- if (!activeTargetGroup.value) {
- return '暂无可展示的同住记录。';
- }
- if (!activePerson.value) {
- return '请选择左侧同住对象后查看酒店与时间明细。';
- }
- if (recordLoading.value) {
- return `${activeTargetGroup.value.name} 与 ${activePerson.value.name} 的同住明细加载中...`;
- }
- const suspectedCount = recordList.value.filter((item) => item.suspectedTogether > 0).length;
- return `${activeTargetGroup.value.name} 与 ${activePerson.value.name} 共命中 ${recordList.value.length} 条记录${
- suspectedCount ? `,其中疑似同住 ${suspectedCount} 条` : ''
- }。`;
- });
- const resolveOutCount = (showMessage = false) => {
- if (!normalizedCountValue.value) return undefined;
- if (positiveIntegerPattern.test(normalizedCountValue.value)) {
- return Number(normalizedCountValue.value);
- }
- if (showMessage) {
- createMessage.warning('同住次数请输入大于 0 的整数');
- }
- return null;
- };
- const resolveTimeGap = (showMessage = false) => {
- if (!normalizedTimeGapValue.value) return undefined;
- const parsed = Number(normalizedTimeGapValue.value);
- if (Number.isFinite(parsed) && parsed > 0) {
- return parsed;
- }
- if (showMessage) {
- createMessage.warning('疑似同住时间间隔请输入大于 0 的数字');
- }
- return null;
- };
- const formatDateOnly = (value?: string) => {
- const text = String(value || '').trim();
- if (!text || text === '-') return '-';
- const parsed = dayjs(text);
- if (parsed.isValid()) {
- return parsed.format('YYYY-MM-DD');
- }
- return text;
- };
- const getTimeValue = (value?: string) => {
- if (!value) return 0;
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed.valueOf() : 0;
- };
- const isPresent = (value: unknown) => {
- if (value === null || value === undefined) return false;
- if (typeof value === 'string') return value.trim().length > 0;
- return true;
- };
- const pickFirstText = (...values: unknown[]) => {
- for (const value of values) {
- if (!isPresent(value)) continue;
- return String(value).trim();
- }
- return undefined;
- };
- const pickFirstNumber = (...values: unknown[]) => {
- for (const value of values) {
- const parsed = Number(value);
- if (Number.isFinite(parsed)) return parsed;
- }
- return 0;
- };
- 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 (value && typeof value === 'object') {
- acc.push(value);
- }
- return acc;
- }, []);
- };
- const extractRootItems = (resp: any): Array<TrackTogetherLiveDTO | Record<string, any>> => {
- if (Array.isArray(resp)) return resp;
- if (Array.isArray(resp?.list)) return resp.list;
- if (Array.isArray(resp?.data)) return resp.data;
- if (Array.isArray(resp?.data?.list)) return resp.data.list;
- if (Array.isArray(resp?.records)) return resp.records;
- 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 extractChildItems = (item: TrackTogetherLiveDTO | Record<string, any>) => {
- if (Array.isArray((item as TrackTogetherLiveDTO)?.list) && (item as TrackTogetherLiveDTO).list?.length) {
- return (item as TrackTogetherLiveDTO).list || [];
- }
- if ((item as TrackTogetherLiveDTO)?.map && typeof (item as TrackTogetherLiveDTO).map === 'object') {
- return Object.values((item as TrackTogetherLiveDTO).map || {});
- }
- if (
- (item as Record<string, any>)?.liveWithName ||
- (item as Record<string, any>)?.togetherName ||
- (item as Record<string, any>)?.hotelName
- ) {
- return [item as TrackPersonTogetherLiveDTO];
- }
- return [];
- };
- const normalizeRecordItem = (
- record: TrackPersonTogetherLiveDTO | Record<string, any>,
- target: TrackTogetherLiveDTO | Record<string, any>,
- key: string,
- ): TogetherLiveRecordItem => {
- const liveWithName =
- pickFirstText(
- record?.liveWithName,
- (record as any)?.togetherName,
- record?.personName,
- (record as any)?.personName,
- ) || '-';
- return {
- ...(record as Record<string, any>),
- id: key,
- raw: record as Record<string, any>,
- objId: pickFirstText((target as any)?.objId, (record as any)?.objId),
- objName: pickFirstText((target as any)?.objName, (target as any)?.personName, (target as any)?.name),
- objCertNo: pickFirstText((target as any)?.idCard, (target as any)?.objIdCard, (target as any)?.personCertNo),
- liveWithName,
- liveWithCertNo: pickFirstText(
- (record as any)?.liveWithCertNo,
- (record as any)?.togetherCertNo,
- (record as any)?.personCertNo,
- ),
- hotelName:
- pickFirstText(record?.hotelName, (record as any)?.togHotel, (record as any)?.tzjd, (record as any)?.ldmc) ||
- '-',
- hotelAddress: pickFirstText((record as any)?.hotelAddress, (record as any)?.address, (record as any)?.hotelAddr),
- roomNo: pickFirstText((record as any)?.roomNo, (record as any)?.roomNum, (record as any)?.fjh),
- inDateTime:
- pickFirstText(
- record?.inDateTime,
- (record as any)?.checkinTime,
- (record as any)?.checkinDate,
- (record as any)?.time,
- ) || '-',
- outDateTime: pickFirstText(
- (record as any)?.outDateTime,
- (record as any)?.checkoutTime,
- (record as any)?.checkoutDate,
- (record as any)?.outTime,
- (record as any)?.checkOutTime,
- ),
- liveCount: pickFirstNumber(record?.liveCount, (record as any)?.count, (record as any)?.stayCount),
- suspectedTogether: pickFirstNumber(
- record?.suspectedTogether,
- (record as any)?.ystz,
- (record as any)?.hasTogether,
- ),
- coType: pickFirstText((record as any)?.coType, (record as any)?.relationType),
- remark: pickFirstText((record as any)?.remark),
- };
- };
- const buildTargetGroup = (
- item: TrackTogetherLiveDTO | Record<string, any>,
- index: number,
- fallbackRecords?: Array<TrackPersonTogetherLiveDTO | Record<string, any>>,
- ): TogetherLiveTargetItem | null => {
- const targetName = pickFirstText((item as any)?.objName, (item as any)?.personName, (item as any)?.name) || '-';
- const targetIdCard =
- pickFirstText((item as any)?.idCard, (item as any)?.objIdCard, (item as any)?.personCertNo) || '';
- const rawRecords = (fallbackRecords && fallbackRecords.length ? fallbackRecords : extractChildItems(item)).filter(
- Boolean,
- );
- const normalizedRecords = rawRecords
- .map((record, recordIndex) => normalizeRecordItem(record, item, `${targetName}-${index}-${recordIndex}`))
- .sort((prev, next) => getTimeValue(next.inDateTime) - getTimeValue(prev.inDateTime));
- const personMap = new Map<string, TogetherLivePersonItem>();
- normalizedRecords.forEach((record) => {
- const personKey = record.liveWithCertNo ? `${record.liveWithName}-${record.liveWithCertNo}` : record.liveWithName;
- const current = personMap.get(personKey);
- if (!current) {
- personMap.set(personKey, {
- id: personKey,
- name: record.liveWithName,
- certNo: record.liveWithCertNo,
- count: record.liveCount || 1,
- suspectedTogether: record.suspectedTogether || 0,
- records: [record],
- });
- return;
- }
- current.records.push(record);
- current.count = Math.max(current.count, record.liveCount || current.records.length);
- current.suspectedTogether = Math.max(current.suspectedTogether, record.suspectedTogether || 0);
- current.certNo = current.certNo || record.liveWithCertNo;
- });
- const persons = Array.from(personMap.values())
- .map((person) => ({
- ...person,
- records: [...person.records].sort(
- (prev, next) => getTimeValue(next.inDateTime) - getTimeValue(prev.inDateTime),
- ),
- }))
- .sort((prev, next) => {
- if (next.count !== prev.count) return next.count - prev.count;
- return prev.name.localeCompare(next.name, 'zh-CN');
- });
- if (!persons.length && targetName === '-') return null;
- return {
- id: `${targetName}-${targetIdCard || index}`,
- name: targetName,
- idCard: targetIdCard || undefined,
- persons,
- raw: item,
- };
- };
- const normalizeTargetGroups = (resp: any) => {
- const rootItems = extractRootItems(resp);
- const directGroups: TogetherLiveTargetItem[] = [];
- const groupedRecords = new Map<string, { header: Record<string, any>; records: Record<string, any>[] }>();
- rootItems.forEach((item, index) => {
- const hasNestedList =
- Array.isArray((item as any)?.list) || ((item as any)?.map && typeof (item as any).map === 'object');
- if (hasNestedList) {
- const group = buildTargetGroup(item, index);
- if (group) directGroups.push(group);
- return;
- }
- const targetName =
- pickFirstText((item as any)?.objName, (item as any)?.personName) ||
- (filters.value.target !== 'all' ? filters.value.target : '-');
- const targetIdCard = pickFirstText((item as any)?.idCard, (item as any)?.objIdCard) || '';
- const key = `${targetName}-${targetIdCard}`;
- const current = groupedRecords.get(key);
- if (!current) {
- groupedRecords.set(key, {
- header: {
- objName: targetName,
- idCard: targetIdCard || undefined,
- },
- records: [item as Record<string, any>],
- });
- return;
- }
- current.records.push(item as Record<string, any>);
- });
- const groupedTargets = Array.from(groupedRecords.values())
- .map((item, index) => buildTargetGroup(item.header, directGroups.length + index, item.records))
- .filter(Boolean) as TogetherLiveTargetItem[];
- return [...directGroups, ...groupedTargets].sort((prev, next) => prev.name.localeCompare(next.name, 'zh-CN'));
- };
- const buildQuery = (outCount?: number, timeGap?: number): TrackTogetherLiveQuery => {
- 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 hotelName = filters.value.hotel.trim() || undefined;
- const personNames = filters.value.target.length ? [...filters.value.target] : undefined;
- return {
- page: 1,
- limit: 200,
- startDate,
- endDate,
- personNames,
- search: personNames,
- hotelName,
- togHotel: hotelName,
- outCount,
- outCountSymbol:
- outCount !== undefined ? normalizeCompareOperator(filters.value.countOp, DEFAULT_COMPARE_OPERATOR) : undefined,
- timeGap,
- };
- };
- const buildSecondQuery = (
- target: TogetherLiveTargetItem,
- person: TogetherLivePersonItem,
- outCount?: number,
- timeGap?: number,
- ): TrackTogetherLiveQuery => {
- const hotelName = filters.value.hotel.trim() || undefined;
- 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;
- return {
- page: 1,
- limit: 200,
- startDate,
- endDate,
- hotelName,
- search: [target.name],
- togHotel: hotelName,
- outCount,
- outCountSymbol:
- outCount !== undefined ? normalizeCompareOperator(filters.value.countOp, DEFAULT_COMPARE_OPERATOR) : undefined,
- timeGap,
- personName: target.name,
- liveWithName: person.name,
- };
- };
- const buildDetailBaseQuery = (record: TogetherLiveRecordItem): Partial<TogetherLiveDetailQuery> => {
- const timeGap = resolveTimeGap(true);
- if (timeGap === null) return {};
- const target = activeTargetGroup.value;
- const person = activePerson.value;
- const objId = pickFirstText((target?.raw as any)?.objId, record.objId, (record.raw as any)?.objId);
- const objName = pickFirstText(target?.name, record.objName, (target?.raw as any)?.objName, record.personName);
- const objIdCard = pickFirstText(
- target?.idCard,
- record.objCertNo,
- (target?.raw as any)?.idCard,
- (target?.raw as any)?.personCertNo,
- (record.raw as any)?.personCertNo,
- );
- const liveWithName = pickFirstText(
- person?.name,
- record.liveWithName,
- (record.raw as any)?.liveWithName,
- (record.raw as any)?.togetherName,
- );
- const liveWithIdCard = pickFirstText(
- person?.certNo,
- record.liveWithCertNo,
- (record.raw as any)?.liveWithCertNo,
- (record.raw as any)?.togetherCertNo,
- );
- const hotelName = pickFirstText(
- record.hotelName,
- (record.raw as any)?.hotelName,
- (record.raw as any)?.togHotel,
- (record.raw as any)?.tzjd,
- );
- const hasTogether = record.suspectedTogether > 0 ? record.suspectedTogether : undefined;
- return {
- objId,
- objName,
- objIdCard,
- hotelName,
- personName: objName,
- personCertNo: objIdCard,
- liveWithName,
- liveWithIdCard,
- liveWithCertNo: liveWithIdCard,
- search: hotelName,
- timeGap,
- ystz: hasTogether !== undefined ? String(hasTogether) : undefined,
- hasTogether,
- };
- };
- const syncSelection = () => {
- const nextTarget =
- targetGroups.value.find((item) => item.id === selectedTarget.value) || targetGroups.value[0] || null;
- selectedTarget.value = nextTarget?.id || null;
- const nextPersonList = nextTarget?.persons || [];
- const nextPerson = nextPersonList.find((item) => item.id === selectedPerson.value) || nextPersonList[0] || null;
- selectedPerson.value = nextPerson?.id || null;
- };
- const fetchRecordDetail = async (
- target: TogetherLiveTargetItem | null = activeTargetGroup.value,
- person: TogetherLivePersonItem | null = activePerson.value,
- ) => {
- if (!target || !person) {
- recordLoading.value = false;
- return;
- }
- const outCount = resolveOutCount(true);
- if (outCount === null) return;
- const timeGap = resolveTimeGap(true);
- if (timeGap === null) return;
- const cacheKey = `${target.id}::${person.id}`;
- if (Object.prototype.hasOwnProperty.call(recordDetailMap.value, cacheKey)) {
- if (activeRecordCacheKey.value === cacheKey) {
- recordLoading.value = false;
- }
- return;
- }
- const sessionId = detailSessionId.value;
- recordLoading.value = true;
- detailModalOpen.value = false;
- try {
- const resp = await statSecondTrackTogetherLive(buildSecondQuery(target, person, outCount, timeGap));
- if (sessionId !== detailSessionId.value) return;
- const rootItems = extractRootItems(resp);
- const records = rootItems
- .flatMap((item, index) => {
- const childItems = extractChildItems(item);
- if (childItems.length) {
- return childItems.map((child, childIndex) =>
- normalizeRecordItem(child, item, `${cacheKey}-${index}-${childIndex}`),
- );
- }
- return [normalizeRecordItem(item as Record<string, any>, target.raw, `${cacheKey}-${index}`)];
- })
- .filter((item) => !person.name || item.liveWithName === person.name)
- .sort((prev, next) => getTimeValue(next.inDateTime) - getTimeValue(prev.inDateTime));
- recordDetailMap.value = {
- ...recordDetailMap.value,
- [cacheKey]: records,
- };
- } catch (error) {
- if (sessionId !== detailSessionId.value) return;
- createMessage.warning('二层同住信息接口调用失败');
- } finally {
- if (sessionId === detailSessionId.value && activeRecordCacheKey.value === cacheKey) {
- recordLoading.value = false;
- }
- }
- };
- const fetchData = async () => {
- const outCount = resolveOutCount(true);
- if (outCount === null) return;
- const timeGap = resolveTimeGap(true);
- if (timeGap === null) return;
- let shouldSyncRecord = false;
- try {
- syncingRecordSelection.value = true;
- await runWithQueryLoading(async () => {
- detailSessionId.value += 1;
- recordLoading.value = false;
- recordDetailMap.value = {};
- detailModalOpen.value = false;
- detailModalBaseQuery.value = {};
- const resp = await statFirstTrackTogetherLive(buildQuery(outCount, timeGap));
- targetGroups.value = normalizeTargetGroups(resp);
- hasSearched.value = true;
- syncAnalysisDataRefreshState(targetGroups.value.length > 0);
- syncSelection();
- shouldSyncRecord = Boolean(activeTargetGroup.value && activePerson.value);
- });
- } finally {
- syncingRecordSelection.value = false;
- }
- if (shouldSyncRecord) {
- void fetchRecordDetail(activeTargetGroup.value, activePerson.value);
- }
- };
- const handleSelectTarget = (id: string) => {
- detailModalOpen.value = false;
- selectedTarget.value = id;
- };
- const handleSelectPerson = (id: string) => {
- detailModalOpen.value = false;
- if (selectedPerson.value === id) {
- void fetchRecordDetail(activeTargetGroup.value, activePerson.value);
- return;
- }
- selectedPerson.value = id;
- };
- const handleViewDetail = (record: TogetherLiveRecordItem) => {
- detailModalBaseQuery.value = buildDetailBaseQuery(record);
- detailModalOpen.value = true;
- };
- const handleSearch = async () => {
- await fetchData();
- };
- const handleReset = async () => {
- filters.value = {
- target: [],
- dateRange: undefined,
- hotel: '',
- countOp: DEFAULT_COMPARE_OPERATOR,
- countValue: '',
- timeGapValue: '12',
- };
- await fetchData();
- };
- const fetchTargetOptions = async () => {
- try {
- targetOptions.value = await getPersonOptions(false);
- } catch (error) {
- targetOptions.value = [];
- }
- };
- const initializePage = async () => {
- if (initialized.value) return;
- initialized.value = true;
- await fetchTargetOptions();
- };
- watch(
- () => activeTargetGroup.value?.id,
- () => {
- const list = activeTargetGroup.value?.persons || [];
- if (!list.length) {
- selectedPerson.value = null;
- return;
- }
- if (!list.some((item) => item.id === selectedPerson.value)) {
- selectedPerson.value = list[0].id;
- }
- },
- );
- watch(
- () => activeRecordCacheKey.value,
- (cacheKey) => {
- if (syncingRecordSelection.value) {
- return;
- }
- if (!cacheKey) {
- recordLoading.value = false;
- return;
- }
- void fetchRecordDetail();
- },
- );
- onMounted(() => {
- void initializePage();
- });
- onActivated(() => {
- if (!initialized.value) {
- void initializePage();
- }
- });
- </script>
- <style lang="less">
- @import '../styles/trans-analysis-table-ui.less';
- @prefix-cls: ~'jeesite-co-residence';
- .@{prefix-cls}.co-residence-page {
- .analysis-page-frame();
- .filter-input {
- width: 180px;
- }
- .filter-micro {
- width: 72px;
- }
- .filter-op {
- width: 84px;
- min-width: 84px;
- }
- .filter-unit {
- color: #6b7280;
- font-size: 12px;
- }
- .filter-date {
- width: 360px;
- }
- .target-tags {
- display: flex;
- gap: 10px;
- margin-top: 0;
- flex-shrink: 0;
- flex-wrap: nowrap;
- padding: 8px;
- border: 1px solid #e2e8f0;
- border-radius: 12px;
- background: #fff;
- box-sizing: border-box;
- overflow-x: auto;
- overflow-y: hidden;
- &::-webkit-scrollbar {
- height: 8px;
- }
- &::-webkit-scrollbar-thumb {
- background: rgba(148, 163, 184, 0.6);
- border-radius: 999px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- }
- .target-tag {
- flex: 0 0 auto;
- padding: 7px 14px;
- background: #fff;
- border: 1px solid #e5e7eb;
- border-radius: 999px;
- font-size: 13px;
- color: #4b5563;
- cursor: pointer;
- transition: all 0.2s ease;
- &:hover {
- border-color: #bfd4f5;
- color: #1677ff;
- }
- &.active {
- background: #f0f6ff;
- border-color: #bfd4f5;
- color: #1677ff;
- }
- }
- .content-panel {
- display: flex;
- gap: 2px;
- }
- .left-sidebar {
- width: 236px;
- flex: 0 0 236px;
- .analysis-card-shell();
- }
- .right-content {
- .analysis-card-shell();
- flex: 1 1 auto;
- min-width: 0;
- }
- .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: 20px;
- }
- .person-list-wrap,
- .record-content {
- flex: 1 1 auto;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- width: 100%;
- }
- .person-list {
- display: flex;
- flex-direction: column;
- overflow-x: hidden;
- overflow-y: auto;
- padding: 6px 0;
- width: 100%;
- &::-webkit-scrollbar {
- width: 8px;
- }
- &::-webkit-scrollbar-thumb {
- background: rgba(148, 163, 184, 0.6);
- border-radius: 999px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- }
- .person-item {
- display: flex;
- flex-direction: column;
- gap: 4px;
- padding: 12px 14px;
- cursor: pointer;
- transition: all 0.2s ease;
- border-left: 3px solid transparent;
- &:hover {
- background: #f5f7ff;
- }
- &.active {
- background: #f0f6ff;
- border-left-color: #1677ff;
- .person-name,
- .person-count {
- color: #1677ff;
- }
- }
- }
- .person-main,
- .person-meta {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 8px;
- }
- .person-name {
- min-width: 0;
- color: #374151;
- font-size: 14px;
- font-weight: 500;
- word-break: break-all;
- }
- .person-count {
- flex: 0 0 auto;
- margin-left: auto;
- color: #6b7280;
- font-size: 13px;
- white-space: nowrap;
- }
- .person-badge,
- .record-badge {
- display: inline-flex;
- align-items: center;
- padding: 2px 8px;
- border-radius: 999px;
- background: #fff7e6;
- color: #d46b08;
- border: 1px solid #ffd591;
- font-size: 12px;
- line-height: 18px;
- }
- .record-content {
- background: transparent;
- }
- .record-list {
- display: flex;
- flex-direction: column;
- gap: 0;
- padding: 0;
- overflow: auto;
- width: 100%;
- }
- .record-item {
- display: grid;
- grid-template-columns: 26px minmax(0, 1fr);
- gap: 10px;
- padding: 12px 0;
- min-height: 64px;
- & + .record-item {
- border-top: 1px dashed #eef2f7;
- }
- }
- .record-timeline {
- position: relative;
- display: flex;
- justify-content: center;
- padding-top: 4px;
- &::after {
- content: '';
- position: absolute;
- top: 18px;
- bottom: -16px;
- left: 50%;
- width: 1px;
- background: #e5e7eb;
- transform: translateX(-50%);
- }
- }
- .record-item:last-child {
- .record-timeline::after {
- display: none;
- }
- }
- .time-dot {
- position: relative;
- z-index: 1;
- width: 12px;
- height: 12px;
- background: #1677ff;
- border-radius: 50%;
- border: 2px solid #fff;
- box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
- &.suspected {
- background: #faad14;
- box-shadow: 0 0 0 3px rgba(250, 173, 20, 0.16);
- }
- }
- .record-main {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 6px;
- }
- .record-grid {
- display: grid;
- grid-template-columns: 196px minmax(220px, 1.3fr) 110px 110px;
- gap: 10px;
- align-items: center;
- min-width: 0;
- }
- .record-cell {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- }
- .record-cell--time {
- min-width: 0;
- }
- .record-time-button {
- min-width: 0;
- padding: 0;
- border: none;
- background: transparent;
- color: #1677ff;
- font-size: 14px;
- font-weight: 500;
- line-height: 20px;
- text-align: left;
- cursor: pointer;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- &:hover {
- color: #4096ff;
- }
- }
- .record-label {
- flex: 0 0 auto;
- color: #6b7280;
- font-size: 13px;
- white-space: nowrap;
- }
- .record-value,
- .record-time-text {
- min-width: 0;
- color: #374151;
- font-size: 13px;
- line-height: 20px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .record-extra {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
- flex-wrap: wrap;
- }
- .record-meta {
- min-width: 0;
- color: #6b7280;
- font-size: 12px;
- line-height: 18px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .panel-empty {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #9ca3af;
- font-size: 13px;
- padding: 16px;
- &--state {
- padding: 20px;
- }
- &--record {
- min-height: 240px;
- }
- &--compact {
- min-height: 160px;
- }
- }
- :deep(.ant-select-selector),
- :deep(.ant-input),
- :deep(.ant-picker),
- :deep(.ant-btn) {
- border-radius: 8px;
- }
- @media screen and (max-width: 1360px) {
- .record-grid {
- grid-template-columns: 188px minmax(170px, 1fr) 100px 100px;
- }
- }
- @media screen and (max-width: 900px) {
- .content-panel {
- flex-direction: column;
- }
- .left-sidebar {
- width: 100%;
- flex-basis: auto;
- border-bottom: 1px solid #eef2f7;
- }
- }
- @media screen and (max-width: 992px) {
- .filter-actions {
- width: 100%;
- margin-left: 0;
- }
- .filter-input,
- .filter-date {
- width: 100%;
- }
- .record-list {
- padding-right: 18px;
- }
- .record-grid {
- grid-template-columns: 1fr;
- gap: 8px;
- }
- .record-cell,
- .record-extra {
- flex-wrap: wrap;
- }
- .record-value,
- .record-time-text,
- .record-meta {
- white-space: normal;
- }
- }
- }
- </style>
|