index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. <template>
  2. <div :class="prefixCls" class="express-delivery-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 v-model:value="filters.dateRange" class="filter-date" />
  23. </div>
  24. <div class="filter-item">
  25. <span class="filter-label">地点:</span>
  26. <Input v-model:value="filters.location" class="filter-input" placeholder="请输入地点" />
  27. </div>
  28. <div class="filter-actions">
  29. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  30. <Button @click="handleReset">重置</Button>
  31. </div>
  32. </div>
  33. <div class="content-panel">
  34. <div class="table-card">
  35. <div class="panel-header">
  36. <div>
  37. <div class="panel-title">快递收发汇总</div>
  38. <div class="panel-desc">
  39. {{
  40. tableData.length
  41. ? `当前命中 ${pagination.total} 条地点汇总记录,可继续查看对应快递收发详情。`
  42. : '展示对象在不同地点的收件、寄件次数,方便后续定位地址与物流轨迹。'
  43. }}
  44. </div>
  45. </div>
  46. </div>
  47. <div class="table-panel">
  48. <div v-if="!hasSearched" class="query-state-panel">
  49. <EmptyState variant="query" title="请点击查询" description="设置条件精细查询,也可点击查询展示所有信息" />
  50. </div>
  51. <div v-else class="table-wrapper">
  52. <BasicTable
  53. :columns="columns"
  54. :action-column="actionColumn"
  55. :data-source="tableData"
  56. :loading="queryLoading"
  57. :pagination="false"
  58. :showIndexColumn="false"
  59. :show-table-setting="false"
  60. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  61. :can-resize="true"
  62. :is-can-resize-parent="true"
  63. :resize-height-offset="32"
  64. row-key="rowKey"
  65. >
  66. <template #action="{ record }">
  67. <Button type="link" size="small" @click="handleViewDetail(record)">查看详情</Button>
  68. </template>
  69. </BasicTable>
  70. </div>
  71. </div>
  72. <div v-if="hasSearched" class="pagination-bar">
  73. <AnalysisPagination
  74. :total="pagination.total"
  75. :current="pagination.current"
  76. :page-size="pagination.pageSize"
  77. :page-size-options="pageSizeOptions"
  78. :loading="queryLoading"
  79. @change="handlePaginationChange"
  80. />
  81. </div>
  82. </div>
  83. </div>
  84. <ExpressInfoDetailModal v-model:open="detailModalOpen" :base-query="detailBaseQuery" />
  85. </div>
  86. </template>
  87. <script lang="ts" setup name="ViewsExpressDeliveryIndex">
  88. import { computed, onMounted, ref } from 'vue';
  89. import dayjs, { type Dayjs } from 'dayjs';
  90. import { Button, DatePicker, Input, Select } from 'ant-design-vue';
  91. import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  92. import { EmptyState } from '@jeesite/core/components/EmptyState';
  93. import { BasicTable } from '@jeesite/core/components/Table';
  94. import type { BasicColumn } from '@jeesite/core/components/Table';
  95. import componentSetting from '@jeesite/core/settings/componentSetting';
  96. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  97. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  98. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  99. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  100. import { pickDetailValue } from '@jeesite/core/utils/detailQuery';
  101. import { getPersonOptions } from '@jeesite/person/api/personApi';
  102. import { statFirstExpressInfo } from '@jeesite/track/api/trackApi';
  103. import type { ExpressInfoDTO } from '@jeesite/track/api/types/dto';
  104. import type { ExpressInfoQuery } from '@jeesite/track/api/types/query';
  105. import ExpressInfoDetailModal from '../components/ExpressInfoDetailModal.vue';
  106. type RangeValue = [Dayjs, Dayjs] | undefined;
  107. interface ExpressSummaryRow {
  108. rowKey: string;
  109. id: number;
  110. personName: string;
  111. location: string;
  112. expressCount: number | string;
  113. recipientCount: number | string;
  114. senderCount: number | string;
  115. raw: ExpressInfoDTO & Record<string, any>;
  116. }
  117. const DEFAULT_SORT_KEY = 'expressCount';
  118. const DEFAULT_SORT_ORDER = 'DESC' as const;
  119. const { prefixCls } = useDesign('express-delivery');
  120. const { createMessage } = useMessage();
  121. const { queryLoading, runWithQueryLoading } = useTableSkill();
  122. const RangePicker = DatePicker.RangePicker;
  123. const filters = ref({
  124. target: [] as string[],
  125. dateRange: undefined as RangeValue,
  126. location: '',
  127. });
  128. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  129. const tableData = ref<ExpressSummaryRow[]>([]);
  130. const hasSearched = ref(false);
  131. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  132. refresh: () => fetchTable(),
  133. hasData: () => hasSearched.value && tableData.value.length > 0,
  134. });
  135. const detailModalOpen = ref(false);
  136. const detailBaseQuery = ref<Partial<ExpressInfoQuery>>({});
  137. const pagination = ref({
  138. current: 1,
  139. pageSize: componentSetting.table.defaultPageSize,
  140. total: 0,
  141. jumpPage: '1',
  142. });
  143. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  144. label: value,
  145. value: Number(value),
  146. }));
  147. const totalPages = computed(() => {
  148. if (!pagination.value.total) return 1;
  149. return Math.max(1, Math.ceil(pagination.value.total / pagination.value.pageSize));
  150. });
  151. const columns: BasicColumn[] = [
  152. { title: '排序', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
  153. { title: '被调查对象', dataIndex: 'personName', key: 'personName', width: 150, align: 'center' },
  154. { title: '地点', dataIndex: 'location', key: 'location', width: 320, align: 'center' },
  155. { title: '快递总数', dataIndex: 'expressCount', key: 'expressCount', width: 120, align: 'center' },
  156. { title: '收件次数', dataIndex: 'recipientCount', key: 'recipientCount', width: 120, align: 'center' },
  157. { title: '寄件次数', dataIndex: 'senderCount', key: 'senderCount', width: 120, align: 'center' },
  158. ];
  159. const actionColumn = {
  160. title: '操作',
  161. dataIndex: 'action',
  162. key: 'action',
  163. width: 120,
  164. align: 'center' as const,
  165. fixed: 'right' as const,
  166. slot: 'action',
  167. };
  168. const normalizeList = (resp: any): ExpressInfoDTO[] => {
  169. if (Array.isArray(resp)) return resp;
  170. if (Array.isArray(resp?.list)) return resp.list;
  171. if (Array.isArray(resp?.data)) return resp.data;
  172. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  173. if (Array.isArray(resp?.records)) return resp.records;
  174. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  175. return [];
  176. };
  177. const normalizeTotal = (resp: any, listLen: number) => {
  178. const total = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? listLen;
  179. return Number.isFinite(Number(total)) ? Number(total) : listLen;
  180. };
  181. const fallbackText = (value: string | number | null | undefined) => {
  182. if (value === 0) return '0';
  183. if (value === null || value === undefined || value === '') return '-';
  184. return String(value);
  185. };
  186. const normalizeCount = (value: number | string | null | undefined) => {
  187. const numericValue = Number(value);
  188. return Number.isFinite(numericValue) ? numericValue : fallbackText(value);
  189. };
  190. const parseCount = (value: number | string | null | undefined) => {
  191. const numericValue = Number(value);
  192. return Number.isFinite(numericValue) ? numericValue : 0;
  193. };
  194. const normalizeIds = (value: unknown): string[] | undefined => {
  195. if (Array.isArray(value)) {
  196. const ids = value.map((item) => String(item).trim()).filter(Boolean);
  197. return ids.length ? ids : undefined;
  198. }
  199. if (typeof value === 'string') {
  200. const ids = value
  201. .split(',')
  202. .map((item) => item.trim())
  203. .filter(Boolean);
  204. return ids.length ? ids : undefined;
  205. }
  206. return undefined;
  207. };
  208. const mapSummaryRow = (item: ExpressInfoDTO, index: number): ExpressSummaryRow => ({
  209. rowKey: item.ids || `${item.personName || 'unknown'}-${item.location || 'unknown'}-${index}`,
  210. id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
  211. personName: fallbackText(item.personName),
  212. location: fallbackText(item.location),
  213. expressCount: normalizeCount(item.expressCount),
  214. recipientCount: normalizeCount(item.recipientCount),
  215. senderCount: normalizeCount(item.senderCount),
  216. raw: item,
  217. });
  218. const sortSummaryList = (list: ExpressInfoDTO[]) =>
  219. [...list].sort((left, right) => {
  220. const countDiff = parseCount(right.expressCount) - parseCount(left.expressCount);
  221. if (countDiff !== 0) return countDiff;
  222. const recipientDiff = parseCount(right.recipientCount) - parseCount(left.recipientCount);
  223. if (recipientDiff !== 0) return recipientDiff;
  224. return String(left.personName || left.location || '').localeCompare(
  225. String(right.personName || right.location || ''),
  226. );
  227. });
  228. const buildQuery = (): ExpressInfoQuery => {
  229. const startDate =
  230. Array.isArray(filters.value.dateRange) && filters.value.dateRange[0]
  231. ? dayjs(filters.value.dateRange[0]).format('YYYY-MM-DD')
  232. : undefined;
  233. const endDate =
  234. Array.isArray(filters.value.dateRange) && filters.value.dateRange[1]
  235. ? dayjs(filters.value.dateRange[1]).format('YYYY-MM-DD')
  236. : undefined;
  237. const personNames = filters.value.target.length ? [...filters.value.target] : undefined;
  238. return {
  239. page: pagination.value.current,
  240. limit: pagination.value.pageSize,
  241. startDate,
  242. endDate,
  243. personNames,
  244. location: filters.value.location.trim() || undefined,
  245. orderKey: DEFAULT_SORT_KEY,
  246. sort: DEFAULT_SORT_ORDER,
  247. };
  248. };
  249. const buildDetailBaseQuery = (record: ExpressSummaryRow): Partial<ExpressInfoQuery> => {
  250. const currentQuery = buildQuery();
  251. const recordPersonName = String(record.raw.personName || '').trim();
  252. return {
  253. startDate: currentQuery.startDate,
  254. endDate: currentQuery.endDate,
  255. // 二层详情的地点固定继承外层筛选条件,不再带行内汇总地点。
  256. location: currentQuery.location,
  257. personName: pickDetailValue(recordPersonName, currentQuery.personNames?.[0]),
  258. personNames: recordPersonName ? [recordPersonName] : currentQuery.personNames,
  259. ids: normalizeIds(record.raw.ids),
  260. };
  261. };
  262. const fetchTable = async () => {
  263. await runWithQueryLoading(async () => {
  264. const resp = await statFirstExpressInfo(buildQuery());
  265. const list = sortSummaryList(normalizeList(resp));
  266. tableData.value = list.map(mapSummaryRow);
  267. pagination.value.total = normalizeTotal(resp, list.length);
  268. hasSearched.value = true;
  269. syncAnalysisDataRefreshState(list.length > 0);
  270. const current = Number((resp as any)?.current ?? (resp as any)?.data?.current ?? pagination.value.current);
  271. const size = Number((resp as any)?.size ?? (resp as any)?.data?.size ?? pagination.value.pageSize);
  272. if (Number.isFinite(current) && current > 0) pagination.value.current = current;
  273. if (Number.isFinite(size) && size > 0) pagination.value.pageSize = size;
  274. pagination.value.jumpPage = String(pagination.value.current);
  275. });
  276. };
  277. const handleSearch = async () => {
  278. pagination.value.current = 1;
  279. await fetchTable();
  280. };
  281. const handleReset = async () => {
  282. filters.value = {
  283. target: [],
  284. dateRange: undefined,
  285. location: '',
  286. };
  287. pagination.value.current = 1;
  288. await fetchTable();
  289. };
  290. const handlePrevPage = async () => {
  291. if (queryLoading.value || pagination.value.current <= 1) return;
  292. pagination.value.current -= 1;
  293. await fetchTable();
  294. };
  295. const handleNextPage = async () => {
  296. if (queryLoading.value || pagination.value.current >= totalPages.value) return;
  297. pagination.value.current += 1;
  298. await fetchTable();
  299. };
  300. const handlePaginationChange = async (page: number, pageSize: number) => {
  301. if (queryLoading.value) return;
  302. if (pageSize !== pagination.value.pageSize) {
  303. pagination.value.pageSize = pageSize;
  304. await handlePageSizeChange();
  305. return;
  306. }
  307. if (page === pagination.value.current - 1) {
  308. await handlePrevPage();
  309. return;
  310. }
  311. if (page === pagination.value.current + 1) {
  312. await handleNextPage();
  313. return;
  314. }
  315. if (page === pagination.value.current) return;
  316. pagination.value.jumpPage = String(page);
  317. await handleJumpPage();
  318. };
  319. const handlePageSizeChange = async () => {
  320. pagination.value.current = 1;
  321. await fetchTable();
  322. };
  323. const handleJumpPage = async () => {
  324. const nextPage = Number(pagination.value.jumpPage);
  325. if (!Number.isInteger(nextPage) || nextPage < 1) {
  326. createMessage.warning('请输入正确页码');
  327. pagination.value.jumpPage = String(pagination.value.current);
  328. return;
  329. }
  330. pagination.value.current = Math.min(nextPage, totalPages.value);
  331. await fetchTable();
  332. };
  333. const handleViewDetail = (record: ExpressSummaryRow) => {
  334. detailBaseQuery.value = buildDetailBaseQuery(record);
  335. detailModalOpen.value = true;
  336. };
  337. const fetchTargetOptions = async () => {
  338. try {
  339. targetOptions.value = await getPersonOptions(false);
  340. } catch (error) {
  341. targetOptions.value = [];
  342. }
  343. };
  344. onMounted(async () => {
  345. await fetchTargetOptions();
  346. });
  347. </script>
  348. <style lang="less">
  349. @import '../styles/trans-analysis-table-ui.less';
  350. @prefix-cls: ~'jeesite-express-delivery';
  351. .@{prefix-cls}.express-delivery-page {
  352. .analysis-page-frame();
  353. .filter-input {
  354. width: 180px;
  355. }
  356. .filter-date {
  357. width: 360px;
  358. }
  359. .content-panel {
  360. flex: 1 1 auto;
  361. min-height: 0;
  362. display: flex;
  363. flex-direction: column;
  364. gap: 2px;
  365. }
  366. .table-card {
  367. .analysis-card-shell();
  368. flex: 1 1 auto;
  369. }
  370. .query-state-panel {
  371. flex: 1 1 auto;
  372. min-height: 0;
  373. display: flex;
  374. align-items: center;
  375. justify-content: center;
  376. padding: 12px 0 0;
  377. }
  378. .table-wrapper {
  379. flex: 1 1 auto;
  380. min-height: 0;
  381. border: 1px solid #eef2f7;
  382. border-radius: 10px;
  383. overflow: hidden;
  384. }
  385. .table-wrapper .jeesite-basic-table {
  386. height: 100%;
  387. display: flex;
  388. flex-direction: column;
  389. }
  390. .table-wrapper .ant-table-wrapper {
  391. height: 100%;
  392. min-height: 0;
  393. }
  394. .table-wrapper .ant-spin-nested-loading,
  395. .table-wrapper .ant-spin-container {
  396. height: 100%;
  397. min-height: 0;
  398. }
  399. .table-wrapper .ant-spin-container,
  400. .table-wrapper .ant-table,
  401. .table-wrapper .ant-table-container {
  402. height: 100%;
  403. min-height: 0;
  404. display: flex;
  405. flex-direction: column;
  406. }
  407. .table-wrapper .ant-table-body {
  408. max-height: 100% !important;
  409. }
  410. .table-wrapper .ant-table-cell {
  411. font-size: 12px;
  412. }
  413. .pagination-input {
  414. width: 48px;
  415. }
  416. :deep(.ant-select-selector),
  417. :deep(.ant-input),
  418. :deep(.ant-picker),
  419. :deep(.ant-btn) {
  420. border-radius: 8px;
  421. }
  422. @media screen and (max-width: 992px) {
  423. .filter-actions {
  424. width: 100%;
  425. margin-left: 0;
  426. }
  427. .filter-input,
  428. .filter-date {
  429. width: 100%;
  430. }
  431. }
  432. }
  433. </style>