StdTable.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. <script setup lang="ts" generic="T=any">
  2. import type { TableProps } from 'ant-design-vue'
  3. import type { Key } from 'ant-design-vue/es/_util/type'
  4. import type { FilterValue } from 'ant-design-vue/es/table/interface'
  5. import type { SorterResult, TablePaginationConfig } from 'ant-design-vue/lib/table/interface'
  6. import type { ComputedRef, Ref } from 'vue'
  7. import type { RouteParams } from 'vue-router'
  8. import type { GetListResponse, Pagination } from '@/api/curd'
  9. import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
  10. import type { Column } from '@/components/StdDesign/types'
  11. import { HolderOutlined } from '@ant-design/icons-vue'
  12. import { message } from 'ant-design-vue'
  13. import { debounce } from 'lodash'
  14. import { getPithyColumns } from '@/components/StdDesign/StdDataDisplay/methods/columns'
  15. import useSortable from '@/components/StdDesign/StdDataDisplay/methods/sortable'
  16. import StdBulkActions from '@/components/StdDesign/StdDataDisplay/StdBulkActions.vue'
  17. import StdDataEntry, { labelRender } from '@/components/StdDesign/StdDataEntry'
  18. import StdPagination from './StdPagination.vue'
  19. const props = withDefaults(defineProps<StdTableProps<T>>(), {
  20. rowKey: 'id',
  21. })
  22. const emit = defineEmits([
  23. 'clickEdit',
  24. 'clickView',
  25. 'clickBatchModify',
  26. ])
  27. const selectedRowKeys = defineModel<(number | string)[]>('selectedRowKeys', {
  28. default: () => reactive([]),
  29. })
  30. const selectedRows = defineModel<T[]>('selectedRows', {
  31. default: () => reactive([]),
  32. })
  33. const route = useRoute()
  34. const dataSource: Ref<T[]> = ref([])
  35. const expandKeysList: Ref<Key[]> = ref([])
  36. watch(dataSource, () => {
  37. if (!props.expandAll)
  38. return
  39. const res: Key[] = []
  40. function buildKeysList(record) {
  41. record.children?.forEach(v => {
  42. buildKeysList(v)
  43. })
  44. res.push(record[props.rowKey])
  45. }
  46. dataSource.value.forEach(v => {
  47. buildKeysList(v)
  48. })
  49. expandKeysList.value = res
  50. })
  51. // eslint-disable-next-line ts/no-explicit-any
  52. const rowsKeyIndexMap: Ref<Record<number, any>> = ref({})
  53. const loading = ref(true)
  54. // eslint-disable-next-line ts/no-explicit-any
  55. const selectedRecords: Ref<Record<any, any>> = ref({})
  56. // This can be useful if there are more than one StdTable in the same page.
  57. // eslint-disable-next-line sonarjs/pseudo-random
  58. const randomId = ref(Math.random().toString(36).substring(2, 8))
  59. const updateFilter = ref(0)
  60. const init = ref(false)
  61. const pagination: Pagination = reactive({
  62. total: 1,
  63. per_page: 10,
  64. current_page: 1,
  65. total_pages: 1,
  66. })
  67. const filterParams = ref({})
  68. const paginationParams = ref({
  69. page: 1,
  70. page_size: 20,
  71. })
  72. const sortParams = ref({
  73. order: 'desc' as 'desc' | 'asc' | undefined,
  74. sort_by: '' as Key | readonly Key[] | undefined,
  75. })
  76. const params = computed(() => {
  77. return {
  78. ...filterParams.value,
  79. ...sortParams.value,
  80. ...props.getParams,
  81. ...props.overwriteParams,
  82. trash: props.inTrash,
  83. }
  84. })
  85. onMounted(() => {
  86. selectedRows.value.forEach(v => {
  87. selectedRecords.value[v[props.rowKey]] = v
  88. })
  89. })
  90. const searchColumns = computed(() => {
  91. const _searchColumns: Column[] = []
  92. props.columns.forEach((column: Column) => {
  93. if (column.search) {
  94. if (typeof column.search === 'object') {
  95. _searchColumns.push({
  96. ...column,
  97. edit: column.search,
  98. })
  99. }
  100. else {
  101. _searchColumns.push({ ...column })
  102. }
  103. }
  104. })
  105. return _searchColumns
  106. })
  107. const pithyColumns = computed<Column[]>(() => {
  108. if (props.pithy)
  109. return getPithyColumns(props.columns)
  110. return props.columns?.filter(c => {
  111. return !c.hiddenInTable
  112. })
  113. })
  114. const batchColumns = computed(() => {
  115. return props.columns?.filter(column => column.batch) || []
  116. })
  117. const radioColumns = computed(() => {
  118. return props.columns?.filter(column => column.radio) || []
  119. })
  120. const get_list = debounce(_get_list, 100, {
  121. leading: false,
  122. trailing: true,
  123. })
  124. onMounted(async () => {
  125. if (!props.disableQueryParams) {
  126. filterParams.value = {
  127. ...route.query,
  128. ...props.getParams,
  129. }
  130. paginationParams.value.page = Number(route.query.page) || 1
  131. paginationParams.value.page_size = Number(route.query.page_size) || 20
  132. }
  133. await nextTick()
  134. get_list()
  135. if (props.sortable)
  136. initSortable()
  137. init.value = true
  138. })
  139. defineExpose({
  140. get_list,
  141. pagination,
  142. resetSelection,
  143. loading,
  144. })
  145. function destroy(id: number | string) {
  146. props.api!.destroy(id, { permanent: props.inTrash }).then(() => {
  147. get_list()
  148. message.success($gettext('Deleted successfully'))
  149. })
  150. }
  151. function recover(id: number | string) {
  152. props.api.recover(id).then(() => {
  153. message.success($gettext('Recovered Successfully'))
  154. get_list()
  155. })
  156. }
  157. // eslint-disable-next-line ts/no-explicit-any
  158. function buildIndexMap(data: any, level: number = 0, index: number = 0, total: number[] = []) {
  159. if (data && data.length > 0) {
  160. // eslint-disable-next-line ts/no-explicit-any
  161. data.forEach((v: any) => {
  162. v.level = level
  163. const current_indexes = [...total, index++]
  164. rowsKeyIndexMap.value[v.id] = current_indexes
  165. if (v.children)
  166. buildIndexMap(v.children, level + 1, 0, current_indexes)
  167. })
  168. }
  169. }
  170. async function _get_list() {
  171. dataSource.value = []
  172. loading.value = true
  173. // eslint-disable-next-line ts/no-explicit-any
  174. await props.api?.get_list({ ...params.value, ...paginationParams.value }).then(async (r: GetListResponse<any>) => {
  175. dataSource.value = r.data
  176. rowsKeyIndexMap.value = {}
  177. if (props.sortable)
  178. buildIndexMap(r.data)
  179. if (r.pagination)
  180. Object.assign(pagination, r.pagination)
  181. })
  182. loading.value = false
  183. }
  184. // eslint-disable-next-line ts/no-explicit-any
  185. function onTableChange(_pagination: TablePaginationConfig, filters: Record<string, FilterValue>, sorter: SorterResult | SorterResult<any>[]) {
  186. if (sorter) {
  187. sorter = sorter as SorterResult
  188. selectedRowKeys.value = []
  189. sortParams.value.sort_by = sorter.field
  190. switch (sorter.order) {
  191. case 'ascend':
  192. sortParams.value.order = 'asc'
  193. break
  194. case 'descend':
  195. sortParams.value.order = 'desc'
  196. break
  197. default:
  198. sortParams.value.order = undefined
  199. break
  200. }
  201. }
  202. if (filters) {
  203. Object.keys(filters).forEach((v: string) => {
  204. params[v] = filters[v]
  205. })
  206. }
  207. if (_pagination)
  208. selectedRowKeys.value = []
  209. }
  210. function expandedTable(keys: Key[]) {
  211. expandKeysList.value = keys
  212. }
  213. // eslint-disable-next-line ts/no-explicit-any
  214. async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
  215. // console.log('onSelect', record, selected, _selectedRows)
  216. if (props.selectionType === 'checkbox' || props.exportExcel || batchColumns.value.length > 0 || props.bulkActions) {
  217. if (selected) {
  218. _selectedRows.forEach(v => {
  219. if (v) {
  220. if (selectedRecords.value[v[props.rowKey]] === undefined)
  221. selectedRowKeys.value.push(v[props.rowKey])
  222. selectedRecords.value[v[props.rowKey]] = v
  223. }
  224. })
  225. }
  226. else {
  227. selectedRowKeys.value.splice(selectedRowKeys.value.indexOf(record[props.rowKey]), 1)
  228. delete selectedRecords.value[record[props.rowKey]]
  229. }
  230. await nextTick()
  231. selectedRows.value = [...selectedRowKeys.value.map(v => selectedRecords.value[v])]
  232. }
  233. else if (selected) {
  234. selectedRowKeys.value = record[props.rowKey]
  235. selectedRows.value = [record]
  236. }
  237. else {
  238. selectedRowKeys.value = []
  239. selectedRows.value = []
  240. }
  241. }
  242. // eslint-disable-next-line ts/no-explicit-any
  243. async function onSelectAll(selected: boolean, _selectedRows: any[], changeRows: any[]) {
  244. // console.log('onSelectAll', selected, selectedRows, changeRows)
  245. // eslint-disable-next-line ts/no-explicit-any
  246. changeRows.forEach((v: any) => {
  247. if (v) {
  248. if (selected) {
  249. selectedRowKeys.value.push(v[props.rowKey])
  250. selectedRecords.value[v[props.rowKey]] = v
  251. }
  252. else {
  253. delete selectedRecords.value[v[props.rowKey]]
  254. }
  255. }
  256. })
  257. if (!selected) {
  258. selectedRowKeys.value.splice(0, selectedRowKeys.value.length, ...selectedRowKeys.value.filter(v => selectedRecords.value[v]))
  259. }
  260. // console.log(selectedRowKeysBuffer.value, selectedRecords.value)
  261. await nextTick()
  262. selectedRows.value.splice(0, selectedRows.value.length, ...selectedRowKeys.value.map(v => selectedRecords.value[v]))
  263. }
  264. function resetSelection() {
  265. selectedRowKeys.value = reactive([])
  266. selectedRows.value = reactive([])
  267. selectedRecords.value = reactive({})
  268. }
  269. const router = useRouter()
  270. async function resetSearch() {
  271. filterParams.value = {}
  272. updateFilter.value++
  273. }
  274. watch(params, async v => {
  275. if (!init.value)
  276. return
  277. paginationParams.value = {
  278. page: 1,
  279. page_size: paginationParams.value.page_size,
  280. }
  281. await nextTick()
  282. if (!props.disableQueryParams)
  283. await router.push({ query: { ...v as unknown as RouteParams, ...paginationParams.value } })
  284. else
  285. get_list()
  286. }, { deep: true })
  287. watch(() => route.query, () => {
  288. if (init.value)
  289. get_list()
  290. })
  291. const rowSelection = computed(() => {
  292. if (batchColumns.value.length > 0 || props.selectionType || props.exportExcel || props.bulkActions) {
  293. return {
  294. selectedRowKeys: unref(selectedRowKeys),
  295. onSelect,
  296. onSelectAll,
  297. getCheckboxProps: props?.getCheckboxProps,
  298. type: (batchColumns.value.length > 0 || props.exportExcel || props.bulkActions) ? 'checkbox' : props.selectionType,
  299. }
  300. }
  301. else {
  302. return null
  303. }
  304. }) as ComputedRef<TableProps['rowSelection']>
  305. const hasSelectedRow = computed(() => {
  306. return batchColumns.value.length > 0 && selectedRowKeys.value.length > 0
  307. })
  308. function clickBatchEdit() {
  309. emit('clickBatchModify', batchColumns.value, selectedRowKeys.value, selectedRows.value)
  310. }
  311. function initSortable() {
  312. useSortable(props, randomId, dataSource, rowsKeyIndexMap, expandKeysList)
  313. }
  314. async function changePage(page: number, page_size: number) {
  315. if (page) {
  316. paginationParams.value = {
  317. page,
  318. page_size,
  319. }
  320. }
  321. else {
  322. paginationParams.value = {
  323. page: 1,
  324. page_size,
  325. }
  326. }
  327. await nextTick()
  328. if (!props.disableQueryParams)
  329. await router.push({ query: { ...route.query, ...paginationParams.value } })
  330. get_list()
  331. }
  332. const paginationSize = computed(() => {
  333. if (props.size === 'small')
  334. return 'small'
  335. else
  336. return 'default'
  337. })
  338. </script>
  339. <template>
  340. <div class="std-table">
  341. <div v-if="radioColumns.length">
  342. <AFormItem
  343. v-for="column in radioColumns"
  344. :key="column.dataIndex as PropertyKey"
  345. :label="labelRender(column.title)"
  346. >
  347. <ARadioGroup v-model:value="params[column.dataIndex as string]">
  348. <ARadioButton :value="undefined">
  349. {{ $gettext('All') }}
  350. </ARadioButton>
  351. <ARadioButton
  352. v-for="(value, key) in column.mask"
  353. :key
  354. :value="key"
  355. >
  356. {{ labelRender(value) }}
  357. </ARadioButton>
  358. </ARadioGroup>
  359. </AFormItem>
  360. </div>
  361. <StdDataEntry
  362. v-if="!disableSearch && searchColumns.length"
  363. :key="updateFilter"
  364. :data-list="searchColumns"
  365. :data-source="filterParams"
  366. type="search"
  367. layout="inline"
  368. >
  369. <template #action>
  370. <ASpace class="action-btn">
  371. <AButton @click="resetSearch">
  372. {{ $gettext('Reset') }}
  373. </AButton>
  374. <AButton
  375. v-if="hasSelectedRow"
  376. @click="clickBatchEdit"
  377. >
  378. {{ $gettext('Batch Modify') }}
  379. </AButton>
  380. <slot name="append-search" />
  381. </ASpace>
  382. </template>
  383. </StdDataEntry>
  384. <StdBulkActions
  385. v-if="bulkActions"
  386. v-model:selected-row-keys="selectedRowKeys"
  387. :api
  388. :in-trash="inTrash"
  389. :actions="bulkActions"
  390. @on-success="() => { resetSelection(); get_list() }"
  391. />
  392. <ATable
  393. :id="`std-table-${randomId}`"
  394. :columns="pithyColumns"
  395. :data-source="dataSource"
  396. :loading="loading"
  397. :pagination="false"
  398. :row-key="rowKey"
  399. :row-selection="rowSelection"
  400. :scroll="{ x: scrollX ?? true }"
  401. :size="size as any"
  402. :expanded-row-keys="expandKeysList"
  403. @change="onTableChange"
  404. @expanded-rows-change="expandedTable"
  405. >
  406. <template #bodyCell="{ text, record, column }: {text: any, record: Record<string, any>, column: any}">
  407. <template v-if="column.handle === true">
  408. <span class="ant-table-drag-icon"><HolderOutlined /></span>
  409. {{ text }}
  410. </template>
  411. <div v-if="column.dataIndex === 'action'" class="action">
  412. <template v-if="!props.disableView && !inTrash">
  413. <AButton
  414. type="link"
  415. size="small"
  416. @click="$emit('clickView', record[props.rowKey], record)"
  417. >
  418. {{ $gettext('View') }}
  419. </AButton>
  420. </template>
  421. <template v-if="!props.disableModify && !inTrash">
  422. <AButton
  423. type="link"
  424. size="small"
  425. @click="$emit('clickEdit', record[props.rowKey], record)"
  426. >
  427. {{ $gettext('Modify') }}
  428. </AButton>
  429. </template>
  430. <slot
  431. name="actions"
  432. :record="record"
  433. />
  434. <template v-if="!props.disableDelete">
  435. <APopconfirm
  436. v-if="!inTrash"
  437. :cancel-text="$gettext('No')"
  438. :ok-text="$gettext('Ok')"
  439. :title="$gettext('Are you sure you want to delete this item?')"
  440. @confirm="destroy(record[rowKey])"
  441. >
  442. <AButton
  443. type="link"
  444. size="small"
  445. >
  446. {{ $gettext('Delete') }}
  447. </AButton>
  448. </APopconfirm>
  449. <APopconfirm
  450. v-else
  451. :cancel-text="$gettext('No')"
  452. :ok-text="$gettext('Ok')"
  453. :title="$gettext('Are you sure you want to recover this item?')"
  454. @confirm="recover(record[rowKey])"
  455. >
  456. <AButton
  457. type="link"
  458. size="small"
  459. >
  460. {{ $gettext('Recover') }}
  461. </AButton>
  462. </APopconfirm>
  463. <APopconfirm
  464. v-if="inTrash"
  465. :cancel-text="$gettext('No')"
  466. :ok-text="$gettext('Ok')"
  467. :title="$gettext('Are you sure you want to delete this item permanently?')"
  468. @confirm="destroy(record[rowKey])"
  469. >
  470. <AButton
  471. type="link"
  472. size="small"
  473. >
  474. {{ $gettext('Delete Permanently') }}
  475. </AButton>
  476. </APopconfirm>
  477. </template>
  478. </div>
  479. </template>
  480. </ATable>
  481. <StdPagination
  482. :size="paginationSize"
  483. :loading="loading"
  484. :pagination="pagination"
  485. @change="changePage"
  486. @change-page-size="onTableChange"
  487. />
  488. </div>
  489. </template>
  490. <style lang="less">
  491. .ant-table-scroll {
  492. .ant-table-body {
  493. overflow-x: auto !important;
  494. overflow-y: hidden !important;
  495. }
  496. }
  497. .std-table {
  498. overflow-x: hidden !important;
  499. overflow-y: hidden !important;
  500. }
  501. </style>
  502. <style lang="less" scoped>
  503. .ant-form {
  504. margin: 10px 0 20px 0;
  505. }
  506. .ant-slider {
  507. min-width: 90px;
  508. }
  509. .action-btn {
  510. // min-height: 50px;
  511. height: 100%;
  512. display: flex;
  513. align-items: flex-start;
  514. }
  515. :deep(.ant-form-inline .ant-form-item) {
  516. margin-bottom: 10px;
  517. }
  518. .ant-divider {
  519. &:last-child {
  520. display: none;
  521. }
  522. }
  523. .action {
  524. @media (max-width: 768px) {
  525. .ant-divider-vertical {
  526. display: none;
  527. }
  528. }
  529. }
  530. </style>
  531. <style lang="less">
  532. .ant-table-drag-icon {
  533. float: left;
  534. margin-right: 16px;
  535. cursor: grab;
  536. }
  537. .sortable-ghost *, .sortable-chosen * {
  538. cursor: grabbing !important;
  539. }
  540. </style>