diff --git a/docs/zh-CN/components/table.md b/docs/zh-CN/components/table.md index b749dadf0..6e457b986 100755 --- a/docs/zh-CN/components/table.md +++ b/docs/zh-CN/components/table.md @@ -1747,6 +1747,16 @@ order: 67 } ``` +## 表格内容高度自适应 + +> 1.5.0 及以上版本 + +通过 `autoFillHeight` 可以让表格内容区自适应高度,具体效果请看这个[示例](../../../examples/crud/auto-fill)。 + +它的展现效果是整个内容区域高度自适应,表格内容较多时在内容区域内出滚动条,这样顶部筛选和底部翻页的位置都是固定的。 + +开启这个配置后会自动关闭 `affixHeader` 功能避免冲突。 + ## 属性表 | 属性名 | 类型 | 默认值 | 说明 | @@ -1773,6 +1783,7 @@ order: 67 | prefixRow | `Array` | | 顶部总结行 | | affixRow | `Array` | | 底部总结行 | | itemBadge | [`BadgeSchema`](./badge) | | 行角标配置 | +| autoFillHeight | `boolean` | | 内容区域自适应高度 | ## 列配置属性表 diff --git a/examples/components/CRUD/TableAutoFill.jsx b/examples/components/CRUD/TableAutoFill.jsx new file mode 100644 index 000000000..551664fd4 --- /dev/null +++ b/examples/components/CRUD/TableAutoFill.jsx @@ -0,0 +1,329 @@ +export default { + title: '表格内容区域自适应屏幕高度,内容超出时在内容区出现滚动条', + remark: 'bla bla bla', + toolbar: [ + { + type: 'button', + actionType: 'dialog', + label: '新增', + icon: 'fa fa-plus pull-left', + primary: true, + dialog: { + title: '新增', + body: { + type: 'form', + name: 'sample-edit-form', + api: 'post:/api/sample', + body: [ + { + type: 'input-text', + name: 'engine', + label: 'Engine', + required: true + }, + { + type: 'divider' + }, + { + type: 'input-text', + name: 'browser', + label: 'Browser', + required: true + }, + { + type: 'divider' + }, + { + type: 'input-text', + name: 'platform', + label: 'Platform(s)', + required: true + }, + { + type: 'divider' + }, + { + type: 'input-text', + name: 'version', + label: 'Engine version' + }, + { + type: 'divider' + }, + { + type: 'input-text', + name: 'grade', + label: 'CSS grade' + } + ] + } + } + } + ], + body: { + type: 'crud', + draggable: true, + api: '/api/sample', + perPage: 50, + keepItemSelectionOnPageChange: true, + maxKeepItemSelectionLength: 11, + autoFillHeight: true, + labelTpl: '${id} ${engine}', + autoGenerateFilter: true, + bulkActions: [ + { + label: '批量删除', + actionType: 'ajax', + api: 'delete:/api/sample/${ids|raw}', + confirmText: '确定要批量删除?' + }, + { + label: '批量修改', + actionType: 'dialog', + dialog: { + title: '批量编辑', + name: 'sample-bulk-edit', + body: { + type: 'form', + api: '/api/sample/bulkUpdate2', + body: [ + { + type: 'hidden', + name: 'ids' + }, + { + type: 'input-text', + name: 'engine', + label: 'Engine' + } + ] + } + } + } + ], + quickSaveApi: '/api/sample/bulkUpdate', + quickSaveItemApi: '/api/sample/$id', + filterTogglable: true, + headerToolbar: [ + 'bulkActions', + { + type: 'tpl', + tpl: '定制内容示例:当前有 ${count} 条数据。', + className: 'v-middle' + }, + { + type: 'link', + href: 'https://www.baidu.com', + body: '百度一下', + htmlTarget: '_parent', + className: 'v-middle' + }, + { + type: 'columns-toggler', + align: 'right' + }, + { + type: 'drag-toggler', + align: 'right' + }, + { + type: 'pagination', + align: 'right' + } + ], + footerToolbar: ['statistics', 'switch-per-page', 'pagination'], + // rowClassNameExpr: '<%= data.id == 1 ? "bg-success" : "" %>', + columns: [ + { + name: 'id', + label: 'ID', + searchable: { + type: 'input-text', + name: 'id', + label: '主键', + placeholder: '输入id' + } + }, + { + name: 'engine', + label: 'Rendering engine' + }, + { + name: 'browser', + label: 'Browser', + searchable: { + type: 'select', + name: 'browser', + label: '浏览器', + placeholder: '选择浏览器', + options: [ + { + label: 'Internet Explorer ', + value: 'ie' + }, + { + label: 'AOL browser', + value: 'aol' + }, + { + label: 'Firefox', + value: 'firefox' + } + ] + } + }, + { + name: 'platform', + label: 'Platform(s)' + }, + { + name: 'version', + label: 'Engine version', + searchable: { + type: 'input-number', + name: 'version', + label: '版本号', + placeholder: '输入版本号', + mode: 'horizontal' + } + }, + { + name: 'grade', + label: 'CSS grade' + }, + { + type: 'operation', + label: '操作', + width: 100, + buttons: [ + { + type: 'button', + icon: 'fa fa-eye', + actionType: 'dialog', + tooltip: '查看', + dialog: { + title: '查看', + body: { + type: 'form', + body: [ + { + type: 'static', + name: 'engine', + label: 'Engine' + }, + { + type: 'divider' + }, + { + type: 'static', + name: 'browser', + label: 'Browser' + }, + { + type: 'divider' + }, + { + type: 'static', + name: 'platform', + label: 'Platform(s)' + }, + { + type: 'divider' + }, + { + type: 'static', + name: 'version', + label: 'Engine version' + }, + { + type: 'divider' + }, + { + type: 'static', + name: 'grade', + label: 'CSS grade' + }, + { + type: 'divider' + }, + { + type: 'html', + html: '

添加其他 Html 片段 需要支持变量替换(todo).

' + } + ] + } + } + }, + { + type: 'button', + icon: 'fa fa-pencil', + tooltip: '编辑', + actionType: 'drawer', + drawer: { + position: 'left', + size: 'lg', + title: '编辑', + body: { + type: 'form', + name: 'sample-edit-form', + api: '/api/sample/$id', + body: [ + { + type: 'input-text', + name: 'engine', + label: 'Engine', + required: true + }, + { + type: 'divider' + }, + { + type: 'input-text', + name: 'browser', + label: 'Browser', + required: true + }, + { + type: 'divider' + }, + { + type: 'input-text', + name: 'platform', + label: 'Platform(s)', + required: true + }, + { + type: 'divider' + }, + { + type: 'input-text', + name: 'version', + label: 'Engine version' + }, + { + type: 'divider' + }, + { + type: 'select', + name: 'grade', + label: 'CSS grade', + options: ['A', 'B', 'C', 'D', 'X'] + } + ] + } + } + }, + { + type: 'button', + icon: 'fa fa-times text-danger', + actionType: 'ajax', + tooltip: '删除', + confirmText: '您确认要删除?', + api: 'delete:/api/sample/$id' + } + ], + toggled: true + } + ] + } +}; diff --git a/examples/components/Example.tsx b/examples/components/Example.tsx index 5886dde41..917171737 100644 --- a/examples/components/Example.tsx +++ b/examples/components/Example.tsx @@ -32,6 +32,7 @@ import Definitions from './Form/Definitions'; import AnchorNav from './Form/AnchorNav'; import TableCrudSchema from './CRUD/Table'; +import TableAutoFillSchema from './CRUD/TableAutoFill'; import ItemActionsSchema from './CRUD/ItemActions'; import GridCrudSchema from './CRUD/Grid'; import ListCrudSchema from './CRUD/List'; @@ -285,6 +286,11 @@ export const examples = [ path: '/examples/crud/table', component: makeSchemaRenderer(TableCrudSchema) }, + { + label: '表格高度自适应', + path: '/examples/crud/auto-fill', + component: makeSchemaRenderer(TableAutoFillSchema) + }, { label: '卡片模式', path: '/examples/crud/grid', diff --git a/scss/components/_table.scss b/scss/components/_table.scss index e37d236ac..47dde7b51 100644 --- a/scss/components/_table.scss +++ b/scss/components/_table.scss @@ -891,6 +891,21 @@ top: 0; left: 0; } + + &--autoFillHeight { + > .#{$ns}Table-contentWrap { + overflow-y: scroll; + + > .#{$ns}Table-content table { + border-top: none; // 不然会导致拖动时顶部露出内容 + } + > .#{$ns}Table-content table thead { + position: sticky; // 简单实现表头吸顶效果,不考虑 IE 11 不然太麻烦 + top: 0; + z-index: 1; // 由于 badge 导致 tbody 里 tr 的 position: relative 了 + } + } + } } .#{$ns}InputTable-toolbar { diff --git a/src/renderers/CRUD.tsx b/src/renderers/CRUD.tsx index 1ea3dd278..348689474 100644 --- a/src/renderers/CRUD.tsx +++ b/src/renderers/CRUD.tsx @@ -287,6 +287,11 @@ export interface CRUDCommonSchema extends BaseSchema { * 开启查询区域,会根据列元素的searchable属性值,自动生成查询条件表单 */ autoGenerateFilter?: boolean; + + /** + * 内容区域占满屏幕剩余空间 + */ + autoFillHeight?: boolean; } export type CRUDCardsSchema = CRUDCommonSchema & { @@ -366,7 +371,8 @@ export default class CRUD extends React.Component { 'onInit', 'onSaved', 'onQuery', - 'formStore' + 'formStore', + 'autoFillHeight' ]; static defaultProps = { toolbarInline: true, @@ -382,7 +388,8 @@ export default class CRUD extends React.Component { filterTogglable: false, filterDefaultVisible: true, loadDataOnce: false, - loadDataOnceFetchOnFilter: true + loadDataOnceFetchOnFilter: true, + autoFillHeight: false }; control: any; @@ -2000,6 +2007,7 @@ export default class CRUD extends React.Component { onQuery, autoGenerateFilter, onSelect, + autoFillHeight, ...rest } = this.props; @@ -2051,6 +2059,7 @@ export default class CRUD extends React.Component { className: cx('Crud-body', bodyClassName), ref: this.controlRef, autoGenerateFilter: !filter && autoGenerateFilter, + autoFillHeight: autoFillHeight, selectable: !!( (this.hasBulkActionsToolbar() && this.hasBulkActions()) || pickerMode diff --git a/src/renderers/Table/TableContent.tsx b/src/renderers/Table/TableContent.tsx index 7b5e530c3..06b55636d 100644 --- a/src/renderers/Table/TableContent.tsx +++ b/src/renderers/Table/TableContent.tsx @@ -1,11 +1,12 @@ import React from 'react'; import {ClassNamesFn} from '../../theme'; -import {IColumn, IRow} from '../../store/table'; +import {IColumn, IRow, ITableStore} from '../../store/table'; import {SchemaNode, Action} from '../../types'; import {TableBody} from './TableBody'; import {LocaleProps} from '../../locale'; import {observer} from 'mobx-react'; import {ActionSchema} from '../Action'; +import ItemActionsWrapper from './ItemActionsWrapper'; export interface TableContentProps extends LocaleProps { className?: string; @@ -50,10 +51,45 @@ export interface TableContentProps extends LocaleProps { prefixRow?: Array; affixRow?: Array; itemAction?: ActionSchema; + itemActions?: Array; + store: ITableStore; } @observer export class TableContent extends React.Component { + renderItemActions() { + const {itemActions, render, store, classnames: cx} = this.props; + const finalActions = Array.isArray(itemActions) + ? itemActions.filter(action => !action.hiddenOnHover) + : []; + + if (!finalActions.length) { + return null; + } + + return ( + +
+ {finalActions.map((action, index) => + render( + `itemAction/${index}`, + { + ...(action as any), + isMenuItem: true + }, + { + key: index, + item: store.hoverRow, + data: store.hoverRow!.locals, + rowIndex: store.hoverRow!.index + } + ) + )} +
+
+ ); + } + render() { const { placeholder, @@ -82,7 +118,8 @@ export class TableContent extends React.Component { locale, translate, itemAction, - affixRow + affixRow, + store } = this.props; const tableClassName = cx('Table-table', this.props.tableClassName); @@ -94,6 +131,7 @@ export class TableContent extends React.Component { className={cx('Table-content', className)} onScroll={onScroll} > + {store.hoverRow ? this.renderItemActions() : null} {columnsGroup.length ? ( diff --git a/src/renderers/Table/index.tsx b/src/renderers/Table/index.tsx index 89ccaff18..6e3d42049 100644 --- a/src/renderers/Table/index.tsx +++ b/src/renderers/Table/index.tsx @@ -53,9 +53,10 @@ import {TableBody} from './TableBody'; import {TplSchema} from '../Tpl'; import {MappingSchema} from '../Mapping'; import {isAlive, getSnapshot} from 'mobx-state-tree'; -import ItemActionsWrapper from './ItemActionsWrapper'; import ColumnToggler from './ColumnToggler'; import {BadgeSchema} from '../../components/Badge'; +import offset from '../../utils/offset'; +import {getStyleNumber} from '../../utils/dom'; /** * 表格列,不指定类型时默认为文本类型。 @@ -422,7 +423,8 @@ export default class Table extends React.Component { 'headerToolbarClassName', 'toolbarClassName', 'footerToolbarClassName', - 'itemBadge' + 'itemBadge', + 'autoFillHeight' ]; static defaultProps: Partial = { className: '', @@ -497,6 +499,7 @@ export default class Table extends React.Component { this.subFormRef = this.subFormRef.bind(this); this.handleColumnToggle = this.handleColumnToggle.bind(this); this.renderAutoFilterForm = this.renderAutoFilterForm.bind(this); + this.updateAutoFillHeight = this.updateAutoFillHeight.bind(this); const { store, @@ -607,6 +610,79 @@ export default class Table extends React.Component { this.affixDetect(); parent.addEventListener('scroll', this.affixDetect); window.addEventListener('resize', this.affixDetect); + this.updateAutoFillHeight(); + window.addEventListener('resize', this.updateAutoFillHeight); + } + + /** + * 自动设置表格高度占满界面剩余区域 + * 用 css 实现有点麻烦,要改很多结构,所以先用 dom hack 了,避免对之前的功能有影响 + */ + updateAutoFillHeight() { + const {autoFillHeight, footerToolbar, classPrefix: ns} = this.props; + if (!autoFillHeight) { + return; + } + const table = findDOMNode(this) as HTMLElement; + const tableContent = table.querySelector( + `.${ns}Table-content` + ) as HTMLElement; + const tableContentWrap = table.querySelector( + `.${ns}Table-contentWrap` + ) as HTMLElement; + const footToolbar = table.querySelector( + `.${ns}Table-footToolbar` + ) as HTMLElement; + if (!tableContent) { + return; + } + + // table 底部 margin + const tableMarginBottom = getStyleNumber(table, 'margin-bottom'); + // 计算 table-content 在 dom 中的位置 + const tableContentTop = offset(tableContent).top; + const viewportHeight = window.innerHeight; + // 有时候会拿不到 footToolbar? + const footToolbarHeight = footToolbar ? offset(footToolbar).height : 0; + // 有时候会拿不到 footToolbar,等一下在执行 + if (!footToolbarHeight && footerToolbar && footerToolbar.length) { + setTimeout(() => { + this.updateAutoFillHeight(); + }, 100); + return; + } + const footToolbarMarginBottom = getStyleNumber( + footToolbar, + 'margin-bottom' + ); + + const tableContentWrapMarginButtom = getStyleNumber( + tableContentWrap, + 'margin-bottom' + ); + + // 循环计算父级节点的 pddding,这里不考虑父级节点还可能会有其它兄弟节点的情况了 + let allParentPaddingButtom = 0; + let parentNode = tableContent.parentElement; + while (parentNode) { + const paddingButtom = getStyleNumber(parentNode, 'padding-bottom'); + const borderBottom = getStyleNumber(parentNode, 'border-bottom-width'); + allParentPaddingButtom = + allParentPaddingButtom + paddingButtom + borderBottom; + parentNode = parentNode.parentElement; + } + + tableContent.style.height = `${ + viewportHeight - + tableContentTop - + tableContentWrapMarginButtom - + footToolbarHeight - + Math.max( + footToolbarMarginBottom, + allParentPaddingButtom, + tableMarginBottom + ) + }px`; } componentDidUpdate(prevProps: TableProps) { @@ -692,6 +768,7 @@ export default class Table extends React.Component { const parent = this.parentNode; parent && parent.removeEventListener('scroll', this.affixDetect); window.removeEventListener('resize', this.affixDetect); + window.removeEventListener('resize', this.updateAutoFillHeight); (this.updateTableInfoLazy as any).cancel(); this.unSensor && this.unSensor(); @@ -881,7 +958,7 @@ export default class Table extends React.Component { } affixDetect() { - if (!this.props.affixHeader || !this.table) { + if (!this.props.affixHeader || !this.table || this.props.autoFillHeight) { return; } @@ -2584,39 +2661,6 @@ export default class Table extends React.Component { : footerNode || toolbarNode || null; } - renderItemActions() { - const {itemActions, render, store, classnames: cx} = this.props; - const finalActions = Array.isArray(itemActions) - ? itemActions.filter(action => !action.hiddenOnHover) - : []; - - if (!finalActions.length) { - return null; - } - - return ( - -
- {finalActions.map((action, index) => - render( - `itemAction/${index}`, - { - ...(action as any), - isMenuItem: true - }, - { - key: index, - item: store.hoverRow, - data: store.hoverRow!.locals, - rowIndex: store.hoverRow!.index - } - ) - )} -
-
- ); - } - renderTableContent() { const { classnames: cx, @@ -2631,8 +2675,11 @@ export default class Table extends React.Component { prefixRow, locale, affixRow, + tableContentClassName, translate, - itemAction + itemAction, + autoFillHeight, + itemActions } = this.props; // 理论上来说 store.rows 应该也行啊 @@ -2645,7 +2692,10 @@ export default class Table extends React.Component { store.combineNum > 0 ? 'Table-table--withCombine' : '', tableClassName )} + className={tableContentClassName} + itemActions={itemActions} itemAction={itemAction} + store={store} classnames={cx} columns={store.filteredColumns} columnsGroup={store.columnGroup} @@ -2681,6 +2731,7 @@ export default class Table extends React.Component { store, classnames: cx, affixColumns, + autoFillHeight, autoGenerateFilter } = this.props; @@ -2697,7 +2748,8 @@ export default class Table extends React.Component { return (
{autoGenerateFilter ? this.renderAutoFilterForm() : null} @@ -2734,7 +2786,6 @@ export default class Table extends React.Component { : null}
{this.renderTableContent()} - {store.hoverRow ? this.renderItemActions() : null} {this.renderAffixHeader(tableClassName)} {footer} diff --git a/src/utils/api.ts b/src/utils/api.ts index fbb2b86c2..3bdbc3fe5 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -198,7 +198,7 @@ export function responseAdaptor(ret: fetcherResult, api: ApiObject) { let hasStatusField = true; if (!data) { - throw new Error('Response is empty!'); + throw new Error('Response is empty'); } // 兼容几种常见写法 diff --git a/src/utils/dom.tsx b/src/utils/dom.tsx index e51cd5743..9cd92a1db 100644 --- a/src/utils/dom.tsx +++ b/src/utils/dom.tsx @@ -267,3 +267,15 @@ export function calculatePosition( activePlacement }; } + +/** + * 专门用来获取样式的像素值,默认返回 0 + */ +export function getStyleNumber(element: HTMLElement, styleName: string) { + if (!element) { + return 0; + } + return ( + parseInt(getComputedStyle(element).getPropertyValue(styleName), 10) || 0 + ); +}