Merge pull request #11011 from CheshireJCat/feat-jinye-1011-crud2-support-mobile

feat: crud2支持配置移动端展示为卡片或自定义schema,支持上拉加载
This commit is contained in:
Allen 2024-10-11 17:04:07 +08:00 committed by GitHub
commit 94ebe12715
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 249 additions and 77 deletions

View File

@ -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);

View File

@ -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>
);

View File

@ -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';

View 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;

View File

@ -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',

View File

@ -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',

View File

@ -384,6 +384,7 @@ register('zh-CN', {
'pullRefresh.loosingText': '释放即可刷新...',
'pullRefresh.loadingText': '加载中...',
'pullRefresh.successText': '加载成功',
'pullRefresh.completedText': '没有更多了',
'Picker.placeholder': '请点击右侧的图标',
'UserSelect.edit': '编辑',
'UserSelect.save': '保存',

View File

@ -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'
)!;

View File

@ -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>
);
}