index.vue 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132
  1. <template>
  2. <div :class="prefixCls" class="sensitive-page">
  3. <div class="filter-bar">
  4. <div class="filter-item">
  5. <span class="filter-label">选择对象:</span>
  6. <Select
  7. v-model:value="filters.target"
  8. mode="multiple"
  9. allow-clear
  10. max-tag-count="responsive"
  11. class="filter-input"
  12. :options="targetOptions"
  13. placeholder="全部"
  14. :getPopupContainer="getPagePopupContainer"
  15. />
  16. </div>
  17. <div class="filter-item">
  18. <span class="filter-label">联系日期:</span>
  19. <RangePicker
  20. v-model:value="filters.dateRange"
  21. class="filter-input filter-date"
  22. :format="DATE_FORMAT"
  23. :value-format="DATE_FORMAT"
  24. :placeholder="['开始日期', '结束日期']"
  25. :getPopupContainer="getPagePopupContainer"
  26. />
  27. </div>
  28. <div class="filter-actions">
  29. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  30. <Button @click="handleReset">重置</Button>
  31. <Button @click="handleOpenSpecialDateModal">特殊日期配置</Button>
  32. </div>
  33. </div>
  34. <div class="content-panel">
  35. <div class="panel-left">
  36. <div class="panel-header">
  37. <div class="panel-title-wrap">
  38. <div class="panel-title">联系对方汇总</div>
  39. <div class="panel-desc">找出与对象在敏感日期保持联系的人员或号码。</div>
  40. </div>
  41. </div>
  42. <div class="panel-search">
  43. <Input
  44. :value="filters.keyword"
  45. placeholder="请输入对方姓名/对方号码搜索"
  46. allow-clear
  47. @update:value="handleKeywordUpdate"
  48. @pressEnter="handleKeywordEnter"
  49. />
  50. </div>
  51. <div v-if="!hasSearched" class="query-state-panel">
  52. <EmptyState
  53. variant="query"
  54. title="请点击查询"
  55. description="设置条件精细查询,也可点击查询展示所有信息"
  56. min-height="100%"
  57. description-width="min(100%, 420px)"
  58. />
  59. </div>
  60. <div v-else class="table-wrapper" :class="{ 'table-wrapper--compact': isSummaryTableCompact }">
  61. <BasicTable
  62. :columns="summaryColumns"
  63. :data-source="summaryData"
  64. :pagination="false"
  65. :show-table-setting="false"
  66. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  67. :can-resize="!isSummaryTableCompact"
  68. :is-can-resize-parent="true"
  69. :resize-height-offset="32"
  70. :rowClassName="summaryRowClassName"
  71. row-key="id"
  72. @change="handleSummaryTableChange"
  73. @row-click="handleSummaryRowClick"
  74. />
  75. </div>
  76. <div v-if="hasSearched" class="pagination-bar">
  77. <AnalysisPagination
  78. :total="summaryTotal"
  79. :current="summaryPagination.current"
  80. :page-size="summaryPagination.pageSize"
  81. :page-size-options="pageSizeOptions"
  82. :loading="queryLoading"
  83. @change="handleSummaryPaginationChange"
  84. />
  85. </div>
  86. </div>
  87. <div class="panel-right">
  88. <div class="panel-header panel-header--actions">
  89. <div class="panel-title-wrap">
  90. <div class="panel-title">通话详情</div>
  91. <div class="panel-desc">敏感日期通话明细</div>
  92. </div>
  93. </div>
  94. <div class="table-wrapper table-wrapper--detail">
  95. <div v-if="detailLoading && !festivalData.length" class="panel-empty panel-empty--record"
  96. >敏感日期通话明细加载中...</div
  97. >
  98. <BasicTable
  99. v-else-if="festivalData.length"
  100. :columns="festivalColumns"
  101. :action-column="detailActionColumn"
  102. :data-source="festivalData"
  103. :loading="detailLoading"
  104. :pagination="false"
  105. :show-table-setting="false"
  106. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  107. :can-resize="true"
  108. :is-can-resize-parent="true"
  109. :resize-height-offset="32"
  110. row-key="id"
  111. />
  112. <div v-else class="panel-empty panel-empty--state panel-empty--record">
  113. <EmptyState
  114. :title="selectedSummaryRow ? '暂无数据' : '请选择对象'"
  115. :description="
  116. selectedSummaryRow ? '当前选中对象暂无敏感日期通话记录' : '左侧选中联系人后,在这里查看敏感日期通话明细'
  117. "
  118. :min-height="240"
  119. />
  120. </div>
  121. </div>
  122. <div v-if="showDetailPagination" class="pagination-bar">
  123. <AnalysisPagination
  124. :total="detailTotal"
  125. :current="detailPagination.current"
  126. :page-size="detailPagination.pageSize"
  127. :page-size-options="pageSizeOptions"
  128. :loading="detailLoading"
  129. @change="handleDetailPaginationChange"
  130. />
  131. </div>
  132. </div>
  133. </div>
  134. <SpecialDateConfigModal v-model:open="specialDateModalOpen" summary="统一维护敏感日期联系分析使用的特殊日期配置" />
  135. </div>
  136. </template>
  137. <script lang="ts" setup name="ViewsSensitiveIndex">
  138. import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
  139. import { useRoute } from 'vue-router';
  140. import { Select, Input, DatePicker, Button } from 'ant-design-vue';
  141. import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  142. import { BasicTable } from '@jeesite/core/components/Table';
  143. import { EmptyState } from '@jeesite/core/components/EmptyState';
  144. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  145. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  146. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  147. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  148. import { maskCertNo } from '@jeesite/core/utils/sensitive';
  149. import { useTimeSeriesJump } from '@jeesite/otg/hooks/useTimeSeriesJump';
  150. import componentSetting from '@jeesite/core/settings/componentSetting';
  151. import { statFirstCallSensitive, statSecondCallSensitive } from '@jeesite/call/api/callApi';
  152. import { getPersonOptions } from '@jeesite/person/api/personApi';
  153. import SpecialDateConfigModal from '@jeesite/person/views/components/SpecialDateConfigModal.vue';
  154. import type { CallSensitiveDTO, CallRecord } from '@jeesite/call/api/types/dto';
  155. import type { CallSensitiveQuery } from '@jeesite/call/api/types/query';
  156. const { prefixCls } = useDesign('sensitive');
  157. const { queryLoading, runWithQueryLoading } = useTableSkill();
  158. const { createMessage } = useMessage();
  159. const { jumping, jumpToBehaviorSpacetime } = useTimeSeriesJump();
  160. const route = useRoute();
  161. const RangePicker = DatePicker.RangePicker;
  162. const DATE_FORMAT = 'YYYY-MM-DD';
  163. type RangeValue = [string, string] | undefined;
  164. const getPagePopupContainer = (triggerNode?: HTMLElement) =>
  165. (triggerNode?.closest('.sensitive-page') as HTMLElement | null) || document.body;
  166. const filters = ref({
  167. target: [] as string[],
  168. dateRange: undefined as RangeValue,
  169. keyword: '',
  170. });
  171. const summaryPagination = ref({
  172. current: 1,
  173. pageSize: componentSetting.table.defaultPageSize,
  174. });
  175. const summaryTotal = ref(0);
  176. const summaryData = ref<any[]>([]);
  177. const hasSearched = ref(false);
  178. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  179. refresh: () => fetchSummaryTable(),
  180. hasData: () => hasSearched.value && summaryData.value.length > 0,
  181. });
  182. const jumpPage = ref('1');
  183. const detailLoading = ref(false);
  184. const detailPagination = ref({
  185. current: 1,
  186. pageSize: componentSetting.table.defaultPageSize,
  187. });
  188. const detailTotal = ref(0);
  189. const detailJumpPage = ref('1');
  190. const selectedSummaryRow = ref<any | null>(null);
  191. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  192. const summaryKeywordTimer = ref<ReturnType<typeof setTimeout> | null>(null);
  193. const DEFAULT_SUMMARY_SORT_KEY = 'callCount';
  194. const DEFAULT_SUMMARY_SORT_ORDER = 'DESC' as const;
  195. type SummarySortOrder = 'ASC' | 'DESC';
  196. const summarySortKey = ref(DEFAULT_SUMMARY_SORT_KEY);
  197. const summarySortOrder = ref<SummarySortOrder>(DEFAULT_SUMMARY_SORT_ORDER);
  198. const specialDateModalOpen = ref(false);
  199. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  200. label: value,
  201. value,
  202. }));
  203. const summaryColumns = computed(() => [
  204. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 120 },
  205. { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 120 },
  206. { title: '对方号码', dataIndex: 'otherNumber', key: 'otherNumber', width: 160 },
  207. {
  208. title: '联系次数',
  209. dataIndex: 'count',
  210. key: 'count',
  211. width: 120,
  212. sorter: true,
  213. sortOrder:
  214. summarySortKey.value === DEFAULT_SUMMARY_SORT_KEY
  215. ? summarySortOrder.value === 'ASC'
  216. ? 'ascend'
  217. : 'descend'
  218. : undefined,
  219. },
  220. ]);
  221. const summaryTotalPages = computed(() => {
  222. if (!summaryTotal.value) return 1;
  223. return Math.max(1, Math.ceil(summaryTotal.value / summaryPagination.value.pageSize));
  224. });
  225. const SUMMARY_COMPACT_ROW_LIMIT = 9;
  226. const isSummaryTableCompact = computed(
  227. () =>
  228. summaryTotalPages.value <= 1 &&
  229. summaryData.value.length > 0 &&
  230. summaryData.value.length <= SUMMARY_COMPACT_ROW_LIMIT,
  231. );
  232. const detailTotalPages = computed(() => {
  233. if (!detailTotal.value) return 1;
  234. return Math.max(1, Math.ceil(detailTotal.value / detailPagination.value.pageSize));
  235. });
  236. const showDetailPagination = computed(() => festivalData.value.length > 0);
  237. const normalizeList = (resp: any): CallSensitiveDTO[] => {
  238. if (Array.isArray(resp)) return resp;
  239. if (Array.isArray(resp?.list)) return resp.list;
  240. if (Array.isArray(resp?.data)) return resp.data;
  241. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  242. if (Array.isArray(resp?.records)) return resp.records;
  243. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  244. return [];
  245. };
  246. const normalizeTotal = (resp: any, listLen: number) => {
  247. const t = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? resp?.data?.records?.length;
  248. return Number.isFinite(Number(t)) ? Number(t) : listLen;
  249. };
  250. const normalizeCurrent = (resp: any, fallback: number) => {
  251. const current = resp?.current ?? resp?.data?.current ?? fallback;
  252. return Number.isFinite(Number(current)) && Number(current) > 0 ? Number(current) : fallback;
  253. };
  254. const normalizeSize = (resp: any, fallback: number) => {
  255. const size = resp?.size ?? resp?.data?.size ?? fallback;
  256. return Number.isFinite(Number(size)) && Number(size) > 0 ? Number(size) : fallback;
  257. };
  258. const normalizeSelectedTargets = (value?: string[] | string | null) => {
  259. if (Array.isArray(value)) {
  260. const list = value.map((item) => String(item).trim()).filter(Boolean);
  261. return list.length ? Array.from(new Set(list)) : [];
  262. }
  263. if (!value) return [];
  264. const list = String(value)
  265. .split(',')
  266. .map((item) => item.trim())
  267. .filter(Boolean);
  268. return list.length ? Array.from(new Set(list)) : [];
  269. };
  270. const mapSummaryRow = (item: CallSensitiveDTO, index: number) => ({
  271. id: (summaryPagination.value.current - 1) * summaryPagination.value.pageSize + index + 1,
  272. personName: item.personName || '-',
  273. otherName: item.otherName || '-',
  274. otherNumber: item.otherPhone || '-',
  275. count: `共联系${item.callCount ?? 0}次`,
  276. countValue: Number(item.callCount ?? 0) || 0,
  277. raw: item,
  278. });
  279. const sortSummaryList = (list: CallSensitiveDTO[]) =>
  280. [...list].sort((left, right) => {
  281. const leftCount = Number(left.callCount ?? 0) || 0;
  282. const rightCount = Number(right.callCount ?? 0) || 0;
  283. if (rightCount !== leftCount) {
  284. return summarySortOrder.value === 'ASC' ? leftCount - rightCount : rightCount - leftCount;
  285. }
  286. return String(left.otherName || left.otherPhone || '').localeCompare(
  287. String(right.otherName || right.otherPhone || ''),
  288. );
  289. });
  290. const resolveSummarySorter = (sorter: any) => {
  291. if (Array.isArray(sorter)) return sorter[0];
  292. return sorter;
  293. };
  294. const clearSummaryKeywordTimer = () => {
  295. if (summaryKeywordTimer.value) {
  296. clearTimeout(summaryKeywordTimer.value);
  297. summaryKeywordTimer.value = null;
  298. }
  299. };
  300. const buildQuery = (): CallSensitiveQuery => {
  301. const range = filters.value.dateRange as RangeValue;
  302. const personNames = normalizeSelectedTargets(filters.value.target);
  303. const keyword = filters.value.keyword?.trim() || '';
  304. return {
  305. page: summaryPagination.value.current,
  306. limit: summaryPagination.value.pageSize,
  307. startDate: range?.[0] || undefined,
  308. endDate: range?.[1] || undefined,
  309. personNames: personNames.length ? personNames : undefined,
  310. keyword,
  311. orderKey: summarySortKey.value,
  312. sort: summarySortOrder.value,
  313. };
  314. };
  315. const fetchSummaryTable = async () => {
  316. await runWithQueryLoading(async () => {
  317. const resp = await statFirstCallSensitive(buildQuery());
  318. const list = sortSummaryList(normalizeList(resp));
  319. summaryData.value = list.map(mapSummaryRow);
  320. summaryTotal.value = normalizeTotal(resp, list.length);
  321. hasSearched.value = true;
  322. syncAnalysisDataRefreshState(list.length > 0);
  323. const current = Number((resp as any)?.data?.current || summaryPagination.value.current);
  324. const size = Number((resp as any)?.data?.size || summaryPagination.value.pageSize);
  325. if (Number.isFinite(current) && current > 0) summaryPagination.value.current = current;
  326. if (Number.isFinite(size) && size > 0) summaryPagination.value.pageSize = size;
  327. jumpPage.value = String(summaryPagination.value.current);
  328. });
  329. };
  330. const handleSearch = async () => {
  331. clearSummaryKeywordTimer();
  332. summaryPagination.value.current = 1;
  333. await fetchSummaryTable();
  334. await refreshRightByFirstRow();
  335. };
  336. const handleReset = async () => {
  337. clearSummaryKeywordTimer();
  338. filters.value = {
  339. target: [],
  340. dateRange: undefined,
  341. keyword: '',
  342. };
  343. summaryPagination.value.current = 1;
  344. await fetchSummaryTable();
  345. await refreshRightByFirstRow();
  346. };
  347. const handlePrevPage = async () => {
  348. if (queryLoading.value || summaryPagination.value.current <= 1) return;
  349. summaryPagination.value.current -= 1;
  350. await fetchSummaryTable();
  351. await refreshRightByFirstRow();
  352. };
  353. const handleNextPage = async () => {
  354. if (queryLoading.value || summaryPagination.value.current >= summaryTotalPages.value) return;
  355. summaryPagination.value.current += 1;
  356. await fetchSummaryTable();
  357. await refreshRightByFirstRow();
  358. };
  359. const handleSummaryPaginationChange = async (page: number, pageSize: number) => {
  360. if (queryLoading.value) return;
  361. if (pageSize !== summaryPagination.value.pageSize) {
  362. summaryPagination.value.pageSize = pageSize;
  363. await handlePageSizeChange();
  364. return;
  365. }
  366. if (page === summaryPagination.value.current - 1) {
  367. await handlePrevPage();
  368. return;
  369. }
  370. if (page === summaryPagination.value.current + 1) {
  371. await handleNextPage();
  372. return;
  373. }
  374. if (page === summaryPagination.value.current) return;
  375. jumpPage.value = String(page);
  376. await handleJumpPage();
  377. };
  378. const handlePageSizeChange = async () => {
  379. summaryPagination.value.current = 1;
  380. await fetchSummaryTable();
  381. await refreshRightByFirstRow();
  382. };
  383. const handleJumpPage = async () => {
  384. const next = Number(jumpPage.value);
  385. if (!Number.isInteger(next) || next < 1) {
  386. createMessage.warning('请输入正确页码');
  387. jumpPage.value = String(summaryPagination.value.current);
  388. return;
  389. }
  390. summaryPagination.value.current = Math.min(next, summaryTotalPages.value);
  391. await fetchSummaryTable();
  392. await refreshRightByFirstRow();
  393. };
  394. const triggerKeywordSearch = async () => {
  395. if (!hasSearched.value) return;
  396. clearSummaryKeywordTimer();
  397. summaryPagination.value.current = 1;
  398. await fetchSummaryTable();
  399. await refreshRightByFirstRow();
  400. };
  401. const handleKeywordUpdate = (value: string) => {
  402. filters.value.keyword = value;
  403. clearSummaryKeywordTimer();
  404. if (!hasSearched.value) return;
  405. summaryKeywordTimer.value = setTimeout(() => {
  406. void triggerKeywordSearch();
  407. }, 300);
  408. };
  409. const handleKeywordEnter = async () => {
  410. if (!hasSearched.value) return;
  411. await triggerKeywordSearch();
  412. };
  413. const handleSummaryTableChange = async (_pagination: any, _filters: any, sorter: any) => {
  414. const currentSorter = resolveSummarySorter(sorter);
  415. if (!currentSorter) return;
  416. const columnKey = String(currentSorter.field || currentSorter.columnKey || '');
  417. if (columnKey !== 'count') return;
  418. summarySortKey.value = DEFAULT_SUMMARY_SORT_KEY;
  419. summarySortOrder.value = currentSorter.order === 'ascend' ? 'ASC' : DEFAULT_SUMMARY_SORT_ORDER;
  420. summaryPagination.value.current = 1;
  421. await fetchSummaryTable();
  422. await refreshRightByFirstRow();
  423. };
  424. const fetchTargetOptions = async () => {
  425. try {
  426. targetOptions.value = await getPersonOptions(false);
  427. } catch (error) {
  428. targetOptions.value = [];
  429. }
  430. };
  431. const applyRouteQuery = (targetOptions: Array<{ label: string; value: string }>) => {
  432. const personNames = normalizeSelectedTargets(
  433. (route.query.personNames as string | string[] | null | undefined) ??
  434. (route.query.personName as string | string[] | null | undefined),
  435. );
  436. const otherName = String(route.query.otherName || '').trim();
  437. filters.value.target = personNames.map((personName) => {
  438. const matched = targetOptions.find((item) => String(item.label).trim() === personName);
  439. return matched ? matched.value : personName;
  440. });
  441. if (otherName) {
  442. filters.value.keyword = otherName;
  443. }
  444. return !!(filters.value.target.length || otherName);
  445. };
  446. const handleOpenSpecialDateModal = () => {
  447. specialDateModalOpen.value = true;
  448. };
  449. const festivalColumns = [
  450. { title: '序号', dataIndex: 'id', key: 'id', width: 80 },
  451. { title: '节日名称', dataIndex: 'festivalName', key: 'festivalName', width: 120 },
  452. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 120 },
  453. { title: '本方号码', dataIndex: 'personPhone', key: 'personPhone', width: 140 },
  454. { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 140 },
  455. { title: '对方号码', dataIndex: 'otherPhone', key: 'otherPhone', width: 140 },
  456. { title: '通话日期时间', dataIndex: 'callDateTime', key: 'callDateTime', width: 180 },
  457. { title: '通话时长(秒)', dataIndex: 'duration', key: 'duration', width: 120 },
  458. { title: '主被叫', dataIndex: 'callDirection', key: 'callDirection', width: 100 },
  459. { title: '本方基站大区号', dataIndex: 'personRegionId', key: 'personRegionId', width: 140 },
  460. { title: '本方基站小区号', dataIndex: 'personCellId', key: 'personCellId', width: 140 },
  461. { title: '本方基站名称', dataIndex: 'personStationName', key: 'personStationName', width: 220 },
  462. { title: '运营商', dataIndex: 'operatorId', key: 'operatorId', width: 120 },
  463. { title: '本方号码归属地', dataIndex: 'personPhoneArea', key: 'personPhoneArea', width: 160 },
  464. { title: '本方证件号码', dataIndex: 'personCertNo', key: 'personCertNo', width: 180 },
  465. { title: '通话类型', dataIndex: 'callType', key: 'callType', width: 100 },
  466. { title: '对方号码归属地', dataIndex: 'otherPhoneArea', key: 'otherPhoneArea', width: 160 },
  467. { title: '对方证件号码', dataIndex: 'otherCertNo', key: 'otherCertNo', width: 180 },
  468. { title: '备注', dataIndex: 'remark', key: 'remark', width: 180 },
  469. ];
  470. const mapCallDirection = (value?: string) => {
  471. if (!value) return '-';
  472. if (value === 'CALL_IN') return '被叫';
  473. if (value === 'CALL_OUT') return '主叫';
  474. return value;
  475. };
  476. const mapCallType = (value?: string) => {
  477. if (!value) return '-';
  478. if (value === 'VOICE') return '语音';
  479. if (value === 'VIDEO') return '视频';
  480. return value;
  481. };
  482. const mapDetailRow = (item: CallRecord, index: number) => ({
  483. id: (detailPagination.value.current - 1) * detailPagination.value.pageSize + index + 1,
  484. festivalName: item.festivalName || '-',
  485. personName: item.personName || '-',
  486. personPhone: item.personPhone || '-',
  487. operatorId: item.operatorId || '-',
  488. personPhoneArea: item.personPhoneArea || '-',
  489. personCertNo: maskCertNo(item.personCertNo),
  490. personRegionId: item.personRegionId || '-',
  491. personCellId: item.personCellId || '-',
  492. personStationName: item.personStationName || '-',
  493. personStationLongitude: item.personStationLongitude || '-',
  494. personStationLatitude: item.personStationLatitude || '-',
  495. callDateTime: item.callDateTime || (item.callDate && item.callTime ? `${item.callDate} ${item.callTime}` : '-'),
  496. otherName: item.otherName || '-',
  497. otherPhone: item.otherPhone || '-',
  498. callDate: item.callDate || '-',
  499. callTime: item.callTime || '-',
  500. duration: item.duration ?? 0,
  501. callDirection: mapCallDirection(item.callDirection),
  502. callType: mapCallType(item.callType),
  503. otherPhoneArea: item.otherPhoneArea || '-',
  504. otherCertNo: maskCertNo(item.otherCertNo),
  505. remark: item.remark || '-',
  506. personLibId: item.personLibId || '-',
  507. raw: item,
  508. });
  509. const normalizeActionRecordId = (value: unknown) => {
  510. const text = String(value ?? '').trim();
  511. if (!text || text === '-' || text === 'null' || text === 'undefined') return undefined;
  512. return text;
  513. };
  514. const buildJumpQuery = (record?: CallRecord) => {
  515. const id = normalizeActionRecordId(record?.id);
  516. const personName = String(record?.personName || selectedSummaryRow.value?.raw?.personName || '').trim();
  517. return {
  518. hasLocal: 'N',
  519. tableName: 'call_record',
  520. tableNameEn: 'call_record',
  521. tableNameCn: '通信记录',
  522. id,
  523. personName: personName || undefined,
  524. personNames: personName ? [personName] : undefined,
  525. };
  526. };
  527. const handleJump = async (record?: CallRecord) => {
  528. await jumpToBehaviorSpacetime(buildJumpQuery(record));
  529. };
  530. const detailActionColumn = computed(() => ({
  531. title: '操作',
  532. dataIndex: 'action',
  533. key: 'action',
  534. width: 90,
  535. fixed: 'right',
  536. align: 'center',
  537. actions: (record: { raw?: CallRecord }) => {
  538. const rawRecord = record.raw;
  539. const query = buildJumpQuery(rawRecord);
  540. return [
  541. {
  542. label: '跳转',
  543. disabled: jumping.value || !query.id,
  544. onClick: () => void handleJump(rawRecord),
  545. },
  546. ];
  547. },
  548. }));
  549. const festivalData = ref<any[]>([]);
  550. const resetDetailPagination = () => {
  551. detailPagination.value.current = 1;
  552. detailJumpPage.value = '1';
  553. };
  554. const fetchDetailTable = async (row?: any) => {
  555. const current = row || selectedSummaryRow.value;
  556. if (!current) {
  557. festivalData.value = [];
  558. detailTotal.value = 0;
  559. detailJumpPage.value = '1';
  560. return;
  561. }
  562. selectedSummaryRow.value = current;
  563. detailLoading.value = true;
  564. try {
  565. const range = filters.value.dateRange as RangeValue;
  566. const resp = await statSecondCallSensitive({
  567. page: detailPagination.value.current,
  568. limit: detailPagination.value.pageSize,
  569. startDate: range?.[0] || undefined,
  570. endDate: range?.[1] || undefined,
  571. personName: current.raw?.personName || undefined,
  572. otherName: current.raw?.otherName || undefined,
  573. otherPhone: current.raw?.otherPhone || undefined,
  574. });
  575. const records = Array.isArray((resp as any)?.records)
  576. ? (resp as any).records
  577. : Array.isArray((resp as any)?.data?.records)
  578. ? (resp as any).data.records
  579. : [];
  580. festivalData.value = records.map(mapDetailRow);
  581. detailTotal.value = normalizeTotal(resp, records.length);
  582. detailPagination.value.current = normalizeCurrent(resp, detailPagination.value.current);
  583. detailPagination.value.pageSize = normalizeSize(resp, detailPagination.value.pageSize);
  584. detailJumpPage.value = String(detailPagination.value.current);
  585. } catch (error) {
  586. festivalData.value = [];
  587. detailTotal.value = 0;
  588. createMessage.warning('敏感第二层详情接口调用失败');
  589. } finally {
  590. detailLoading.value = false;
  591. }
  592. };
  593. const handleSummaryRowClick = async (record: any) => {
  594. resetDetailPagination();
  595. await fetchDetailTable(record);
  596. };
  597. const refreshRightByFirstRow = async () => {
  598. resetDetailPagination();
  599. if (summaryData.value.length) {
  600. await fetchDetailTable(summaryData.value[0]);
  601. } else {
  602. festivalData.value = [];
  603. detailTotal.value = 0;
  604. detailJumpPage.value = '1';
  605. selectedSummaryRow.value = null;
  606. }
  607. };
  608. const handleDetailPrevPage = async () => {
  609. if (detailLoading.value || detailPagination.value.current <= 1) return;
  610. detailPagination.value.current -= 1;
  611. await fetchDetailTable();
  612. };
  613. const handleDetailNextPage = async () => {
  614. if (detailLoading.value || detailPagination.value.current >= detailTotalPages.value) return;
  615. detailPagination.value.current += 1;
  616. await fetchDetailTable();
  617. };
  618. const handleDetailPaginationChange = async (page: number, pageSize: number) => {
  619. if (detailLoading.value) return;
  620. if (pageSize !== detailPagination.value.pageSize) {
  621. detailPagination.value.pageSize = pageSize;
  622. await handleDetailPageSizeChange();
  623. return;
  624. }
  625. if (page === detailPagination.value.current - 1) {
  626. await handleDetailPrevPage();
  627. return;
  628. }
  629. if (page === detailPagination.value.current + 1) {
  630. await handleDetailNextPage();
  631. return;
  632. }
  633. if (page === detailPagination.value.current) return;
  634. detailJumpPage.value = String(page);
  635. await handleDetailJumpPage();
  636. };
  637. const handleDetailPageSizeChange = async () => {
  638. detailPagination.value.current = 1;
  639. await fetchDetailTable();
  640. };
  641. const handleDetailJumpPage = async () => {
  642. const next = Number(detailJumpPage.value);
  643. if (!Number.isInteger(next) || next < 1) {
  644. createMessage.warning('请输入正确页码');
  645. detailJumpPage.value = String(detailPagination.value.current);
  646. return;
  647. }
  648. detailPagination.value.current = Math.min(next, detailTotalPages.value);
  649. await fetchDetailTable();
  650. };
  651. const summaryRowClassName = (record: any) => {
  652. return selectedSummaryRow.value && selectedSummaryRow.value.id === record.id ? 'summary-row-selected' : '';
  653. };
  654. onMounted(async () => {
  655. await fetchTargetOptions();
  656. if (applyRouteQuery(targetOptions.value)) {
  657. await handleSearch();
  658. }
  659. });
  660. onBeforeUnmount(() => {
  661. clearSummaryKeywordTimer();
  662. });
  663. </script>
  664. <style lang="less">
  665. @import (reference) '../../../trans/views/styles/trans-analysis-table-ui.less';
  666. @prefix-cls: ~'jeesite-sensitive';
  667. .@{prefix-cls}.sensitive-page {
  668. .trans-analysis-table-ui();
  669. background: #fff;
  670. padding: 0 8px 2px;
  671. min-height: 100%;
  672. height: 100%;
  673. display: flex;
  674. flex-direction: column;
  675. overflow: hidden;
  676. box-sizing: border-box;
  677. width: calc(100% + 24px);
  678. margin: 0 -12px;
  679. padding-left: 0;
  680. padding-right: 0;
  681. :deep(.ant-btn) {
  682. border-radius: 8px;
  683. }
  684. :deep(.ant-btn-primary) {
  685. box-shadow: none;
  686. }
  687. :deep(.ant-input),
  688. :deep(.ant-input-affix-wrapper),
  689. :deep(.ant-picker),
  690. :deep(.ant-select-selector) {
  691. border-radius: 8px;
  692. }
  693. & > .filter-bar {
  694. display: flex;
  695. align-items: center;
  696. gap: 8px;
  697. padding: 6px;
  698. min-height: 40px;
  699. border: 0;
  700. border-bottom: 1px solid #e5e7eb;
  701. border-radius: 0;
  702. background: transparent;
  703. flex-wrap: wrap;
  704. flex-shrink: 0;
  705. box-sizing: border-box;
  706. }
  707. & > .filter-bar > .filter-item {
  708. display: flex;
  709. align-items: center;
  710. gap: 6px;
  711. }
  712. .filter-label {
  713. color: #6b7280;
  714. font-size: 12px;
  715. white-space: nowrap;
  716. }
  717. .filter-input {
  718. width: 180px;
  719. }
  720. .filter-date {
  721. width: 360px;
  722. }
  723. & > .filter-bar > .filter-actions {
  724. display: flex;
  725. align-items: center;
  726. gap: 8px;
  727. margin-left: auto;
  728. }
  729. & > .filter-bar .filter-input,
  730. & > .filter-bar .filter-date,
  731. & > .filter-bar .filter-actions :deep(.ant-btn) {
  732. align-self: center;
  733. }
  734. .panel-header {
  735. display: flex;
  736. align-items: flex-start;
  737. justify-content: flex-start;
  738. gap: 12px;
  739. padding: 0 0 8px;
  740. width: 100%;
  741. box-sizing: border-box;
  742. border-bottom: 1px solid #f1f5f9;
  743. flex: 0 0 auto;
  744. }
  745. .panel-header.jeesite-basic-table-panel-setting-host {
  746. border-bottom: 1px solid #f1f5f9;
  747. }
  748. .panel-header--actions {
  749. align-items: center;
  750. }
  751. .panel-title-wrap {
  752. display: flex;
  753. flex-direction: column;
  754. gap: 4px;
  755. min-width: 0;
  756. }
  757. .panel-title {
  758. font-size: 14px;
  759. font-weight: 600;
  760. color: #111827;
  761. line-height: 22px;
  762. }
  763. .panel-desc {
  764. color: #6b7280;
  765. font-size: 12px;
  766. line-height: 20px;
  767. }
  768. .panel-actions {
  769. display: flex;
  770. align-items: center;
  771. gap: 8px;
  772. flex-shrink: 0;
  773. }
  774. .panel-search {
  775. padding: 0;
  776. margin-bottom: 0;
  777. width: 100%;
  778. box-sizing: border-box;
  779. flex-shrink: 0;
  780. }
  781. .content-panel > .panel-left > .panel-search,
  782. .content-panel > .panel-right > .panel-search {
  783. padding: 0;
  784. margin: 0;
  785. }
  786. .panel-search :deep(.ant-input),
  787. .panel-search :deep(.ant-input-affix-wrapper) {
  788. width: 100%;
  789. }
  790. .table-wrapper {
  791. flex: 1 1 auto;
  792. min-height: 0;
  793. margin: 0;
  794. width: 100%;
  795. box-sizing: border-box;
  796. border: 0;
  797. border-radius: 0;
  798. background: transparent;
  799. overflow: hidden;
  800. }
  801. .content-panel > .panel-left > .table-wrapper,
  802. .content-panel > .panel-right > .table-wrapper {
  803. margin: 0;
  804. }
  805. .table-wrapper .jeesite-basic-table {
  806. height: 100%;
  807. display: flex;
  808. flex-direction: column;
  809. }
  810. .table-wrapper .jeesite-basic-table > div:first-child {
  811. display: none;
  812. }
  813. .table-wrapper .ant-table-wrapper,
  814. .table-wrapper .ant-spin-nested-loading,
  815. .table-wrapper .ant-spin-container,
  816. .table-wrapper .ant-table,
  817. .table-wrapper .ant-table-container {
  818. height: 100%;
  819. min-height: 0;
  820. }
  821. .table-wrapper .ant-spin-container,
  822. .table-wrapper .ant-table,
  823. .table-wrapper .ant-table-container {
  824. display: flex;
  825. flex-direction: column;
  826. }
  827. .table-wrapper .ant-table-body {
  828. max-height: 100% !important;
  829. }
  830. .table-wrapper .ant-table-body::-webkit-scrollbar {
  831. width: 8px;
  832. height: 8px;
  833. }
  834. .table-wrapper .ant-table-body::-webkit-scrollbar-thumb {
  835. background: rgb(148 163 184 / 60%);
  836. border-radius: 999px;
  837. }
  838. .table-wrapper .ant-table-body::-webkit-scrollbar-track {
  839. background: transparent;
  840. }
  841. .table-wrapper--compact {
  842. flex: 0 0 auto;
  843. }
  844. .query-state-panel {
  845. flex: 1 1 auto;
  846. min-height: 0;
  847. display: flex;
  848. align-items: center;
  849. justify-content: center;
  850. padding: 12px 0 0;
  851. }
  852. .table-wrapper--compact .jeesite-basic-table,
  853. .table-wrapper--compact .ant-table-wrapper,
  854. .table-wrapper--compact .ant-spin-nested-loading,
  855. .table-wrapper--compact .ant-spin-container,
  856. .table-wrapper--compact .ant-table,
  857. .table-wrapper--compact .ant-table-container {
  858. height: auto;
  859. }
  860. .table-wrapper--compact .ant-table-body {
  861. overflow-y: hidden !important;
  862. scrollbar-width: none;
  863. }
  864. .table-wrapper--compact .ant-table-body::-webkit-scrollbar {
  865. width: 0;
  866. height: 0;
  867. }
  868. .table-wrapper--compact :deep(.ant-table-cell-scrollbar) {
  869. width: 0 !important;
  870. min-width: 0 !important;
  871. max-width: 0 !important;
  872. padding: 0 !important;
  873. border: 0 !important;
  874. background: transparent !important;
  875. }
  876. .table-wrapper :deep(.ant-table-thead > tr > th),
  877. .table-wrapper :deep(.ant-table-tbody > tr > td) {
  878. vertical-align: middle;
  879. }
  880. .panel-export {
  881. flex-shrink: 0;
  882. }
  883. .pagination-bar {
  884. display: flex;
  885. align-items: center;
  886. justify-content: flex-end;
  887. gap: 8px;
  888. color: #6b7280;
  889. font-size: 12px;
  890. flex: 0 0 auto;
  891. flex-wrap: wrap;
  892. padding: 0;
  893. width: 100%;
  894. box-sizing: border-box;
  895. border-top: 1px solid #f1f5f9;
  896. background: transparent;
  897. }
  898. .content-panel > .panel-left > .pagination-bar,
  899. .content-panel > .panel-right > .pagination-bar {
  900. padding: 0;
  901. margin: 0;
  902. }
  903. .pagination-total {
  904. margin-right: 0;
  905. white-space: nowrap;
  906. }
  907. .pagination-size,
  908. .pagination-actions,
  909. .pagination-jump {
  910. display: flex;
  911. align-items: center;
  912. gap: 6px;
  913. white-space: nowrap;
  914. }
  915. .pagination-input {
  916. width: 48px;
  917. }
  918. .table-wrapper--detail {
  919. margin-bottom: 0;
  920. }
  921. .analysis-flat-layout();
  922. padding: 0 8px 2px;
  923. > .filter-bar {
  924. padding: 6px;
  925. min-height: 40px;
  926. box-sizing: border-box;
  927. }
  928. > .filter-bar > .filter-item {
  929. display: flex;
  930. align-items: center;
  931. gap: 6px;
  932. }
  933. > .filter-bar > .filter-actions {
  934. display: flex;
  935. align-items: center;
  936. gap: 8px;
  937. margin-left: auto;
  938. }
  939. > .filter-bar .filter-input,
  940. > .filter-bar .filter-date,
  941. > .filter-bar .filter-actions :deep(.ant-btn) {
  942. align-self: center;
  943. }
  944. .content-panel {
  945. display: flex;
  946. gap: 2px;
  947. margin-top: 0;
  948. padding-top: 2px;
  949. padding-left: 2px;
  950. padding-right: 2px;
  951. padding-bottom: 2px;
  952. flex: 1 1 auto;
  953. min-height: 0;
  954. overflow: hidden;
  955. background: #edf2f7;
  956. box-sizing: border-box;
  957. }
  958. .content-panel > .panel-left,
  959. .content-panel > .panel-right {
  960. border: 0;
  961. border-radius: 12px;
  962. background: #fff;
  963. box-shadow: inset 0 0 0 1px #e2e8f0;
  964. min-height: 0;
  965. display: flex;
  966. flex-direction: column;
  967. overflow: hidden;
  968. padding: 8px;
  969. box-sizing: border-box;
  970. }
  971. .content-panel > .panel-left {
  972. flex: 0 0 42%;
  973. min-width: 0;
  974. }
  975. .content-panel > .panel-right {
  976. flex: 1 1 auto;
  977. min-width: 0;
  978. }
  979. @media screen and (max-width: 900px) {
  980. .content-panel {
  981. flex-direction: column;
  982. }
  983. .panel-left {
  984. flex-basis: auto;
  985. }
  986. }
  987. @media screen and (max-width: 992px) {
  988. .filter-actions {
  989. width: 100%;
  990. margin-left: 0;
  991. }
  992. .filter-input,
  993. .filter-date {
  994. width: 100%;
  995. }
  996. .pagination-bar {
  997. justify-content: flex-start;
  998. }
  999. }
  1000. }
  1001. </style>