StdTable.vue 16 KB

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