| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286 |
- <template>
- <div ref="nightPageRef" :class="prefixCls" class="night-page">
- <div class="page-header">
- <div class="page-title">夜间联系</div>
- <div class="page-subtitle">识别与对象在夜间联系频繁的人员或号码,并支持下钻查看时段趋势。</div>
- </div>
- <div class="filter-bar">
- <div class="filter-item filter-item--target">
- <span class="filter-label">选择对象:</span>
- <Select
- v-model:value="filters.target"
- mode="multiple"
- allow-clear
- max-tag-count="responsive"
- class="filter-input"
- :options="targetOptions"
- placeholder="全部"
- />
- </div>
- <div class="filter-item filter-item--month">
- <span class="filter-label">联系月数:</span>
- <Input v-model:value="filters.monthValue" class="filter-input" :status="monthValueStatus" />
- </div>
- <div class="filter-item filter-item--date">
- <span class="filter-label">联系日期:</span>
- <RangePicker
- v-model:value="filters.dateRange"
- class="filter-input filter-date"
- :format="DATE_FORMAT"
- :value-format="DATE_FORMAT"
- :placeholder="['开始日期', '结束日期']"
- />
- </div>
- <div class="filter-item filter-item--night">
- <span class="filter-label">夜间时间段:</span>
- <Select v-model:value="filters.nightStart" class="filter-op" :options="nightHourOptions" />
- <span class="filter-label">至</span>
- <Select v-model:value="filters.nightEnd" class="filter-op" :options="nightHourOptions" />
- </div>
- <div class="filter-item">
- <span class="filter-label">对方姓名:</span>
- <Input v-model:value="filters.otherName" class="filter-input" placeholder="请输入对方姓名" />
- </div>
- <div class="filter-item">
- <span class="filter-label">对方号码:</span>
- <Input v-model:value="filters.otherPhone" class="filter-input" placeholder="请输入对方号码" />
- </div>
- <div class="filter-actions">
- <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
- <Button @click="handleReset">重置</Button>
- </div>
- </div>
- <div class="content-panel">
- <div class="summary-panel">
- <div class="panel-header">
- <div class="panel-title-wrap">
- <div class="panel-title">夜间联系对象汇总</div>
- <div class="panel-desc">汇总满足当前筛选条件的对象的夜间联系情况,可点击单条下方显示趋势。</div>
- </div>
- </div>
- <div v-if="!hasSearched" class="query-state-panel">
- <EmptyState
- variant="query"
- title="请点击查询"
- description="设置条件精细查询,也可点击查询展示所有信息"
- min-height="100%"
- description-width="min(100%, 420px)"
- />
- </div>
- <div v-else class="table-panel">
- <div class="table-wrapper">
- <BasicTable
- class="night-summary-table"
- :columns="columns"
- :data-source="tableData"
- :pagination="false"
- :show-table-setting="false"
- :can-resize="true"
- :is-can-resize-parent="true"
- :resize-height-offset="32"
- :rowClassName="rowClassName"
- row-key="id"
- @register="handleNightTableRegister"
- @row-click="handleRowClick"
- />
- </div>
- </div>
- <div v-if="hasSearched" class="pagination-bar">
- <AnalysisPagination
- :total="total"
- :current="pagination.current"
- :page-size="pagination.pageSize"
- :page-size-options="pageSizeOptions"
- :loading="queryLoading"
- @change="handlePaginationChange"
- />
- </div>
- </div>
- <div class="trend-panel">
- <div class="panel-header">
- <div class="panel-title-wrap">
- <div class="panel-title">时段联系趋势</div>
- <div class="panel-desc">上方选中对象时段趋势,可点击图表查看详情。</div>
- </div>
- </div>
- <div class="trend-panel-body">
- <div class="trend-chart-shell">
- <div v-show="!trendVisible" class="trend-empty">
- <EmptyState
- variant="compact"
- title="暂无趋势数据"
- :description="trendEmptyDescription"
- :min-height="220"
- />
- </div>
- <div v-show="trendVisible" ref="chartRef" class="trend-chart"></div>
- </div>
- </div>
- </div>
- </div>
- <CallRecordModal v-model:open="callRecordModalOpen" title="通信记录" :base-query="callRecordBaseQuery" />
- </div>
- </template>
- <script lang="ts" setup name="ViewsNightIndex">
- import { computed, nextTick, onMounted, onUnmounted, ref, Ref, reactive } from 'vue';
- import { useRoute } from 'vue-router';
- import { Select, Input, DatePicker, Button } from 'ant-design-vue';
- import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
- import { BasicTable } from '@jeesite/core/components/Table';
- import { createTableContext } from '@jeesite/core/components/Table/src/hooks/useTableContext';
- import { EmptyState } from '@jeesite/core/components/EmptyState';
- import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
- import { useDesign } from '@jeesite/core/hooks/web/useDesign';
- import { useECharts } from '@jeesite/core/hooks/web/useECharts';
- import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
- import { useMessage } from '@jeesite/core/hooks/web/useMessage';
- import { pickDetailValue } from '@jeesite/core/utils/detailQuery';
- import componentSetting from '@jeesite/core/settings/componentSetting';
- import { statFirstCallNight, statSecondCallNight } from '@jeesite/call/api/callApi';
- import { getPersonOptions } from '@jeesite/person/api/personApi';
- import CallRecordModal from '@jeesite/call/views/components/CallRecordModal.vue';
- import * as echarts from 'echarts';
- import type { EChartsOption } from 'echarts';
- import type { CallNightDTO } from '@jeesite/call/api/types/dto';
- import type { CallNightQuery, CallRecordQuery } from '@jeesite/call/api/types/query';
- const { prefixCls } = useDesign('night');
- const { queryLoading, runWithQueryLoading } = useTableSkill();
- const route = useRoute();
- const currentDetailRow = ref<any | null>(null);
- const selectedRow = ref<any | null>(null);
- const { createMessage } = useMessage();
- const callRecordModalOpen = ref(false);
- const callRecordBaseQuery = reactive<Partial<CallRecordQuery>>({});
- const RangePicker = DatePicker.RangePicker;
- const DATE_FORMAT = 'YYYY-MM-DD';
- const nightPageRef = ref<HTMLElement | null>(null);
- const nightTableAction = ref<any>(null);
- type RangeValue = [string, string] | undefined;
- const createDefaultFilters = () => ({
- target: [] as string[],
- monthValue: '2',
- dateRange: undefined as RangeValue,
- nightStart: '18',
- nightEnd: '06',
- otherName: '',
- otherPhone: '',
- });
- const filters = ref(createDefaultFilters());
- const pagination = ref({
- current: 1,
- pageSize: componentSetting.table.defaultPageSize,
- });
- const jumpPage = ref('1');
- const total = ref(0);
- const tableData = ref<any[]>([]);
- const hasSearched = ref(false);
- const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
- refresh: () => fetchTable(),
- hasData: () => hasSearched.value && tableData.value.length > 0,
- });
- const trendVisible = ref(false);
- const targetOptions = ref<Array<{ label: string; value: string }>>([]);
- const monthValuePattern = /^[1-9]\d*$/;
- const DEFAULT_SORT_KEY = 'callCount';
- const DEFAULT_SORT_ORDER = 'DESC' as const;
- const sortState = ref({
- orderKey: DEFAULT_SORT_KEY,
- sort: DEFAULT_SORT_ORDER,
- });
- const nightHourOptions = [
- { label: '18点', value: '18' },
- { label: '20点', value: '20' },
- { label: '22点', value: '22' },
- { label: '00点', value: '00' },
- { label: '02点', value: '02' },
- { label: '04点', value: '04' },
- { label: '06点', value: '06' },
- ];
- const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
- label: value,
- value,
- }));
- const openDetailModal = async (record: any, showMessage = true) => {
- currentDetailRow.value = record;
- selectedRow.value = record;
- await fetchSecondLevelTrend(record, showMessage);
- };
- const columns = computed(() => [
- { title: '本方姓名', dataIndex: 'selfName', key: 'selfName', width: 140 },
- { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 140 },
- { title: '对方号码', dataIndex: 'otherPhone', key: 'otherPhone', width: 220 },
- {
- title: '夜间联系次数',
- dataIndex: 'count',
- key: 'callCount',
- width: 160,
- },
- {
- title: '涉及月份',
- dataIndex: 'duration',
- key: 'totalMonthCount',
- width: 140,
- },
- ]);
- const handleNightTableRegister = (action: any) => {
- nightTableAction.value = action;
- };
- const getNightTableColumns = (...args: any[]) => nightTableAction.value?.getColumns?.(...args) || [];
- createTableContext({
- wrapRef: nightPageRef,
- getBindValues: computed(() => ({
- columns: columns.value,
- showIndexColumn: true,
- rowSelection: undefined,
- isTreeTable: false,
- })),
- getDefaultRowSelection: () => nightTableAction.value?.getDefaultRowSelection?.(),
- getColumns: getNightTableColumns,
- getCacheColumns: () => nightTableAction.value?.getCacheColumns?.() || [],
- setColumns: (nextColumns: any) => nightTableAction.value?.setColumns?.(nextColumns),
- setProps: (props: any) => nightTableAction.value?.setProps?.(props),
- } as any);
- const totalPages = computed(() => {
- if (!total.value) return 1;
- return Math.max(1, Math.ceil(total.value / pagination.value.pageSize));
- });
- const normalizedMonthValue = computed(() => String(filters.value.monthValue ?? '').trim());
- const monthValueStatus = computed<'error' | undefined>(() => {
- return monthValuePattern.test(normalizedMonthValue.value) ? undefined : 'error';
- });
- const trendEmptyDescription = computed(() => {
- if (!tableData.value.length) {
- return '当前筛选条件下暂无夜间联系记录,请调整筛选条件后重试';
- }
- if (selectedRow.value) {
- return '当前选中对象暂无第二层时段趋势,请切换其他记录查看';
- }
- return '请直接选择左侧列表中的一行记录';
- });
- const normalizePhoneList = (value?: string | string[] | null) => {
- if (Array.isArray(value)) return value.filter(Boolean);
- if (!value) return undefined;
- const list = String(value)
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
- return list.length ? list : undefined;
- };
- const normalizeIdList = (value?: string | string[] | null) => {
- if (Array.isArray(value)) {
- const list = value.map((item) => String(item).trim()).filter(Boolean);
- return list.length ? Array.from(new Set(list)) : undefined;
- }
- if (!value) return undefined;
- const list = String(value)
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
- return list.length ? Array.from(new Set(list)) : undefined;
- };
- const normalizeSelectedTargets = (value?: string[] | string | null) => {
- if (Array.isArray(value)) {
- const list = value.map((item) => String(item).trim()).filter(Boolean);
- return list.length ? Array.from(new Set(list)) : [];
- }
- if (!value) return [];
- const list = String(value)
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
- return list.length ? Array.from(new Set(list)) : [];
- };
- const normalizeList = (resp: any): CallNightDTO[] => {
- if (Array.isArray(resp)) return resp;
- if (Array.isArray(resp?.list)) return resp.list;
- if (Array.isArray(resp?.data)) return resp.data;
- if (Array.isArray(resp?.data?.list)) return resp.data.list;
- if (Array.isArray(resp?.records)) return resp.records;
- if (Array.isArray(resp?.data?.records)) return resp.data.records;
- return [];
- };
- const normalizeTotal = (resp: any, listLen: number) => {
- const t = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? resp?.data?.records?.length;
- return Number.isFinite(Number(t)) ? Number(t) : listLen;
- };
- const getOtherPhoneText = (item?: Pick<CallNightDTO, 'otherPhone' | 'otherPhones'> | null) => {
- return item?.otherPhone || normalizePhoneList(item?.otherPhones)?.join(', ') || '';
- };
- const mapRow = (item: CallNightDTO, index: number) => ({
- id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
- selfName: item.personName || '-',
- otherName: item.otherName || '-',
- otherPhone: getOtherPhoneText(item) || '-',
- count: String(item.callCount ?? 0),
- duration: `${item.totalMonthCount ?? 0}个月`,
- countValue: Number(item.callCount ?? 0) || 0,
- durationValue: Number(item.totalMonthCount ?? 0) || 0,
- raw: item,
- });
- const sortNightRows = (list: CallNightDTO[]) => {
- const { orderKey, sort } = sortState.value;
- const direction = sort === 'ASC' ? 1 : -1;
- return [...list].sort((left, right) => {
- const leftCount = Number(left.callCount ?? 0) || 0;
- const rightCount = Number(right.callCount ?? 0) || 0;
- const leftMonthCount = Number(left.totalMonthCount ?? 0) || 0;
- const rightMonthCount = Number(right.totalMonthCount ?? 0) || 0;
- if (orderKey === 'totalMonthCount' && leftMonthCount !== rightMonthCount) {
- return (leftMonthCount - rightMonthCount) * direction;
- }
- if (leftCount !== rightCount) {
- return (leftCount - rightCount) * direction;
- }
- if (leftMonthCount !== rightMonthCount) {
- return (leftMonthCount - rightMonthCount) * direction;
- }
- return String(left.otherName || left.otherPhone || '').localeCompare(
- String(right.otherName || right.otherPhone || ''),
- );
- });
- };
- const resolveFilterMonthVal = (showMessage = false) => {
- if (monthValuePattern.test(normalizedMonthValue.value)) {
- return Number(normalizedMonthValue.value);
- }
- if (showMessage) {
- createMessage.warning('联系月数请输入大于 0 的整数');
- }
- return null;
- };
- const buildQuery = (monthVal: number): CallNightQuery => {
- const range = filters.value.dateRange as RangeValue;
- const personNames = normalizeSelectedTargets(filters.value.target);
- const otherName = String(filters.value.otherName || '').trim();
- const otherPhone = String(filters.value.otherPhone || '').trim();
- return {
- page: pagination.value.current,
- limit: pagination.value.pageSize,
- startDate: range?.[0] || undefined,
- endDate: range?.[1] || undefined,
- monthVal,
- nightStart: Number(filters.value.nightStart),
- nightEnd: Number(filters.value.nightEnd),
- personNames: personNames.length ? personNames : undefined,
- otherName: otherName || undefined,
- otherPhone: otherPhone || undefined,
- orderKey: sortState.value.orderKey,
- sort: sortState.value.sort,
- };
- };
- const fetchTable = async () => {
- const monthVal = resolveFilterMonthVal(true);
- if (monthVal === null) return;
- await runWithQueryLoading(async () => {
- const resp = await statFirstCallNight(buildQuery(monthVal));
- const list = sortNightRows(normalizeList(resp));
- tableData.value = list.map(mapRow);
- total.value = normalizeTotal(resp, list.length);
- hasSearched.value = true;
- syncAnalysisDataRefreshState(list.length > 0);
- const current = Number((resp as any)?.data?.current || pagination.value.current);
- const size = Number((resp as any)?.data?.size || pagination.value.pageSize);
- if (Number.isFinite(current) && current > 0) pagination.value.current = current;
- if (Number.isFinite(size) && size > 0) pagination.value.pageSize = size;
- jumpPage.value = String(pagination.value.current);
- if (tableData.value.length) {
- await openDetailModal(tableData.value[0], false);
- } else {
- trendVisible.value = false;
- selectedRow.value = null;
- currentDetailRow.value = null;
- secondLevelTrendList.value = [];
- }
- });
- };
- const handleRowClick = async (record: any) => {
- await openDetailModal(record, false);
- };
- const rowClassName = (record: any) => {
- return selectedRow.value && selectedRow.value.id === record.id ? 'summary-row-selected' : '';
- };
- const handleSearch = async () => {
- pagination.value.current = 1;
- await fetchTable();
- };
- const handleReset = async () => {
- filters.value = createDefaultFilters();
- sortState.value = {
- orderKey: DEFAULT_SORT_KEY,
- sort: DEFAULT_SORT_ORDER,
- };
- pagination.value.current = 1;
- await fetchTable();
- };
- const handlePrevPage = async () => {
- if (queryLoading.value || pagination.value.current <= 1) return;
- pagination.value.current -= 1;
- await fetchTable();
- };
- const handleNextPage = async () => {
- if (queryLoading.value || pagination.value.current >= totalPages.value) return;
- pagination.value.current += 1;
- await fetchTable();
- };
- const handlePaginationChange = async (page: number, pageSize: number) => {
- if (queryLoading.value) return;
- if (pageSize !== pagination.value.pageSize) {
- pagination.value.pageSize = pageSize;
- await handlePageSizeChange();
- return;
- }
- if (page === pagination.value.current - 1) {
- await handlePrevPage();
- return;
- }
- if (page === pagination.value.current + 1) {
- await handleNextPage();
- return;
- }
- if (page === pagination.value.current) return;
- jumpPage.value = String(page);
- await handleJumpPage();
- };
- const handlePageSizeChange = async () => {
- pagination.value.current = 1;
- await fetchTable();
- };
- const handleJumpPage = async () => {
- const next = Number(jumpPage.value);
- if (!Number.isInteger(next) || next < 1) {
- createMessage.warning('请输入正确页码');
- jumpPage.value = String(pagination.value.current);
- return;
- }
- pagination.value.current = Math.min(next, totalPages.value);
- await fetchTable();
- };
- const fetchSecondLevelTrend = async (record: any, showMessage = true) => {
- const monthVal = resolveFilterMonthVal(showMessage);
- if (monthVal === null) return;
- try {
- const range = filters.value.dateRange as RangeValue;
- const selectedTargets = normalizeSelectedTargets(filters.value.target);
- const personName = record?.raw?.personName || selectedTargets[0] || undefined;
- const otherPhone = pickDetailValue(getOtherPhoneText(record?.raw), String(filters.value.otherPhone || '').trim());
- const otherName = pickDetailValue(record?.raw?.otherName, String(filters.value.otherName || '').trim());
- const query: CallNightQuery = {
- page: 1,
- limit: 200,
- startDate: range?.[0] || undefined,
- endDate: range?.[1] || undefined,
- monthVal,
- nightStart: Number(filters.value.nightStart),
- nightEnd: Number(filters.value.nightEnd),
- personName,
- personNames: personName ? [personName] : undefined,
- otherName,
- otherPhone,
- };
- const resp = await statSecondCallNight(query);
- const list = normalizeList(resp);
- if (!list.length) {
- trendVisible.value = false;
- secondLevelTrendList.value = [];
- if (showMessage) {
- createMessage.info('当前选中对象暂无第二层时段趋势');
- }
- return;
- }
- const wasTrendHidden = !trendVisible.value;
- trendVisible.value = true;
- if (wasTrendHidden) {
- await nextTick();
- }
- renderTrendChart(list, true);
- if (showMessage) {
- createMessage.success('已加载第二层时段联系趋势');
- }
- } catch (error) {
- trendVisible.value = false;
- secondLevelTrendList.value = [];
- createMessage.warning('第二层夜间通话接口调用失败');
- }
- };
- const fetchTargetOptions = async () => {
- try {
- targetOptions.value = await getPersonOptions(false);
- } catch (error) {
- targetOptions.value = [];
- }
- };
- const applyRouteQuery = (targetOptions: Array<{ label: string; value: string }>) => {
- filters.value = createDefaultFilters();
- const personNames = normalizeSelectedTargets(
- (route.query.personNames as string | string[] | null | undefined) ??
- (route.query.personName as string | string[] | null | undefined),
- );
- const otherName = String(route.query.otherName || '').trim();
- const otherPhone = String(route.query.otherPhone || '').trim();
- filters.value.target = personNames.map((personName) => {
- const matched = targetOptions.find((item) => String(item.label).trim() === personName);
- return matched ? matched.value : personName;
- });
- if (otherName) {
- filters.value.otherName = otherName;
- }
- if (otherPhone) {
- filters.value.otherPhone = otherPhone;
- }
- return !!(filters.value.target.length || otherName || otherPhone);
- };
- const chartRef = ref<HTMLDivElement | null>(null);
- const { setOptions, getInstance } = useECharts(chartRef as Ref<HTMLDivElement>);
- const secondLevelTrendList = ref<CallNightDTO[]>([]);
- const NIGHT_TREND_TOOLTIP_HANDLER = '_handleNightTrendDetail';
- const normalizeTimeValue = (value?: string | number | null) => {
- if (value === null || value === undefined || value === '') return undefined;
- if (typeof value === 'number') {
- const hours = Math.max(0, Math.min(23, value));
- return `${String(hours).padStart(2, '0')}:00:00`;
- }
- const normalized = String(value).trim();
- if (!normalized) return undefined;
- const timeMatch = normalized.match(/^(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?$/);
- if (!timeMatch) return undefined;
- const hours = Number(timeMatch[1]);
- if (!Number.isFinite(hours)) return undefined;
- const minutes = timeMatch[2] || '00';
- const seconds = timeMatch[3] || '00';
- return `${String(hours).padStart(2, '0')}:${minutes}:${seconds}`;
- };
- const parseTimeRangeText = (value?: string) => {
- const normalized = String(value || '').trim();
- if (!normalized) return {};
- const matches = normalized.match(/(\d{1,2})(?::\d{2})?\s*[-~至]\s*(\d{1,2})(?::\d{2})?/);
- if (!matches) return {};
- const startTime = normalizeTimeValue(matches[1]);
- const endTime = normalizeTimeValue(matches[2]);
- return {
- startTime,
- endTime,
- };
- };
- const resolveNodeTimeRange = (node: CallNightDTO, axisLabel?: string) => {
- const startTime = normalizeTimeValue(node.startHour);
- const endTime = normalizeTimeValue(node.endHour);
- if (startTime && endTime) {
- return { startTime, endTime };
- }
- const parsedFromNode = parseTimeRangeText(node.time);
- if (parsedFromNode.startTime && parsedFromNode.endTime) {
- return parsedFromNode;
- }
- const parsedFromAxis = parseTimeRangeText(axisLabel);
- if (parsedFromAxis.startTime && parsedFromAxis.endTime) {
- return parsedFromAxis;
- }
- return {
- startTime: normalizeTimeValue(filters.value.nightStart),
- endTime: normalizeTimeValue(filters.value.nightEnd),
- };
- };
- const formatTrendAxisLabel = (node: CallNightDTO) => {
- if (node.time) return node.time;
- const startHour = String(node.startHour ?? Number(filters.value.nightStart)).padStart(2, '0');
- const endHour = String(node.endHour ?? Number(filters.value.nightEnd)).padStart(2, '0');
- return `${startHour}~${endHour}`;
- };
- const updateCallRecordBaseQueryByNode = (node: CallNightDTO, axisLabel?: string) => {
- const monthVal = resolveFilterMonthVal(true);
- if (monthVal === null) return false;
- const range = filters.value.dateRange as RangeValue;
- const { startTime, endTime } = resolveNodeTimeRange(node, axisLabel || formatTrendAxisLabel(node));
- callRecordBaseQuery.personName = pickDetailValue(
- currentDetailRow.value?.raw?.personName,
- normalizeSelectedTargets(filters.value.target)[0],
- );
- callRecordBaseQuery.otherName = pickDetailValue(node.otherName, currentDetailRow.value?.raw?.otherName);
- callRecordBaseQuery.otherPhones = normalizePhoneList(
- getOtherPhoneText(node) || getOtherPhoneText(currentDetailRow.value?.raw),
- );
- // callRecordBaseQuery.ids = normalizeIdList(node.ids);
- callRecordBaseQuery.startDate = range?.[0] || undefined;
- callRecordBaseQuery.endDate = range?.[1] || undefined;
- callRecordBaseQuery.monthVal = Number.isFinite(monthVal) ? monthVal : undefined;
- callRecordBaseQuery.startTime = startTime;
- callRecordBaseQuery.endTime = endTime;
- return true;
- };
- const escapeTooltipHtml = (value?: string | number | null) => {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- };
- const formatTooltipCount = (value?: string | number | null) => {
- const numericValue = Number(value || 0);
- return numericValue.toLocaleString('zh-CN');
- };
- const buildTrendTooltipCard = (params: any[]) => {
- const current = params?.[0];
- const dataIndex = Number(current?.dataIndex ?? -1);
- const node = secondLevelTrendList.value[dataIndex];
- if (!node) return '';
- const axisLabel = escapeTooltipHtml(
- String(current?.axisValueLabel || current?.axisValue || formatTrendAxisLabel(node)),
- );
- const otherName = pickDetailValue(node.otherName, currentDetailRow.value?.raw?.otherName);
- const safeOtherName = escapeTooltipHtml(otherName || '-');
- const countValue = formatTooltipCount(node.callCount ?? current?.value ?? 0);
- return [
- '<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);">',
- '<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px;">',
- `<span style="font-size:14px; line-height:20px; color:#f8fafc; font-weight:600;">${axisLabel}</span>`,
- `<span style="font-size:12px; line-height:18px; color:#8ac5ff; cursor:pointer;" onclick='window.${NIGHT_TREND_TOOLTIP_HANDLER}(${dataIndex})'>查看详情</span>`,
- '</div>',
- '<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">',
- '<span style="display:flex; align-items:center; gap:8px; font-size:13px; line-height:20px; color:#cbd5e1;">',
- '<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>',
- '联系次数',
- '</span>',
- `<span style="font-size:13px; line-height:20px; color:#eff6ff; font-weight:600;">${countValue} 次</span>`,
- '</div>',
- '<div style="height:8px;"></div>',
- '<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">',
- '<span style="font-size:13px; line-height:20px; color:#94a3b8;">联系对象</span>',
- `<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>`,
- '</div>',
- '</div>',
- ].join('');
- };
- const getTrendTooltipPosition = (
- point: number[],
- _params: unknown,
- dom: HTMLElement,
- _rect: unknown,
- size: { viewSize: number[] },
- ) => {
- const [pointX, pointY] = point;
- const [viewWidth, viewHeight] = size.viewSize;
- const cardWidth = dom.offsetWidth || 208;
- const cardHeight = dom.offsetHeight || 116;
- const gap = 10;
- const left = Math.min(Math.max(pointX - cardWidth / 2, gap), Math.max(gap, viewWidth - cardWidth - gap));
- const top = Math.min(Math.max(pointY - cardHeight - 18, gap), Math.max(gap, viewHeight - cardHeight - gap));
- return [left, top];
- };
- const handleNightTrendDetail = (dataIndex: number) => {
- const node = secondLevelTrendList.value[dataIndex];
- if (!node) return;
- if (!updateCallRecordBaseQueryByNode(node)) return;
- callRecordModalOpen.value = true;
- };
- const bindChartClick = () => {
- const chart = getInstance();
- if (!chart) return;
- chart.off('click');
- chart.on('click', (params: any) => {
- const dataIndex = Number(params?.dataIndex ?? -1);
- const node = secondLevelTrendList.value[dataIndex];
- if (!node) return;
- if (!updateCallRecordBaseQueryByNode(node, params?.name)) return;
- callRecordModalOpen.value = true;
- });
- };
- const renderTrendChart = (list: CallNightDTO[], isSecondLevel = false) => {
- const source = isSecondLevel ? list : [];
- secondLevelTrendList.value = source;
- const labels = source.length ? source.map((item) => formatTrendAxisLabel(item)) : ['22~06'];
- const values = source.length ? source.map((item) => Number(item.callCount || 0)) : [0];
- const options: EChartsOption = {
- tooltip: {
- trigger: 'axis',
- enterable: true,
- confine: true,
- transitionDuration: 0,
- hideDelay: 180,
- renderMode: 'html',
- backgroundColor: 'transparent',
- borderWidth: 0,
- padding: 0,
- axisPointer: {
- type: 'line',
- lineStyle: {
- color: 'rgba(63, 93, 230, 0.22)',
- width: 2,
- },
- },
- position: getTrendTooltipPosition,
- extraCssText: 'box-shadow:none;background:transparent;',
- formatter: (params: any) => buildTrendTooltipCard(Array.isArray(params) ? params : [params]),
- },
- xAxis: {
- type: 'category',
- boundaryGap: false,
- data: labels,
- axisTick: { show: false },
- axisLine: { lineStyle: { color: '#d7dee8' } },
- axisLabel: {
- color: '#6b7280',
- fontSize: 12,
- margin: 10,
- },
- },
- yAxis: {
- type: 'value',
- axisTick: { show: false },
- axisLine: { show: false },
- axisLabel: {
- color: '#6b7280',
- fontSize: 12,
- },
- splitNumber: 4,
- splitLine: {
- lineStyle: {
- color: 'rgba(217, 225, 238, 0.92)',
- type: 'dashed',
- },
- },
- },
- grid: { left: '3%', right: '3%', top: 24, bottom: 8, outerBoundsMode: 'same', outerBoundsContain: 'axisLabel' },
- series: [
- {
- data: values,
- type: 'line',
- smooth: true,
- showSymbol: true,
- symbol: 'circle',
- symbolSize: 4,
- lineStyle: { color: '#1677ff', width: 3 },
- itemStyle: {
- color: '#1677ff',
- borderColor: '#ffffff',
- borderWidth: 2,
- shadowBlur: 4,
- shadowColor: 'rgba(22, 119, 255, 0.16)',
- },
- areaStyle: {
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: 'rgba(22, 119, 255, 0.18)' },
- { offset: 1, color: 'rgba(22, 119, 255, 0.02)' },
- ]),
- },
- emphasis: {
- focus: 'series',
- scale: true,
- itemStyle: {
- color: '#003eb3',
- borderColor: '#ffffff',
- borderWidth: 5,
- shadowBlur: 26,
- shadowColor: 'rgba(0, 62, 179, 0.58)',
- },
- lineStyle: {
- width: 4,
- },
- label: {
- show: true,
- position: 'top',
- color: '#003eb3',
- fontSize: 12,
- fontWeight: 700,
- formatter: '{c}',
- },
- scaleSize: 16,
- },
- },
- ],
- };
- setOptions(options);
- if (isSecondLevel) {
- setTimeout(() => bindChartClick(), 80);
- }
- };
- onMounted(async () => {
- (window as any)[NIGHT_TREND_TOOLTIP_HANDLER] = handleNightTrendDetail;
- await fetchTargetOptions();
- if (applyRouteQuery(targetOptions.value)) {
- await handleSearch();
- }
- });
- onUnmounted(() => {
- const chart = getInstance();
- chart?.off('click');
- if ((window as any)[NIGHT_TREND_TOOLTIP_HANDLER]) {
- delete (window as any)[NIGHT_TREND_TOOLTIP_HANDLER];
- }
- });
- </script>
- <style lang="less">
- @import '../../../trans/views/styles/trans-analysis-table-ui.less';
- @prefix-cls: ~'jeesite-night';
- .@{prefix-cls}.night-page {
- .trans-analysis-table-ui();
- background: #fff;
- padding: 0 8px 2px;
- min-height: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- box-sizing: border-box;
- width: calc(100% + 24px);
- margin: 0 -12px;
- padding-left: 0;
- padding-right: 0;
- :deep(.ant-btn) {
- border-radius: 8px;
- }
- :deep(.ant-btn-primary) {
- box-shadow: none;
- }
- :deep(.ant-input),
- :deep(.ant-input-affix-wrapper),
- :deep(.ant-picker),
- :deep(.ant-select-selector) {
- border-radius: 8px;
- }
- .page-header {
- display: none;
- margin-bottom: 0;
- flex-shrink: 0;
- }
- .page-title {
- font-size: 16px;
- font-weight: 600;
- color: #111827;
- padding-bottom: 8px;
- border-bottom: 1px solid #e5e7eb;
- }
- .page-subtitle {
- margin-top: 8px;
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- }
- .filter-bar {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px;
- min-height: 40px;
- border: 0;
- border-radius: 0;
- background: transparent;
- flex-wrap: nowrap;
- flex-shrink: 0;
- overflow: hidden;
- box-sizing: border-box;
- }
- .filter-item {
- display: flex;
- align-items: center;
- gap: 4px;
- flex: 0 1 auto;
- min-width: 0;
- }
- .filter-label {
- color: #6b7280;
- font-size: 12px;
- white-space: nowrap;
- }
- .filter-input {
- width: 126px;
- min-width: 0;
- }
- .filter-item--target .filter-input {
- width: 142px;
- }
- .filter-item--month .filter-input {
- width: 112px;
- }
- .filter-date {
- width: 252px;
- }
- .filter-op {
- width: 78px;
- min-width: 78px;
- }
- .filter-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-left: auto;
- flex-shrink: 0;
- }
- .filter-input,
- .filter-date,
- .filter-op,
- .filter-actions :deep(.ant-btn) {
- align-self: center;
- }
- .content-panel {
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- gap: 2px;
- padding-left: 2px;
- padding-right: 2px;
- min-height: 0;
- overflow: hidden;
- background: #edf2f7;
- box-sizing: border-box;
- &::before,
- &::after {
- content: '';
- display: block;
- flex: 0 0 2px;
- min-height: 2px;
- background: #edf2f7;
- }
- }
- .summary-panel,
- .trend-panel {
- flex: 1 1 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- max-height: none;
- padding: 8px;
- border: 0;
- border-radius: 12px;
- background: #fff;
- box-shadow: inset 0 0 0 1px #e2e8f0;
- box-sizing: border-box;
- }
- .summary-panel {
- gap: 0;
- min-height: 0;
- }
- .query-state-panel {
- flex: 1 1 auto;
- min-height: 0;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 12px 0 0;
- }
- .table-panel {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- padding: 0;
- }
- .summary-panel .table-wrapper {
- flex: 1 1 auto;
- min-height: 0;
- border: 0;
- border-radius: 0;
- overflow: hidden;
- }
- .summary-panel .night-summary-table .ant-table-wrapper {
- padding: 0;
- }
- .panel-title-wrap {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
- }
- .panel-title {
- font-size: 14px;
- font-weight: 600;
- color: #111827;
- }
- .panel-desc {
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- }
- .panel-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 12px;
- padding: 0 0 8px;
- width: 100%;
- box-sizing: border-box;
- border-bottom: 1px solid #f1f5f9;
- flex: 0 0 auto;
- }
- .table-wrapper {
- min-height: 0;
- display: flex;
- flex-direction: column;
- }
- .table-wrapper .jeesite-basic-table {
- height: 100%;
- min-height: 0;
- display: flex;
- flex-direction: column;
- }
- .table-wrapper .night-summary-table > div:first-child {
- display: none;
- }
- .table-wrapper .night-summary-table > .ant-table-wrapper,
- .table-wrapper .ant-spin-nested-loading,
- .table-wrapper .ant-spin-container,
- .table-wrapper .ant-table,
- .table-wrapper .ant-table-container {
- flex: 1 1 auto;
- min-height: 0;
- height: 100%;
- }
- .table-wrapper .ant-spin-container,
- .table-wrapper .ant-table,
- .table-wrapper .ant-table-container {
- display: flex;
- flex-direction: column;
- }
- .table-wrapper .ant-table-body {
- max-height: 100% !important;
- }
- .pagination-bar {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
- flex: 0 0 auto;
- flex-wrap: wrap;
- padding: 8px 0 0;
- width: 100%;
- box-sizing: border-box;
- color: #6b7280;
- font-size: 12px;
- border-top: 1px solid #f1f5f9;
- }
- .pagination-total {
- margin-right: 0;
- white-space: nowrap;
- }
- .pagination-size,
- .pagination-actions,
- .pagination-jump {
- display: flex;
- align-items: center;
- gap: 6px;
- white-space: nowrap;
- }
- .pagination-input {
- width: 48px;
- }
- .trend-panel {
- gap: 12px;
- min-height: 0;
- }
- .trend-panel-body {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
- gap: 12px;
- padding: 8px 0 0;
- width: 100%;
- box-sizing: border-box;
- }
- .trend-chart-shell {
- flex: 1 1 auto;
- min-height: 0;
- padding: 0;
- border: 0;
- border-radius: 0;
- background: transparent;
- box-shadow: none;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .trend-empty {
- height: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 10px;
- border: 1px dashed #d7e3f4;
- border-radius: 10px;
- background: #fafcff;
- }
- .trend-empty-title {
- font-size: 14px;
- font-weight: 600;
- color: #111827;
- }
- .trend-empty-tip {
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- text-align: center;
- }
- .trend-chart {
- flex: 1 1 auto;
- min-height: 180px;
- width: 100%;
- }
- @media screen and (max-width: 1280px) {
- .filter-bar {
- flex-wrap: wrap;
- overflow: visible;
- }
- .filter-actions {
- width: 100%;
- margin-left: 0;
- }
- }
- @media screen and (max-width: 992px) {
- .filter-input,
- .filter-date {
- width: 100%;
- }
- }
- }
- </style>
|