index.vue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140
  1. <template>
  2. <div ref="morningEveningCallPageRef" :class="prefixCls" class="morning-evening-call-page">
  3. <div class="page-header">
  4. <div class="page-title">晨晚通话</div>
  5. <div class="page-subtitle">围绕晨间和夜间时间窗口统计通话地点与记录,辅助识别异常生活轨迹和活动区域。</div>
  6. </div>
  7. <div class="filter-bar">
  8. <div class="filter-item">
  9. <span class="filter-label">选择对象:</span>
  10. <Select
  11. v-model:value="filters.target"
  12. mode="multiple"
  13. allow-clear
  14. max-tag-count="responsive"
  15. class="filter-input"
  16. :options="targetOptions"
  17. placeholder="请选择对象"
  18. />
  19. </div>
  20. <div class="filter-item">
  21. <span class="filter-label">通话时间:</span>
  22. <RangePicker
  23. v-model:value="filters.dateRange"
  24. class="filter-date"
  25. :format="DATE_FORMAT"
  26. :value-format="DATE_FORMAT"
  27. :placeholder="['开始日期', '结束日期']"
  28. />
  29. </div>
  30. <div class="filter-item">
  31. <span class="filter-label">早上时间:</span>
  32. <Input :value="'6点'" class="filter-time filter-time--fixed" disabled />
  33. <span class="filter-label">至</span>
  34. <Select v-model:value="filters.morningEnd" class="filter-time" :options="morningEndOptions" />
  35. </div>
  36. <div class="filter-item">
  37. <span class="filter-label">晚上时间:</span>
  38. <Select v-model:value="filters.eveningStart" class="filter-time" :options="eveningStartOptions" />
  39. <span class="filter-label">至</span>
  40. <Input :value="'6点'" class="filter-time filter-time--fixed" disabled />
  41. </div>
  42. <div class="filter-item">
  43. <span class="filter-label">通话次数:</span>
  44. <Select v-model:value="filters.countOp" class="filter-op" :options="opOptions" />
  45. <Input v-model:value="filters.countValue" class="filter-micro" :status="countValueStatus" />
  46. <span class="filter-unit">次</span>
  47. </div>
  48. <div class="filter-actions">
  49. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  50. <Button @click="handleReset">重置</Button>
  51. </div>
  52. </div>
  53. <div v-if="targetList.length" class="target-tags">
  54. <div
  55. v-for="target in targetList"
  56. :key="target.id"
  57. class="target-tag"
  58. :class="{ active: selectedTarget === target.id }"
  59. @click="handleSelectTarget(target.id)"
  60. >
  61. {{ target.name }}
  62. </div>
  63. </div>
  64. <div class="content-panel">
  65. <div class="left-panel">
  66. <div class="panel-header">
  67. <div>
  68. <div class="panel-title">通话地点</div>
  69. <div class="panel-desc">{{ leftPanelDesc }}</div>
  70. </div>
  71. </div>
  72. <div v-if="!hasSearched" class="panel-empty panel-empty--state">
  73. <EmptyState
  74. variant="query"
  75. title="请点击查询"
  76. description="设置条件精细查询,也可点击查询展示所有信息"
  77. :min-height="220"
  78. />
  79. </div>
  80. <div v-else-if="queryLoading && !locationList.length" class="panel-empty">地点数据加载中...</div>
  81. <div v-else-if="locationList.length" class="location-list">
  82. <div
  83. v-for="location in locationList"
  84. :key="location.id"
  85. class="location-item"
  86. :class="{ active: selectedLocation === location.id }"
  87. @click="handleSelectLocation(location.id)"
  88. >
  89. <div class="location-name">{{ location.name }}</div>
  90. <div class="location-lac">LAC:{{ location.lac }}</div>
  91. <div class="location-cid">CID:{{ location.cid }}</div>
  92. <div class="location-count">通话:{{ location.count }}次</div>
  93. </div>
  94. </div>
  95. <div v-else class="panel-empty panel-empty--state">
  96. <EmptyState
  97. variant="compact"
  98. title="暂无地点数据"
  99. description="当前筛选条件下暂无早晚通话地统计结果"
  100. :min-height="220"
  101. />
  102. </div>
  103. </div>
  104. <div class="right-panel">
  105. <div class="panel-header panel-header--actions">
  106. <div>
  107. <div class="panel-title">通话记录</div>
  108. <div class="panel-desc">{{ rightPanelDesc }}</div>
  109. </div>
  110. </div>
  111. <div v-if="activeLocation" class="table-wrapper table-wrapper--detail">
  112. <BasicTable
  113. :columns="columns"
  114. :data-source="detailTableData"
  115. :loading="detailLoading"
  116. :pagination="false"
  117. :show-table-setting="false"
  118. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  119. :index-column-props="indexColumnProps"
  120. :can-resize="true"
  121. :is-can-resize-parent="true"
  122. :resize-height-offset="32"
  123. row-key="rowKey"
  124. @register="handleMorningEveningCallTableRegister"
  125. />
  126. </div>
  127. <div v-else class="panel-empty panel-empty--state panel-empty--record">
  128. <EmptyState
  129. variant="compact"
  130. title="请选择地点"
  131. description="点击左侧通话地点后,右侧将使用通话底层接口展示对应记录。"
  132. :min-height="240"
  133. />
  134. </div>
  135. <div v-if="activeLocation" class="pagination-bar">
  136. <AnalysisPagination
  137. :total="pagination.total"
  138. :current="pagination.current"
  139. :page-size="pagination.pageSize"
  140. :page-size-options="pageSizeOptions"
  141. :loading="detailLoading"
  142. @change="handleDetailPaginationChange"
  143. />
  144. </div>
  145. </div>
  146. </div>
  147. </div>
  148. </template>
  149. <script lang="ts" setup name="ViewsMorningEveningCallIndex">
  150. import { computed, onMounted, ref } from 'vue';
  151. import { Select, Input, Button, DatePicker } from 'ant-design-vue';
  152. import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  153. import { EmptyState } from '@jeesite/core/components/EmptyState';
  154. import { BasicTable } from '@jeesite/core/components/Table';
  155. import { createTableContext } from '@jeesite/core/components/Table/src/hooks/useTableContext';
  156. import type { BasicColumn } from '@jeesite/core/components/Table';
  157. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  158. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  159. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  160. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  161. import type { CallRecord } from '@jeesite/call/api/types/dto';
  162. import componentSetting from '@jeesite/core/settings/componentSetting';
  163. import { getPersonOptions } from '@jeesite/person/api/personApi';
  164. import { statFirstTrackEnLocal, statSecondTrackEnLocal } from '@jeesite/track/api/trackApi';
  165. import type { TrackEnLocalDTO } from '@jeesite/track/api/types/dto';
  166. import type { TrackEnLocalQuery } from '@jeesite/track/api/types/query';
  167. interface MorningTargetItem {
  168. id: string;
  169. name: string;
  170. locations: MorningLocationItem[];
  171. }
  172. interface MorningLocationItem {
  173. id: string;
  174. targetName: string;
  175. name: string;
  176. lac: string;
  177. cid: string;
  178. count: number;
  179. raw: TrackEnLocalDTO;
  180. }
  181. interface MorningCallRecordRow {
  182. rowKey: string;
  183. id: number;
  184. personName: string;
  185. personPhone: string;
  186. personStationName: string;
  187. personRegionId: string;
  188. personCellId: string;
  189. callDateTime: string;
  190. duration: number | string;
  191. callDirection: string;
  192. callType: string;
  193. otherName: string;
  194. otherPhone: string;
  195. festivalName: string;
  196. raw: CallRecord;
  197. }
  198. const { prefixCls } = useDesign('morning-evening-call');
  199. const { createMessage } = useMessage();
  200. const { queryLoading, runWithQueryLoading } = useTableSkill();
  201. const RangePicker = DatePicker.RangePicker;
  202. const DATE_FORMAT = 'YYYY-MM-DD';
  203. const morningEveningCallPageRef = ref<HTMLElement | null>(null);
  204. const morningEveningCallTableAction = ref<any>(null);
  205. const filters = ref({
  206. target: [] as string[],
  207. dateRange: undefined as [string, string] | undefined,
  208. morningStart: '6',
  209. morningEnd: '11',
  210. eveningStart: '20',
  211. eveningEnd: '6',
  212. countOp: 'ge',
  213. countValue: '',
  214. });
  215. const selectedTarget = ref<string | null>(null);
  216. const selectedLocation = ref<string | null>(null);
  217. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  218. const targetGroups = ref<MorningTargetItem[]>([]);
  219. const hasSearched = ref(false);
  220. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  221. refresh: () => fetchSummaryData(),
  222. hasData: () => hasSearched.value && targetGroups.value.length > 0,
  223. });
  224. const positiveIntegerPattern = /^[1-9]\d*$/;
  225. const opOptions = [
  226. { label: '>=', value: 'ge' },
  227. { label: '<=', value: 'le' },
  228. ];
  229. const timeOptions = [
  230. { label: '0点', value: '0' },
  231. { label: '1点', value: '1' },
  232. { label: '2点', value: '2' },
  233. { label: '3点', value: '3' },
  234. { label: '4点', value: '4' },
  235. { label: '5点', value: '5' },
  236. { label: '6点', value: '6' },
  237. { label: '7点', value: '7' },
  238. { label: '8点', value: '8' },
  239. { label: '9点', value: '9' },
  240. { label: '10点', value: '10' },
  241. { label: '11点', value: '11' },
  242. { label: '12点', value: '12' },
  243. { label: '13点', value: '13' },
  244. { label: '14点', value: '14' },
  245. { label: '15点', value: '15' },
  246. { label: '16点', value: '16' },
  247. { label: '17点', value: '17' },
  248. { label: '18点', value: '18' },
  249. { label: '19点', value: '19' },
  250. { label: '20点', value: '20' },
  251. { label: '21点', value: '21' },
  252. { label: '22点', value: '22' },
  253. { label: '23点', value: '23' },
  254. ];
  255. const morningEndOptions = timeOptions.filter((item) => {
  256. const hour = Number(item.value);
  257. return hour >= 7 && hour <= 12;
  258. });
  259. const eveningStartOptions = timeOptions.filter((item) => {
  260. const hour = Number(item.value);
  261. return hour >= 20 || hour <= 5;
  262. });
  263. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  264. label: value,
  265. value: Number(value),
  266. }));
  267. const targetList = computed(() => targetGroups.value.map((item) => ({ id: item.id, name: item.name })));
  268. const activeTarget = computed(() => targetGroups.value.find((item) => item.id === selectedTarget.value) || null);
  269. const locationList = computed(() => activeTarget.value?.locations || []);
  270. const activeLocation = computed(() => locationList.value.find((item) => item.id === selectedLocation.value) || null);
  271. const normalizedCountValue = computed(() => String(filters.value.countValue ?? '').trim());
  272. const countValueStatus = computed<'error' | undefined>(() => {
  273. if (!normalizedCountValue.value) return undefined;
  274. return positiveIntegerPattern.test(normalizedCountValue.value) ? undefined : 'error';
  275. });
  276. const leftPanelDesc = computed(() => {
  277. if (!targetGroups.value.length) {
  278. return '从左侧地点切换查看晨间和夜间高频通话区域,辅助做固定活动范围研判。';
  279. }
  280. if (!activeTarget.value) {
  281. return '请选择对象后查看对应地点统计。';
  282. }
  283. return `${activeTarget.value.name} 当前命中 ${locationList.value.length} 个早晚通话地点。`;
  284. });
  285. const rightPanelDesc = computed(() => {
  286. if (!targetGroups.value.length) {
  287. return '请选择对象并定位左侧地点后,右侧将展示底层通话记录。';
  288. }
  289. if (!activeLocation.value) {
  290. return '请选择左侧地点后查看通话记录。';
  291. }
  292. if (detailLoading.value) {
  293. return `${activeLocation.value.name} 的底层通话记录加载中...`;
  294. }
  295. if (!pagination.value.total) {
  296. return `${activeLocation.value.name} 暂未查询到命中的底层通话记录。`;
  297. }
  298. return `${activeLocation.value.name} 共命中 ${pagination.value.total} 条底层通话记录。`;
  299. });
  300. const columns: BasicColumn[] = [
  301. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 120, align: 'center' },
  302. { title: '本方号码', dataIndex: 'personPhone', key: 'personPhone', width: 140, align: 'center' },
  303. { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 120, align: 'center' },
  304. { title: '对方号码', dataIndex: 'otherPhone', key: 'otherPhone', width: 140, align: 'center' },
  305. { title: '通话日期时间', dataIndex: 'callDateTime', key: 'callDateTime', width: 180, align: 'center' },
  306. { title: '通话时长(秒)', dataIndex: 'duration', key: 'duration', width: 110, align: 'center' },
  307. { title: '主被叫', dataIndex: 'callDirection', key: 'callDirection', width: 90, align: 'center' },
  308. { title: '通话类型', dataIndex: 'callType', key: 'callType', width: 90, align: 'center' },
  309. { title: '基站名称', dataIndex: 'personStationName', key: 'personStationName', width: 220 },
  310. { title: 'LAC', dataIndex: 'personRegionId', key: 'personRegionId', width: 100, align: 'center' },
  311. { title: 'CID', dataIndex: 'personCellId', key: 'personCellId', width: 100, align: 'center' },
  312. ];
  313. const indexColumnProps: BasicColumn = {
  314. width: 60,
  315. align: 'center',
  316. customRender: ({ index }: { index: number }) => {
  317. return (pagination.value.current - 1) * pagination.value.pageSize + index + 1;
  318. },
  319. };
  320. const handleMorningEveningCallTableRegister = (action: any) => {
  321. morningEveningCallTableAction.value = action;
  322. };
  323. const getMorningEveningCallTableColumns = (...args: any[]) =>
  324. morningEveningCallTableAction.value?.getColumns?.(...args) || [];
  325. createTableContext({
  326. wrapRef: morningEveningCallPageRef,
  327. getBindValues: computed(() => ({
  328. columns,
  329. showIndexColumn: true,
  330. rowSelection: undefined,
  331. isTreeTable: false,
  332. })),
  333. getDefaultRowSelection: () => morningEveningCallTableAction.value?.getDefaultRowSelection?.(),
  334. getColumns: getMorningEveningCallTableColumns,
  335. getCacheColumns: () => morningEveningCallTableAction.value?.getCacheColumns?.() || [],
  336. setColumns: (nextColumns: any) => morningEveningCallTableAction.value?.setColumns?.(nextColumns),
  337. setProps: (props: any) => morningEveningCallTableAction.value?.setProps?.(props),
  338. } as any);
  339. const detailLoading = ref(false);
  340. const recordList = ref<CallRecord[]>([]);
  341. const pagination = ref({
  342. current: 1,
  343. pageSize: componentSetting.table.defaultPageSize,
  344. total: 0,
  345. jumpPage: '1',
  346. });
  347. const detailRequestSerial = ref(0);
  348. const detailTotalPages = computed(() => {
  349. if (!pagination.value.total) return 1;
  350. return Math.max(1, Math.ceil(pagination.value.total / pagination.value.pageSize));
  351. });
  352. const detailTableData = computed<MorningCallRecordRow[]>(() => {
  353. const start = (pagination.value.current - 1) * pagination.value.pageSize;
  354. const pageList = recordList.value.slice(start, start + pagination.value.pageSize);
  355. return pageList.map((item, index) => mapDetailRow(item, start + index));
  356. });
  357. const fallbackText = (value: string | number | null | undefined) => {
  358. if (value === 0) return '0';
  359. if (value === null || value === undefined || value === '') return '-';
  360. return String(value);
  361. };
  362. const normalizeCallRecordList = (resp: any): CallRecord[] => {
  363. if (Array.isArray(resp)) return resp;
  364. if (Array.isArray(resp?.records)) return resp.records;
  365. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  366. if (Array.isArray(resp?.list)) return resp.list;
  367. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  368. if (Array.isArray(resp?.data)) return resp.data;
  369. return [];
  370. };
  371. const normalizeCallRecordTotal = (resp: any, listLen: number) => {
  372. const total = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? listLen;
  373. return Number.isFinite(Number(total)) ? Number(total) : listLen;
  374. };
  375. const resolveCountValue = (showMessage = false) => {
  376. if (!normalizedCountValue.value) return undefined;
  377. if (positiveIntegerPattern.test(normalizedCountValue.value)) {
  378. return Number(normalizedCountValue.value);
  379. }
  380. if (showMessage) {
  381. createMessage.warning('通话次数请输入大于 0 的整数');
  382. }
  383. return null;
  384. };
  385. const parseTimeValue = (value?: string) => {
  386. const numericValue = Number(value);
  387. return Number.isFinite(numericValue) ? numericValue : undefined;
  388. };
  389. const normalizeTimeValue = (value?: string | number | null, endOfHour = false) => {
  390. if (value === null || value === undefined || value === '') return undefined;
  391. const numericValue = typeof value === 'number' ? value : Number(value);
  392. if (!Number.isFinite(numericValue)) return undefined;
  393. const hours = Math.max(0, Math.min(23, numericValue));
  394. return `${String(hours).padStart(2, '0')}:${endOfHour ? '59:59' : '00:00'}`;
  395. };
  396. const matchesCountFilter = (count: number, compareValue?: number) => {
  397. if (compareValue === undefined) return true;
  398. switch (filters.value.countOp) {
  399. case 'gt':
  400. return count > compareValue;
  401. case 'lt':
  402. return count < compareValue;
  403. case 'le':
  404. return count <= compareValue;
  405. case 'eq':
  406. return count === compareValue;
  407. case 'ge':
  408. default:
  409. return count >= compareValue;
  410. }
  411. };
  412. const normalizeMapPayload = (resp: any): Record<string, TrackEnLocalDTO[]> => {
  413. if (resp?.data && typeof resp.data === 'object' && !Array.isArray(resp.data)) return resp.data;
  414. if (resp && typeof resp === 'object' && !Array.isArray(resp)) return resp;
  415. return {};
  416. };
  417. const mapLocationItem = (item: TrackEnLocalDTO, fallbackName: string, index: number): MorningLocationItem => {
  418. const targetName = item.personName || fallbackName || '-';
  419. const stationName = fallbackText(item.personStationName);
  420. const lac = fallbackText(item.personRegionId);
  421. const cid = fallbackText(item.personCellId);
  422. const count = Number.isFinite(Number(item.dayNightTotal)) ? Number(item.dayNightTotal) : 0;
  423. return {
  424. id: [targetName, stationName, lac, cid, index].join('||'),
  425. targetName,
  426. name: stationName,
  427. lac,
  428. cid,
  429. count,
  430. raw: item,
  431. };
  432. };
  433. const normalizeTargetGroups = (resp: any, compareValue?: number): MorningTargetItem[] => {
  434. const mapPayload = normalizeMapPayload(resp);
  435. return Object.entries(mapPayload)
  436. .map(([personName, list], groupIndex) => {
  437. const locations = (Array.isArray(list) ? list : [])
  438. .map((item, index) => mapLocationItem(item, personName, groupIndex * 1000 + index))
  439. .filter((item) => matchesCountFilter(item.count, compareValue))
  440. .sort((prev, next) => next.count - prev.count);
  441. return {
  442. id: `${personName || 'unknown'}-${groupIndex}`,
  443. name: personName || locations[0]?.targetName || '-',
  444. locations,
  445. };
  446. })
  447. .filter((item) => item.locations.length);
  448. };
  449. const buildDetailTimeRanges = () => {
  450. const ranges: Array<{ startTime?: string; endTime?: string }> = [];
  451. const appendRange = (start?: string, end?: string) => {
  452. const startHour = parseTimeValue(start);
  453. const endHour = parseTimeValue(end);
  454. if (startHour === undefined || endHour === undefined) return;
  455. if (startHour <= endHour) {
  456. ranges.push({
  457. startTime: normalizeTimeValue(startHour),
  458. endTime: normalizeTimeValue(endHour, true),
  459. });
  460. return;
  461. }
  462. ranges.push(
  463. {
  464. startTime: normalizeTimeValue(startHour),
  465. endTime: '23:59:59',
  466. },
  467. {
  468. startTime: '00:00:00',
  469. endTime: normalizeTimeValue(endHour, true),
  470. },
  471. );
  472. };
  473. appendRange(filters.value.morningStart, filters.value.morningEnd);
  474. appendRange(filters.value.eveningStart, filters.value.eveningEnd);
  475. return Array.from(new Map(ranges.map((item) => [`${item.startTime || ''}-${item.endTime || ''}`, item])).values());
  476. };
  477. const buildDetailBaseQuery = (location: MorningLocationItem): Partial<TrackEnLocalQuery> => {
  478. return {
  479. personName: location.raw.personName || activeTarget.value?.name || undefined,
  480. personRegionId: location.raw.personRegionId || undefined,
  481. personCellId: location.raw.personCellId || undefined,
  482. personStationName: location.raw.personStationName || undefined,
  483. startDate: filters.value.dateRange?.[0] || undefined,
  484. endDate: filters.value.dateRange?.[1] || undefined,
  485. morningTime: filters.value.morningEnd || undefined,
  486. nightTime: filters.value.eveningEnd || undefined,
  487. orderKey: 'callDateTime',
  488. sort: 'DESC',
  489. } as Partial<TrackEnLocalQuery>;
  490. };
  491. const buildRecordKey = (item: CallRecord) => {
  492. if (item.id !== undefined && item.id !== null) return `id-${item.id}`;
  493. return [
  494. item.personPhone || '',
  495. item.otherPhone || '',
  496. item.callDateTime || `${item.callDate || ''} ${item.callTime || ''}`,
  497. item.personRegionId || '',
  498. item.personCellId || '',
  499. item.callDirection || '',
  500. ].join('||');
  501. };
  502. const sortRecordsDesc = (list: CallRecord[]) => {
  503. return [...list].sort((prev, next) => {
  504. const prevTime = prev.callDateTime || `${prev.callDate || ''} ${prev.callTime || ''}`;
  505. const nextTime = next.callDateTime || `${next.callDate || ''} ${next.callTime || ''}`;
  506. return String(nextTime).localeCompare(String(prevTime));
  507. });
  508. };
  509. const mapCallDirection = (value?: string) => {
  510. if (!value) return '-';
  511. if (value === 'CALL_IN') return '被叫';
  512. if (value === 'CALL_OUT') return '主叫';
  513. return value;
  514. };
  515. const mapCallType = (value?: string) => {
  516. if (!value) return '-';
  517. if (value === 'VOICE') return '语音';
  518. if (value === 'VIDEO') return '视频';
  519. return value;
  520. };
  521. const mapDetailRow = (item: CallRecord, index: number): MorningCallRecordRow => ({
  522. rowKey: String(item.id ?? buildRecordKey(item) ?? index),
  523. id: index + 1,
  524. personName: item.personName || '-',
  525. personPhone: item.personPhone || '-',
  526. personStationName: item.personStationName || '-',
  527. personRegionId: item.personRegionId || '-',
  528. personCellId: item.personCellId || '-',
  529. callDateTime: item.callDateTime || (item.callDate && item.callTime ? `${item.callDate} ${item.callTime}` : '-'),
  530. duration: item.duration ?? 0,
  531. callDirection: mapCallDirection(item.callDirection),
  532. callType: mapCallType(item.callType),
  533. otherName: item.otherName || '-',
  534. otherPhone: item.otherPhone || '-',
  535. festivalName: item.festivalName || '-',
  536. raw: item,
  537. });
  538. const resetDetailTable = () => {
  539. recordList.value = [];
  540. pagination.value.total = 0;
  541. pagination.value.current = 1;
  542. pagination.value.jumpPage = '1';
  543. };
  544. const fetchAllCallRecords = async (baseQuery: Partial<TrackEnLocalQuery>) => {
  545. const pageSize = 500;
  546. const maxPages = 100;
  547. const results: CallRecord[] = [];
  548. for (let page = 1; page <= maxPages; page += 1) {
  549. const resp = await statSecondTrackEnLocal({
  550. ...(baseQuery as TrackEnLocalQuery),
  551. page,
  552. limit: pageSize,
  553. });
  554. const list = normalizeCallRecordList(resp);
  555. results.push(...list);
  556. const total = normalizeCallRecordTotal(resp, list.length);
  557. if (!list.length || list.length < pageSize || results.length >= total) {
  558. break;
  559. }
  560. }
  561. return results;
  562. };
  563. const syncSelection = () => {
  564. if (!targetGroups.value.length) {
  565. selectedTarget.value = null;
  566. selectedLocation.value = null;
  567. return;
  568. }
  569. if (!targetGroups.value.some((item) => item.id === selectedTarget.value)) {
  570. selectedTarget.value = targetGroups.value[0].id;
  571. }
  572. const currentLocations = targetGroups.value.find((item) => item.id === selectedTarget.value)?.locations || [];
  573. if (!currentLocations.some((item) => item.id === selectedLocation.value)) {
  574. selectedLocation.value = currentLocations[0]?.id || null;
  575. }
  576. };
  577. const buildQuery = (countValue?: number): TrackEnLocalQuery => {
  578. const personNames = filters.value.target.length ? [...filters.value.target] : undefined;
  579. return {
  580. page: 1,
  581. limit: 200,
  582. startDate: filters.value.dateRange?.[0] || undefined,
  583. endDate: filters.value.dateRange?.[1] || undefined,
  584. personNames,
  585. dayNightTotal:
  586. countValue !== undefined && ['ge', 'gt', 'eq'].includes(filters.value.countOp) ? countValue : undefined,
  587. // Backend currently accepts morning/night threshold cutoffs, so the UI range maps to the end/start boundaries.
  588. morningTime: parseTimeValue(filters.value.morningEnd),
  589. nightTime: parseTimeValue(filters.value.eveningEnd),
  590. };
  591. };
  592. const fetchSummaryData = async () => {
  593. const countValue = resolveCountValue(true);
  594. if (countValue === null) return;
  595. try {
  596. await runWithQueryLoading(async () => {
  597. const resp = await statFirstTrackEnLocal(buildQuery(countValue));
  598. targetGroups.value = normalizeTargetGroups(resp, countValue);
  599. hasSearched.value = true;
  600. syncAnalysisDataRefreshState(targetGroups.value.length > 0);
  601. resetDetailTable();
  602. syncSelection();
  603. });
  604. await fetchDetailTable();
  605. } catch (error) {
  606. targetGroups.value = [];
  607. hasSearched.value = true;
  608. syncAnalysisDataRefreshState(false);
  609. selectedTarget.value = null;
  610. selectedLocation.value = null;
  611. resetDetailTable();
  612. createMessage.warning('早晚通话地第一层接口调用失败');
  613. }
  614. };
  615. const handleSearch = async () => {
  616. await fetchSummaryData();
  617. };
  618. const handleReset = async () => {
  619. filters.value = {
  620. target: [],
  621. dateRange: undefined,
  622. morningStart: '6',
  623. morningEnd: '11',
  624. eveningStart: '20',
  625. eveningEnd: '6',
  626. countOp: 'ge',
  627. countValue: '',
  628. };
  629. await fetchSummaryData();
  630. };
  631. const handleSelectTarget = async (targetId: string) => {
  632. selectedTarget.value = targetId;
  633. selectedLocation.value = activeTarget.value?.locations[0]?.id || null;
  634. pagination.value.current = 1;
  635. pagination.value.jumpPage = '1';
  636. await fetchDetailTable();
  637. };
  638. const handleSelectLocation = async (locationId: string) => {
  639. selectedLocation.value = locationId;
  640. pagination.value.current = 1;
  641. pagination.value.jumpPage = '1';
  642. await fetchDetailTable();
  643. };
  644. const fetchDetailTable = async () => {
  645. const currentLocation = activeLocation.value;
  646. if (!currentLocation) {
  647. resetDetailTable();
  648. return;
  649. }
  650. const requestSerial = ++detailRequestSerial.value;
  651. detailLoading.value = true;
  652. try {
  653. const baseQuery = buildDetailBaseQuery(currentLocation);
  654. // const timeRanges = buildDetailTimeRanges();
  655. // const queryList = timeRanges.length ? timeRanges : [{ startTime: undefined, endTime: undefined }];
  656. const responses = await fetchAllCallRecords({ ...baseQuery });
  657. if (requestSerial !== detailRequestSerial.value) return;
  658. const mergedMap = new Map<string, CallRecord>();
  659. responses.flat().forEach((item) => {
  660. mergedMap.set(buildRecordKey(item), item);
  661. });
  662. recordList.value = sortRecordsDesc(Array.from(mergedMap.values()));
  663. pagination.value.total = recordList.value.length;
  664. pagination.value.current = 1;
  665. pagination.value.jumpPage = '1';
  666. } catch (error) {
  667. if (requestSerial !== detailRequestSerial.value) return;
  668. resetDetailTable();
  669. createMessage.warning('通话底层接口调用失败');
  670. } finally {
  671. if (requestSerial === detailRequestSerial.value) {
  672. detailLoading.value = false;
  673. }
  674. }
  675. };
  676. const handleDetailPrevPage = () => {
  677. if (detailLoading.value || pagination.value.current <= 1) return;
  678. pagination.value.current -= 1;
  679. pagination.value.jumpPage = String(pagination.value.current);
  680. };
  681. const handleDetailNextPage = () => {
  682. if (detailLoading.value || pagination.value.current >= detailTotalPages.value) return;
  683. pagination.value.current += 1;
  684. pagination.value.jumpPage = String(pagination.value.current);
  685. };
  686. const handleDetailPaginationChange = (page: number, pageSize: number) => {
  687. if (detailLoading.value) return;
  688. if (pageSize !== pagination.value.pageSize) {
  689. pagination.value.pageSize = pageSize;
  690. handleDetailPageSizeChange();
  691. return;
  692. }
  693. if (page === pagination.value.current - 1) {
  694. handleDetailPrevPage();
  695. return;
  696. }
  697. if (page === pagination.value.current + 1) {
  698. handleDetailNextPage();
  699. return;
  700. }
  701. if (page === pagination.value.current) return;
  702. pagination.value.jumpPage = String(page);
  703. handleDetailJumpPage();
  704. };
  705. const handleDetailPageSizeChange = () => {
  706. pagination.value.current = 1;
  707. pagination.value.jumpPage = '1';
  708. };
  709. const handleDetailJumpPage = () => {
  710. const next = Number(pagination.value.jumpPage);
  711. if (!Number.isInteger(next) || next < 1) {
  712. createMessage.warning('请输入正确页码');
  713. pagination.value.jumpPage = String(pagination.value.current);
  714. return;
  715. }
  716. pagination.value.current = Math.min(next, detailTotalPages.value);
  717. pagination.value.jumpPage = String(pagination.value.current);
  718. };
  719. const fetchTargetOptions = async () => {
  720. try {
  721. targetOptions.value = await getPersonOptions(false);
  722. } catch (error) {
  723. targetOptions.value = [];
  724. }
  725. };
  726. onMounted(async () => {
  727. await fetchTargetOptions();
  728. });
  729. </script>
  730. <style lang="less">
  731. @import '../styles/trans-analysis-table-ui.less';
  732. @prefix-cls: ~'jeesite-morning-evening-call';
  733. .@{prefix-cls}.morning-evening-call-page {
  734. .analysis-page-frame();
  735. .filter-input {
  736. width: 180px;
  737. }
  738. .filter-micro {
  739. width: 50px;
  740. }
  741. .filter-op {
  742. width: 84px;
  743. min-width: 84px;
  744. }
  745. .filter-time {
  746. width: 70px;
  747. }
  748. .filter-time--fixed {
  749. :deep(.ant-input[disabled]) {
  750. color: #111827;
  751. background: #fff;
  752. cursor: default;
  753. }
  754. }
  755. .filter-unit {
  756. color: #6b7280;
  757. font-size: 12px;
  758. }
  759. .filter-date {
  760. width: 360px;
  761. }
  762. .target-tags {
  763. display: flex;
  764. gap: 12px;
  765. margin-top: 0;
  766. flex-shrink: 0;
  767. flex-wrap: wrap;
  768. padding: 8px;
  769. border: 1px solid #e2e8f0;
  770. border-radius: 12px;
  771. background: #fff;
  772. box-sizing: border-box;
  773. }
  774. .target-tag {
  775. padding: 8px 16px;
  776. background: #fff;
  777. border: 1px solid #e5e7eb;
  778. border-radius: 999px;
  779. font-size: 13px;
  780. color: #4b5563;
  781. cursor: pointer;
  782. transition: all 0.2s ease;
  783. &:hover {
  784. border-color: #93c5fd;
  785. }
  786. &.active {
  787. background: #eef6ff;
  788. border-color: #93c5fd;
  789. color: #2563eb;
  790. }
  791. }
  792. .content-panel {
  793. display: flex;
  794. gap: 2px;
  795. }
  796. .left-panel {
  797. width: 260px;
  798. flex: 0 0 auto;
  799. .analysis-card-shell();
  800. }
  801. .right-panel {
  802. .analysis-card-shell();
  803. flex: 1;
  804. min-width: 0;
  805. }
  806. .panel-header {
  807. padding: 0 0 8px;
  808. border-bottom: 0;
  809. flex: 0 0 auto;
  810. width: 100%;
  811. box-sizing: border-box;
  812. }
  813. .panel-header--actions {
  814. display: flex;
  815. align-items: flex-start;
  816. gap: 12px;
  817. > div:first-child {
  818. flex: 1 1 auto;
  819. min-width: 0;
  820. }
  821. }
  822. .panel-title {
  823. font-size: 14px;
  824. font-weight: 600;
  825. color: #111827;
  826. line-height: 22px;
  827. }
  828. .panel-desc {
  829. margin-top: 4px;
  830. color: #6b7280;
  831. font-size: 12px;
  832. line-height: 20px;
  833. }
  834. .location-list {
  835. display: flex;
  836. flex-direction: column;
  837. flex: 1 1 auto;
  838. min-height: 0;
  839. overflow: auto;
  840. padding: 0;
  841. gap: 0;
  842. width: 100%;
  843. }
  844. .location-item {
  845. padding: 10px 0;
  846. cursor: pointer;
  847. transition: all 0.2s ease;
  848. border: 0;
  849. border-bottom: 1px solid #eef2f7;
  850. border-radius: 0;
  851. background: transparent;
  852. &:hover {
  853. background: rgba(245, 247, 255, 0.72);
  854. }
  855. &.active {
  856. background: rgba(239, 246, 255, 0.72);
  857. }
  858. .location-name {
  859. font-size: 12px;
  860. color: #374151;
  861. margin-bottom: 4px;
  862. }
  863. .location-lac,
  864. .location-cid {
  865. font-size: 11px;
  866. color: #6b7280;
  867. }
  868. .location-count {
  869. font-size: 11px;
  870. color: #6b7280;
  871. margin-top: 6px;
  872. }
  873. }
  874. .panel-empty {
  875. display: flex;
  876. align-items: center;
  877. justify-content: center;
  878. flex: 1 1 auto;
  879. min-height: 0;
  880. padding: 16px;
  881. color: #6b7280;
  882. font-size: 12px;
  883. }
  884. .panel-empty--state {
  885. padding: 20px 16px;
  886. }
  887. .panel-empty--record {
  888. border-top: 0;
  889. }
  890. .table-wrapper {
  891. flex: 1 1 auto;
  892. min-height: 0;
  893. margin: 0;
  894. border: 0;
  895. border-radius: 0;
  896. overflow: hidden;
  897. width: 100%;
  898. }
  899. .table-wrapper .jeesite-basic-table {
  900. height: 100%;
  901. display: flex;
  902. flex-direction: column;
  903. }
  904. .table-wrapper .ant-table-wrapper {
  905. height: 100%;
  906. min-height: 0;
  907. }
  908. .table-wrapper .ant-spin-nested-loading,
  909. .table-wrapper .ant-spin-container {
  910. height: 100%;
  911. min-height: 0;
  912. }
  913. .table-wrapper .ant-spin-container,
  914. .table-wrapper .ant-table,
  915. .table-wrapper .ant-table-container {
  916. height: 100%;
  917. min-height: 0;
  918. }
  919. .table-wrapper .ant-spin-container,
  920. .table-wrapper .ant-table,
  921. .table-wrapper .ant-table-container {
  922. display: flex;
  923. flex-direction: column;
  924. }
  925. .table-wrapper .ant-table-body {
  926. max-height: 100% !important;
  927. }
  928. .table-wrapper .ant-table-body::-webkit-scrollbar {
  929. width: 8px;
  930. height: 8px;
  931. }
  932. .table-wrapper .ant-table-body::-webkit-scrollbar-thumb {
  933. background: rgba(148, 163, 184, 0.6);
  934. border-radius: 999px;
  935. }
  936. .table-wrapper .ant-table-body::-webkit-scrollbar-track {
  937. background: transparent;
  938. }
  939. .table-wrapper .ant-table-cell {
  940. font-size: 12px;
  941. }
  942. .pagination-bar {
  943. display: flex;
  944. align-items: center;
  945. justify-content: flex-end;
  946. gap: 8px;
  947. color: #6b7280;
  948. font-size: 12px;
  949. flex: 0 0 auto;
  950. flex-wrap: wrap;
  951. padding: 0;
  952. background: transparent;
  953. }
  954. .pagination-total {
  955. margin-right: 0;
  956. white-space: nowrap;
  957. }
  958. .pagination-size {
  959. display: flex;
  960. align-items: center;
  961. gap: 6px;
  962. white-space: nowrap;
  963. }
  964. .pagination-actions {
  965. display: flex;
  966. gap: 6px;
  967. align-items: center;
  968. white-space: nowrap;
  969. }
  970. .pagination-jump {
  971. display: flex;
  972. align-items: center;
  973. gap: 6px;
  974. white-space: nowrap;
  975. }
  976. .pagination-input {
  977. width: 48px;
  978. }
  979. :deep(.ant-select-selector),
  980. :deep(.ant-input),
  981. :deep(.ant-picker),
  982. :deep(.ant-btn) {
  983. border-radius: 8px;
  984. }
  985. @media screen and (max-width: 900px) {
  986. .content-panel {
  987. flex-direction: column;
  988. }
  989. .left-panel {
  990. width: 100%;
  991. }
  992. }
  993. @media screen and (max-width: 992px) {
  994. .filter-actions {
  995. width: 100%;
  996. margin-left: 0;
  997. }
  998. .filter-input,
  999. .filter-date {
  1000. width: 100%;
  1001. }
  1002. }
  1003. }
  1004. </style>