mirror of
https://gitee.com/baidu/amis.git
synced 2024-11-29 18:48:45 +08:00
feat: crud2支持配置移动端展示为卡片或自定义schema,支持上滑加载更多
This commit is contained in:
parent
afcd44069b
commit
bb5fe33f0b
@ -1,5 +1,4 @@
|
||||
.#{$ns}PullRefresh {
|
||||
|
||||
&-wrap {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
@ -17,6 +16,11 @@
|
||||
line-height: var(--gap-lg);
|
||||
color: #999;
|
||||
}
|
||||
&-footer {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
animation: var(--Button-animation-spin);
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
import React, {forwardRef, useEffect} from 'react';
|
||||
import {ClassNamesFn, themeable} from 'amis-core';
|
||||
import {useSetState} from '../hooks';
|
||||
import {useOnScreen, useSetState} from '../hooks';
|
||||
import useTouch from '../hooks/use-touch';
|
||||
import {Icon} from './icons';
|
||||
import {TranslateFn} from 'amis-core';
|
||||
@ -16,10 +16,13 @@ export interface PullRefreshProps {
|
||||
classPrefix: string;
|
||||
translate: TranslateFn;
|
||||
disabled?: boolean;
|
||||
completed?: boolean;
|
||||
direction?: 'up' | 'down';
|
||||
pullingText?: string;
|
||||
loosingText?: string;
|
||||
loadingText?: string;
|
||||
successText?: string;
|
||||
completedText?: string;
|
||||
onRefresh?: () => void;
|
||||
loading?: boolean;
|
||||
successDuration?: number;
|
||||
@ -34,9 +37,14 @@ export interface PullRefreshState {
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
const defaultProps: {
|
||||
successDuration: number;
|
||||
loadingDuration: number;
|
||||
direction: 'up' | 'down';
|
||||
} = {
|
||||
successDuration: 0,
|
||||
loadingDuration: 0
|
||||
loadingDuration: 0,
|
||||
direction: 'up'
|
||||
};
|
||||
|
||||
const defaultHeaderHeight = 28;
|
||||
@ -47,17 +55,23 @@ const PullRefresh = forwardRef<{}, PullRefreshProps>((props, ref) => {
|
||||
translate: __,
|
||||
children,
|
||||
successDuration,
|
||||
loadingDuration
|
||||
loadingDuration,
|
||||
direction,
|
||||
completed
|
||||
} = props;
|
||||
|
||||
const refreshText = {
|
||||
pullingText: __('pullRefresh.pullingText'),
|
||||
loosingText: __('pullRefresh.loosingText'),
|
||||
loadingText: __('pullRefresh.loadingText'),
|
||||
successText: __('pullRefresh.successText')
|
||||
successText: __('pullRefresh.successText'),
|
||||
completedText: __('pullRefresh.completedText')
|
||||
};
|
||||
|
||||
const touch = useTouch();
|
||||
const loadingRef = React.useRef<HTMLDivElement>(null);
|
||||
// 当占位文字在屏幕内时,需要刷新
|
||||
const needRefresh = useOnScreen(loadingRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.loading === false) {
|
||||
@ -72,6 +86,8 @@ const PullRefresh = forwardRef<{}, PullRefreshProps>((props, ref) => {
|
||||
|
||||
const isTouchable = () => {
|
||||
return (
|
||||
!completed &&
|
||||
needRefresh &&
|
||||
!props.disabled &&
|
||||
state.status !== 'loading' &&
|
||||
state.status !== 'success'
|
||||
@ -100,7 +116,7 @@ const PullRefresh = forwardRef<{}, PullRefreshProps>((props, ref) => {
|
||||
status = 'loading';
|
||||
} else if (distance === 0) {
|
||||
status = 'normal';
|
||||
} else if (distance < pullDistance) {
|
||||
} else if (Math.abs(distance) < pullDistance) {
|
||||
status = 'pulling';
|
||||
} else {
|
||||
status = 'loosing';
|
||||
@ -136,8 +152,13 @@ const PullRefresh = forwardRef<{}, PullRefreshProps>((props, ref) => {
|
||||
if (isTouchable()) {
|
||||
touch.move(event);
|
||||
updateState({});
|
||||
if (touch.isVertical() && touch.deltaY > 0) {
|
||||
setStatus(ease(touch.deltaY));
|
||||
|
||||
if (touch.isVertical()) {
|
||||
if (direction === 'up' && touch.deltaY > 0) {
|
||||
setStatus(ease(touch.deltaY));
|
||||
} else if (direction === 'down' && touch.deltaY < 0) {
|
||||
setStatus(-1 * ease(-1 * touch.deltaY));
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@ -145,8 +166,7 @@ const PullRefresh = forwardRef<{}, PullRefreshProps>((props, ref) => {
|
||||
|
||||
const onTouchEnd = (event: any) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (isTouchable() && state.offsetY > 0) {
|
||||
if (isTouchable() && state.offsetY !== 0) {
|
||||
if (state.status === 'loosing') {
|
||||
if (loadingDuration) {
|
||||
setStatus(defaultHeaderHeight, true);
|
||||
@ -162,10 +182,18 @@ const PullRefresh = forwardRef<{}, PullRefreshProps>((props, ref) => {
|
||||
|
||||
const transformStyle = {
|
||||
transform: `translate3d(0, ${state.offsetY}px, 0)`,
|
||||
touchAction: 'none'
|
||||
// 不清楚历史原因为什么要加这个,兼容一下
|
||||
...(direction === 'up'
|
||||
? {
|
||||
touchAction: 'none'
|
||||
}
|
||||
: {})
|
||||
};
|
||||
|
||||
const getStatusText = (status: statusText) => {
|
||||
if (completed) {
|
||||
return refreshText.completedText;
|
||||
}
|
||||
if (status === 'normal') {
|
||||
return '';
|
||||
}
|
||||
@ -181,13 +209,29 @@ const PullRefresh = forwardRef<{}, PullRefreshProps>((props, ref) => {
|
||||
onTouchCancel={onTouchEnd}
|
||||
>
|
||||
<div className={cx('PullRefresh-wrap')} style={transformStyle}>
|
||||
<div className={cx('PullRefresh-header')}>
|
||||
{state.status === 'loading' && (
|
||||
<Icon icon="loading-outline" className="icon loading-icon" />
|
||||
)}
|
||||
{getStatusText(state.status)}
|
||||
</div>
|
||||
{direction === 'up' ? (
|
||||
<div className={cx('PullRefresh-header')} ref={loadingRef}>
|
||||
{state.status === 'loading' && (
|
||||
<Icon icon="loading-outline" className="icon loading-icon" />
|
||||
)}
|
||||
{getStatusText(state.status)}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
{direction === 'down' ? (
|
||||
completed ? (
|
||||
<div className={cx('PullRefresh-footer')} ref={loadingRef}>
|
||||
{refreshText.completedText}
|
||||
</div>
|
||||
) : (
|
||||
<div className={cx('PullRefresh-footer')} ref={loadingRef}>
|
||||
{state.status === 'loading' && (
|
||||
<Icon icon="loading-outline" className="icon loading-icon" />
|
||||
)}
|
||||
{getStatusText(state.status)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -5,3 +5,4 @@ export {default as useSetState} from './use-set-state';
|
||||
export {default as useUpdateEffect} from './use-update-effect';
|
||||
export {default as useTouch} from './use-touch';
|
||||
export {default as useValidationResolver} from './use-validation-resolver';
|
||||
export {default as useOnScreen} from './use-on-screen';
|
||||
|
28
packages/amis-ui/src/hooks/use-on-screen.ts
Normal file
28
packages/amis-ui/src/hooks/use-on-screen.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 检测元素是否在屏幕中
|
||||
*/
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const useOnScreen = (ref: any) => {
|
||||
const [isIntersecting, setIsIntersecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setIsIntersecting(entry.isIntersecting);
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current);
|
||||
}
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return isIntersecting;
|
||||
};
|
||||
|
||||
export default useOnScreen;
|
@ -409,6 +409,7 @@ register('de-DE', {
|
||||
'pullRefresh.loosingText': 'Zum Aktualisieren freigeben...',
|
||||
'pullRefresh.loadingText': 'Laden...',
|
||||
'pullRefresh.successText': 'Laden erfolgreich',
|
||||
'pullRefresh.completedText': 'Keine weiteren Daten',
|
||||
'Picker.placeholder': 'Klicken Sie rechts auf das Symbol',
|
||||
'UserSelect.edit': 'bearbeiten',
|
||||
'UserSelect.save': 'Konservierung',
|
||||
|
@ -391,6 +391,7 @@ register('en-US', {
|
||||
'pullRefresh.loosingText': 'Release to refresh...',
|
||||
'pullRefresh.loadingText': 'Loading...',
|
||||
'pullRefresh.successText': 'Loading success',
|
||||
'pullRefresh.completedText': 'No More Data',
|
||||
'Picker.placeholder': 'Click icon on the right',
|
||||
'UserSelect.edit': 'edit',
|
||||
'UserSelect.save': 'preservation',
|
||||
|
@ -384,6 +384,7 @@ register('zh-CN', {
|
||||
'pullRefresh.loosingText': '释放即可刷新...',
|
||||
'pullRefresh.loadingText': '加载中...',
|
||||
'pullRefresh.successText': '加载成功',
|
||||
'pullRefresh.completedText': '没有更多了',
|
||||
'Picker.placeholder': '请点击右侧的图标',
|
||||
'UserSelect.edit': '编辑',
|
||||
'UserSelect.save': '保存',
|
||||
|
@ -479,7 +479,7 @@ test('Renderer:condition-builder with custom field', async () => {
|
||||
fireEvent.click(await findByText('请选择操作'));
|
||||
fireEvent.click(await findByText('等于(自定义)'));
|
||||
|
||||
await wait(400);
|
||||
await wait(1000);
|
||||
const colorInputs = container.querySelectorAll(
|
||||
'.cxd-CBValue .cxd-ColorPicker-input'
|
||||
)!;
|
||||
|
@ -34,7 +34,7 @@ import {
|
||||
JSONTraverse
|
||||
} from 'amis-core';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import {Html, SpinnerExtraProps} from 'amis-ui';
|
||||
import {Html, PullRefresh, SpinnerExtraProps} from 'amis-ui';
|
||||
import {
|
||||
BaseSchema,
|
||||
SchemaApi,
|
||||
@ -50,6 +50,7 @@ import {SchemaCollection} from '../Schema';
|
||||
|
||||
import type {Table2RendererEvent} from './Table2';
|
||||
import type {CardsRendererEvent} from './Cards';
|
||||
import isPlainObject from 'lodash/isPlainObject';
|
||||
|
||||
export type CRUDRendererEvent = Table2RendererEvent | CardsRendererEvent;
|
||||
|
||||
@ -678,7 +679,7 @@ export default class CRUD2 extends React.Component<CRUD2Props, any> {
|
||||
silent,
|
||||
pageField,
|
||||
perPageField,
|
||||
loadDataMode: false,
|
||||
loadDataMode,
|
||||
syncResponse2Query,
|
||||
columns: store.columns ?? columns,
|
||||
isTable2: true
|
||||
@ -1158,6 +1159,16 @@ export default class CRUD2 extends React.Component<CRUD2Props, any> {
|
||||
);
|
||||
}
|
||||
|
||||
@autobind
|
||||
async handlePullRefresh() {
|
||||
const {dispatchEvent, data} = this.props;
|
||||
const rendererEvent = await dispatchEvent('pullRefresh', data);
|
||||
if (rendererEvent?.prevented) {
|
||||
return;
|
||||
}
|
||||
this.handleLoadMore();
|
||||
}
|
||||
|
||||
@autobind
|
||||
renderChild(region: string, schema: any, props: object = {}) {
|
||||
const {render, store, primaryField = 'id'} = this.props;
|
||||
@ -1323,6 +1334,41 @@ export default class CRUD2 extends React.Component<CRUD2Props, any> {
|
||||
);
|
||||
}
|
||||
|
||||
transformTable2cards() {
|
||||
const {store, columns, card} = this.props;
|
||||
const body: any[] = [];
|
||||
const actions: any[] = [];
|
||||
|
||||
((store.columns ?? columns) || []).forEach((item: any) => {
|
||||
if (!isPlainObject(item)) {
|
||||
return;
|
||||
} else if (item.type === 'operation') {
|
||||
actions.push(...(item?.buttons || []));
|
||||
} else if (item.type === 'button' && item.name === 'operation') {
|
||||
actions.push(item);
|
||||
} else {
|
||||
if (!item.label && item.title) {
|
||||
item.label = item.title;
|
||||
}
|
||||
body.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
if (!body.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
columnsCount: 1,
|
||||
type: 'cards',
|
||||
card: {
|
||||
...card,
|
||||
body,
|
||||
actions
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
columns,
|
||||
@ -1364,9 +1410,90 @@ export default class CRUD2 extends React.Component<CRUD2Props, any> {
|
||||
footerToolbarClassName,
|
||||
id,
|
||||
testIdBuilder,
|
||||
mobileMode,
|
||||
mobileUI,
|
||||
pullRefresh: _pullRefresh,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
let pullRefresh: any;
|
||||
|
||||
let mobileModeProps: any = {};
|
||||
if (mobileMode && mobileUI && mode.includes('table')) {
|
||||
if (typeof mobileMode === 'string' && mobileMode === 'cards') {
|
||||
const cardsSchema = this.transformTable2cards();
|
||||
if (cardsSchema) {
|
||||
mobileModeProps = cardsSchema;
|
||||
}
|
||||
} else if (typeof mobileMode === 'object') {
|
||||
mobileModeProps = {...mobileMode};
|
||||
}
|
||||
// 移动端模式,默认开启上拉刷新
|
||||
if (mobileModeProps && !_pullRefresh?.disabled) {
|
||||
pullRefresh = {
|
||||
..._pullRefresh,
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
} else {
|
||||
pullRefresh = _pullRefresh;
|
||||
}
|
||||
|
||||
const body = render(
|
||||
'body',
|
||||
{
|
||||
...rest,
|
||||
// 通用事件 例如cus-event 如果直接透传给table 则会被触发2次
|
||||
// 因此只将下层组件table、cards中自定义事件透传下去 否则通过crud配置了也不会执行
|
||||
onEvent: omitBy(
|
||||
onEvent,
|
||||
(event, key: any) => !INNER_EVENTS.includes(key)
|
||||
),
|
||||
type: mode,
|
||||
columns: mode.startsWith('table')
|
||||
? store.columns || columns
|
||||
: undefined,
|
||||
id,
|
||||
...mobileModeProps
|
||||
},
|
||||
{
|
||||
key: 'body',
|
||||
className: cx('Crud2-body', bodyClassName),
|
||||
ref: this.controlRef,
|
||||
autoGenerateFilter: !filterSchema && autoGenerateFilter,
|
||||
autoFillHeight: autoFillHeight,
|
||||
checkAll: false, // 不使用组件的全选,因为不在工具栏里
|
||||
selectable: !!(selectable ?? pickerMode),
|
||||
itemActions,
|
||||
multiple: multiple,
|
||||
// columnsTogglable在CRUD2中渲染 但需要给table2传columnsTogglable为false 否则列数超过5 table2会自动渲染
|
||||
columnsTogglable: false,
|
||||
selected:
|
||||
pickerMode || keepItemSelectionOnPageChange
|
||||
? store.selectedItemsAsArray
|
||||
: undefined,
|
||||
keepItemSelectionOnPageChange,
|
||||
maxKeepItemSelectionLength,
|
||||
// valueField: valueField || primaryField,
|
||||
primaryField: primaryField,
|
||||
testIdBuilder,
|
||||
items: store.data.items,
|
||||
query: store.query,
|
||||
orderBy: store.query.orderBy,
|
||||
orderDir: store.query.orderDir,
|
||||
popOverContainer,
|
||||
onSave: this.handleSave.bind(this),
|
||||
onSaveOrder: this.handleSaveOrder,
|
||||
onSearch: this.handleQuerySearch,
|
||||
onSort: this.handleQuerySearch,
|
||||
onSelect: this.handleSelect,
|
||||
onAction: this.handleAction,
|
||||
data: store.mergedData,
|
||||
loading: store.loading,
|
||||
host: this
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('Crud2', className, {
|
||||
@ -1391,65 +1518,30 @@ export default class CRUD2 extends React.Component<CRUD2Props, any> {
|
||||
? this.renderSelection()
|
||||
: null}
|
||||
|
||||
{render(
|
||||
'body',
|
||||
{
|
||||
...rest,
|
||||
// 通用事件 例如cus-event 如果直接透传给table 则会被触发2次
|
||||
// 因此只将下层组件table、cards中自定义事件透传下去 否则通过crud配置了也不会执行
|
||||
onEvent: omitBy(
|
||||
onEvent,
|
||||
(event, key: any) => !INNER_EVENTS.includes(key)
|
||||
),
|
||||
type: mode,
|
||||
columns: mode.startsWith('table')
|
||||
? store.columns || columns
|
||||
: undefined,
|
||||
id
|
||||
},
|
||||
{
|
||||
key: 'body',
|
||||
className: cx('Crud2-body', bodyClassName),
|
||||
ref: this.controlRef,
|
||||
autoGenerateFilter: !filterSchema && autoGenerateFilter,
|
||||
autoFillHeight: autoFillHeight,
|
||||
checkAll: false, // 不使用组件的全选,因为不在工具栏里
|
||||
selectable: !!(selectable ?? pickerMode),
|
||||
itemActions,
|
||||
multiple: multiple,
|
||||
// columnsTogglable在CRUD2中渲染 但需要给table2传columnsTogglable为false 否则列数超过5 table2会自动渲染
|
||||
columnsTogglable: false,
|
||||
selected:
|
||||
pickerMode || keepItemSelectionOnPageChange
|
||||
? store.selectedItemsAsArray
|
||||
: undefined,
|
||||
keepItemSelectionOnPageChange,
|
||||
maxKeepItemSelectionLength,
|
||||
// valueField: valueField || primaryField,
|
||||
primaryField: primaryField,
|
||||
testIdBuilder,
|
||||
items: store.data.items,
|
||||
query: store.query,
|
||||
orderBy: store.query.orderBy,
|
||||
orderDir: store.query.orderDir,
|
||||
popOverContainer,
|
||||
onSave: this.handleSave.bind(this),
|
||||
onSaveOrder: this.handleSaveOrder,
|
||||
onSearch: this.handleQuerySearch,
|
||||
onSort: this.handleQuerySearch,
|
||||
onSelect: this.handleSelect,
|
||||
onAction: this.handleAction,
|
||||
data: store.mergedData,
|
||||
loading: store.loading,
|
||||
host: this
|
||||
}
|
||||
{mobileUI && pullRefresh && !pullRefresh.disabled ? (
|
||||
<PullRefresh
|
||||
{...pullRefresh}
|
||||
translate={__}
|
||||
onRefresh={this.handlePullRefresh}
|
||||
direction="down"
|
||||
completed={
|
||||
!store.loading &&
|
||||
store.lastPage > 0 &&
|
||||
store.page >= store.lastPage
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</PullRefresh>
|
||||
) : (
|
||||
<>
|
||||
{body}
|
||||
<div className={cx('Crud2-toolbar', footerToolbarClassName)}>
|
||||
{this.renderToolbar('footerToolbar', footerToolbar)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* spinner可以交给孩子处理 */}
|
||||
{/* <Spinner overlay size="lg" key="info" show={store.loading} /> */}
|
||||
|
||||
<div className={cx('Crud2-toolbar', footerToolbarClassName)}>
|
||||
{this.renderToolbar('footerToolbar', footerToolbar)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user