index.vue 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  1. <template>
  2. <div ref="nightPageRef" :class="prefixCls" class="night-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 filter-item--target">
  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 filter-item--month">
  21. <span class="filter-label">联系月数:</span>
  22. <Input v-model:value="filters.monthValue" class="filter-input" :status="monthValueStatus" />
  23. </div>
  24. <div class="filter-item filter-item--date">
  25. <span class="filter-label">联系日期:</span>
  26. <RangePicker
  27. v-model:value="filters.dateRange"
  28. class="filter-input filter-date"
  29. :format="DATE_FORMAT"
  30. :value-format="DATE_FORMAT"
  31. :placeholder="['开始日期', '结束日期']"
  32. />
  33. </div>
  34. <div class="filter-item filter-item--night">
  35. <span class="filter-label">夜间时间段:</span>
  36. <Select v-model:value="filters.nightStart" class="filter-op" :options="nightHourOptions" />
  37. <span class="filter-label">至</span>
  38. <Select v-model:value="filters.nightEnd" class="filter-op" :options="nightHourOptions" />
  39. </div>
  40. <div class="filter-item">
  41. <span class="filter-label">对方姓名:</span>
  42. <Input v-model:value="filters.otherName" class="filter-input" placeholder="请输入对方姓名" />
  43. </div>
  44. <div class="filter-item">
  45. <span class="filter-label">对方号码:</span>
  46. <Input v-model:value="filters.otherPhone" class="filter-input" placeholder="请输入对方号码" />
  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 class="content-panel">
  54. <div class="summary-panel">
  55. <div class="panel-header">
  56. <div class="panel-title-wrap">
  57. <div class="panel-title">夜间联系对象汇总</div>
  58. <div class="panel-desc">汇总满足当前筛选条件的对象的夜间联系情况,可点击单条下方显示趋势。</div>
  59. </div>
  60. </div>
  61. <div v-if="!hasSearched" class="query-state-panel">
  62. <EmptyState
  63. variant="query"
  64. title="请点击查询"
  65. description="设置条件精细查询,也可点击查询展示所有信息"
  66. min-height="100%"
  67. description-width="min(100%, 420px)"
  68. />
  69. </div>
  70. <div v-else class="table-panel">
  71. <div class="table-wrapper">
  72. <BasicTable
  73. class="night-summary-table"
  74. :columns="columns"
  75. :data-source="tableData"
  76. :pagination="false"
  77. :show-table-setting="false"
  78. :can-resize="true"
  79. :is-can-resize-parent="true"
  80. :resize-height-offset="32"
  81. :rowClassName="rowClassName"
  82. row-key="id"
  83. @register="handleNightTableRegister"
  84. @row-click="handleRowClick"
  85. />
  86. </div>
  87. </div>
  88. <div v-if="hasSearched" class="pagination-bar">
  89. <AnalysisPagination
  90. :total="total"
  91. :current="pagination.current"
  92. :page-size="pagination.pageSize"
  93. :page-size-options="pageSizeOptions"
  94. :loading="queryLoading"
  95. @change="handlePaginationChange"
  96. />
  97. </div>
  98. </div>
  99. <div class="trend-panel">
  100. <div class="panel-header">
  101. <div class="panel-title-wrap">
  102. <div class="panel-title">时段联系趋势</div>
  103. <div class="panel-desc">上方选中对象时段趋势,可点击图表查看详情。</div>
  104. </div>
  105. </div>
  106. <div class="trend-panel-body">
  107. <div class="trend-chart-shell">
  108. <div v-show="!trendVisible" class="trend-empty">
  109. <EmptyState
  110. variant="compact"
  111. title="暂无趋势数据"
  112. :description="trendEmptyDescription"
  113. :min-height="220"
  114. />
  115. </div>
  116. <div v-show="trendVisible" ref="chartRef" class="trend-chart"></div>
  117. </div>
  118. </div>
  119. </div>
  120. </div>
  121. <CallRecordModal v-model:open="callRecordModalOpen" title="通信记录" :base-query="callRecordBaseQuery" />
  122. </div>
  123. </template>
  124. <script lang="ts" setup name="ViewsNightIndex">
  125. import { computed, nextTick, onMounted, onUnmounted, ref, Ref, reactive } from 'vue';
  126. import { useRoute } from 'vue-router';
  127. import { Select, Input, DatePicker, Button } from 'ant-design-vue';
  128. import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  129. import { BasicTable } from '@jeesite/core/components/Table';
  130. import { createTableContext } from '@jeesite/core/components/Table/src/hooks/useTableContext';
  131. import { EmptyState } from '@jeesite/core/components/EmptyState';
  132. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  133. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  134. import { useECharts } from '@jeesite/core/hooks/web/useECharts';
  135. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  136. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  137. import { pickDetailValue } from '@jeesite/core/utils/detailQuery';
  138. import componentSetting from '@jeesite/core/settings/componentSetting';
  139. import { statFirstCallNight, statSecondCallNight } from '@jeesite/call/api/callApi';
  140. import { getPersonOptions } from '@jeesite/person/api/personApi';
  141. import CallRecordModal from '@jeesite/call/views/components/CallRecordModal.vue';
  142. import * as echarts from 'echarts';
  143. import type { EChartsOption } from 'echarts';
  144. import type { CallNightDTO } from '@jeesite/call/api/types/dto';
  145. import type { CallNightQuery, CallRecordQuery } from '@jeesite/call/api/types/query';
  146. const { prefixCls } = useDesign('night');
  147. const { queryLoading, runWithQueryLoading } = useTableSkill();
  148. const route = useRoute();
  149. const currentDetailRow = ref<any | null>(null);
  150. const selectedRow = ref<any | null>(null);
  151. const { createMessage } = useMessage();
  152. const callRecordModalOpen = ref(false);
  153. const callRecordBaseQuery = reactive<Partial<CallRecordQuery>>({});
  154. const RangePicker = DatePicker.RangePicker;
  155. const DATE_FORMAT = 'YYYY-MM-DD';
  156. const nightPageRef = ref<HTMLElement | null>(null);
  157. const nightTableAction = ref<any>(null);
  158. type RangeValue = [string, string] | undefined;
  159. const createDefaultFilters = () => ({
  160. target: [] as string[],
  161. monthValue: '2',
  162. dateRange: undefined as RangeValue,
  163. nightStart: '18',
  164. nightEnd: '06',
  165. otherName: '',
  166. otherPhone: '',
  167. });
  168. const filters = ref(createDefaultFilters());
  169. const pagination = ref({
  170. current: 1,
  171. pageSize: componentSetting.table.defaultPageSize,
  172. });
  173. const jumpPage = ref('1');
  174. const total = ref(0);
  175. const tableData = ref<any[]>([]);
  176. const hasSearched = ref(false);
  177. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  178. refresh: () => fetchTable(),
  179. hasData: () => hasSearched.value && tableData.value.length > 0,
  180. });
  181. const trendVisible = ref(false);
  182. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  183. const monthValuePattern = /^[1-9]\d*$/;
  184. const DEFAULT_SORT_KEY = 'callCount';
  185. const DEFAULT_SORT_ORDER = 'DESC' as const;
  186. const sortState = ref({
  187. orderKey: DEFAULT_SORT_KEY,
  188. sort: DEFAULT_SORT_ORDER,
  189. });
  190. const nightHourOptions = [
  191. { label: '18点', value: '18' },
  192. { label: '20点', value: '20' },
  193. { label: '22点', value: '22' },
  194. { label: '00点', value: '00' },
  195. { label: '02点', value: '02' },
  196. { label: '04点', value: '04' },
  197. { label: '06点', value: '06' },
  198. ];
  199. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  200. label: value,
  201. value,
  202. }));
  203. const openDetailModal = async (record: any, showMessage = true) => {
  204. currentDetailRow.value = record;
  205. selectedRow.value = record;
  206. await fetchSecondLevelTrend(record, showMessage);
  207. };
  208. const columns = computed(() => [
  209. { title: '本方姓名', dataIndex: 'selfName', key: 'selfName', width: 140 },
  210. { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 140 },
  211. { title: '对方号码', dataIndex: 'otherPhone', key: 'otherPhone', width: 220 },
  212. {
  213. title: '夜间联系次数',
  214. dataIndex: 'count',
  215. key: 'callCount',
  216. width: 160,
  217. },
  218. {
  219. title: '涉及月份',
  220. dataIndex: 'duration',
  221. key: 'totalMonthCount',
  222. width: 140,
  223. },
  224. ]);
  225. const handleNightTableRegister = (action: any) => {
  226. nightTableAction.value = action;
  227. };
  228. const getNightTableColumns = (...args: any[]) => nightTableAction.value?.getColumns?.(...args) || [];
  229. createTableContext({
  230. wrapRef: nightPageRef,
  231. getBindValues: computed(() => ({
  232. columns: columns.value,
  233. showIndexColumn: true,
  234. rowSelection: undefined,
  235. isTreeTable: false,
  236. })),
  237. getDefaultRowSelection: () => nightTableAction.value?.getDefaultRowSelection?.(),
  238. getColumns: getNightTableColumns,
  239. getCacheColumns: () => nightTableAction.value?.getCacheColumns?.() || [],
  240. setColumns: (nextColumns: any) => nightTableAction.value?.setColumns?.(nextColumns),
  241. setProps: (props: any) => nightTableAction.value?.setProps?.(props),
  242. } as any);
  243. const totalPages = computed(() => {
  244. if (!total.value) return 1;
  245. return Math.max(1, Math.ceil(total.value / pagination.value.pageSize));
  246. });
  247. const normalizedMonthValue = computed(() => String(filters.value.monthValue ?? '').trim());
  248. const monthValueStatus = computed<'error' | undefined>(() => {
  249. return monthValuePattern.test(normalizedMonthValue.value) ? undefined : 'error';
  250. });
  251. const trendEmptyDescription = computed(() => {
  252. if (!tableData.value.length) {
  253. return '当前筛选条件下暂无夜间联系记录,请调整筛选条件后重试';
  254. }
  255. if (selectedRow.value) {
  256. return '当前选中对象暂无第二层时段趋势,请切换其他记录查看';
  257. }
  258. return '请直接选择左侧列表中的一行记录';
  259. });
  260. const normalizePhoneList = (value?: string | string[] | null) => {
  261. if (Array.isArray(value)) return value.filter(Boolean);
  262. if (!value) return undefined;
  263. const list = String(value)
  264. .split(',')
  265. .map((item) => item.trim())
  266. .filter(Boolean);
  267. return list.length ? list : undefined;
  268. };
  269. const normalizeIdList = (value?: string | string[] | null) => {
  270. if (Array.isArray(value)) {
  271. const list = value.map((item) => String(item).trim()).filter(Boolean);
  272. return list.length ? Array.from(new Set(list)) : undefined;
  273. }
  274. if (!value) return undefined;
  275. const list = String(value)
  276. .split(',')
  277. .map((item) => item.trim())
  278. .filter(Boolean);
  279. return list.length ? Array.from(new Set(list)) : undefined;
  280. };
  281. const normalizeSelectedTargets = (value?: string[] | string | null) => {
  282. if (Array.isArray(value)) {
  283. const list = value.map((item) => String(item).trim()).filter(Boolean);
  284. return list.length ? Array.from(new Set(list)) : [];
  285. }
  286. if (!value) return [];
  287. const list = String(value)
  288. .split(',')
  289. .map((item) => item.trim())
  290. .filter(Boolean);
  291. return list.length ? Array.from(new Set(list)) : [];
  292. };
  293. const normalizeList = (resp: any): CallNightDTO[] => {
  294. if (Array.isArray(resp)) return resp;
  295. if (Array.isArray(resp?.list)) return resp.list;
  296. if (Array.isArray(resp?.data)) return resp.data;
  297. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  298. if (Array.isArray(resp?.records)) return resp.records;
  299. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  300. return [];
  301. };
  302. const normalizeTotal = (resp: any, listLen: number) => {
  303. const t = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? resp?.data?.records?.length;
  304. return Number.isFinite(Number(t)) ? Number(t) : listLen;
  305. };
  306. const getOtherPhoneText = (item?: Pick<CallNightDTO, 'otherPhone' | 'otherPhones'> | null) => {
  307. return item?.otherPhone || normalizePhoneList(item?.otherPhones)?.join(', ') || '';
  308. };
  309. const mapRow = (item: CallNightDTO, index: number) => ({
  310. id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
  311. selfName: item.personName || '-',
  312. otherName: item.otherName || '-',
  313. otherPhone: getOtherPhoneText(item) || '-',
  314. count: String(item.callCount ?? 0),
  315. duration: `${item.totalMonthCount ?? 0}个月`,
  316. countValue: Number(item.callCount ?? 0) || 0,
  317. durationValue: Number(item.totalMonthCount ?? 0) || 0,
  318. raw: item,
  319. });
  320. const sortNightRows = (list: CallNightDTO[]) => {
  321. const { orderKey, sort } = sortState.value;
  322. const direction = sort === 'ASC' ? 1 : -1;
  323. return [...list].sort((left, right) => {
  324. const leftCount = Number(left.callCount ?? 0) || 0;
  325. const rightCount = Number(right.callCount ?? 0) || 0;
  326. const leftMonthCount = Number(left.totalMonthCount ?? 0) || 0;
  327. const rightMonthCount = Number(right.totalMonthCount ?? 0) || 0;
  328. if (orderKey === 'totalMonthCount' && leftMonthCount !== rightMonthCount) {
  329. return (leftMonthCount - rightMonthCount) * direction;
  330. }
  331. if (leftCount !== rightCount) {
  332. return (leftCount - rightCount) * direction;
  333. }
  334. if (leftMonthCount !== rightMonthCount) {
  335. return (leftMonthCount - rightMonthCount) * direction;
  336. }
  337. return String(left.otherName || left.otherPhone || '').localeCompare(
  338. String(right.otherName || right.otherPhone || ''),
  339. );
  340. });
  341. };
  342. const resolveFilterMonthVal = (showMessage = false) => {
  343. if (monthValuePattern.test(normalizedMonthValue.value)) {
  344. return Number(normalizedMonthValue.value);
  345. }
  346. if (showMessage) {
  347. createMessage.warning('联系月数请输入大于 0 的整数');
  348. }
  349. return null;
  350. };
  351. const buildQuery = (monthVal: number): CallNightQuery => {
  352. const range = filters.value.dateRange as RangeValue;
  353. const personNames = normalizeSelectedTargets(filters.value.target);
  354. const otherName = String(filters.value.otherName || '').trim();
  355. const otherPhone = String(filters.value.otherPhone || '').trim();
  356. return {
  357. page: pagination.value.current,
  358. limit: pagination.value.pageSize,
  359. startDate: range?.[0] || undefined,
  360. endDate: range?.[1] || undefined,
  361. monthVal,
  362. nightStart: Number(filters.value.nightStart),
  363. nightEnd: Number(filters.value.nightEnd),
  364. personNames: personNames.length ? personNames : undefined,
  365. otherName: otherName || undefined,
  366. otherPhone: otherPhone || undefined,
  367. orderKey: sortState.value.orderKey,
  368. sort: sortState.value.sort,
  369. };
  370. };
  371. const fetchTable = async () => {
  372. const monthVal = resolveFilterMonthVal(true);
  373. if (monthVal === null) return;
  374. await runWithQueryLoading(async () => {
  375. const resp = await statFirstCallNight(buildQuery(monthVal));
  376. const list = sortNightRows(normalizeList(resp));
  377. tableData.value = list.map(mapRow);
  378. total.value = normalizeTotal(resp, list.length);
  379. hasSearched.value = true;
  380. syncAnalysisDataRefreshState(list.length > 0);
  381. const current = Number((resp as any)?.data?.current || pagination.value.current);
  382. const size = Number((resp as any)?.data?.size || pagination.value.pageSize);
  383. if (Number.isFinite(current) && current > 0) pagination.value.current = current;
  384. if (Number.isFinite(size) && size > 0) pagination.value.pageSize = size;
  385. jumpPage.value = String(pagination.value.current);
  386. if (tableData.value.length) {
  387. await openDetailModal(tableData.value[0], false);
  388. } else {
  389. trendVisible.value = false;
  390. selectedRow.value = null;
  391. currentDetailRow.value = null;
  392. secondLevelTrendList.value = [];
  393. }
  394. });
  395. };
  396. const handleRowClick = async (record: any) => {
  397. await openDetailModal(record, false);
  398. };
  399. const rowClassName = (record: any) => {
  400. return selectedRow.value && selectedRow.value.id === record.id ? 'summary-row-selected' : '';
  401. };
  402. const handleSearch = async () => {
  403. pagination.value.current = 1;
  404. await fetchTable();
  405. };
  406. const handleReset = async () => {
  407. filters.value = createDefaultFilters();
  408. sortState.value = {
  409. orderKey: DEFAULT_SORT_KEY,
  410. sort: DEFAULT_SORT_ORDER,
  411. };
  412. pagination.value.current = 1;
  413. await fetchTable();
  414. };
  415. const handlePrevPage = async () => {
  416. if (queryLoading.value || pagination.value.current <= 1) return;
  417. pagination.value.current -= 1;
  418. await fetchTable();
  419. };
  420. const handleNextPage = async () => {
  421. if (queryLoading.value || pagination.value.current >= totalPages.value) return;
  422. pagination.value.current += 1;
  423. await fetchTable();
  424. };
  425. const handlePaginationChange = async (page: number, pageSize: number) => {
  426. if (queryLoading.value) return;
  427. if (pageSize !== pagination.value.pageSize) {
  428. pagination.value.pageSize = pageSize;
  429. await handlePageSizeChange();
  430. return;
  431. }
  432. if (page === pagination.value.current - 1) {
  433. await handlePrevPage();
  434. return;
  435. }
  436. if (page === pagination.value.current + 1) {
  437. await handleNextPage();
  438. return;
  439. }
  440. if (page === pagination.value.current) return;
  441. jumpPage.value = String(page);
  442. await handleJumpPage();
  443. };
  444. const handlePageSizeChange = async () => {
  445. pagination.value.current = 1;
  446. await fetchTable();
  447. };
  448. const handleJumpPage = async () => {
  449. const next = Number(jumpPage.value);
  450. if (!Number.isInteger(next) || next < 1) {
  451. createMessage.warning('请输入正确页码');
  452. jumpPage.value = String(pagination.value.current);
  453. return;
  454. }
  455. pagination.value.current = Math.min(next, totalPages.value);
  456. await fetchTable();
  457. };
  458. const fetchSecondLevelTrend = async (record: any, showMessage = true) => {
  459. const monthVal = resolveFilterMonthVal(showMessage);
  460. if (monthVal === null) return;
  461. try {
  462. const range = filters.value.dateRange as RangeValue;
  463. const selectedTargets = normalizeSelectedTargets(filters.value.target);
  464. const personName = record?.raw?.personName || selectedTargets[0] || undefined;
  465. const otherPhone = pickDetailValue(getOtherPhoneText(record?.raw), String(filters.value.otherPhone || '').trim());
  466. const otherName = pickDetailValue(record?.raw?.otherName, String(filters.value.otherName || '').trim());
  467. const query: CallNightQuery = {
  468. page: 1,
  469. limit: 200,
  470. startDate: range?.[0] || undefined,
  471. endDate: range?.[1] || undefined,
  472. monthVal,
  473. nightStart: Number(filters.value.nightStart),
  474. nightEnd: Number(filters.value.nightEnd),
  475. personName,
  476. personNames: personName ? [personName] : undefined,
  477. otherName,
  478. otherPhone,
  479. };
  480. const resp = await statSecondCallNight(query);
  481. const list = normalizeList(resp);
  482. if (!list.length) {
  483. trendVisible.value = false;
  484. secondLevelTrendList.value = [];
  485. if (showMessage) {
  486. createMessage.info('当前选中对象暂无第二层时段趋势');
  487. }
  488. return;
  489. }
  490. const wasTrendHidden = !trendVisible.value;
  491. trendVisible.value = true;
  492. if (wasTrendHidden) {
  493. await nextTick();
  494. }
  495. renderTrendChart(list, true);
  496. if (showMessage) {
  497. createMessage.success('已加载第二层时段联系趋势');
  498. }
  499. } catch (error) {
  500. trendVisible.value = false;
  501. secondLevelTrendList.value = [];
  502. createMessage.warning('第二层夜间通话接口调用失败');
  503. }
  504. };
  505. const fetchTargetOptions = async () => {
  506. try {
  507. targetOptions.value = await getPersonOptions(false);
  508. } catch (error) {
  509. targetOptions.value = [];
  510. }
  511. };
  512. const applyRouteQuery = (targetOptions: Array<{ label: string; value: string }>) => {
  513. filters.value = createDefaultFilters();
  514. const personNames = normalizeSelectedTargets(
  515. (route.query.personNames as string | string[] | null | undefined) ??
  516. (route.query.personName as string | string[] | null | undefined),
  517. );
  518. const otherName = String(route.query.otherName || '').trim();
  519. const otherPhone = String(route.query.otherPhone || '').trim();
  520. filters.value.target = personNames.map((personName) => {
  521. const matched = targetOptions.find((item) => String(item.label).trim() === personName);
  522. return matched ? matched.value : personName;
  523. });
  524. if (otherName) {
  525. filters.value.otherName = otherName;
  526. }
  527. if (otherPhone) {
  528. filters.value.otherPhone = otherPhone;
  529. }
  530. return !!(filters.value.target.length || otherName || otherPhone);
  531. };
  532. const chartRef = ref<HTMLDivElement | null>(null);
  533. const { setOptions, getInstance } = useECharts(chartRef as Ref<HTMLDivElement>);
  534. const secondLevelTrendList = ref<CallNightDTO[]>([]);
  535. const NIGHT_TREND_TOOLTIP_HANDLER = '_handleNightTrendDetail';
  536. const normalizeTimeValue = (value?: string | number | null) => {
  537. if (value === null || value === undefined || value === '') return undefined;
  538. if (typeof value === 'number') {
  539. const hours = Math.max(0, Math.min(23, value));
  540. return `${String(hours).padStart(2, '0')}:00:00`;
  541. }
  542. const normalized = String(value).trim();
  543. if (!normalized) return undefined;
  544. const timeMatch = normalized.match(/^(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?$/);
  545. if (!timeMatch) return undefined;
  546. const hours = Number(timeMatch[1]);
  547. if (!Number.isFinite(hours)) return undefined;
  548. const minutes = timeMatch[2] || '00';
  549. const seconds = timeMatch[3] || '00';
  550. return `${String(hours).padStart(2, '0')}:${minutes}:${seconds}`;
  551. };
  552. const parseTimeRangeText = (value?: string) => {
  553. const normalized = String(value || '').trim();
  554. if (!normalized) return {};
  555. const matches = normalized.match(/(\d{1,2})(?::\d{2})?\s*[-~至]\s*(\d{1,2})(?::\d{2})?/);
  556. if (!matches) return {};
  557. const startTime = normalizeTimeValue(matches[1]);
  558. const endTime = normalizeTimeValue(matches[2]);
  559. return {
  560. startTime,
  561. endTime,
  562. };
  563. };
  564. const resolveNodeTimeRange = (node: CallNightDTO, axisLabel?: string) => {
  565. const startTime = normalizeTimeValue(node.startHour);
  566. const endTime = normalizeTimeValue(node.endHour);
  567. if (startTime && endTime) {
  568. return { startTime, endTime };
  569. }
  570. const parsedFromNode = parseTimeRangeText(node.time);
  571. if (parsedFromNode.startTime && parsedFromNode.endTime) {
  572. return parsedFromNode;
  573. }
  574. const parsedFromAxis = parseTimeRangeText(axisLabel);
  575. if (parsedFromAxis.startTime && parsedFromAxis.endTime) {
  576. return parsedFromAxis;
  577. }
  578. return {
  579. startTime: normalizeTimeValue(filters.value.nightStart),
  580. endTime: normalizeTimeValue(filters.value.nightEnd),
  581. };
  582. };
  583. const formatTrendAxisLabel = (node: CallNightDTO) => {
  584. if (node.time) return node.time;
  585. const startHour = String(node.startHour ?? Number(filters.value.nightStart)).padStart(2, '0');
  586. const endHour = String(node.endHour ?? Number(filters.value.nightEnd)).padStart(2, '0');
  587. return `${startHour}~${endHour}`;
  588. };
  589. const updateCallRecordBaseQueryByNode = (node: CallNightDTO, axisLabel?: string) => {
  590. const monthVal = resolveFilterMonthVal(true);
  591. if (monthVal === null) return false;
  592. const range = filters.value.dateRange as RangeValue;
  593. const { startTime, endTime } = resolveNodeTimeRange(node, axisLabel || formatTrendAxisLabel(node));
  594. callRecordBaseQuery.personName = pickDetailValue(
  595. currentDetailRow.value?.raw?.personName,
  596. normalizeSelectedTargets(filters.value.target)[0],
  597. );
  598. callRecordBaseQuery.otherName = pickDetailValue(node.otherName, currentDetailRow.value?.raw?.otherName);
  599. callRecordBaseQuery.otherPhones = normalizePhoneList(
  600. getOtherPhoneText(node) || getOtherPhoneText(currentDetailRow.value?.raw),
  601. );
  602. // callRecordBaseQuery.ids = normalizeIdList(node.ids);
  603. callRecordBaseQuery.startDate = range?.[0] || undefined;
  604. callRecordBaseQuery.endDate = range?.[1] || undefined;
  605. callRecordBaseQuery.monthVal = Number.isFinite(monthVal) ? monthVal : undefined;
  606. callRecordBaseQuery.startTime = startTime;
  607. callRecordBaseQuery.endTime = endTime;
  608. return true;
  609. };
  610. const escapeTooltipHtml = (value?: string | number | null) => {
  611. return String(value ?? '')
  612. .replace(/&/g, '&amp;')
  613. .replace(/</g, '&lt;')
  614. .replace(/>/g, '&gt;')
  615. .replace(/"/g, '&quot;')
  616. .replace(/'/g, '&#39;');
  617. };
  618. const formatTooltipCount = (value?: string | number | null) => {
  619. const numericValue = Number(value || 0);
  620. return numericValue.toLocaleString('zh-CN');
  621. };
  622. const buildTrendTooltipCard = (params: any[]) => {
  623. const current = params?.[0];
  624. const dataIndex = Number(current?.dataIndex ?? -1);
  625. const node = secondLevelTrendList.value[dataIndex];
  626. if (!node) return '';
  627. const axisLabel = escapeTooltipHtml(
  628. String(current?.axisValueLabel || current?.axisValue || formatTrendAxisLabel(node)),
  629. );
  630. const otherName = pickDetailValue(node.otherName, currentDetailRow.value?.raw?.otherName);
  631. const safeOtherName = escapeTooltipHtml(otherName || '-');
  632. const countValue = formatTooltipCount(node.callCount ?? current?.value ?? 0);
  633. return [
  634. '<div style="padding:14px 16px; min-width:208px; background:linear-gradient(180deg, rgba(19,33,62,0.98) 0%, rgba(15,23,42,0.98) 100%); border:1px solid rgba(148,163,184,0.22); border-radius:14px; box-shadow:0 16px 34px rgba(2,6,23,0.32);">',
  635. '<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px;">',
  636. `<span style="font-size:14px; line-height:20px; color:#f8fafc; font-weight:600;">${axisLabel}</span>`,
  637. `<span style="font-size:12px; line-height:18px; color:#8ac5ff; cursor:pointer;" onclick='window.${NIGHT_TREND_TOOLTIP_HANDLER}(${dataIndex})'>查看详情</span>`,
  638. '</div>',
  639. '<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">',
  640. '<span style="display:flex; align-items:center; gap:8px; font-size:13px; line-height:20px; color:#cbd5e1;">',
  641. '<span style="width:10px; height:10px; border-radius:50%; background:#62c4ff; box-shadow:0 0 0 4px rgba(255,255,255,0.06); display:inline-block;"></span>',
  642. '联系次数',
  643. '</span>',
  644. `<span style="font-size:13px; line-height:20px; color:#eff6ff; font-weight:600;">${countValue} 次</span>`,
  645. '</div>',
  646. '<div style="height:8px;"></div>',
  647. '<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">',
  648. '<span style="font-size:13px; line-height:20px; color:#94a3b8;">联系对象</span>',
  649. `<span style="font-size:13px; line-height:20px; color:#eff6ff; font-weight:600; max-width:120px; text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${safeOtherName}</span>`,
  650. '</div>',
  651. '</div>',
  652. ].join('');
  653. };
  654. const getTrendTooltipPosition = (
  655. point: number[],
  656. _params: unknown,
  657. dom: HTMLElement,
  658. _rect: unknown,
  659. size: { viewSize: number[] },
  660. ) => {
  661. const [pointX, pointY] = point;
  662. const [viewWidth, viewHeight] = size.viewSize;
  663. const cardWidth = dom.offsetWidth || 208;
  664. const cardHeight = dom.offsetHeight || 116;
  665. const gap = 10;
  666. const left = Math.min(Math.max(pointX - cardWidth / 2, gap), Math.max(gap, viewWidth - cardWidth - gap));
  667. const top = Math.min(Math.max(pointY - cardHeight - 18, gap), Math.max(gap, viewHeight - cardHeight - gap));
  668. return [left, top];
  669. };
  670. const handleNightTrendDetail = (dataIndex: number) => {
  671. const node = secondLevelTrendList.value[dataIndex];
  672. if (!node) return;
  673. if (!updateCallRecordBaseQueryByNode(node)) return;
  674. callRecordModalOpen.value = true;
  675. };
  676. const bindChartClick = () => {
  677. const chart = getInstance();
  678. if (!chart) return;
  679. chart.off('click');
  680. chart.on('click', (params: any) => {
  681. const dataIndex = Number(params?.dataIndex ?? -1);
  682. const node = secondLevelTrendList.value[dataIndex];
  683. if (!node) return;
  684. if (!updateCallRecordBaseQueryByNode(node, params?.name)) return;
  685. callRecordModalOpen.value = true;
  686. });
  687. };
  688. const renderTrendChart = (list: CallNightDTO[], isSecondLevel = false) => {
  689. const source = isSecondLevel ? list : [];
  690. secondLevelTrendList.value = source;
  691. const labels = source.length ? source.map((item) => formatTrendAxisLabel(item)) : ['22~06'];
  692. const values = source.length ? source.map((item) => Number(item.callCount || 0)) : [0];
  693. const options: EChartsOption = {
  694. tooltip: {
  695. trigger: 'axis',
  696. enterable: true,
  697. confine: true,
  698. transitionDuration: 0,
  699. hideDelay: 180,
  700. renderMode: 'html',
  701. backgroundColor: 'transparent',
  702. borderWidth: 0,
  703. padding: 0,
  704. axisPointer: {
  705. type: 'line',
  706. lineStyle: {
  707. color: 'rgba(63, 93, 230, 0.22)',
  708. width: 2,
  709. },
  710. },
  711. position: getTrendTooltipPosition,
  712. extraCssText: 'box-shadow:none;background:transparent;',
  713. formatter: (params: any) => buildTrendTooltipCard(Array.isArray(params) ? params : [params]),
  714. },
  715. xAxis: {
  716. type: 'category',
  717. boundaryGap: false,
  718. data: labels,
  719. axisTick: { show: false },
  720. axisLine: { lineStyle: { color: '#d7dee8' } },
  721. axisLabel: {
  722. color: '#6b7280',
  723. fontSize: 12,
  724. margin: 10,
  725. },
  726. },
  727. yAxis: {
  728. type: 'value',
  729. axisTick: { show: false },
  730. axisLine: { show: false },
  731. axisLabel: {
  732. color: '#6b7280',
  733. fontSize: 12,
  734. },
  735. splitNumber: 4,
  736. splitLine: {
  737. lineStyle: {
  738. color: 'rgba(217, 225, 238, 0.92)',
  739. type: 'dashed',
  740. },
  741. },
  742. },
  743. grid: { left: '3%', right: '3%', top: 24, bottom: 8, outerBoundsMode: 'same', outerBoundsContain: 'axisLabel' },
  744. series: [
  745. {
  746. data: values,
  747. type: 'line',
  748. smooth: true,
  749. showSymbol: true,
  750. symbol: 'circle',
  751. symbolSize: 4,
  752. lineStyle: { color: '#1677ff', width: 3 },
  753. itemStyle: {
  754. color: '#1677ff',
  755. borderColor: '#ffffff',
  756. borderWidth: 2,
  757. shadowBlur: 4,
  758. shadowColor: 'rgba(22, 119, 255, 0.16)',
  759. },
  760. areaStyle: {
  761. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  762. { offset: 0, color: 'rgba(22, 119, 255, 0.18)' },
  763. { offset: 1, color: 'rgba(22, 119, 255, 0.02)' },
  764. ]),
  765. },
  766. emphasis: {
  767. focus: 'series',
  768. scale: true,
  769. itemStyle: {
  770. color: '#003eb3',
  771. borderColor: '#ffffff',
  772. borderWidth: 5,
  773. shadowBlur: 26,
  774. shadowColor: 'rgba(0, 62, 179, 0.58)',
  775. },
  776. lineStyle: {
  777. width: 4,
  778. },
  779. label: {
  780. show: true,
  781. position: 'top',
  782. color: '#003eb3',
  783. fontSize: 12,
  784. fontWeight: 700,
  785. formatter: '{c}',
  786. },
  787. scaleSize: 16,
  788. },
  789. },
  790. ],
  791. };
  792. setOptions(options);
  793. if (isSecondLevel) {
  794. setTimeout(() => bindChartClick(), 80);
  795. }
  796. };
  797. onMounted(async () => {
  798. (window as any)[NIGHT_TREND_TOOLTIP_HANDLER] = handleNightTrendDetail;
  799. await fetchTargetOptions();
  800. if (applyRouteQuery(targetOptions.value)) {
  801. await handleSearch();
  802. }
  803. });
  804. onUnmounted(() => {
  805. const chart = getInstance();
  806. chart?.off('click');
  807. if ((window as any)[NIGHT_TREND_TOOLTIP_HANDLER]) {
  808. delete (window as any)[NIGHT_TREND_TOOLTIP_HANDLER];
  809. }
  810. });
  811. </script>
  812. <style lang="less">
  813. @import '../../../trans/views/styles/trans-analysis-table-ui.less';
  814. @prefix-cls: ~'jeesite-night';
  815. .@{prefix-cls}.night-page {
  816. .trans-analysis-table-ui();
  817. background: #fff;
  818. padding: 0 8px 2px;
  819. min-height: 100%;
  820. height: 100%;
  821. display: flex;
  822. flex-direction: column;
  823. overflow: hidden;
  824. box-sizing: border-box;
  825. width: calc(100% + 24px);
  826. margin: 0 -12px;
  827. padding-left: 0;
  828. padding-right: 0;
  829. :deep(.ant-btn) {
  830. border-radius: 8px;
  831. }
  832. :deep(.ant-btn-primary) {
  833. box-shadow: none;
  834. }
  835. :deep(.ant-input),
  836. :deep(.ant-input-affix-wrapper),
  837. :deep(.ant-picker),
  838. :deep(.ant-select-selector) {
  839. border-radius: 8px;
  840. }
  841. .page-header {
  842. display: none;
  843. margin-bottom: 0;
  844. flex-shrink: 0;
  845. }
  846. .page-title {
  847. font-size: 16px;
  848. font-weight: 600;
  849. color: #111827;
  850. padding-bottom: 8px;
  851. border-bottom: 1px solid #e5e7eb;
  852. }
  853. .page-subtitle {
  854. margin-top: 8px;
  855. color: #6b7280;
  856. font-size: 12px;
  857. line-height: 20px;
  858. }
  859. .filter-bar {
  860. display: flex;
  861. align-items: center;
  862. gap: 10px;
  863. padding: 6px;
  864. min-height: 40px;
  865. border: 0;
  866. border-radius: 0;
  867. background: transparent;
  868. flex-wrap: nowrap;
  869. flex-shrink: 0;
  870. overflow: hidden;
  871. box-sizing: border-box;
  872. }
  873. .filter-item {
  874. display: flex;
  875. align-items: center;
  876. gap: 4px;
  877. flex: 0 1 auto;
  878. min-width: 0;
  879. }
  880. .filter-label {
  881. color: #6b7280;
  882. font-size: 12px;
  883. white-space: nowrap;
  884. }
  885. .filter-input {
  886. width: 126px;
  887. min-width: 0;
  888. }
  889. .filter-item--target .filter-input {
  890. width: 142px;
  891. }
  892. .filter-item--month .filter-input {
  893. width: 112px;
  894. }
  895. .filter-date {
  896. width: 252px;
  897. }
  898. .filter-op {
  899. width: 78px;
  900. min-width: 78px;
  901. }
  902. .filter-actions {
  903. display: flex;
  904. align-items: center;
  905. gap: 8px;
  906. margin-left: auto;
  907. flex-shrink: 0;
  908. }
  909. .filter-input,
  910. .filter-date,
  911. .filter-op,
  912. .filter-actions :deep(.ant-btn) {
  913. align-self: center;
  914. }
  915. .content-panel {
  916. display: flex;
  917. flex-direction: column;
  918. flex: 1 1 auto;
  919. gap: 2px;
  920. padding-left: 2px;
  921. padding-right: 2px;
  922. min-height: 0;
  923. overflow: hidden;
  924. background: #edf2f7;
  925. box-sizing: border-box;
  926. &::before,
  927. &::after {
  928. content: '';
  929. display: block;
  930. flex: 0 0 2px;
  931. min-height: 2px;
  932. background: #edf2f7;
  933. }
  934. }
  935. .summary-panel,
  936. .trend-panel {
  937. flex: 1 1 0;
  938. min-height: 0;
  939. display: flex;
  940. flex-direction: column;
  941. overflow: hidden;
  942. max-height: none;
  943. padding: 8px;
  944. border: 0;
  945. border-radius: 12px;
  946. background: #fff;
  947. box-shadow: inset 0 0 0 1px #e2e8f0;
  948. box-sizing: border-box;
  949. }
  950. .summary-panel {
  951. gap: 0;
  952. min-height: 0;
  953. }
  954. .query-state-panel {
  955. flex: 1 1 auto;
  956. min-height: 0;
  957. height: 100%;
  958. display: flex;
  959. align-items: center;
  960. justify-content: center;
  961. padding: 12px 0 0;
  962. }
  963. .table-panel {
  964. flex: 1 1 auto;
  965. min-height: 0;
  966. display: flex;
  967. flex-direction: column;
  968. overflow: hidden;
  969. padding: 0;
  970. }
  971. .summary-panel .table-wrapper {
  972. flex: 1 1 auto;
  973. min-height: 0;
  974. border: 0;
  975. border-radius: 0;
  976. overflow: hidden;
  977. }
  978. .summary-panel .night-summary-table .ant-table-wrapper {
  979. padding: 0;
  980. }
  981. .panel-title-wrap {
  982. display: flex;
  983. flex-direction: column;
  984. gap: 4px;
  985. min-width: 0;
  986. }
  987. .panel-title {
  988. font-size: 14px;
  989. font-weight: 600;
  990. color: #111827;
  991. }
  992. .panel-desc {
  993. color: #6b7280;
  994. font-size: 12px;
  995. line-height: 20px;
  996. }
  997. .panel-header {
  998. display: flex;
  999. align-items: flex-start;
  1000. justify-content: space-between;
  1001. gap: 12px;
  1002. padding: 0 0 8px;
  1003. width: 100%;
  1004. box-sizing: border-box;
  1005. border-bottom: 1px solid #f1f5f9;
  1006. flex: 0 0 auto;
  1007. }
  1008. .table-wrapper {
  1009. min-height: 0;
  1010. display: flex;
  1011. flex-direction: column;
  1012. }
  1013. .table-wrapper .jeesite-basic-table {
  1014. height: 100%;
  1015. min-height: 0;
  1016. display: flex;
  1017. flex-direction: column;
  1018. }
  1019. .table-wrapper .night-summary-table > div:first-child {
  1020. display: none;
  1021. }
  1022. .table-wrapper .night-summary-table > .ant-table-wrapper,
  1023. .table-wrapper .ant-spin-nested-loading,
  1024. .table-wrapper .ant-spin-container,
  1025. .table-wrapper .ant-table,
  1026. .table-wrapper .ant-table-container {
  1027. flex: 1 1 auto;
  1028. min-height: 0;
  1029. height: 100%;
  1030. }
  1031. .table-wrapper .ant-spin-container,
  1032. .table-wrapper .ant-table,
  1033. .table-wrapper .ant-table-container {
  1034. display: flex;
  1035. flex-direction: column;
  1036. }
  1037. .table-wrapper .ant-table-body {
  1038. max-height: 100% !important;
  1039. }
  1040. .pagination-bar {
  1041. display: flex;
  1042. align-items: center;
  1043. justify-content: flex-end;
  1044. gap: 8px;
  1045. flex: 0 0 auto;
  1046. flex-wrap: wrap;
  1047. padding: 8px 0 0;
  1048. width: 100%;
  1049. box-sizing: border-box;
  1050. color: #6b7280;
  1051. font-size: 12px;
  1052. border-top: 1px solid #f1f5f9;
  1053. }
  1054. .pagination-total {
  1055. margin-right: 0;
  1056. white-space: nowrap;
  1057. }
  1058. .pagination-size,
  1059. .pagination-actions,
  1060. .pagination-jump {
  1061. display: flex;
  1062. align-items: center;
  1063. gap: 6px;
  1064. white-space: nowrap;
  1065. }
  1066. .pagination-input {
  1067. width: 48px;
  1068. }
  1069. .trend-panel {
  1070. gap: 12px;
  1071. min-height: 0;
  1072. }
  1073. .trend-panel-body {
  1074. flex: 1 1 auto;
  1075. min-height: 0;
  1076. display: flex;
  1077. flex-direction: column;
  1078. gap: 12px;
  1079. padding: 8px 0 0;
  1080. width: 100%;
  1081. box-sizing: border-box;
  1082. }
  1083. .trend-chart-shell {
  1084. flex: 1 1 auto;
  1085. min-height: 0;
  1086. padding: 0;
  1087. border: 0;
  1088. border-radius: 0;
  1089. background: transparent;
  1090. box-shadow: none;
  1091. display: flex;
  1092. flex-direction: column;
  1093. overflow: hidden;
  1094. }
  1095. .trend-empty {
  1096. height: 100%;
  1097. display: flex;
  1098. flex-direction: column;
  1099. align-items: center;
  1100. justify-content: center;
  1101. gap: 10px;
  1102. border: 1px dashed #d7e3f4;
  1103. border-radius: 10px;
  1104. background: #fafcff;
  1105. }
  1106. .trend-empty-title {
  1107. font-size: 14px;
  1108. font-weight: 600;
  1109. color: #111827;
  1110. }
  1111. .trend-empty-tip {
  1112. color: #6b7280;
  1113. font-size: 12px;
  1114. line-height: 20px;
  1115. text-align: center;
  1116. }
  1117. .trend-chart {
  1118. flex: 1 1 auto;
  1119. min-height: 180px;
  1120. width: 100%;
  1121. }
  1122. @media screen and (max-width: 1280px) {
  1123. .filter-bar {
  1124. flex-wrap: wrap;
  1125. overflow: visible;
  1126. }
  1127. .filter-actions {
  1128. width: 100%;
  1129. margin-left: 0;
  1130. }
  1131. }
  1132. @media screen and (max-width: 992px) {
  1133. .filter-input,
  1134. .filter-date {
  1135. width: 100%;
  1136. }
  1137. }
  1138. }
  1139. </style>