index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. <!-- 📚📚📚 Pro-Table 文档: https://juejin.cn/post/7166068828202336263 -->
  2. <template>
  3. <!-- 表格主体 -->
  4. <div class="pro-table-main">
  5. <!-- 表格头部 操作按钮 -->
  6. <div class="table-header">
  7. <div class="header-button-lf">
  8. <slot name="tableHeader" :selected-list="selectedList" :selected-list-ids="selectedListIds" :is-selected="isSelected" />
  9. </div>
  10. <div v-if="toolButton" class="header-button-ri">
  11. <slot name="toolButton">
  12. <el-progress :stroke-width="10" :percentage="downloadObj.percentage" v-if="downloadObj.downloading">
  13. <template #default="{ percentage }">
  14. <span class="font14">{{ percentage }}%</span>
  15. <span class="font14">导出进度</span>
  16. </template>
  17. </el-progress>
  18. <el-button v-if="showToolButton('refresh')" circle @click="onRefresh" title="刷新表格" :disabled="loading">
  19. <SvgIcon name="ele-Refresh" />
  20. </el-button>
  21. <el-button v-if="showToolButton('setting') && columns.length" circle @click="openColSetting" title="列设置">
  22. <SvgIcon name="ele-Setting" />
  23. </el-button>
  24. <el-button
  25. v-if="showToolButton('exportCurrent') && columns.length"
  26. circle
  27. @click="exportCurrent"
  28. title="导出当前页"
  29. :disabled="exportLoading || loading"
  30. >
  31. <SvgIcon name="iconfont icon-daochu" />
  32. </el-button>
  33. <el-button
  34. v-if="showToolButton('exportAll') && columns.length"
  35. circle
  36. @click="exportAll"
  37. title="导出全部"
  38. :disabled="exportLoading || loading"
  39. >
  40. <SvgIcon name="iconfont icon-export" />
  41. </el-button>
  42. <el-button v-if="exportLoading" :loading="exportLoading">导出中,请稍后。。。</el-button>
  43. </slot>
  44. <slot name="description"> </slot>
  45. </div>
  46. </div>
  47. <!-- 表格主体 -->
  48. <el-table
  49. ref="tableRef"
  50. v-bind="$attrs"
  51. :data="processTableData"
  52. :border="border"
  53. :row-key="rowKey"
  54. @selection-change="selectionChange"
  55. :scrollbar-always-on="true"
  56. v-horizontal-scroll="'always'"
  57. v-loading="loading"
  58. height="100%"
  59. :key="Math.random()"
  60. :row-style="{height: '24px'}"
  61. :cell-style="{padding: '4px 0'}"
  62. >
  63. <!-- 默认插槽 -->
  64. <slot />
  65. <template v-for="item in tableColumns" :key="item">
  66. <!-- selection || radio || index || expand || sort -->
  67. <el-table-column
  68. v-if="item.type && columnTypes.includes(item.type)"
  69. v-bind="item"
  70. :align="item.align ?? 'left'"
  71. :reserve-selection="item.type == 'selection'"
  72. >
  73. <template #default="scope">
  74. <!-- expand -->
  75. <template v-if="item.type == 'expand'">
  76. <component :is="item.render" v-bind="scope" v-if="item.render" />
  77. <slot v-else :name="item.type" v-bind="scope" />
  78. </template>
  79. <!-- radio -->
  80. <el-radio v-if="item.type == 'radio'" v-model="radio" :label="scope.row[rowKey]">
  81. <i></i>
  82. </el-radio>
  83. </template>
  84. </el-table-column>
  85. <!-- other -->
  86. <TableColumn v-if="!item.type && item.prop && item.isShow" :column="item" col-setting="">
  87. <template v-for="slot in Object.keys($slots)" #[slot]="scope">
  88. <slot :name="slot" v-bind="scope" />
  89. </template>
  90. </TableColumn>
  91. </template>
  92. <!-- 插入表格最后一行之后的插槽 -->
  93. <template #append>
  94. <slot name="append" />
  95. </template>
  96. <!-- 无数据 -->
  97. <template #empty>
  98. <el-empty :image-size="120" />
  99. </template>
  100. </el-table>
  101. <!-- 分页组件 -->
  102. <slot name="pagination">
  103. <PaginationEl
  104. v-if="pagination"
  105. @pagination="onRefresh"
  106. :total="total"
  107. v-model:current-page="pageIndex"
  108. v-model:page-size="pageSize"
  109. :layout="paginationLayout"
  110. />
  111. </slot>
  112. </div>
  113. <!-- 列设置 -->
  114. <ColSetting v-if="toolButton" ref="colRef" v-model:col-setting="colSetting" @update:colSetting="updateColSetting" @changeRow="changeRow" />
  115. </template>
  116. <script setup lang="ts" name="ProTable">
  117. import {ref, provide, onMounted, unref, computed, reactive, PropType, watch, nextTick} from 'vue';
  118. import { ElMessageBox, ElTable } from 'element-plus';
  119. import { useSelection } from '@/hooks/useSelection';
  120. import { ColumnProps, TypeProps } from '@/components/ProTable/interface';
  121. import PaginationEl from './components/Pagination.vue';
  122. import ColSetting from './components/ColSetting.vue';
  123. import TableColumn from './components/TableColumn.vue';
  124. import { downloadFileByStream } from '@/utils/tools';
  125. import { useResizeObserver } from '@vueuse/core'
  126. // 接受父组件参数,配置默认值
  127. const props = defineProps({
  128. columns: {
  129. // 列配置项 ==> 必传
  130. type: Array as PropType<ColumnProps[]>,
  131. required: true,
  132. },
  133. data: {
  134. // 静态 table data 数据
  135. type: Array,
  136. default: () => [],
  137. required: true,
  138. },
  139. pagination: {
  140. // 是否需要分页组件 ==> 非必传(默认为true)
  141. type: Boolean,
  142. default: true,
  143. },
  144. total: {
  145. // 分页组件的 total ==> 非必传(默认为0)
  146. type: Number,
  147. default: 0,
  148. },
  149. pageIndex: {
  150. // 分页组件的 currentPage ==> 非必传(默认为1)
  151. type: Number,
  152. default: 1,
  153. },
  154. pageSize: {
  155. // 分页组件的 pageSize ==> 非必传(默认为10)
  156. type: Number,
  157. default: 10,
  158. },
  159. border: {
  160. // 是否带有纵向边框 ==> 非必传(默认为false)
  161. type: Boolean,
  162. default: false,
  163. },
  164. toolButton: {
  165. // 是否显示表格功能按钮 ==> 非必传(默认为true)
  166. type: [Array, Boolean],
  167. // default: true,
  168. default: ['refresh', 'setting'],
  169. },
  170. rowKey: {
  171. // 行数据的 Key,用来优化 Table 的渲染,当表格数据多选时,所指定的 id ==> 非必传(默认为 id)
  172. type: String,
  173. default: 'id',
  174. },
  175. loading: {
  176. // 是否显示加载中效果
  177. type: Boolean,
  178. default: false,
  179. },
  180. radio: {
  181. // 单选框的值
  182. type: String,
  183. default: '',
  184. },
  185. paginationLayout: {
  186. // 分页组件布局
  187. type: String,
  188. default: 'total, sizes, prev, pager, next, jumper',
  189. },
  190. exportWithParent: {
  191. // 导出是否带有子父级关系
  192. type: Boolean,
  193. default: false,
  194. },
  195. exportMethod: {
  196. // 导出方法
  197. type: Function,
  198. default: () => {},
  199. },
  200. exportParams: {
  201. // 导出参数
  202. type: Object,
  203. default: () => {},
  204. },
  205. isSpecialExport: {
  206. // 是否特殊导出(导出传递参数不同)
  207. type: Boolean,
  208. default: false,
  209. },
  210. });
  211. const emit = defineEmits([
  212. 'dargSort',
  213. 'updateColSetting',
  214. 'selectChange',
  215. 'updateTable',
  216. 'update:pagination',
  217. 'exportCurrent',
  218. 'exportAll',
  219. 'update:pageSize',
  220. 'update:pageIndex',
  221. 'update:radio',
  222. ]);
  223. const pageSize = computed({
  224. get() {
  225. return props.pageSize;
  226. },
  227. set(val) {
  228. emit('update:pageSize', val);
  229. },
  230. });
  231. const pageIndex = computed({
  232. get() {
  233. return props.pageIndex;
  234. },
  235. set(val) {
  236. emit('update:pageIndex', val);
  237. },
  238. });
  239. const radio = computed({
  240. get() {
  241. return props.radio;
  242. },
  243. set(val) {
  244. emit('update:radio', val);
  245. },
  246. });
  247. // table 实例
  248. const tableRef = ref<InstanceType<typeof ElTable>>();
  249. // column 列类型
  250. const columnTypes: TypeProps[] = ['selection', 'radio', 'index', 'expand', 'sort'];
  251. // 控制 ToolButton 显示
  252. const showToolButton = (key: 'refresh' | 'setting' | 'exportCurrent' | 'exportAll') => {
  253. return Array.isArray(props.toolButton) ? props.toolButton.includes(key) : props.toolButton;
  254. };
  255. // 表格多选 Hooks
  256. const { selectionChange, selectedList, selectedListIds, isSelected } = useSelection(props.rowKey);
  257. // 清空选中数据列表
  258. const clearSelection = () => tableRef.value!.clearSelection();
  259. // 初始化表格数据 && 拖拽排序
  260. onMounted(() => {
  261. useResizeObserver(tableRef, () => {
  262. tableRef.value.doLayout();
  263. })
  264. });
  265. // 处理表格数据
  266. const processTableData = computed(() => {
  267. if (!props.pagination) return props.data;
  268. return props.data;
  269. });
  270. // 监听 tableData 的变化来更新表格布局
  271. watch(processTableData, () => {
  272. if (tableRef.value) {
  273. tableRef.value.doLayout();
  274. }
  275. });
  276. // 接收 columns 并设置为响应式
  277. const tableColumns = ref(props.columns);
  278. // 扁平化 columns
  279. const flatColumns = computed(() => flatColumnsFunc(tableColumns.value));
  280. // 定义 enumMap 存储 enum 值(避免异步请求无法格式化单元格内容 || 无法填充搜索下拉选择)
  281. const enumMap = ref(new Map<string, { [key: string]: any }[]>());
  282. const setEnumMap = async ({ prop, enum: enumValue }: ColumnProps) => {
  283. if (!enumValue) return;
  284. // 如果当前 enumMap 存在相同的值 return
  285. if (enumMap.value.has(prop!) && (typeof enumValue === 'function' || enumMap.value.get(prop!) === enumValue)) return;
  286. // 当前 enum 为静态数据,则直接存储到 enumMap
  287. if (typeof enumValue !== 'function') return enumMap.value.set(prop!, unref(enumValue!));
  288. // 为了防止接口执行慢,而存储慢,导致重复请求,所以预先存储为[],接口返回后再二次存储
  289. enumMap.value.set(prop!, []);
  290. // 当前 enum 为后台数据需要请求数据,则调用该请求接口,并存储到 enumMap
  291. const { data } = await enumValue();
  292. enumMap.value.set(prop!, data);
  293. };
  294. // 注入 enumMap
  295. provide('enumMap', enumMap);
  296. // 扁平化 columns 的方法
  297. const flatColumnsFunc = (columns: ColumnProps[], flatArr: ColumnProps[] = []) => {
  298. columns.forEach(async (col: any) => {
  299. col.name = col.label ?? '';
  300. if (col._children?.length) flatArr.push(...flatColumnsFunc(col._children));
  301. flatArr.push(col);
  302. // column 添加默认 isShow
  303. col.isShow = col.isShow ?? true;
  304. // 设置 enumMap
  305. await setEnumMap(col);
  306. });
  307. return flatArr.filter((item) => !item._children?.length);
  308. };
  309. // 过滤需要搜索的配置项 && 排序
  310. const searchColumns = computed(() => {
  311. return flatColumns.value?.filter((item) => item.search?.el || item.search?.render).sort((a, b) => a.search!.order! - b.search!.order!);
  312. });
  313. // 设置 搜索表单默认排序 && 搜索表单项的默认值
  314. searchColumns.value?.forEach((column, index) => {});
  315. // 列设置 ==> 需要过滤掉不需要设置的列
  316. const colRef = ref();
  317. const colSetting = tableColumns.value;
  318. /*const colSetting: any = tableColumns!.filter((item) => {
  319. const { type, prop } = item;
  320. return !columnTypes.includes(type!) && prop !== 'operation';
  321. });*/
  322. const openColSetting = () => colRef.value.openColSetting();
  323. // 刷新事件
  324. const onRefresh = () => {
  325. clearSelection();
  326. emit('updateTable');
  327. };
  328. // 更新列设置
  329. const updateColSetting = (test: ColumnProps[]) => {
  330. // 平铺数组后获取isShow为true的对象
  331. const flatColumnsArr = flatColumnsFunc(test);
  332. const exportNewColumns = flatColumnsArr.filter((item) => item.isShow);
  333. // 获取prop
  334. const newColumnsProp = exportNewColumns.map((item) => item.prop);
  335. emit('updateColSetting', exportNewColumns, newColumnsProp);
  336. };
  337. // 递归获取isShow的列保持子父级关系
  338. const findItems = (tree: any, conditionFn: (item: any) => boolean, parent: any = null) => {
  339. let results: [] = [];
  340. for (let node: any of tree) {
  341. // 如果当前节点满足条件,则将其添加到结果中,并保留父级关系
  342. if (conditionFn(node)) {
  343. results.push(node);
  344. }
  345. // 递归处理当前节点的子节点
  346. if (node.children && node.children.length > 0) {
  347. results = results.concat(findItems(node.children, conditionFn, node));
  348. }
  349. }
  350. return results;
  351. };
  352. // 导出当前页
  353. const exportLoading = ref(false);
  354. const exportCurrent = () => {
  355. let exportNewColumns: any = [];
  356. if (props.exportWithParent) {
  357. // 导出带子父级关系
  358. exportNewColumns = findItems(tableColumns.value, (item) => item.isShow && !columnTypes.includes(item.type!) && item.prop !== 'operation');
  359. } else {
  360. exportNewColumns = flatColumnsFunc(colSetting).filter(
  361. (item: any) => item.isShow && !columnTypes.includes(item.type!) && item.prop !== 'operation'
  362. );
  363. }
  364. ElMessageBox.confirm(`您确定要导出当前页数据,是否继续?`, '提示', {
  365. confirmButtonText: '确认',
  366. cancelButtonText: '取消',
  367. type: 'warning',
  368. draggable: true,
  369. cancelButtonClass: 'default-button',
  370. autofocus: false,
  371. })
  372. .then(async () => {
  373. exportLoading.value = true;
  374. const request = {
  375. queryDto: props.exportParams,
  376. columnInfos: exportNewColumns,
  377. isExportAll: false,
  378. };
  379. props.exportMethod &&
  380. props
  381. .exportMethod(request)
  382. .then((res: any) => {
  383. downloadFileByStream(res);
  384. emit('exportCurrent', exportNewColumns);
  385. exportLoading.value = false;
  386. })
  387. .catch((e) => {
  388. console.log(`导出失败:${e}`);
  389. exportLoading.value = false;
  390. });
  391. })
  392. .catch(() => {});
  393. };
  394. // 导出全部
  395. const exportAll = () => {
  396. let exportNewColumns: any = [];
  397. if (props.exportWithParent) {
  398. // 导出带子父级关系
  399. exportNewColumns = findItems(tableColumns.value, (item) => item.isShow && !columnTypes.includes(item.type!) && item.prop !== 'operation');
  400. } else {
  401. exportNewColumns = flatColumnsFunc(colSetting).filter(
  402. (item: any) => item.isShow && !columnTypes.includes(item.type!) && item.prop !== 'operation'
  403. );
  404. }
  405. ElMessageBox.confirm(`您确定要导出全部数据,是否继续?`, '提示', {
  406. confirmButtonText: '确认',
  407. cancelButtonText: '取消',
  408. type: 'warning',
  409. draggable: true,
  410. cancelButtonClass: 'default-button',
  411. autofocus: false,
  412. })
  413. .then(() => {
  414. exportLoading.value = true;
  415. const request = {
  416. queryDto: props.exportParams,
  417. columnInfos: exportNewColumns,
  418. isExportAll: true,
  419. };
  420. if (props.isSpecialExport) {
  421. const specialRequest = {
  422. ...props.exportParams,
  423. AddColumnName: exportNewColumns.map((item: any) => item.label),
  424. };
  425. props.exportMethod &&
  426. props
  427. .exportMethod(specialRequest)
  428. .then((res: any) => {
  429. downloadFileByStream(res);
  430. emit('exportAll', exportNewColumns);
  431. exportLoading.value = false;
  432. })
  433. .catch((e) => {
  434. console.log(`导出失败:${e}`);
  435. exportLoading.value = false;
  436. });
  437. } else {
  438. props.exportMethod &&
  439. props
  440. .exportMethod(request)
  441. .then((res: any) => {
  442. downloadFileByStream(res);
  443. emit('exportAll', exportNewColumns);
  444. exportLoading.value = false;
  445. })
  446. .catch((e) => {
  447. console.log(`导出失败:${e}`);
  448. exportLoading.value = false;
  449. });
  450. }
  451. })
  452. .catch(() => {});
  453. };
  454. // 改变行排序
  455. const changeRow = ({ oldIndex, newIndex }) => {
  456. // 改变表头的顺序
  457. const draggedItem = tableColumns.value.splice(oldIndex, 1)[0];
  458. tableColumns.value.splice(newIndex, 0, draggedItem);
  459. };
  460. watch(
  461. // 监听table数据改变后重置选择
  462. () => props.data,
  463. () => {
  464. clearSelection();
  465. }
  466. );
  467. //下载逻辑
  468. const downloadObj = reactive({
  469. fileName: '',
  470. downloading: false,
  471. range: 0,
  472. fileBlob: [],
  473. percentage: 0,
  474. });
  475. const download = async () => {
  476. downloadObj.fileBlob = [];
  477. downloadObj.downloading = true;
  478. downloadObj.range = 0; //文件总大小
  479. downloadObj.percentage = 0; //下载进度
  480. const params = {
  481. md5: '7343784583fsdufhusdfgsudfe8934',
  482. };
  483. const chunkSize = 5 * 1024 * 1024;
  484. //第一次调接口获取到响应头的content-range,文件总大小,用于计算下载切割
  485. const config = {
  486. headers: {
  487. Range: `bytes=0-${chunkSize}`,
  488. },
  489. };
  490. const data = await downLoadbyPiece(params, config);
  491. //获取文件总大小
  492. const arr = data.headers['content-range'].split('/');
  493. downloadObj.range = Number(arr[1]);
  494. //存储每片文件流
  495. downloadObj.fileBlob.push(data.data);
  496. //获取文件名称
  497. let fileName = '';
  498. let cd = data.headers['content-disposition'];
  499. if (cd) {
  500. let index = cd.lastIndexOf('=');
  501. fileName = decodeURI(cd.substring(index + 1, cd.length));
  502. }
  503. await chunkUpload(params, fileName, chunkSize);
  504. };
  505. //拿到文件总大小downloadObj.range,计算分为多少都段下载
  506. const chunkUpload = async (params, fileName, chunkSize) => {
  507. //获取分段下载的数组
  508. let chunkList = [];
  509. function chunkPush(page = 1) {
  510. chunkList.push((page - 1) * chunkSize);
  511. if (page * chunkSize < downloadObj.range) {
  512. chunkPush(page + 1);
  513. }
  514. }
  515. chunkPush();
  516. //加上文件大小在最后一位
  517. chunkList.push(downloadObj.range);
  518. console.log(chunkList, 'chunkList');
  519. //分段组合传参格式处理 0-1024 1024-2048
  520. let uploadRange = [];
  521. chunkList.forEach((item, i) => {
  522. if (i == chunkList.length - 1) return;
  523. uploadRange.push(`${chunkList[i]}-${chunkList[i + 1]}`);
  524. });
  525. console.log(uploadRange, 'uploadRang');
  526. for (let index = 0; index < uploadRange.length; index++) {
  527. //第一次调接口已经传过了第一组,从第二位开始
  528. if (index > 0) {
  529. const config = {
  530. headers: {
  531. Range: `bytes=${uploadRange[index]}`,
  532. },
  533. };
  534. const data = await downLoadbyPiece(params, config);
  535. //计算下载进度
  536. downloadObj.percentage = Math.floor(((index + 1) / uploadRange.length) * 100);
  537. emit('getDownloadpercent', downloadObj.percentage);
  538. //存储每一片文件流
  539. downloadObj.fileBlob.push(data.data);
  540. }
  541. }
  542. //合并
  543. const blob = new Blob(downloadObj.fileBlob, {
  544. type: downloadObj.fileBlob[0].type,
  545. });
  546. downloadObj.downloading = false;
  547. //下载
  548. const link = document.createElement('a');
  549. const URL = window.URL || window.webkitURL;
  550. link.href = URL.createObjectURL(blob);
  551. link.download = fileName;
  552. link.click();
  553. URL.revokeObjectURL(link.href);
  554. };
  555. // 暴露给父组件的参数和方法 (外部需要什么,都可以从这里暴露出去)
  556. defineExpose({
  557. element: tableRef,
  558. tableData: processTableData,
  559. radio,
  560. clearSelection,
  561. enumMap,
  562. isSelected,
  563. selectedList,
  564. selectedListIds,
  565. });
  566. </script>
  567. <style scoped lang="scss">
  568. .pro-table-main{
  569. display: flex;
  570. flex-direction: column;
  571. height:calc(100% - 40px);
  572. .el-table{
  573. flex:1;
  574. }
  575. }
  576. .table-header {
  577. margin-bottom: 10px;
  578. display: flex;
  579. justify-content: space-between;
  580. }
  581. .header-button-lf {
  582. flex: 1;
  583. }
  584. .header-button-ri {
  585. }
  586. </style>