: 移动端录单与筛选新设计 (#969)

Co-authored-by: sealday <zhanglin@daoyoucloud.com>
Co-authored-by: hello@lv <2256334253@qq.com>
Co-authored-by: bai.zixv <bai.zixv@foxmail.com>
Reviewed-on: daoyoucloud/tachybase#951
Co-authored-by: wjh <wwwjh0710@163.com>
Co-committed-by: wjh <wwwjh0710@163.com>
Co-authored-by: wjh <wwwjh0710@163.com>
Co-authored-by: bai.zixv <bai.zixv@foxmail.com>
Reviewed-on: daoyoucloud/tachybase#969
This commit is contained in:
sealday 2024-05-15 11:45:42 +08:00
parent 0602743922
commit d7b2695612
28 changed files with 564 additions and 82 deletions

View File

@ -35,6 +35,11 @@ import {
usePropsRelatedImageSearchItemField,
useTabSearchFieldItemProps,
useTabSearchFieldItemRelatedProps,
MInput,
MCheckbox,
MDatePicker,
MRadio,
MImageUploader,
} from './schema';
import './bridge';
import './assets/svg';
@ -70,6 +75,11 @@ export const MobileCore: React.FC = (props) => {
ImageSearchItemView: ImageSearchItemView,
// NoticeBlock,
// NoticeBlockInitializer,
MInput,
MCheckbox,
MDatePicker,
MRadio,
MImageUploader,
}}
scope={{
useGridCardBlockItemProps,

View File

@ -0,0 +1,69 @@
import { useCollectionField } from '@tachybase/client';
import { connect, isValid, mapProps, mapReadPretty, useField } from '@tachybase/schema';
import { Checkbox, CheckboxGroupProps, CheckboxProps, Tag } from 'antd-mobile';
import React, { useEffect } from 'react';
type ComposedCheckBox = React.FC<CheckboxProps> & {
Group?: any;
};
export const MCheckbox: ComposedCheckBox = connect(
Checkbox,
mapProps((props) => {
return { ...props };
}),
);
MCheckbox.Group = connect(
(props) => {
const collectionField = useCollectionField();
const dataSource = collectionField?.uiSchema.enum || [];
const check = props.value || [];
return (
<Checkbox.Group value={check}>
{dataSource.map((item, index) => {
return (
<Checkbox
value={item.value}
key={index}
style={{ marginRight: '10px' }}
onChange={(status) => {
if (status) {
check.push(item.value);
} else {
const itemIndex = check.findIndex((cItem) => item.value?.toString() === cItem);
check.splice(itemIndex, 1);
}
props.onChange(check);
}}
>
{item.label}
</Checkbox>
);
})}
</Checkbox.Group>
);
},
mapProps((props: any, field: any) => {
return { ...props };
}),
mapReadPretty((props) => {
if (!isValid(props.value)) {
return <div></div>;
}
const { value } = props;
const field = useField<any>();
const collectionField = useCollectionField();
const dataSource = field.dataSource || collectionField?.uiSchema.enum || [];
return (
<div>
{dataSource
.filter((option) => option.value == value)
.map((option, key) => (
<Tag key={key} color={option.color}>
{option.label}
</Tag>
))}
</div>
);
}),
);
export default MCheckbox;

View File

@ -0,0 +1,37 @@
import { dayjs } from '@tachybase/utils/client';
import { connect, mapProps } from '@tachybase/schema';
import { Button, DatePicker, Input, Space } from 'antd-mobile';
import React, { useState } from 'react';
export const MDatePicker = connect(
(props) => {
const [visible, setVisible] = useState(false);
const nowDate = props.value || new Date();
return (
<>
<Button
onClick={() => {
setVisible(true);
}}
>
{dayjs(nowDate).format('YYYY-MM-DD')}
</Button>
<DatePicker
visible={visible}
onClose={() => {
setVisible(false);
}}
onConfirm={(value) => {
props.onChange(value);
setVisible(false);
}}
/>
</>
);
},
mapProps((props) => {
return { ...props };
}),
);
export default MDatePicker;

View File

@ -0,0 +1,65 @@
import { useAPIClient } from '@tachybase/client';
import { connect, mapProps, mapReadPretty, useForm } from '@tachybase/schema';
import { ImageUploadItem, ImageUploader } from 'antd-mobile';
import React, { useEffect, useState } from 'react';
export const MImageUploader = connect(
(props) => {
const [fileList, setFileList] = useState<ImageUploadItem[]>([]);
const [change, setChange] = useState(false);
const field = props.value || [];
useEffect(() => {
if (!change && props.value) {
const data = props.value.map((item) => {
return { url: item.url, key: item.id, thumbnailUrl: item.url };
});
setFileList(data);
}
}, [props.value]);
const api = useAPIClient();
return (
<ImageUploader
value={fileList}
upload={async (file) => {
const { name } = file;
const imageField = isImage(name);
if (imageField) {
const formData = new FormData();
formData.append('file', file);
let result;
await api
.request({ url: props.action, method: 'post', data: formData })
.then((res) => {
result = res?.data?.data;
field.push(result);
})
.catch(() => {});
return {
url: result?.url,
key: result?.id,
thumbnailUrl: result?.url,
};
}
}}
onChange={(file) => {
setFileList(file);
setChange(true);
props.onChange(field);
}}
/>
);
},
mapProps((props) => {
return { ...props };
}),
);
export default MImageUploader;
export const isImage = (extName: string) => {
const reg = /\.(png|jpg|jpeg|gif|webp)$/i;
return reg.test(extName);
};
export const isPdf = (extName: string) => {
return extName.toLowerCase().endsWith('.pdf');
};

View File

@ -0,0 +1,23 @@
import { connect, mapProps } from '@tachybase/schema';
import { Input, InputProps, TextArea, TextAreaProps } from 'antd-mobile';
type ComposedInput = React.FC<InputProps> & {
TextArea?: React.FC<TextAreaProps>;
};
export const MInput: ComposedInput = connect(
Input,
mapProps((props) => {
return { placeholder: '请输入内容', clearable: true, ...props };
}),
);
const MTextArea = connect(
TextArea,
mapProps((props) => {
return { placeholder: '请输入内容', ...props };
}),
);
MInput.TextArea = MTextArea;
export default MInput;

View File

@ -0,0 +1,67 @@
import { useCollectionField } from '@tachybase/client';
import { connect, isValid, mapProps, mapReadPretty, useField } from '@tachybase/schema';
import { Radio, RadioGroupProps, RadioProps, Tag } from 'antd-mobile';
import React, { useEffect } from 'react';
type ComposedRadio = React.FC<RadioProps> & {
Group?: any;
};
export const MRadio: ComposedRadio = connect(
Radio,
mapProps({
value: 'checked',
onInput: 'onChange',
}),
);
MRadio.Group = connect(
(props) => {
const collectionField = useCollectionField();
const dataSource = collectionField?.uiSchema.enum || [];
return (
<Radio.Group {...props}>
{dataSource.map((item, index) => {
return (
<Radio value={item.value} key={index} style={{ marginRight: '10px' }}>
{item.label}
</Radio>
);
})}
</Radio.Group>
);
},
mapProps((props: any, field: any) => {
useEffect(() => {
const defaultOption = field.dataSource?.find((option) => option.value == props.value);
if (defaultOption) {
field.setValue(defaultOption.value);
}
}, [props.value, field.dataSource]);
return {
...props,
};
}),
mapReadPretty((props) => {
if (!isValid(props.value)) {
return <div></div>;
}
const { value } = props;
const field = useField<any>();
const collectionField = useCollectionField();
const dataSource = field.dataSource || collectionField?.uiSchema.enum || [];
return (
<div>
{dataSource
.filter((option) => option.value == value)
.map((option, key) => (
<Tag key={key} color={option.color}>
{option.label}
</Tag>
))}
</div>
);
}),
);
export default MRadio;

View File

@ -0,0 +1,5 @@
export * from './Input';
export * from './Checkbox';
export * from './DatePicker';
export * from './ImageUploader';
export * from './Radio';

View File

@ -4,6 +4,7 @@ import React, { useEffect } from 'react';
import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
import { ContainerDesigner } from './Container.Designer';
import useStyles from './style';
import { MobileProvider } from '../../provider/MobileProvider';
const findGrid = (schema, uid) => {
return schema.reduceProperties((final, next) => {
@ -52,37 +53,39 @@ const InternalContainer: React.FC = (props) => {
}, [location.pathname, navigate, params.name, redirectToUid]);
return (
<SortableItem eid="nb-mobile-scroll-wrapper" className={cx('nb-mobile-container', styles.mobileContainer)}>
<Designer></Designer>
<div
style={{
paddingBottom: redirectToUid ? token.paddingLG * 2 : 0,
}}
className="nb-mobile-container-content"
>
{redirectToUid ? (
<TabContentComponent />
) : (
<SchemaComponent
filterProperties={(schema) => {
return schema['x-component'] !== 'MTabBar';
}}
schema={fieldSchema}
/>
)}
</div>
{isTabBarEnabled && (
<div className={cx('nb-mobile-container-tab-bar', styles.tabBar)}>
<SchemaComponent
onlyRenderProperties
filterProperties={(schema) => {
return schema['x-component'] === 'MTabBar';
}}
schema={fieldSchema}
></SchemaComponent>
<MobileProvider>
<SortableItem eid="nb-mobile-scroll-wrapper" className={cx('nb-mobile-container', styles.mobileContainer)}>
<Designer></Designer>
<div
style={{
paddingBottom: redirectToUid ? token.paddingLG * 2 : 0,
}}
className="nb-mobile-container-content"
>
{redirectToUid ? (
<TabContentComponent />
) : (
<SchemaComponent
filterProperties={(schema) => {
return schema['x-component'] !== 'MTabBar';
}}
schema={fieldSchema}
/>
)}
</div>
)}
</SortableItem>
{isTabBarEnabled && (
<div className={cx('nb-mobile-container-tab-bar', styles.tabBar)}>
<SchemaComponent
onlyRenderProperties
filterProperties={(schema) => {
return schema['x-component'] === 'MTabBar';
}}
schema={fieldSchema}
></SchemaComponent>
</div>
)}
</SortableItem>
</MobileProvider>
);
};

View File

@ -0,0 +1,10 @@
const custonComponent = {
Input: 'MInput',
'Input.TextArea': 'MInput.TextArea',
'Radio.Group': 'MRadio.Group',
'Checkbox.Group': 'MCheckbox.Group',
'Upload.Attachment': 'MImageUploader',
DatePicker: 'MDatePicker',
};
export const canMobileField = (componentName: string) => custonComponent[componentName];

View File

@ -0,0 +1,146 @@
import {
AssociatedFields,
CompatibleSchemaInitializer,
ParentCollectionFields,
SchemaInitializerItemType,
formItemInitializers_deprecated,
gridRowColWrap,
useActionContext,
useCollectionManager_deprecated,
useCollection_deprecated,
} from '@tachybase/client';
import { Schema, useForm } from '@tachybase/schema';
import { useIsMobile } from '../../hooks';
import { canMobileField } from './CustomComponent';
export const useFormItemInitializerFields = (options?: any) => {
const { name, currentFields } = useCollection_deprecated();
const isMobile = useIsMobile();
const { getInterface, getCollection } = useCollectionManager_deprecated();
const form = useForm();
const { readPretty = form.readPretty, block = 'Form' } = options || {};
const { fieldSchema } = useActionContext();
const action = fieldSchema?.['x-action'];
return currentFields
?.filter((field) => field?.interface && !field?.isForeignKey && !field?.treeChildren)
?.map((field) => {
const interfaceConfig = getInterface(field.interface);
const targetCollection = getCollection(field.target);
const isFileCollection = field?.target && getCollection(field?.target)?.template === 'file';
const isAssociationField = targetCollection;
const fieldNames = field?.uiSchema['x-component-props']?.['fieldNames'];
const isMobileComponent = canMobileField(field.uiSchema['x-component']);
const schema = {
type: 'string',
name: field.name,
'x-toolbar': 'FormItemSchemaToolbar',
'x-settings': 'fieldSettings:FormItem',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': `${name}.${field.name}`,
'x-component-props': isFileCollection
? {
fieldNames: {
label: 'preview',
value: 'id',
},
}
: isAssociationField && fieldNames
? {
fieldNames: { ...fieldNames, label: targetCollection?.titleField || fieldNames.label },
}
: {},
'x-read-pretty': field?.uiSchema?.['x-read-pretty'],
};
if (isMobile && isMobileComponent) {
schema['x-component-props']['component'] = isMobileComponent;
}
const resultItem = {
type: 'item',
name: field.name,
title: field?.uiSchema?.title || field.name,
Component: 'CollectionFieldInitializer',
remove: removeGridFormItem,
schemaInitialize: (s) => {
interfaceConfig?.schemaInitialize?.(s, {
field,
block,
readPretty,
action,
targetCollection,
});
},
schema,
} as SchemaInitializerItemType;
if (block == 'Kanban') {
resultItem['find'] = (schema: Schema, key: string, action: string) => {
const s = findSchema(schema, 'x-component', block);
return findSchema(s, key, action);
};
}
return resultItem;
});
};
export const MobileFormItemInitializers = new CompatibleSchemaInitializer(
{
name: 'form:configureFields',
wrap: gridRowColWrap,
icon: 'SettingOutlined',
title: '{{t("Configure fields")}}',
items: [
{
type: 'itemGroup',
name: 'displayFields',
title: '{{t("Display fields")}}',
useChildren: useFormItemInitializerFields,
},
{
name: 'parentCollectionFields',
Component: ParentCollectionFields,
},
{
name: 'associationFields',
Component: AssociatedFields,
},
{
name: 'divider',
type: 'divider',
},
{
name: 'addText',
title: '{{t("Add text")}}',
Component: 'MarkdownFormItemInitializer',
},
],
},
formItemInitializers_deprecated,
);
export const removeGridFormItem = (schema, cb) => {
cb(schema, {
removeParentsIfNoChildren: true,
breakRemoveOn: {
'x-component': 'Grid',
},
});
};
const findSchema = (schema: Schema, key: string, action: string) => {
if (!Schema.isSchemaInstance(schema)) return null;
return schema.reduceProperties((buf, s) => {
if (s[key] === action) {
return s;
}
if (s['x-component'] !== 'Action.Container' && s['x-component'] !== 'AssociationField.Viewer') {
const c = findSchema(s, key, action);
if (c) {
return c;
}
}
return buf;
});
};

View File

@ -0,0 +1 @@
export * from './MobileFormItemInitializers';

View File

@ -1,7 +1,7 @@
import { SchemaInitializer, useCollection, useCollectionManager } from '@tachybase/client';
import { useIsMobile } from '../tab-search/components/field-item/hooks';
import { canBeOptionalField, canBeRelatedField } from '../tab-search/utils';
import { createSchemaImageSearchItem } from './search-item/ImageSearchItem.schema';
import { useIsMobile } from '../../hooks';
export const ImageSearchConfigureFields = new SchemaInitializer({
name: 'ImageSearchView:configureFields',

View File

@ -8,3 +8,5 @@ export * from './image-search';
export * from './swiper';
export * from './tab-search';
export * from './notice';
export * from './form';
export * from './antd-mobile';

View File

@ -1,10 +1,11 @@
import React, { useState } from 'react';
import { useTranslation } from '../../../../../../locale';
import { Grid, Divider, Picker, Input, Space, ActionSheet, DatePicker, CalendarPicker } from 'antd-mobile';
import { dayjs } from '@tachybase/utils/client';
import { ActionSheet, Button, Calendar, Divider, Grid, Input, Picker, Popup } from 'antd-mobile';
import { DownOutline } from 'antd-mobile-icons';
import type { Action } from 'antd-mobile/es/components/action-sheet';
import { changFormat, convertFormat } from '../../utils';
import { dayjs } from '@tachybase/utils/client';
import React, { useRef, useState } from 'react';
import { useTranslation } from '../../../../../../locale';
import { convertFormat } from '../../utils';
import { css } from '@tachybase/client';
export const ISelect = (props) => {
const { options, onChange, customLabelKey } = props;
@ -59,13 +60,34 @@ export const IDatePicker = (props) => {
const { options, value, onChange, onInputChange } = props;
const time = value.split('&');
const [visible, setVisible] = useState(false);
const [clickCount, setClickCount] = useState(0);
const minDate = dayjs().subtract(10, 'year').toDate();
const maxDate = dayjs().add(3, 'year').toDate();
const onClick = () => {
setVisible(true);
};
const onChangeDate = ([start, end]) => {
const startTime = dayjs(start).startOf('date').toISOString();
const endTime = dayjs(end).endOf('date').toISOString();
// TODO: 此处受上游影响,格式必须确定为这样, 时间有限,不再往上追查
const timeString = `"${startTime}"&"${endTime}"`;
onInputChange(timeString);
onChange(timeString);
// XXX: UI 组件库这个组件是实验性组件, 此处模拟实现自动关闭浮层功能
if (clickCount > 0 && clickCount % 2 === 1) {
setVisible(false);
setClickCount(0);
} else {
setClickCount((preClickCount) => preClickCount + 1);
}
};
return (
<Grid.Item span={options.length > 1 ? 3 : 4}>
<div
onClick={() => {
setVisible(true);
}}
>
<div onClick={onClick}>
<Grid columns={5}>
<Grid.Item span={2} style={{ textAlign: 'end' }}>
{convertFormat(JSON.parse(time[0]))}
@ -78,22 +100,24 @@ export const IDatePicker = (props) => {
</Grid.Item>
</Grid>
</div>
<CalendarPicker
<Popup
visible={visible}
selectionMode="range"
destroyOnClose
mask
closeOnMaskClick
onMaskClick={() => setVisible(false)}
onClose={() => setVisible(false)}
onConfirm={([start, end]) => {
const startTime = dayjs(start).startOf('date').toISOString();
const endTime = dayjs(end).endOf('date').toISOString();
// TODO: 此处受上游影响,格式必须确定为这样, 时间有限,不再往上追查
const timeString = `"${startTime}"&"${endTime}"`;
onInputChange(timeString);
onChange(timeString);
}}
/>
// bodyStyle={{ height: '50vh' }}
>
<Calendar
selectionMode="range"
allowClear
min={minDate}
max={maxDate}
// defaultValue={defaultRange}
onChange={onChangeDate}
/>
</Popup>
</Grid.Item>
);
};

View File

@ -81,9 +81,3 @@ export const useTabSearchCollapsibleInputItem = () => {
onSelected,
};
};
export const useIsMobile = () => {
const fieldSchema = useFieldSchema();
const isMobile = Object.values(fieldSchema.root.properties).some((value) => value['x-component'] === 'MContainer');
return isMobile;
};

View File

@ -14,7 +14,8 @@ import {
import React, { useCallback } from 'react';
import { tval } from '../../../../../locale';
import { canBeDataField, canBeOptionalField, canBeRelatedField, canBeSearchField } from '../utils';
import { useIsMobile } from '../components/field-item/hooks';
import { useIsMobile } from '../../../hooks';
import { mapToMobile } from '../mapToMobile';
interface ItemInterface {
field: FieldOptions;
@ -159,7 +160,7 @@ function getItemInput(params: ItemInterface, isMobile?: boolean) {
// 然后排除其他类型的关联字段
if (isAssocField(field)) return null;
if (!(canBeSearchField(_interface) && !canBeRelatedField(_interface) && !canBeDataField(_interface))) {
if (!canBeSearchField(_interface) && !canBeRelatedField(_interface) && !canBeDataField(_interface)) {
return null;
}
@ -201,10 +202,7 @@ function getItemInput(params: ItemInterface, isMobile?: boolean) {
'x-collection-field': `${collectionName}.${schemaName}`,
// 'x-decorator': 'FormItem',
// 'x-component': 'CollectionField',
'x-component': matchTruthValue({
['TabSearchCollapsibleInputMItem']: isMobile,
['TabSearchCollapsibleInputItem']: !isMobile,
}),
'x-component': mapToMobile('TabSearchCollapsibleInputItem'),
'x-component-props': {
fieldNames: {
label,
@ -317,10 +315,7 @@ function getItemChoice(params: ItemInterface, isMobile?: boolean) {
'x-collection-field': `${collectionName}.${schemaName}`,
// 'x-decorator': 'FormItem',
// 'x-component': 'CollectionField',
'x-component': matchTruthValue({
['TabSearchFieldMItem']: isMobile,
['TabSearchFieldItem']: !isMobile,
}),
'x-component': mapToMobile('TabSearchFieldItem'),
'x-component-props': {
fieldNames: {
label,

View File

@ -1,9 +1,9 @@
import { SchemaInitializer, useCollection, useCollectionManager } from '@tachybase/client';
import { tval } from '../../../../../locale';
import { useIsMobile } from '../components/field-item/hooks';
import { createTabSearchItemSchema } from '../create/createTabSearchItemSchema';
import { canBeDataField, canBeOptionalField, canBeRelatedField, canBeSearchField } from '../utils';
import { TabSearchAssociatedFields } from './TabSearchAssociated.fields';
import { useIsMobile } from '../../../hooks';
export const TabSearchFieldSchemaInitializer = new SchemaInitializer({
name: 'tabSearch:configureFields',

View File

@ -0,0 +1,9 @@
const customComponent = {
['TabSearchCollapsibleInputItem']: 'TabSearchCollapsibleInputMItem',
['useTabSearchFieldItemProps']: 'useTabSearchFieldItemRelatedProps',
['TabSearchFieldItem']: 'TabSearchFieldMItem',
};
export function mapToMobile(componentName: string) {
return customComponent[componentName];
}

View File

@ -1 +1,2 @@
export * from './useSchemaPatch';
export * from './useIsMobile';

View File

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { MobileContext } from '../provider/MobileProvider';
export const useIsMobile = () => {
const ctx = useContext(MobileContext);
return ctx.isMobile;
};

View File

@ -31,11 +31,11 @@ export const mBlockInitializers_deprecated = new CompatibleSchemaInitializer({
title: '{{t("Form")}}',
Component: 'FormBlockInitializer',
},
// {
// name: 'details',
// title: '{{t("Details")}}',
// Component: 'DetailsBlockInitializer',
// },
{
name: 'details',
title: '{{t("Details")}}',
Component: 'DetailsBlockInitializer',
},
{
name: 'calendar',
title: '{{t("Calendar")}}',
@ -96,11 +96,11 @@ export const mBlockInitializers = new CompatibleSchemaInitializer(
title: '{{t("Table")}}',
Component: 'TableBlockInitializer',
},
// {
// name: 'form',
// title: '{{t("Form")}}',
// Component: 'FormBlockInitializer',
// },
{
name: 'form',
title: '{{t("Form")}}',
Component: 'FormBlockInitializer',
},
{
name: 'details',
title: '{{t("Details")}}',

View File

@ -0,0 +1,7 @@
import React, { createContext } from 'react';
export const MobileContext = createContext<any>({});
export const MobileProvider = (props) => {
return <MobileContext.Provider value={{ isMobile: true }}> {props.children}</MobileContext.Provider>;
};

View File

@ -8,6 +8,7 @@ import {
ImageSearchItemFieldSettings,
mBlockInitializers,
mBlockInitializers_deprecated,
MobileFormItemInitializers,
TabSearchFieldSchemaInitializer,
TabSearchItemFieldSettings,
} from './core/schema';
@ -27,7 +28,8 @@ export class MobileClientPlugin extends Plugin {
this.app.schemaInitializerManager.add(TabSearchFieldSchemaInitializer);
this.app.schemaSettingsManager.add(ImageSearchItemFieldSettings);
this.schemaSettingsManager.add(TabSearchItemFieldSettings);
this.app.schemaSettingsManager.add(TabSearchItemFieldSettings);
this.app.schemaInitializerManager.add(MobileFormItemInitializers);
}
addSettings() {