StdTable.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. <script setup lang="ts">
  2. import gettext from '@/gettext'
  3. import StdDataEntry from '@/components/StdDataEntry'
  4. import StdPagination from './StdPagination.vue'
  5. import {computed, onMounted, reactive, ref, watch} from 'vue'
  6. import {useRoute, useRouter} from 'vue-router'
  7. import {message} from 'ant-design-vue'
  8. import {downloadCsv} from '@/lib/helper'
  9. import dayjs from 'dayjs'
  10. import Sortable from 'sortablejs'
  11. import {HolderOutlined} from '@ant-design/icons-vue'
  12. import {toRaw} from '@vue/reactivity'
  13. const {$gettext, interpolate} = gettext
  14. const emit = defineEmits(['onSelected', 'onSelectedRecord', 'clickEdit', 'update:selectedRowKeys', 'clickBatchModify'])
  15. const props = defineProps({
  16. api: Object,
  17. columns: Array,
  18. data_key: {
  19. type: String,
  20. default: 'data'
  21. },
  22. disable_search: {
  23. type: Boolean,
  24. default: false
  25. },
  26. disable_query_params: {
  27. type: Boolean,
  28. default: false
  29. },
  30. disable_add: {
  31. type: Boolean,
  32. default: false
  33. },
  34. edit_text: String,
  35. deletable: {
  36. type: Boolean,
  37. default: true
  38. },
  39. get_params: {
  40. type: Object,
  41. default() {
  42. return {}
  43. }
  44. },
  45. editable: {
  46. type: Boolean,
  47. default: true
  48. },
  49. selectionType: {
  50. type: String,
  51. validator: function (value: string) {
  52. return ['checkbox', 'radio'].indexOf(value) !== -1
  53. }
  54. },
  55. pithy: {
  56. type: Boolean,
  57. default: false
  58. },
  59. scrollX: {
  60. type: [Number, Boolean],
  61. default: true
  62. },
  63. rowKey: {
  64. type: String,
  65. default: 'id'
  66. },
  67. exportCsv: {
  68. type: Boolean,
  69. default: false
  70. },
  71. size: String,
  72. selectedRowKeys: {
  73. type: Array
  74. },
  75. useSortable: Boolean
  76. })
  77. const data_source: any = ref([])
  78. const expand_keys_list: any = ref([])
  79. const rows_key_index_map: any = ref({})
  80. const loading = ref(true)
  81. const pagination = reactive({
  82. total: 1,
  83. per_page: 10,
  84. current_page: 1,
  85. total_pages: 1
  86. })
  87. const route = useRoute()
  88. const params = reactive({
  89. ...props.get_params
  90. })
  91. const selectedKeysLocalBuffer: any = ref([])
  92. const selectedRowKeysBuffer = computed({
  93. get() {
  94. return props.selectedRowKeys || selectedKeysLocalBuffer.value
  95. },
  96. set(v) {
  97. selectedKeysLocalBuffer.value = v
  98. emit('update:selectedRowKeys', v)
  99. }
  100. })
  101. const searchColumns = getSearchColumns()
  102. const pithyColumns = getPithyColumns()
  103. const batchColumns = getBatchEditColumns()
  104. onMounted(() => {
  105. if (!props.disable_query_params) {
  106. Object.assign(params, route.query)
  107. }
  108. get_list()
  109. if (props.useSortable) {
  110. initSortable()
  111. }
  112. })
  113. defineExpose({
  114. get_list
  115. })
  116. function destroy(id: any) {
  117. props.api!.destroy(id).then(() => {
  118. get_list()
  119. message.success(interpolate($gettext('Delete ID: %{id}'), {id: id}))
  120. }).catch((e: any) => {
  121. message.error($gettext(e?.message ?? 'Server error'))
  122. })
  123. }
  124. function get_list(page_num = null, page_size = 20) {
  125. loading.value = true
  126. if (page_num) {
  127. params['page'] = page_num
  128. params['page_size'] = page_size
  129. }
  130. props.api!.get_list(params).then(async (r: any) => {
  131. data_source.value = r.data
  132. rows_key_index_map.value = {}
  133. if (props.useSortable) {
  134. function buildIndexMap(data: any, level: number = 0, index: number = 0, total: number[] = []) {
  135. if (data && data.length > 0) {
  136. data.forEach((v: any) => {
  137. v.level = level
  138. let current_index = [...total, index++]
  139. rows_key_index_map.value[v.id] = current_index
  140. if (v.children) buildIndexMap(v.children, level + 1, 0, current_index)
  141. })
  142. }
  143. }
  144. buildIndexMap(r.data)
  145. }
  146. if (r.pagination !== undefined) {
  147. Object.assign(pagination, r.pagination)
  148. }
  149. loading.value = false
  150. }).catch((e: any) => {
  151. message.error(e?.message ?? $gettext('Server error'))
  152. })
  153. }
  154. function stdChange(pagination: any, filters: any, sorter: any) {
  155. if (sorter) {
  156. selectedRowKeysBuffer.value = []
  157. params['order_by'] = sorter.field
  158. params['sort'] = sorter.order === 'ascend' ? 'asc' : 'desc'
  159. switch (sorter.order) {
  160. case 'ascend':
  161. params['sort'] = 'asc'
  162. break
  163. case 'descend':
  164. params['sort'] = 'desc'
  165. break
  166. default:
  167. params['sort'] = null
  168. break
  169. }
  170. }
  171. if (pagination) {
  172. selectedRowKeysBuffer.value = []
  173. }
  174. }
  175. function expandedTable(keys: any) {
  176. expand_keys_list.value = keys
  177. }
  178. function getSearchColumns() {
  179. let searchColumns: any = []
  180. props.columns!.forEach((column: any) => {
  181. if (column.search) {
  182. searchColumns.push(column)
  183. }
  184. })
  185. return searchColumns
  186. }
  187. function getBatchEditColumns() {
  188. let batch: any = []
  189. props.columns!.forEach((column: any) => {
  190. if (column.batch) {
  191. batch.push(column)
  192. }
  193. })
  194. return batch
  195. }
  196. function getPithyColumns() {
  197. if (props.pithy) {
  198. return props.columns!.filter((c: any, index: any, columns: any) => {
  199. return c.pithy === true && c.display !== false
  200. })
  201. }
  202. return props.columns!.filter((c: any, index: any, columns: any) => {
  203. return c.display !== false
  204. })
  205. }
  206. function checked(c: any) {
  207. params[c.target.value] = c.target.checked
  208. }
  209. const crossPageSelect: any = {}
  210. async function onSelectChange(_selectedRowKeys: any) {
  211. const page = params.page || 1
  212. crossPageSelect[page] = await _selectedRowKeys
  213. let t: any = []
  214. Object.keys(crossPageSelect).forEach(v => {
  215. t.push(...crossPageSelect[v])
  216. })
  217. const n: any = [..._selectedRowKeys]
  218. t = await t.concat(n)
  219. // console.log(crossPageSelect)
  220. const set = new Set(t)
  221. selectedRowKeysBuffer.value = Array.from(set)
  222. emit('onSelected', selectedRowKeysBuffer.value)
  223. }
  224. function onSelect(record: any) {
  225. emit('onSelectedRecord', record)
  226. }
  227. const router = useRouter()
  228. const reset_search = async () => {
  229. Object.keys(params).forEach(v => {
  230. delete params[v]
  231. })
  232. Object.assign(params, {
  233. ...props.get_params
  234. })
  235. router.push({query: {}}).catch(() => {
  236. })
  237. }
  238. watch(params, () => {
  239. if (!props.disable_query_params) {
  240. router.push({query: params})
  241. }
  242. get_list()
  243. })
  244. const rowSelection = computed(() => {
  245. if (batchColumns.length > 0 || props.selectionType) {
  246. return {
  247. selectedRowKeys: selectedRowKeysBuffer.value, onChange: onSelectChange,
  248. onSelect: onSelect, type: batchColumns.length > 0 ? 'checkbox' : props.selectionType
  249. }
  250. } else {
  251. return null
  252. }
  253. })
  254. function fn(obj: Object, desc: string) {
  255. const arr: string[] = desc.split('.')
  256. while (arr.length) {
  257. // @ts-ignore
  258. const top = obj[arr.shift()]
  259. if (top === undefined) {
  260. return null
  261. }
  262. obj = top
  263. }
  264. return obj
  265. }
  266. async function export_csv() {
  267. let header = []
  268. let headerKeys: any[] = []
  269. const showColumnsMap: any = {}
  270. // @ts-ignore
  271. for (let showColumnsKey in pithyColumns) {
  272. // @ts-ignore
  273. if (pithyColumns[showColumnsKey].dataIndex === 'action') continue
  274. // @ts-ignore
  275. let t = pithyColumns[showColumnsKey].title
  276. if (typeof t === 'function') {
  277. t = t()
  278. }
  279. header.push({
  280. title: t,
  281. // @ts-ignore
  282. key: pithyColumns[showColumnsKey].dataIndex
  283. })
  284. // @ts-ignore
  285. headerKeys.push(pithyColumns[showColumnsKey].dataIndex)
  286. // @ts-ignore
  287. showColumnsMap[pithyColumns[showColumnsKey].dataIndex] = pithyColumns[showColumnsKey]
  288. }
  289. let dataSource: any = []
  290. let hasMore = true
  291. let page = 1
  292. while (hasMore) {
  293. // 准备 DataSource
  294. await props.api!.get_list({page}).then((response: any) => {
  295. if (response.data.length === 0) {
  296. hasMore = false
  297. return
  298. }
  299. if (response[props.data_key] === undefined) {
  300. dataSource = dataSource.concat(...response.data)
  301. } else {
  302. dataSource = dataSource.concat(...response[props.data_key])
  303. }
  304. }).catch((e: any) => {
  305. message.error(e.message ?? $gettext('Server error'))
  306. hasMore = false
  307. return
  308. })
  309. page += 1
  310. }
  311. const data: any[] = []
  312. dataSource.forEach((row: Object) => {
  313. let obj: any = {}
  314. headerKeys.forEach(key => {
  315. let data = fn(row, key)
  316. const c = showColumnsMap[key]
  317. data = c?.customRender?.({text: data}) ?? data
  318. obj[c.dataIndex] = data
  319. })
  320. data.push(obj)
  321. })
  322. downloadCsv(header, data,
  323. `${$gettext('Export')}-${dayjs().format('YYYYMMDDHHmmss')}.csv`)
  324. }
  325. const hasSelectedRow = computed(() => {
  326. return batchColumns.length > 0 && selectedRowKeysBuffer.value.length > 0
  327. })
  328. function click_batch_edit() {
  329. emit('clickBatchModify', batchColumns, selectedRowKeysBuffer.value)
  330. }
  331. function getLeastIndex(index: number) {
  332. return index >= 1 ? index : 1
  333. }
  334. function getTargetData(data: any, indexList: number[]): any {
  335. let target: any = {children: data}
  336. indexList.forEach((index: number) => {
  337. target.children[index].parent = target
  338. target = target.children[index]
  339. })
  340. return target
  341. }
  342. function initSortable() {
  343. const table: any = document.querySelector('#std-table tbody')
  344. new Sortable(table, {
  345. handle: '.ant-table-drag-icon',
  346. animation: 150,
  347. sort: true,
  348. forceFallback: true,
  349. setData: function (dataTransfer) {
  350. dataTransfer.setData('Text', '')
  351. },
  352. onStart({item}) {
  353. let targetRowKey = Number(item.dataset.rowKey)
  354. if (targetRowKey) {
  355. expand_keys_list.value = expand_keys_list.value.filter((item: number) => item !== targetRowKey)
  356. }
  357. },
  358. onMove({dragged, related}) {
  359. const oldRow: number[] = rows_key_index_map.value?.[Number(dragged.dataset.rowKey)]
  360. const newRow: number[] = rows_key_index_map.value?.[Number(related.dataset.rowKey)]
  361. if (oldRow.length !== newRow.length || oldRow[oldRow.length - 2] != newRow[newRow.length - 2]) {
  362. return false
  363. }
  364. },
  365. async onEnd({item, newIndex, oldIndex}) {
  366. if (newIndex === oldIndex) return
  367. const indexDelta: number = Number(oldIndex) - Number(newIndex)
  368. const direction: number = indexDelta > 0 ? +1 : -1
  369. let rowIndex: number[] = rows_key_index_map.value?.[Number(item.dataset.rowKey)]
  370. const newRow = getTargetData(data_source.value, rowIndex)
  371. const newRowParent = newRow.parent
  372. const level: number = newRow.level
  373. let currentRowIndex: number[] = [...rows_key_index_map.value?.
  374. [Number(table.children[Number(newIndex) + direction].dataset.rowKey)]]
  375. let currentRow: any = getTargetData(data_source.value, currentRowIndex)
  376. // Reset parent
  377. currentRow.parent = newRow.parent = null
  378. newRowParent.children.splice(rowIndex[level], 1)
  379. newRowParent.children.splice(currentRowIndex[level], 0, toRaw(newRow))
  380. let changeIds: number[] = []
  381. function processChanges(row: any, children: boolean = false, newIndex: number | undefined = undefined) {
  382. // Build changes ID list expect new row
  383. if (children || newIndex === undefined) changeIds.push(row.id)
  384. if (newIndex !== undefined)
  385. rows_key_index_map.value[row.id][level] = newIndex
  386. else if (children)
  387. rows_key_index_map.value[row.id][level] += direction
  388. row.parent = null
  389. if (row.children) {
  390. row.children.forEach((v: any) => processChanges(v, true, newIndex))
  391. }
  392. }
  393. // Replace row index for new row
  394. processChanges(newRow, false, currentRowIndex[level])
  395. // Rebuild row index maps for changes row
  396. for (let i = Number(oldIndex); i != newIndex; i -= direction) {
  397. let rowIndex: number[] = rows_key_index_map.value?.[table.children[i].dataset.rowKey]
  398. rowIndex[level] += direction
  399. processChanges(getTargetData(data_source.value, rowIndex))
  400. }
  401. console.log('Change row id', newRow.id, 'order', newRow.id, '=>', currentRow.id, ', direction: ', direction,
  402. ', changes IDs:', changeIds)
  403. props.api!.update_order({
  404. target_id: newRow.id,
  405. direction: direction,
  406. affected_ids: changeIds
  407. }).then(() => {
  408. message.success($gettext('Updated successfully'))
  409. }).catch((e: any) => {
  410. message.error(e?.message ?? $gettext('Server error'))
  411. })
  412. }
  413. })
  414. }
  415. </script>
  416. <template>
  417. <div class="std-table">
  418. <std-data-entry
  419. v-if="!disable_search && searchColumns.length"
  420. :data-list="searchColumns"
  421. v-model:data-source="params"
  422. layout="inline"
  423. >
  424. <template #action>
  425. <a-space class="action-btn">
  426. <a-button v-if="exportCsv" @click="export_csv" type="primary" ghost>
  427. {{ $gettext('Export') }}
  428. </a-button>
  429. <a-button @click="reset_search">
  430. {{ $gettext('Reset') }}
  431. </a-button>
  432. <a-button v-if="hasSelectedRow" @click="click_batch_edit">
  433. {{ $gettext('Batch Modify') }}
  434. </a-button>
  435. </a-space>
  436. </template>
  437. </std-data-entry>
  438. <a-table
  439. :columns="pithyColumns"
  440. :data-source="data_source"
  441. :loading="loading"
  442. :pagination="false"
  443. :row-key="rowKey"
  444. :rowSelection="rowSelection"
  445. @change="stdChange"
  446. :scroll="{ x: scrollX }"
  447. :size="size"
  448. id="std-table"
  449. @expandedRowsChange="expandedTable"
  450. :expandedRowKeys="expand_keys_list"
  451. >
  452. <template
  453. v-slot:bodyCell="{text, record, index, column}"
  454. >
  455. <template v-if="column.handle === true">
  456. <span class="ant-table-drag-icon"><HolderOutlined/></span>
  457. {{ text }}
  458. </template>
  459. <template v-if="column.dataIndex === 'action'">
  460. <a-button type="link" size="small" v-if="props.editable"
  461. @click="$emit('clickEdit', record[props.rowKey], record)">
  462. {{ props.edit_text || $gettext('Modify') }}
  463. </a-button>
  464. <slot name="actions" :record="record"/>
  465. <template v-if="props.deletable">
  466. <a-divider type="vertical"/>
  467. <a-popconfirm
  468. :cancelText="$gettext('No')"
  469. :okText="$gettext('OK')"
  470. :title="$gettext('Are you sure you want to delete?')"
  471. @confirm="destroy(record[rowKey])">
  472. <a-button type="link" size="small">{{ $gettext('Delete') }}</a-button>
  473. </a-popconfirm>
  474. </template>
  475. </template>
  476. </template>
  477. </a-table>
  478. <std-pagination :size="size" :pagination="pagination" @change="get_list" @changePageSize="stdChange"/>
  479. </div>
  480. </template>
  481. <style lang="less">
  482. .ant-table-scroll {
  483. .ant-table-body {
  484. overflow-x: auto !important;
  485. }
  486. }
  487. </style>
  488. <style lang="less" scoped>
  489. .ant-form {
  490. margin: 10px 0 20px 0;
  491. }
  492. .ant-slider {
  493. min-width: 90px;
  494. }
  495. .std-table {
  496. .ant-table-wrapper {
  497. // overflow-x: scroll;
  498. }
  499. }
  500. .action-btn {
  501. // min-height: 50px;
  502. height: 100%;
  503. display: flex;
  504. align-items: flex-start;
  505. }
  506. :deep(.ant-form-inline .ant-form-item) {
  507. margin-bottom: 10px;
  508. }
  509. </style>
  510. <style lang="less">
  511. .ant-table-drag-icon {
  512. float: left;
  513. margin-right: 16px;
  514. cursor: grab;
  515. }
  516. .sortable-ghost *, .sortable-chosen * {
  517. cursor: grabbing !important;
  518. }
  519. </style>