index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. <template>
  2. <div :class="prefixCls" class="base-station-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 v-model:value="filters.stationName" class="filter-input" placeholder="请输入基站名称" />
  33. </div>
  34. <div class="filter-actions">
  35. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  36. <Button @click="handleReset">重置</Button>
  37. </div>
  38. </div>
  39. <div class="content-panel">
  40. <div class="table-card">
  41. <div class="panel-header">
  42. <div>
  43. <div class="panel-title">基站命中列表</div>
  44. <div class="panel-desc">
  45. {{
  46. tableData.length
  47. ? `当前命中 ${pagination.total} 条基站记录,可查看坐标位置并继续下钻。`
  48. : '展示对象在不同基站的通信次数、LAC/CID 和时间区间,适合做高频位置聚类的前置筛查。'
  49. }}
  50. </div>
  51. </div>
  52. </div>
  53. <div class="table-panel">
  54. <div v-if="!hasSearched" class="query-state-panel">
  55. <EmptyState variant="query" title="请点击查询" description="设置条件精细查询,也可点击查询展示所有信息" />
  56. </div>
  57. <div v-else class="table-wrapper">
  58. <BasicTable
  59. :columns="columns"
  60. :action-column="actionColumn"
  61. :data-source="tableData"
  62. :loading="queryLoading"
  63. :pagination="false"
  64. :showIndexColumn="false"
  65. :table-setting-store="false"
  66. :show-table-setting="false"
  67. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  68. :can-resize="true"
  69. :is-can-resize-parent="true"
  70. :resize-height-offset="32"
  71. row-key="rowKey"
  72. >
  73. <template #action="{ record }">
  74. <Button type="link" size="small" @click="handleViewDetail(record)">查看详情</Button>
  75. <Button type="link" size="small" @click="handleOpenMap(record)">地图位置</Button>
  76. </template>
  77. </BasicTable>
  78. </div>
  79. </div>
  80. <div v-if="hasSearched" class="pagination-bar">
  81. <AnalysisPagination
  82. :total="pagination.total"
  83. :current="pagination.current"
  84. :page-size="pagination.pageSize"
  85. :page-size-options="pageSizeOptions"
  86. :loading="queryLoading"
  87. @change="handlePaginationChange"
  88. />
  89. </div>
  90. </div>
  91. <BaseStationDetailModal v-model:open="detailModalOpen" :base-query="detailBaseQuery" />
  92. <BaseStationMapModal v-model:open="mapModalOpen" :title="mapModalTitle" :stations="mapModalStations" />
  93. </div>
  94. </div>
  95. </template>
  96. <script lang="ts" setup name="ViewsBaseStationIndex">
  97. import { computed, onMounted, ref } from 'vue';
  98. import { Select, Input, Button, DatePicker } from 'ant-design-vue';
  99. import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  100. import { EmptyState } from '@jeesite/core/components/EmptyState';
  101. import { BasicTable } from '@jeesite/core/components/Table';
  102. import type { BasicColumn } from '@jeesite/core/components/Table';
  103. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  104. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  105. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  106. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  107. import { mergeDetailDateRange, pickDetailValue } from '@jeesite/core/utils/detailQuery';
  108. import componentSetting from '@jeesite/core/settings/componentSetting';
  109. import { getPersonOptions } from '@jeesite/person/api/personApi';
  110. import { statFirstTrackCellTower } from '@jeesite/track/api/trackApi';
  111. import type { TrackCellTowerDTO } from '@jeesite/track/api/types/dto';
  112. import type { TrackCellTowerQuery } from '@jeesite/track/api/types/query';
  113. import BaseStationDetailModal from '../components/BaseStationDetailModal.vue';
  114. import BaseStationMapModal from '../components/BaseStationMapModal.vue';
  115. interface BaseStationRow {
  116. rowKey: string;
  117. id: number;
  118. stationName: string;
  119. target: string;
  120. callCount: number | string;
  121. lac: string;
  122. cid: string;
  123. timeRange: string;
  124. raw: TrackCellTowerDTO;
  125. }
  126. type RangeValue = [string, string] | undefined;
  127. const DEFAULT_SORT_KEY = 'callCount';
  128. const DEFAULT_SORT_ORDER = 'DESC' as const;
  129. const { prefixCls } = useDesign('base-station');
  130. const { createMessage } = useMessage();
  131. const { queryLoading, runWithQueryLoading } = useTableSkill();
  132. const RangePicker = DatePicker.RangePicker;
  133. const DATE_FORMAT = 'YYYY-MM-DD';
  134. const filters = ref({
  135. target: [] as string[],
  136. dateRange: undefined as RangeValue,
  137. stationName: '',
  138. });
  139. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  140. const tableData = ref<BaseStationRow[]>([]);
  141. const hasSearched = ref(false);
  142. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  143. refresh: () => fetchTable(),
  144. hasData: () => hasSearched.value && tableData.value.length > 0,
  145. });
  146. const detailModalOpen = ref(false);
  147. const detailBaseQuery = ref<Partial<TrackCellTowerQuery>>({});
  148. const mapModalOpen = ref(false);
  149. const mapModalTitle = ref('基站地图位置');
  150. const mapModalStations = ref<TrackCellTowerDTO[]>([]);
  151. const pagination = ref({
  152. current: 1,
  153. pageSize: componentSetting.table.defaultPageSize,
  154. total: 0,
  155. jumpPage: '1',
  156. });
  157. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  158. label: value,
  159. value: Number(value),
  160. }));
  161. const totalPages = computed(() => {
  162. if (!pagination.value.total) return 1;
  163. return Math.max(1, Math.ceil(pagination.value.total / pagination.value.pageSize));
  164. });
  165. const columns: BasicColumn[] = [
  166. { title: '序号', dataIndex: 'id', key: 'id', width: 70, align: 'center' },
  167. { title: '基站名称', dataIndex: 'stationName', key: 'stationName', width: 320, align: 'center' },
  168. { title: '被调查对象', dataIndex: 'target', key: 'target', width: 120, align: 'center' },
  169. { title: '通信次数', dataIndex: 'callCount', key: 'callCount', width: 100, align: 'center' },
  170. { title: 'LAC', dataIndex: 'lac', key: 'lac', width: 100, align: 'center' },
  171. { title: 'CID', dataIndex: 'cid', key: 'cid', width: 100, align: 'center' },
  172. { title: '通信时间区间', dataIndex: 'timeRange', key: 'timeRange', width: 220, align: 'center' },
  173. ];
  174. const actionColumn = {
  175. title: '操作',
  176. dataIndex: 'action',
  177. key: 'action',
  178. width: 220,
  179. align: 'center' as const,
  180. fixed: 'right' as const,
  181. slot: 'action',
  182. };
  183. const normalizeList = (resp: any): TrackCellTowerDTO[] => {
  184. if (Array.isArray(resp)) return resp;
  185. if (Array.isArray(resp?.list)) return resp.list;
  186. if (Array.isArray(resp?.records)) return resp.records;
  187. if (Array.isArray(resp?.data)) return resp.data;
  188. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  189. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  190. return [];
  191. };
  192. const normalizeTotal = (resp: any, listLen: number) => {
  193. const total = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? listLen;
  194. return Number.isFinite(Number(total)) ? Number(total) : listLen;
  195. };
  196. const fallbackText = (value: string | number | null | undefined) => {
  197. if (value === 0) return '0';
  198. if (value === null || value === undefined || value === '') return '-';
  199. return String(value);
  200. };
  201. const formatTimeRange = (startDate?: string, endDate?: string) => {
  202. if (startDate && endDate) return `${startDate} - ${endDate}`;
  203. return fallbackText(startDate || endDate);
  204. };
  205. const mapRow = (item: TrackCellTowerDTO, index: number): BaseStationRow => ({
  206. rowKey:
  207. [item.personName, item.personStationName, item.personRegionId, item.personCellId, index]
  208. .filter(Boolean)
  209. .join('||') || `${pagination.value.current}-${index}`,
  210. id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
  211. stationName: fallbackText(item.personStationName),
  212. target: fallbackText(item.personName),
  213. callCount: Number.isFinite(Number(item.callCount)) ? Number(item.callCount) : fallbackText(item.callCount),
  214. lac: fallbackText(item.personRegionId),
  215. cid: fallbackText(item.personCellId),
  216. timeRange: formatTimeRange(item.startDate, item.endDate),
  217. raw: item,
  218. });
  219. const parseCount = (value: number | string | null | undefined) => {
  220. const numericValue = Number(value);
  221. return Number.isFinite(numericValue) ? numericValue : 0;
  222. };
  223. const sortList = (list: TrackCellTowerDTO[]) =>
  224. [...list].sort((left, right) => {
  225. const callCountDiff = parseCount(right.callCount) - parseCount(left.callCount);
  226. if (callCountDiff !== 0) return callCountDiff;
  227. return String(left.personName || left.personStationName || '').localeCompare(
  228. String(right.personName || right.personStationName || ''),
  229. );
  230. });
  231. const buildQuery = (): TrackCellTowerQuery => {
  232. const personNames = filters.value.target.length ? [...filters.value.target] : undefined;
  233. return {
  234. page: pagination.value.current,
  235. limit: pagination.value.pageSize,
  236. startDate: filters.value.dateRange?.[0] || undefined,
  237. endDate: filters.value.dateRange?.[1] || undefined,
  238. personNames,
  239. personStationName: filters.value.stationName.trim() || undefined,
  240. orderKey: DEFAULT_SORT_KEY,
  241. sort: DEFAULT_SORT_ORDER,
  242. };
  243. };
  244. const buildDetailBaseQuery = (record: BaseStationRow): Partial<TrackCellTowerQuery> => {
  245. const currentQuery = buildQuery();
  246. const { startDate, endDate } = mergeDetailDateRange(
  247. {
  248. startDate: currentQuery.startDate,
  249. endDate: currentQuery.endDate,
  250. },
  251. {
  252. startDate: record.raw.startDate,
  253. endDate: record.raw.endDate,
  254. },
  255. );
  256. return {
  257. startDate,
  258. endDate,
  259. personName: pickDetailValue(record.raw.personName, currentQuery.personNames?.[0]),
  260. personStationName: pickDetailValue(record.raw.personStationName, currentQuery.personStationName),
  261. personRegionId: pickDetailValue(record.raw.personRegionId),
  262. personCellId: pickDetailValue(record.raw.personCellId),
  263. };
  264. };
  265. const fetchTable = async () => {
  266. await runWithQueryLoading(async () => {
  267. const resp = await statFirstTrackCellTower(buildQuery());
  268. const list = sortList(normalizeList(resp));
  269. tableData.value = list.map(mapRow);
  270. hasSearched.value = true;
  271. syncAnalysisDataRefreshState(list.length > 0);
  272. pagination.value.total = normalizeTotal(resp, list.length);
  273. const current = Number((resp as any)?.current ?? (resp as any)?.data?.current ?? pagination.value.current);
  274. const size = Number((resp as any)?.size ?? (resp as any)?.data?.size ?? pagination.value.pageSize);
  275. if (Number.isFinite(current) && current > 0) pagination.value.current = current;
  276. if (Number.isFinite(size) && size > 0) pagination.value.pageSize = size;
  277. pagination.value.jumpPage = String(pagination.value.current);
  278. });
  279. };
  280. const handleSearch = async () => {
  281. pagination.value.current = 1;
  282. await fetchTable();
  283. };
  284. const handleReset = async () => {
  285. filters.value = {
  286. target: [],
  287. dateRange: undefined,
  288. stationName: '',
  289. };
  290. pagination.value.current = 1;
  291. await fetchTable();
  292. };
  293. const handlePrevPage = async () => {
  294. if (queryLoading.value || pagination.value.current <= 1) return;
  295. pagination.value.current -= 1;
  296. await fetchTable();
  297. };
  298. const handleNextPage = async () => {
  299. if (queryLoading.value || pagination.value.current >= totalPages.value) return;
  300. pagination.value.current += 1;
  301. await fetchTable();
  302. };
  303. const handlePaginationChange = async (page: number, pageSize: number) => {
  304. if (queryLoading.value) return;
  305. if (pageSize !== pagination.value.pageSize) {
  306. pagination.value.pageSize = pageSize;
  307. await handlePageSizeChange();
  308. return;
  309. }
  310. if (page === pagination.value.current - 1) {
  311. await handlePrevPage();
  312. return;
  313. }
  314. if (page === pagination.value.current + 1) {
  315. await handleNextPage();
  316. return;
  317. }
  318. if (page === pagination.value.current) return;
  319. pagination.value.jumpPage = String(page);
  320. await handleJumpPage();
  321. };
  322. const handlePageSizeChange = async () => {
  323. pagination.value.current = 1;
  324. await fetchTable();
  325. };
  326. const handleJumpPage = async () => {
  327. const nextPage = Number(pagination.value.jumpPage);
  328. if (!Number.isInteger(nextPage) || nextPage < 1) {
  329. createMessage.warning('请输入正确页码');
  330. pagination.value.jumpPage = String(pagination.value.current);
  331. return;
  332. }
  333. pagination.value.current = Math.min(nextPage, totalPages.value);
  334. await fetchTable();
  335. };
  336. const handleViewDetail = (record: BaseStationRow) => {
  337. detailBaseQuery.value = buildDetailBaseQuery(record);
  338. detailModalOpen.value = true;
  339. };
  340. const handleOpenMap = (record: BaseStationRow) => {
  341. const longitude = String(record.raw.personStationLongitude || '').trim();
  342. const latitude = String(record.raw.personStationLatitude || '').trim();
  343. if (!longitude || !latitude) {
  344. createMessage.warning('当前基站暂无坐标信息');
  345. return;
  346. }
  347. mapModalTitle.value = `${record.target === '-' ? '对象' : record.target} · ${
  348. record.stationName === '-' ? '基站位置' : record.stationName
  349. }`;
  350. mapModalStations.value = [record.raw];
  351. mapModalOpen.value = true;
  352. };
  353. const fetchTargetOptions = async () => {
  354. try {
  355. targetOptions.value = await getPersonOptions(false);
  356. } catch (error) {
  357. targetOptions.value = [];
  358. }
  359. };
  360. onMounted(async () => {
  361. await fetchTargetOptions();
  362. });
  363. </script>
  364. <style lang="less">
  365. @import '../styles/trans-analysis-table-ui.less';
  366. @prefix-cls: ~'jeesite-base-station';
  367. .@{prefix-cls}.base-station-page {
  368. .analysis-single-panel-frame();
  369. .filter-input {
  370. width: 180px;
  371. }
  372. .filter-date {
  373. width: 360px;
  374. }
  375. .table-wrapper .ant-table-cell {
  376. font-size: 12px;
  377. }
  378. .query-state-panel {
  379. flex: 1 1 auto;
  380. min-height: 0;
  381. display: flex;
  382. align-items: center;
  383. justify-content: center;
  384. padding: 12px 0 0;
  385. }
  386. .pagination-input {
  387. width: 48px;
  388. }
  389. :deep(.ant-select-selector),
  390. :deep(.ant-input),
  391. :deep(.ant-picker),
  392. :deep(.ant-btn) {
  393. border-radius: 8px;
  394. }
  395. @media screen and (max-width: 992px) {
  396. .filter-input,
  397. .filter-date {
  398. width: 100%;
  399. }
  400. }
  401. }
  402. </style>