feat: infinite scroll and linkable card items
Co-authored-by: sealday <zhanglin@daoyoucloud.com> Reviewed-on: daoyoucloud/tachybase#997 Co-authored-by: bai.zixv <bai.zixv@foxmail.com> Co-committed-by: bai.zixv <bai.zixv@foxmail.com>
This commit is contained in:
		
							parent
							
								
									519bcc703e
								
							
						
					
					
						commit
						f0e51e1e89
					
				| @ -30,6 +30,8 @@ export const createGridCardBlockUISchema = (options: { | ||||
|       }, | ||||
|       runWhenParamsChanged: true, | ||||
|       rowKey, | ||||
|       isLinkable: false, | ||||
|       needInfiniteScroll: false, | ||||
|       layoutDirection: 'column', | ||||
|     }, | ||||
|     'x-component': 'BlockItem', | ||||
|  | ||||
| @ -277,6 +277,54 @@ export const gridCardBlockSettings = new SchemaSettings({ | ||||
|         }; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       name: 'setLinkable', | ||||
|       type: 'switch', | ||||
|       useComponentProps() { | ||||
|         const { t } = useTranslation(); | ||||
|         const fieldSchema = useFieldSchema(); | ||||
|         const field = useField(); | ||||
|         const { dn } = useDesignable(); | ||||
|         return { | ||||
|           title: t('Set Linkable'), | ||||
|           checked: fieldSchema['x-decorator-props']?.['isLinkable'] ?? false, | ||||
|           onChange: (isLinkable) => { | ||||
|             _.set(fieldSchema, 'x-decorator-props.isLinkable', isLinkable); | ||||
|             field.decoratorProps.isLinkable = isLinkable; | ||||
|             dn.emit('patch', { | ||||
|               schema: { | ||||
|                 ['x-uid']: fieldSchema['x-uid'], | ||||
|                 'x-decorator-props': fieldSchema['x-decorator-props'], | ||||
|               }, | ||||
|             }); | ||||
|           }, | ||||
|         }; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       name: 'setInfiniteScroll', | ||||
|       type: 'switch', | ||||
|       useComponentProps() { | ||||
|         const { t } = useTranslation(); | ||||
|         const fieldSchema = useFieldSchema(); | ||||
|         const field = useField(); | ||||
|         const { dn } = useDesignable(); | ||||
|         return { | ||||
|           title: t('Set InfiniteScroll'), | ||||
|           checked: fieldSchema['x-decorator-props']?.['needInfiniteScroll'], | ||||
|           onChange: (needInfiniteScroll) => { | ||||
|             _.set(fieldSchema, 'x-decorator-props.needInfiniteScroll', needInfiniteScroll); | ||||
|             field.decoratorProps.needInfiniteScroll = needInfiniteScroll; | ||||
|             dn.emit('patch', { | ||||
|               schema: { | ||||
|                 ['x-uid']: fieldSchema['x-uid'], | ||||
|                 'x-decorator-props': fieldSchema['x-decorator-props'], | ||||
|               }, | ||||
|             }); | ||||
|           }, | ||||
|         }; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       name: 'ConvertReferenceToDuplicate', | ||||
|       Component: SchemaSettingsTemplate, | ||||
|  | ||||
| @ -33,6 +33,7 @@ const InternalGridCardBlockProvider = (props) => { | ||||
|         service, | ||||
|         resource, | ||||
|         columnCount: props.columnCount, | ||||
|         needInfiniteScroll: props.needInfiniteScroll, | ||||
|       }} | ||||
|     > | ||||
|       <FormContext.Provider value={form}> | ||||
|  | ||||
| @ -1,11 +1,14 @@ | ||||
| import { css } from '@emotion/css'; | ||||
| import { useCollection } from '@tachybase/client'; | ||||
| import { ObjectField } from '@tachybase/schema'; | ||||
| import { useField } from '@tachybase/schema'; | ||||
| import { useField, useFieldSchema } from '@tachybase/schema'; | ||||
| import { Card } from 'antd'; | ||||
| import React from 'react'; | ||||
| import { useCollectionParentRecordData } from '../../../data-source/collection-record/CollectionRecordProvider'; | ||||
| import { RecordProvider } from '../../../record-provider'; | ||||
| import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import { useGridCardDetailUrl } from './hooks'; | ||||
| 
 | ||||
| const itemCss = css` | ||||
|   display: flex; | ||||
| @ -17,13 +20,28 @@ const itemCss = css` | ||||
| `;
 | ||||
| 
 | ||||
| export const GridCardItem = withDynamicSchemaProps((props) => { | ||||
|   const collection = useCollection(); | ||||
|   const field = useField<ObjectField>(); | ||||
|   const fieldSchema = useFieldSchema(); | ||||
|   const parentRecordData = useCollectionParentRecordData(); | ||||
|   const navigate = useNavigate(); | ||||
|   const detailUrl = useGridCardDetailUrl({ collection, field, fieldSchema }); | ||||
|   // XXX: 实现的有些丑陋, 需要想想有没有更好的办法
 | ||||
|   const handleClick = () => { | ||||
|     // 1. 依赖schema的层级,不合适
 | ||||
|     const parentSchema = fieldSchema?.parent?.parent; | ||||
|     const isLinkable = parentSchema?.['x-decorator-props']?.isLinkable; | ||||
|     if (isLinkable) { | ||||
|       // 2. 固定的链接格式写法,不合适; 需要想个能配置的办法
 | ||||
|       navigate(detailUrl); | ||||
|     } | ||||
|   }; | ||||
|   return ( | ||||
|     <Card | ||||
|       role="button" | ||||
|       aria-label="grid-card-item" | ||||
|       className={css` | ||||
|         /* background-color: re/d; */ | ||||
|         height: 100%; | ||||
|         > .ant-card-body { | ||||
|           padding: 24px 24px 0px; | ||||
| @ -33,8 +51,10 @@ export const GridCardItem = withDynamicSchemaProps((props) => { | ||||
|           padding: 5px 0; | ||||
|         } | ||||
|       `}
 | ||||
|       onClick={handleClick} | ||||
|     > | ||||
|       <div className={itemCss}> | ||||
|         {/* @ts-ignore */} | ||||
|         <RecordProvider record={field.value} parent={parentRecordData}> | ||||
|           {props.children} | ||||
|         </RecordProvider> | ||||
|  | ||||
| @ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; | ||||
| import { ArrayField } from '@tachybase/schema'; | ||||
| import { RecursionField, Schema, useField, useFieldSchema } from '@tachybase/schema'; | ||||
| import { List as AntdList, Col, PaginationProps } from 'antd'; | ||||
| import React, { useCallback, useState } from 'react'; | ||||
| import React, { useCallback, useEffect, useState } from 'react'; | ||||
| import { SortableItem } from '../../common'; | ||||
| import { SchemaComponentOptions } from '../../core'; | ||||
| import { useDesigner, useProps } from '../../hooks'; | ||||
| @ -12,6 +12,7 @@ import { GridCardItem } from './GridCard.Item'; | ||||
| import { useGridCardActionBarProps } from './hooks'; | ||||
| import { defaultColumnCount, pageSizeOptions } from './options'; | ||||
| import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps'; | ||||
| import { InfiniteScroll } from '../../common/infinite-scroll/infinite-scroll'; | ||||
| 
 | ||||
| const rowGutter = { | ||||
|   md: 12, | ||||
| @ -59,8 +60,7 @@ const designerCss = css` | ||||
| const InternalGridCard = (props) => { | ||||
|   // 新版 UISchema(1.0 之后)中已经废弃了 useProps,这里之所以继续保留是为了兼容旧版的 UISchema
 | ||||
|   const { columnCount: columnCountProp, pagination } = useProps(props); | ||||
| 
 | ||||
|   const { service, columnCount: _columnCount = defaultColumnCount } = useGridCardBlockContext(); | ||||
|   const { service, columnCount: _columnCount = defaultColumnCount, needInfiniteScroll } = useGridCardBlockContext(); | ||||
|   const columnCount = columnCountProp || _columnCount; | ||||
|   const { run, params } = service; | ||||
|   const meta = service?.data?.meta; | ||||
| @ -68,6 +68,7 @@ const InternalGridCard = (props) => { | ||||
|   const field = useField<ArrayField>(); | ||||
|   const Designer = useDesigner(); | ||||
|   const [schemaMap] = useState(new Map()); | ||||
| 
 | ||||
|   const getSchema = useCallback( | ||||
|     (key) => { | ||||
|       if (!schemaMap.has(key)) { | ||||
| @ -99,6 +100,31 @@ const InternalGridCard = (props) => { | ||||
|     [run, params], | ||||
|   ); | ||||
| 
 | ||||
|   /* 以下为无限滚动逻辑 */ | ||||
|   // XXX: 需要仔细梳理这里的逻辑, 目前的实现有点效果问题
 | ||||
|   const [data, setData] = useState(field.value || []); | ||||
|   const [hasMore, setHasMore] = useState(true); | ||||
| 
 | ||||
|   const loadMore = async () => { | ||||
|     const { count, pageSize = 10, page = 1 } = meta || {}; | ||||
|     await onPaginationChange(page + 1, pageSize); | ||||
|     setHasMore(false); | ||||
|   }; | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const { count, pageSize = 10, page = 1 } = meta || {}; | ||||
|     const hasMore = count > page * pageSize; | ||||
| 
 | ||||
|     setData((val = []) => { | ||||
|       const currentVal = field.value || []; | ||||
|       return [...val, ...currentVal]; | ||||
|     }); | ||||
| 
 | ||||
|     setHasMore(hasMore); | ||||
|   }, [meta?.page]); | ||||
| 
 | ||||
|   /* 以上为无限滚动逻辑 */ | ||||
| 
 | ||||
|   return ( | ||||
|     <SchemaComponentOptions | ||||
|       scope={{ | ||||
| @ -109,7 +135,7 @@ const InternalGridCard = (props) => { | ||||
|       <SortableItem className={cx('nb-card-list', designerCss)}> | ||||
|         <AntdList | ||||
|           pagination={ | ||||
|             !meta || meta.count <= meta.pageSize | ||||
|             !meta || meta.count <= meta.pageSize || needInfiniteScroll | ||||
|               ? false | ||||
|               : { | ||||
|                   ...pagination, | ||||
| @ -120,7 +146,7 @@ const InternalGridCard = (props) => { | ||||
|                   pageSizeOptions, | ||||
|                 } | ||||
|           } | ||||
|           dataSource={field.value} | ||||
|           dataSource={needInfiniteScroll ? data : field.value} | ||||
|           grid={{ | ||||
|             ...columnCount, | ||||
|             sm: columnCount.xs, | ||||
| @ -142,6 +168,7 @@ const InternalGridCard = (props) => { | ||||
|           }} | ||||
|           loading={service?.loading} | ||||
|         /> | ||||
|         {needInfiniteScroll && <InfiniteScroll loadMore={loadMore} hasMore={hasMore} />} | ||||
|         <Designer /> | ||||
|       </SortableItem> | ||||
|     </SchemaComponentOptions> | ||||
|  | ||||
| @ -10,3 +10,13 @@ export const useGridCardActionBarProps = () => { | ||||
|     spaceProps, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export const useGridCardDetailUrl = ({ collection, field, fieldSchema }) => { | ||||
|   const name = 'detail'; | ||||
|   const { datasource = 'main', name: collectionName } = collection; | ||||
|   const { record } = field; | ||||
|   const primaryKey = 'id'; | ||||
|   const primaryKeyValue = record[primaryKey]; | ||||
| 
 | ||||
|   return `/mobile/${name}/${datasource}/${collectionName}/${primaryKeyValue}`; | ||||
| }; | ||||
|  | ||||
| @ -1,54 +0,0 @@ | ||||
| import React, { useContext } from 'react'; | ||||
| import type { FC, ReactNode } from 'react'; | ||||
| 
 | ||||
| // TODO: 待继续
 | ||||
| // import { Locale } from '../../locales/base';
 | ||||
| // import zhCN from '../../locales/zh-CN';
 | ||||
| type Locale = {}; | ||||
| const zhCN = {}; | ||||
| 
 | ||||
| type Config = { | ||||
|   locale: Locale; | ||||
| }; | ||||
| 
 | ||||
| export const defaultConfigRef: { | ||||
|   current: Config; | ||||
| } = { | ||||
|   current: { | ||||
|     locale: zhCN, | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export function setDefaultConfig(config: Config) { | ||||
|   defaultConfigRef.current = config; | ||||
| } | ||||
| 
 | ||||
| export function getDefaultConfig() { | ||||
|   return defaultConfigRef.current; | ||||
| } | ||||
| 
 | ||||
| const ConfigContext = React.createContext<Config | null>(null); | ||||
| 
 | ||||
| export type ConfigProviderProps = Config & { | ||||
|   children?: ReactNode; | ||||
| }; | ||||
| 
 | ||||
| export const ConfigProvider: FC<ConfigProviderProps> = (props) => { | ||||
|   const { children, ...config } = props; | ||||
|   const parentConfig = useConfig(); | ||||
| 
 | ||||
|   return ( | ||||
|     <ConfigContext.Provider | ||||
|       value={{ | ||||
|         ...parentConfig, | ||||
|         ...config, | ||||
|       }} | ||||
|     > | ||||
|       {children} | ||||
|     </ConfigContext.Provider> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export function useConfig() { | ||||
|   return useContext(ConfigContext) ?? getDefaultConfig(); | ||||
| } | ||||
| @ -1,85 +0,0 @@ | ||||
| import classNames from 'classnames'; | ||||
| import type { CSSProperties, ReactElement } from 'react'; | ||||
| import React, { AriaAttributes } from 'react'; | ||||
| 
 | ||||
| const canUseDom = !!( | ||||
|   typeof window !== 'undefined' && | ||||
|   typeof document !== 'undefined' && | ||||
|   window.document && | ||||
|   window.document.createElement | ||||
| ); | ||||
| 
 | ||||
| type ScrollElement = HTMLElement | Window; | ||||
| 
 | ||||
| const defaultRoot = canUseDom ? window : undefined; | ||||
| 
 | ||||
| const overflowStylePatterns = ['scroll', 'auto', 'overlay']; | ||||
| 
 | ||||
| function isElement(node: Element) { | ||||
|   const ELEMENT_NODE_TYPE = 1; | ||||
|   return node.nodeType === ELEMENT_NODE_TYPE; | ||||
| } | ||||
| export function getScrollParent( | ||||
|   el: Element, | ||||
|   root: ScrollElement | null | undefined = defaultRoot, | ||||
| ): Window | Element | null | undefined { | ||||
|   let node = el; | ||||
| 
 | ||||
|   while (node && node !== root && isElement(node)) { | ||||
|     if (node === document.body) { | ||||
|       return root; | ||||
|     } | ||||
|     const { overflowY } = window.getComputedStyle(node); | ||||
|     if (overflowStylePatterns.includes(overflowY) && node.scrollHeight > node.clientHeight) { | ||||
|       return node; | ||||
|     } | ||||
|     node = node.parentNode as Element; | ||||
|   } | ||||
|   return root; | ||||
| } | ||||
| 
 | ||||
| export function mergeProps<A, B>(a: A, b: B): B & A; | ||||
| export function mergeProps<A, B, C>(a: A, b: B, c: C): C & B & A; | ||||
| export function mergeProps(...items: any[]) { | ||||
|   const ret: any = {}; | ||||
|   items.forEach((item) => { | ||||
|     Object.keys(item).forEach((key) => { | ||||
|       if (item[key] !== undefined) { | ||||
|         ret[key] = item[key]; | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
|   return ret; | ||||
| } | ||||
| 
 | ||||
| export type NativeProps<S extends string = never> = { | ||||
|   className?: string; | ||||
|   style?: CSSProperties & Partial<Record<S, string>>; | ||||
|   tabIndex?: number; | ||||
| } & AriaAttributes; | ||||
| 
 | ||||
| export function withNativeProps<P extends NativeProps>(props: P, element: ReactElement) { | ||||
|   const p = { | ||||
|     ...element.props, | ||||
|   }; | ||||
|   if (props.className) { | ||||
|     p.className = classNames(element.props.className, props.className); | ||||
|   } | ||||
|   if (props.style) { | ||||
|     p.style = { | ||||
|       ...p.style, | ||||
|       ...props.style, | ||||
|     }; | ||||
|   } | ||||
|   if (props.tabIndex !== undefined) { | ||||
|     p.tabIndex = props.tabIndex; | ||||
|   } | ||||
|   for (const key in props) { | ||||
|     // if (!props.hasOwnProperty(key)) continue;
 | ||||
|     if (!Object.hasOwn(props, key)) continue; | ||||
|     if (key.startsWith('data-') || key.startsWith('aria-')) { | ||||
|       p[key] = props[key]; | ||||
|     } | ||||
|   } | ||||
|   return React.cloneElement(element, p); | ||||
| } | ||||
| @ -1,4 +1,4 @@ | ||||
| export const pageSizeOptions = [12, 24, 60, 96, 120]; | ||||
| export const pageSizeOptions = [5, 6, 10, 12, 24, 60, 96, 120]; | ||||
| export const gridSizes = ['xs', 'md', 'lg', 'xxl']; | ||||
| export const screenSizeTitleMaps = { | ||||
|   xs: 'Phone device', | ||||
|  | ||||
| @ -1,41 +1,20 @@ | ||||
| import { getScrollParent, merge } from '@tachybase/utils/client'; | ||||
| import { useLockFn, useThrottleFn } from 'ahooks'; | ||||
| import type { FC, ReactNode } from 'react'; | ||||
| import React, { useEffect, useRef, useState } from 'react'; | ||||
| // import { useConfig } from '../config-provider';
 | ||||
| // import DotLoading from '../dot-loading';
 | ||||
| import { NativeProps, getScrollParent, mergeProps, withNativeProps } from './utils'; | ||||
| 
 | ||||
| // TODO: 待继续
 | ||||
| const useConfig = () => { | ||||
|   return { | ||||
|     locale: { | ||||
|       InfiniteScroll: { | ||||
|         retry: false, | ||||
|         noMore: false, | ||||
|         failedToLoad: '范德萨发', | ||||
|       }, | ||||
|       common: { | ||||
|         loading: false, | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| // TODO: 待继续
 | ||||
| const DotLoading = () => null; | ||||
| import { Spin } from 'antd'; | ||||
| import React, { ReactNode, useEffect, useRef, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import useStyles from './style'; | ||||
| 
 | ||||
| function isWindow(element: any | Window): element is Window { | ||||
|   return element === window; | ||||
| } | ||||
| 
 | ||||
| const classPrefix = `adm-infinite-scroll`; | ||||
| 
 | ||||
| export type InfiniteScrollProps = { | ||||
|   loadMore: (isRetry: boolean) => Promise<void>; | ||||
|   hasMore: boolean; | ||||
|   threshold?: number; | ||||
|   children?: ReactNode | ((hasMore: boolean, failed: boolean, retry: () => void) => ReactNode); | ||||
| } & NativeProps; | ||||
| }; | ||||
| 
 | ||||
| const defaultProps: Required<Pick<InfiniteScrollProps, 'threshold' | 'children'>> = { | ||||
|   threshold: 250, | ||||
| @ -44,13 +23,17 @@ const defaultProps: Required<Pick<InfiniteScrollProps, 'threshold' | 'children'> | ||||
|   ), | ||||
| }; | ||||
| 
 | ||||
| export const InfiniteScroll: FC<InfiniteScrollProps> = (p) => { | ||||
|   const props = mergeProps(defaultProps, p); | ||||
| 
 | ||||
| export const InfiniteScroll = ({ | ||||
|   threshold = defaultProps.threshold, | ||||
|   loadMore, | ||||
|   children = defaultProps.children, | ||||
|   hasMore, | ||||
| }: InfiniteScrollProps) => { | ||||
|   const { styles } = useStyles(); | ||||
|   const [failed, setFailed] = useState(false); | ||||
|   const doLoadMore = useLockFn(async (isRetry: boolean) => { | ||||
|     try { | ||||
|       await props.loadMore(isRetry); | ||||
|       await loadMore(isRetry); | ||||
|     } catch (e) { | ||||
|       setFailed(true); | ||||
|       throw e; | ||||
| @ -68,7 +51,7 @@ export const InfiniteScroll: FC<InfiniteScrollProps> = (p) => { | ||||
|   const { run: check } = useThrottleFn( | ||||
|     async () => { | ||||
|       if (nextFlagRef.current !== flag) return; | ||||
|       if (!props.hasMore) return; | ||||
|       if (!hasMore) return; | ||||
|       const element = elementRef.current; | ||||
|       if (!element) return; | ||||
|       if (!element.offsetParent) return; | ||||
| @ -78,14 +61,14 @@ export const InfiniteScroll: FC<InfiniteScrollProps> = (p) => { | ||||
|       const rect = element.getBoundingClientRect(); | ||||
|       const elementTop = rect.top; | ||||
|       const current = isWindow(parent) ? window.innerHeight : parent.getBoundingClientRect().bottom; | ||||
|       if (current >= elementTop - props.threshold) { | ||||
|       if (current >= elementTop - threshold) { | ||||
|         const nextFlag = {}; | ||||
|         nextFlagRef.current = nextFlag; | ||||
|         try { | ||||
|           await doLoadMore(false); | ||||
|           setFlag(nextFlag); | ||||
|         } catch (e) { | ||||
|           console.log('%c Line:88 🍯 e', 'font-size:18px;color:#2eafb0;background:#3f7cff', e); | ||||
|           console.error(e); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
| @ -120,39 +103,35 @@ export const InfiniteScroll: FC<InfiniteScrollProps> = (p) => { | ||||
|       await doLoadMore(true); | ||||
|       setFlag(nextFlagRef.current); | ||||
|     } catch (e) { | ||||
|       console.log('%c Line:121 🥥 e', 'font-size:18px;color:#3f7cff;background:#2eafb0', e); | ||||
|       console.error(e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return withNativeProps( | ||||
|     props, | ||||
|     <div className={classPrefix} ref={elementRef}> | ||||
|       {typeof props.children === 'function' ? props.children(props.hasMore, failed, retry) : props.children} | ||||
|     </div>, | ||||
|   return ( | ||||
|     <div className={styles.infiniteScroll} ref={elementRef}> | ||||
|       {typeof children === 'function' ? children(hasMore, failed, retry) : children} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const InfiniteScrollContent: FC<{ | ||||
|   hasMore: boolean; | ||||
|   failed: boolean; | ||||
|   retry: () => void; | ||||
| }> = (props) => { | ||||
|   const { locale } = useConfig(); | ||||
| const InfiniteScrollContent = (props: { hasMore: boolean; failed: boolean; retry: () => void }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { styles } = useStyles(); | ||||
| 
 | ||||
|   if (!props.hasMore) { | ||||
|     return <span>{locale.InfiniteScroll.noMore}</span>; | ||||
|     return <span>{t('no more')}</span>; | ||||
|   } | ||||
| 
 | ||||
|   if (props.failed) { | ||||
|     return ( | ||||
|       <span> | ||||
|         <span className={`${classPrefix}-failed-text`}>{locale.InfiniteScroll.failedToLoad}</span> | ||||
|         <span className={`${styles.infiniteScroll}-failed-text`}>{t('failed to load')}</span> | ||||
|         <a | ||||
|           onClick={() => { | ||||
|             props.retry(); | ||||
|           }} | ||||
|         > | ||||
|           {locale.InfiniteScroll.retry} | ||||
|           {t('retry')} | ||||
|         </a> | ||||
|       </span> | ||||
|     ); | ||||
| @ -160,14 +139,8 @@ const InfiniteScrollContent: FC<{ | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <span>{locale.common.loading}</span> | ||||
|       <DotLoading /> | ||||
|       <span>{t('loading')}</span> | ||||
|       <Spin /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const keepComp = (props) => { | ||||
|   return <InfiniteScroll {...props} />; | ||||
| }; | ||||
| 
 | ||||
| keepComp({}); | ||||
| @ -0,0 +1,21 @@ | ||||
| import { createStyles } from 'antd-style'; | ||||
| 
 | ||||
| const useStyles = createStyles(({ token, css }) => { | ||||
|   return { | ||||
|     infiniteScroll: css` | ||||
|       color: ${token.colorTextDescription}; | ||||
|       padding: 18px; | ||||
|       display: flex; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       font-size: ${token.fontSize}; | ||||
| 
 | ||||
|       &-failed-text { | ||||
|         display: inline-block; | ||||
|         margin-right: 8px; | ||||
|       } | ||||
|     `,
 | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| export default useStyles; | ||||
| @ -19,3 +19,4 @@ export * from './uid'; | ||||
| export * from './url'; | ||||
| export { dayjs, lodash }; | ||||
| export * from './parseHTML'; | ||||
| export * from './dom'; | ||||
|  | ||||
							
								
								
									
										35
									
								
								packages/core/utils/src/dom.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								packages/core/utils/src/dom.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| export const canUseDom = !!( | ||||
|   typeof window !== 'undefined' && | ||||
|   typeof document !== 'undefined' && | ||||
|   window.document && | ||||
|   window.document.createElement | ||||
| ); | ||||
| 
 | ||||
| type ScrollElement = HTMLElement | Window; | ||||
| 
 | ||||
| const defaultRoot = canUseDom ? window : undefined; | ||||
| 
 | ||||
| const overflowStylePatterns = ['scroll', 'auto', 'overlay']; | ||||
| 
 | ||||
| function isElement(node: Element) { | ||||
|   const ELEMENT_NODE_TYPE = 1; | ||||
|   return node.nodeType === ELEMENT_NODE_TYPE; | ||||
| } | ||||
| export function getScrollParent( | ||||
|   el: Element, | ||||
|   root: ScrollElement | null | undefined = defaultRoot, | ||||
| ): Window | Element | null | undefined { | ||||
|   let node = el; | ||||
| 
 | ||||
|   while (node && node !== root && isElement(node)) { | ||||
|     if (node === document.body) { | ||||
|       return root; | ||||
|     } | ||||
|     const { overflowY } = window.getComputedStyle(node); | ||||
|     if (overflowStylePatterns.includes(overflowY) && node.scrollHeight > node.clientHeight) { | ||||
|       return node; | ||||
|     } | ||||
|     node = node.parentNode as Element; | ||||
|   } | ||||
|   return root; | ||||
| } | ||||
| @ -12,6 +12,9 @@ export const HomePage: React.FC<{}> = () => { | ||||
|       'appends[]': 'pictures', | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   console.log('%c Line:10 🥐 data', 'font-size:18px;color:#4fff4B;background:#e41a6a', data); | ||||
| 
 | ||||
|   if (loading) { | ||||
|     return render(); | ||||
|   } | ||||
|  | ||||
| @ -6,10 +6,12 @@ | ||||
|   "main": "dist/server/index.js", | ||||
|   "dependencies": { | ||||
|     "@ant-design/icons": "^5.3.6", | ||||
|     "antd-mobile": "^5.35.0" | ||||
|     "antd-mobile": "^5.35.0", | ||||
|     "react-router-dom": "^6.23.1" | ||||
|   }, | ||||
|   "peerDependencies": { | ||||
|     "@tachybase/client": "0.x", | ||||
|     "@tachybase/plugin-mobile-client": "0.x", | ||||
|     "@tachybase/server": "0.x", | ||||
|     "@tachybase/test": "0.x" | ||||
|   }, | ||||
|  | ||||
| @ -1,11 +0,0 @@ | ||||
| import { Button } from 'antd-mobile'; | ||||
| import React from 'react'; | ||||
| 
 | ||||
| export const TestComponent = () => { | ||||
|   const handleClick = () => {}; | ||||
|   return ( | ||||
|     <Button style={{ alignSelf: 'center', fontSize: '5rem' }} block color="primary" size="large" onClick={handleClick}> | ||||
|       Hello | ||||
|     </Button> | ||||
|   ); | ||||
| }; | ||||
| @ -1,21 +1,13 @@ | ||||
| import { Modal, Toast, Image, Button } from 'antd-mobile'; | ||||
| import { ShareAltOutlined } from '@ant-design/icons'; | ||||
| import { CustomComponentType } from '@hera/plugin-core/client'; | ||||
| import { Button, Image, Modal, Toast } from 'antd-mobile'; | ||||
| import React from 'react'; | ||||
| import downloadImage from '../assets/download.svg'; | ||||
| import { CustomComponentType } from '@hera/plugin-core/client'; | ||||
| import './style.less'; | ||||
| 
 | ||||
| export const ShareProduct = () => { | ||||
|   return ( | ||||
|     <Button | ||||
|       style={{ | ||||
|         alignSelf: 'center', | ||||
|         fontSize: 16, | ||||
|       }} | ||||
|       color="primary" | ||||
|       size="small" | ||||
|       block | ||||
|       onClick={showModal} | ||||
|     > | ||||
|     <Button className={'m-share'} color="primary" size="small" block onClick={showModal}> | ||||
|       分享产品 | ||||
|       <ShareAltOutlined /> | ||||
|     </Button> | ||||
| @ -26,7 +18,8 @@ ShareProduct.displayName = 'ShareProduct'; | ||||
| ShareProduct.__componentType = CustomComponentType.CUSTOM_FORM_ITEM; | ||||
| ShareProduct.__componentLabel = '移动端-三聪头-分享产品'; | ||||
| 
 | ||||
| const showModal = () => { | ||||
| const showModal = (event) => { | ||||
|   event.stopPropagation(); | ||||
|   Modal.show({ | ||||
|     bodyStyle: { | ||||
|       background: 'transparent', | ||||
| @ -0,0 +1,23 @@ | ||||
| import { CustomComponentType } from '@hera/plugin-core/client'; | ||||
| import React from 'react'; | ||||
| import { Link, useNavigate } from 'react-router-dom'; | ||||
| import './style.less'; | ||||
| import { getPathProductDetail } from '../utils/path'; | ||||
| 
 | ||||
| // @deprecated
 | ||||
| export const ShowDetail = () => { | ||||
|   // const navigate = useNavigate();
 | ||||
|   // const handleClick = () => {
 | ||||
|   //   navigate('/mobile/');
 | ||||
|   // };
 | ||||
|   return ( | ||||
|     <Link className={'m-detail'} to={getPathProductDetail({ dataSource: 'main', collection: 'cards', id: '15' })}> | ||||
|       显示详情 | ||||
|     </Link> | ||||
|   ); | ||||
|   Ø; | ||||
| }; | ||||
| 
 | ||||
| ShowDetail.displayName = 'ShowDetail'; | ||||
| ShowDetail.__componentType = CustomComponentType.CUSTOM_FORM_ITEM; | ||||
| ShowDetail.__componentLabel = '移动端-三聪头-显示详情'; | ||||
| @ -0,0 +1,11 @@ | ||||
| .m-detail { | ||||
|     /* background-color: red; */ | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: flex-end; | ||||
| } | ||||
| 
 | ||||
| .m-share { | ||||
|     align-self: 'center'; | ||||
|     font-size: 16; | ||||
| } | ||||
| @ -1,10 +1,13 @@ | ||||
| import { Plugin } from '@tachybase/client'; | ||||
| import { ProductDetail } from './pages/ProductDetail'; | ||||
| import { ShareProduct } from './components/ShareProduct'; | ||||
| // import { ProductDetail } from './pages/ProductDetail.1';
 | ||||
| import { ShareProduct } from './custom-components/ShareProduct'; | ||||
| import { ShowDetail } from './custom-components/ShowDetail'; | ||||
| import { CardDetailSC, SCCardDetail } from './pages/CardDetail.schema'; | ||||
| import { getPathProductDetail } from './utils/path'; | ||||
| 
 | ||||
| export class PluginSancongtouClient extends Plugin { | ||||
|   async afterAdd() { | ||||
|     // await this.app.pm.add()
 | ||||
|     await this.app.pm.add(SCCardDetail); | ||||
|   } | ||||
| 
 | ||||
|   async beforeLoad() {} | ||||
| @ -12,10 +15,18 @@ export class PluginSancongtouClient extends Plugin { | ||||
|   // You can get and modify the app instance here
 | ||||
|   async load() { | ||||
|     this.addRoutes(); | ||||
| 
 | ||||
|     this.app.addComponents({ | ||||
|       // 自定义字段组件
 | ||||
|       // GridCard: {
 | ||||
|       //   ...GridCard,
 | ||||
|       //   // @ts-ignore
 | ||||
|       //   // Decorator: GridCardDe,
 | ||||
|       // },
 | ||||
|       ShowDetail, | ||||
|       ShareProduct, | ||||
|     }); | ||||
|     console.log(this.app); | ||||
|     // console.log(this.app);
 | ||||
|     // this.app.addComponents({})
 | ||||
|     // this.app.addScopes({})
 | ||||
|     // this.app.addProvider()
 | ||||
| @ -23,9 +34,18 @@ export class PluginSancongtouClient extends Plugin { | ||||
|   } | ||||
| 
 | ||||
|   addRoutes() { | ||||
|     this.app.router.add('sancongtou/detail', { | ||||
|       path: '/mobile/products/detail', | ||||
|       Component: ProductDetail, | ||||
|     // this.app.router.add('sancongtou/detail', {
 | ||||
|     //   path: '/mobile/products/detail',
 | ||||
|     //   Component: ProductDetail,
 | ||||
|     // });
 | ||||
|     this.app.router.add('mobile.productDetail', { | ||||
|       path: getPathProductDetail({ | ||||
|         name: ':name', | ||||
|         dataSource: ':dataSource', | ||||
|         collection: ':collection', | ||||
|         id: ':id', | ||||
|       }), | ||||
|       Component: CardDetailSC, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,27 @@ | ||||
| import { DataBlockProvider, useCollectionManager } from '@tachybase/client'; | ||||
| import React from 'react'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| // import { ProductDetail } from './ProductDetail';
 | ||||
| 
 | ||||
| // THINK: provider 主要作用是定位于, 连接数据库, 不做其他的逻辑
 | ||||
| export const CardDetailProvider = (props) => { | ||||
|   const params = useParams(); | ||||
|   const { collection: collectionName } = params; | ||||
|   const cm = useCollectionManager(); | ||||
|   const collection = cm.getCollection(collectionName); | ||||
|   const primaryKey = collection.getPrimaryKey(); | ||||
| 
 | ||||
|   return ( | ||||
|     <DataBlockProvider | ||||
|       dataSource="main" | ||||
|       collection={collection} | ||||
|       action="get" | ||||
|       params={{ | ||||
|         appends: ['main_images'], | ||||
|         filterByTk: params[primaryKey], | ||||
|       }} | ||||
|     > | ||||
|       {props.children} | ||||
|     </DataBlockProvider> | ||||
|   ); | ||||
| }; | ||||
| @ -0,0 +1,78 @@ | ||||
| import { Plugin, SchemaComponent, useCollectionManager } from '@tachybase/client'; | ||||
| import { MPage } from '@tachybase/plugin-mobile-client/client'; | ||||
| import React from 'react'; | ||||
| import { CardDetailProvider } from './CardDetail.provider'; | ||||
| import { CardDetailView } from './CardDetail.view'; | ||||
| import { useScopeCardDetail } from './CardDetail.scope'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| 
 | ||||
| // THINK: plugin的注册,应该每个组件自己管自己的, 提高内聚性, 就近原则;
 | ||||
| // 或者单独写一个轻量的给组件用的 plugin, 用于注册
 | ||||
| // 这里暂时用 SC 的前缀 表明这一用途, schema component
 | ||||
| // 对于那些零散的组件, 都在共同的文件夹根目录声明
 | ||||
| // 尝试, 注册部分直接放在 schema 文件里, 会不会更好一些?
 | ||||
| // 那么把转化部分也放进来, 会不会更好? 尝试一下
 | ||||
| // 注册部分和转化部分, 都是样板代码, 心智模式无需关注, 只需关注 schema 的声明实现
 | ||||
| // 声明部分, 是数据和视图的桥梁, 数据通过 context, provider 提供. 在 component 里消费
 | ||||
| 
 | ||||
| // 声明部分
 | ||||
| // THINK: 代码逻辑部分写在这里, 会明显导致卡顿甚至卡死, 需要思考为什么? 目前还是将 provider 外移
 | ||||
| const useSchema = () => { | ||||
|   // const params = useParams();
 | ||||
|   // const { collection: collectionName } = params;
 | ||||
|   // const cm = useCollectionManager();
 | ||||
|   // const collection = cm.getCollection(collectionName);
 | ||||
|   // const primaryKey = collection.getPrimaryKey();
 | ||||
| 
 | ||||
|   return { | ||||
|     type: 'void', | ||||
|     'x-designer': 'MPage.Designer', | ||||
|     'x-component': 'MPage', | ||||
|     'x-component-props': {}, | ||||
|     properties: { | ||||
|       DetailView: { | ||||
|         type: 'void', | ||||
|         // 'x-decorator-props': {
 | ||||
|         //   dataSource: 'main',
 | ||||
|         //   collection: collection,
 | ||||
|         //   action: 'get',
 | ||||
|         //   params: {
 | ||||
|         //     appends: ['main_images'],
 | ||||
|         //     filterByTk: params[primaryKey],
 | ||||
|         //   },
 | ||||
|         // },
 | ||||
|         'x-decorator': 'page.provider.CardDetailProvider', | ||||
|         'x-use-component-props': 'useScopeCardDetail', | ||||
|         'x-component': 'page.view.CardDetailView', | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| // 注册部分
 | ||||
| export class SCCardDetail extends Plugin { | ||||
|   async load() { | ||||
|     this.app.addComponents({ | ||||
|       'page.provider.CardDetailProvider': CardDetailProvider, | ||||
|       'page.view.CardDetailView': CardDetailView, | ||||
|     }); | ||||
|     // THINK: 这里不能声明成形如: page.scope.useScopeCardDetail 的形式, 底层没有做处理.
 | ||||
|     // 也没想好这里的标准应该是什么? 也许需要归类?
 | ||||
|     this.app.addScopes({ | ||||
|       useScopeCardDetail: useScopeCardDetail, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // 转化部分
 | ||||
| export const CardDetailSC = () => { | ||||
|   const schema = useSchema(); | ||||
|   return ( | ||||
|     <SchemaComponent | ||||
|       components={{ | ||||
|         MPage, | ||||
|       }} | ||||
|       schema={schema} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| @ -0,0 +1,11 @@ | ||||
| import { useCollectionRecordData } from '@tachybase/client'; | ||||
| 
 | ||||
| export const useScopeCardDetail = () => { | ||||
|   const data = useCollectionRecordData(); | ||||
|   const { ['main_images']: mainImages = [] } = data || {}; | ||||
|   const images = mainImages.map((image) => image.url); | ||||
| 
 | ||||
|   return { | ||||
|     images, | ||||
|   }; | ||||
| }; | ||||
| @ -0,0 +1,37 @@ | ||||
| import { withDynamicSchemaProps } from '@tachybase/client'; | ||||
| import { Image, NavBar, Skeleton } from 'antd-mobile'; | ||||
| import React from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import './style.less'; | ||||
| 
 | ||||
| // TODO: 需要提升图片加载速度, 现在是数据库请求然后用 url 去云端拿图片,导致体验有延迟
 | ||||
| export const CardDetailView = withDynamicSchemaProps( | ||||
|   (props) => { | ||||
|     const { images = [] } = props; | ||||
|     const navigate = useNavigate(); | ||||
|     const onBack = () => { | ||||
|       navigate(-1); | ||||
|     }; | ||||
|     return ( | ||||
|       <div className="m-product-container"> | ||||
|         <NavBar back="返回" onBack={onBack}> | ||||
|           产品详情 | ||||
|         </NavBar> | ||||
|         <div className={'m-share-image'}> | ||||
|           <ImageWithSkeletonCustom imageSrc={images[0]} /> | ||||
|           <ImageWithSkeletonCustom imageSrc={images[1]} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   }, | ||||
|   { displayName: 'CardDetailView' }, | ||||
| ); | ||||
| 
 | ||||
| const ImageWithSkeletonCustom = (props) => { | ||||
|   const { imageSrc } = props; | ||||
|   if (imageSrc) { | ||||
|     return <Image src={imageSrc} width="100%" />; | ||||
|   } else { | ||||
|     return <Skeleton animated className={'customSkeleton'} />; | ||||
|   } | ||||
| }; | ||||
| @ -1,49 +0,0 @@ | ||||
| import { NavBar, Image } from 'antd-mobile'; | ||||
| import React from 'react'; | ||||
| import detailHeaderImpage from '../assets/detail_lt_header.png'; | ||||
| import detailContentImage from '../assets/detail_lt_content.png'; | ||||
| import { css } from '@tachybase/client'; | ||||
| 
 | ||||
| export const ProductDetail = () => { | ||||
|   const onBack = () => { | ||||
|     const history = window.history; | ||||
|     history.back(); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <NavBar | ||||
|         className={css` | ||||
|           .adm-nav-bar-left, | ||||
|           .adm-nav-bar-title { | ||||
|             font-size: 2vw; | ||||
|           } | ||||
|         `}
 | ||||
|         style={{ | ||||
|           position: 'fixed', | ||||
|           zIndex: 1, | ||||
|           backgroundColor: '#fff', | ||||
|           width: '100%', | ||||
|           height: '5%', | ||||
|         }} | ||||
|         back="返回" | ||||
|         onBack={onBack} | ||||
|       > | ||||
|         产品详情 | ||||
|       </NavBar> | ||||
|       <div | ||||
|         style={{ | ||||
|           display: 'flex', | ||||
|           flexDirection: 'column', | ||||
|           justifyContent: 'flex-start', | ||||
|           alignItems: 'center', | ||||
|           width: '100%', | ||||
|           height: '100%', | ||||
|         }} | ||||
|       > | ||||
|         <Image src={detailHeaderImpage} width="100%" /> | ||||
|         <Image src={detailContentImage} width="100%" /> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| @ -0,0 +1,19 @@ | ||||
| .m-product-container { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   height: 100vh; | ||||
|   background-color: #f3f3f3; | ||||
| } | ||||
| .m-share-image { | ||||
|   flex: 1; | ||||
|   width: 100%; | ||||
|   overflow: auto; | ||||
|   clip-path: inset(0 10px); | ||||
| } | ||||
| 
 | ||||
| .customSkeleton { | ||||
|   --width: 100%; | ||||
|   --height: 50%; | ||||
|   --border-radius: 8px; | ||||
| } | ||||
| 
 | ||||
| @ -0,0 +1,10 @@ | ||||
| interface ProductDetailParams { | ||||
|   name?: string; | ||||
|   dataSource?: string; | ||||
|   collection: string; | ||||
|   id?: string; | ||||
| } | ||||
| export function getPathProductDetail(params: ProductDetailParams) { | ||||
|   const { name = 'detail', dataSource = 'main', collection, id } = params; | ||||
|   return `/mobile/${name}/${dataSource}/${collection}/${id}`; | ||||
| } | ||||
| @ -1,22 +0,0 @@ | ||||
| export const images = [ | ||||
|   { | ||||
|     label: '全部商品', | ||||
|     imageUrl: 'https://courier.dzkbdd.cn/static/jx-h5/assets/operator_all_select-884b60ce.png', | ||||
|   }, | ||||
|   { | ||||
|     label: '中国电信', | ||||
|     imageUrl: 'https://courier.dzkbdd.cn/static/jx-h5/assets/operator_dx-7df8411f.png', | ||||
|   }, | ||||
|   { | ||||
|     label: '中国移动', | ||||
|     imageUrl: 'https://courier.dzkbdd.cn/static/jx-h5/assets/operator_yd-7b994595.png', | ||||
|   }, | ||||
|   { | ||||
|     label: '中国联通', | ||||
|     imageUrl: 'https://courier.dzkbdd.cn/static/jx-h5/assets/operator_lt-d9888233.png', | ||||
|   }, | ||||
|   { | ||||
|     label: '中国广电', | ||||
|     imageUrl: 'https://courier.dzkbdd.cn/static/jx-h5/assets/operator_gd-cc48f3e6.png', | ||||
|   }, | ||||
| ]; | ||||
| @ -1,2 +1 @@ | ||||
| export * from './useAction.ImageSearchItemView'; | ||||
| export * from './useSelect'; | ||||
|  | ||||
| @ -1,61 +0,0 @@ | ||||
| import { useCollection, useDesigner } from '@tachybase/client'; | ||||
| import { useFieldSchema } from '@tachybase/schema'; | ||||
| import React from 'react'; | ||||
| import { useTranslation } from '../../../../../locale'; | ||||
| 
 | ||||
| export function useActionImageSearchItemView(props) { | ||||
|   const { list, onSelected, valueKey: _valueKey, labelKey: _labelKey, filterKey } = props; | ||||
|   const fieldSchema = useFieldSchema(); | ||||
|   const collection = useCollection(); | ||||
|   const { t } = useTranslation(); | ||||
|   const collectionField = React.useMemo( | ||||
|     () => collection?.getField(fieldSchema['fieldName'] as any), | ||||
|     [collection, fieldSchema['fieldName']], | ||||
|   ); | ||||
|   const Designer = useDesigner(); | ||||
|   const valueKey = _valueKey || collectionField?.targetKey || 'id'; | ||||
|   const labelKey = _labelKey || fieldSchema['x-component-props']?.fieldNames?.label || valueKey; | ||||
| 
 | ||||
|   const onSelect = (itemKey) => { | ||||
|     const key = itemKey.keyPath?.[0] || itemKey; | ||||
|     onSelected([key], filterKey); | ||||
|   }; | ||||
| 
 | ||||
|   const fieldNames = { | ||||
|     title: labelKey || valueKey, | ||||
|     key: valueKey, | ||||
|   }; | ||||
| 
 | ||||
|   const itemsData = (list || []).map((item) => { | ||||
|     const { type, ['image_show']: imageObj, [fieldNames.title]: label, [fieldNames.key]: key } = item; | ||||
| 
 | ||||
|     // TODO: 需要更好的处理url方式
 | ||||
|     const origin = location?.origin || ''; | ||||
|     const sourceUrl = imageObj?.[0]?.url; | ||||
|     let imageUrl = sourceUrl ?? ''; | ||||
|     if (!imageUrl.includes('http')) { | ||||
|       imageUrl = `${origin}${sourceUrl}`; | ||||
|     } | ||||
| 
 | ||||
|     if (type === 'all') { | ||||
|       return { | ||||
|         label: t('AllProducts'), | ||||
|         key: 'all', | ||||
|         imageUrl: imageUrl, | ||||
|       }; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       label, | ||||
|       key, | ||||
|       imageUrl: imageUrl, | ||||
|     }; | ||||
|   }); | ||||
| 
 | ||||
|   return { | ||||
|     collectionField, | ||||
|     Designer, | ||||
|     items: itemsData, | ||||
|     onSelect, | ||||
|   }; | ||||
| } | ||||
| @ -3,6 +3,5 @@ export * from './ImageSearch.initializer'; | ||||
| export * from './ImageSearch.provider'; | ||||
| export * from './ImageSearch.schema'; | ||||
| export * from './ImageSearch.view'; | ||||
| export * from './data'; | ||||
| export * from './hooks'; | ||||
| export * from './search-item'; | ||||
|  | ||||
| @ -1,11 +1,13 @@ | ||||
| import { SortableItem, css, withDynamicSchemaProps } from '@tachybase/client'; | ||||
| import { SortableItem, css, useCollection, useDesigner, withDynamicSchemaProps } from '@tachybase/client'; | ||||
| import { useFieldSchema } from '@tachybase/schema'; | ||||
| import { Image, JumboTabs } from 'antd-mobile'; | ||||
| import React from 'react'; | ||||
| import { useActionImageSearchItemView } from '../hooks/useAction.ImageSearchItemView'; | ||||
| import { useTranslation } from '../../../../../locale'; | ||||
| // import { useActionImageSearchItemView } from '../hooks/useAction.ImageSearchItemView';
 | ||||
| 
 | ||||
| export const ImageSearchItemView = withDynamicSchemaProps( | ||||
|   (props) => { | ||||
|     const { collectionField, Designer, items, onSelect } = useActionImageSearchItemView(props); | ||||
|     const { collectionField, Designer, items, onSelect } = useAction(props); | ||||
| 
 | ||||
|     if (!collectionField) { | ||||
|       return null; | ||||
| @ -25,6 +27,63 @@ export const ImageSearchItemView = withDynamicSchemaProps( | ||||
|   { displayName: 'ImageSearchItemView' }, | ||||
| ); | ||||
| 
 | ||||
| function useAction(props) { | ||||
|   const { list, onSelected, valueKey: _valueKey, labelKey: _labelKey, filterKey } = props; | ||||
|   const fieldSchema = useFieldSchema(); | ||||
|   const collection = useCollection(); | ||||
|   const { t } = useTranslation(); | ||||
|   const collectionField = React.useMemo( | ||||
|     () => collection?.getField(fieldSchema['fieldName'] as any), | ||||
|     [collection, fieldSchema['fieldName']], | ||||
|   ); | ||||
|   const Designer = useDesigner(); | ||||
|   const valueKey = _valueKey || collectionField?.targetKey || 'id'; | ||||
|   const labelKey = _labelKey || fieldSchema['x-component-props']?.fieldNames?.label || valueKey; | ||||
| 
 | ||||
|   const onSelect = (itemKey) => { | ||||
|     const key = itemKey.keyPath?.[0] || itemKey; | ||||
|     onSelected([key], filterKey); | ||||
|   }; | ||||
| 
 | ||||
|   const fieldNames = { | ||||
|     title: labelKey || valueKey, | ||||
|     key: valueKey, | ||||
|   }; | ||||
| 
 | ||||
|   const itemsData = (list || []).map((item) => { | ||||
|     const { type, ['image_show']: imageObj, [fieldNames.title]: label, [fieldNames.key]: key } = item; | ||||
| 
 | ||||
|     // TODO: 需要更好的处理url方式
 | ||||
|     const origin = location?.origin || ''; | ||||
|     const sourceUrl = imageObj?.[0]?.url; | ||||
|     let imageUrl = sourceUrl ?? ''; | ||||
|     if (!imageUrl.includes('http')) { | ||||
|       imageUrl = `${origin}${sourceUrl}`; | ||||
|     } | ||||
| 
 | ||||
|     if (type === 'all') { | ||||
|       return { | ||||
|         label: t('AllProducts'), | ||||
|         key: 'all', | ||||
|         imageUrl: imageUrl, | ||||
|       }; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       label, | ||||
|       key, | ||||
|       imageUrl: imageUrl, | ||||
|     }; | ||||
|   }); | ||||
| 
 | ||||
|   return { | ||||
|     collectionField, | ||||
|     Designer, | ||||
|     items: itemsData, | ||||
|     onSelect, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| const ImageDescription = (props) => { | ||||
|   const { srcUrl, label } = props; | ||||
|   return ( | ||||
|  | ||||
| @ -9,6 +9,8 @@ import { | ||||
|   mBlockInitializers, | ||||
|   mBlockInitializers_deprecated, | ||||
|   MobileFormItemInitializers, | ||||
|   SwiperFieldSettings, | ||||
|   SwiperPage, | ||||
|   TabSearchFieldSchemaInitializer, | ||||
|   TabSearchItemFieldSettings, | ||||
| } from './core/schema'; | ||||
| @ -27,6 +29,7 @@ export class MobileClientPlugin extends Plugin { | ||||
|     this.app.schemaInitializerManager.add(ImageSearchConfigureFields); | ||||
|     this.app.schemaInitializerManager.add(TabSearchFieldSchemaInitializer); | ||||
| 
 | ||||
|     this.app.schemaSettingsManager.add(SwiperFieldSettings); | ||||
|     this.app.schemaSettingsManager.add(ImageSearchItemFieldSettings); | ||||
|     this.app.schemaSettingsManager.add(TabSearchItemFieldSettings); | ||||
|     this.app.schemaInitializerManager.add(MobileFormItemInitializers); | ||||
| @ -82,7 +85,7 @@ export class MobileClientPlugin extends Plugin { | ||||
|     }); | ||||
|     this.app.router.add('mobile.swiper.page', { | ||||
|       path: '/mobile/:name/image/:collection/:field/:fieldParams', | ||||
|       Component: 'SwiperPage', | ||||
|       Component: SwiperPage, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1778,6 +1778,9 @@ importers: | ||||
|       '@tachybase/client': | ||||
|         specifier: 0.x | ||||
|         version: link:../../../core/client | ||||
|       '@tachybase/plugin-mobile-client': | ||||
|         specifier: 0.x | ||||
|         version: link:../../@tachybase/plugin-mobile-client | ||||
|       '@tachybase/server': | ||||
|         specifier: 0.x | ||||
|         version: link:../../../core/server | ||||
| @ -1787,6 +1790,9 @@ importers: | ||||
|       antd-mobile: | ||||
|         specifier: ^5.35.0 | ||||
|         version: 5.35.0(react-dom@18.2.0)(react@18.2.0) | ||||
|       react-router-dom: | ||||
|         specifier: ^6.23.1 | ||||
|         version: 6.23.1(react-dom@18.2.0)(react@18.2.0) | ||||
| 
 | ||||
|   packages/plugins/@nocobase/plugin-acl: | ||||
|     dependencies: | ||||
| @ -10803,6 +10809,11 @@ packages: | ||||
|     engines: {node: '>=14.0.0'} | ||||
|     dev: true | ||||
| 
 | ||||
|   /@remix-run/router@1.16.1: | ||||
|     resolution: {integrity: sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==} | ||||
|     engines: {node: '>=14.0.0'} | ||||
|     dev: false | ||||
| 
 | ||||
|   /@restart/hooks@0.4.15(react@18.2.0): | ||||
|     resolution: {integrity: sha512-cZFXYTxbpzYcieq/mBwSyXgqnGMHoBVh3J7MU0CCoIB4NRZxV9/TuwTBAaLMqpNhC3zTPMCgkQ5Ey07L02Xmcw==} | ||||
|     peerDependencies: | ||||
| @ -26612,6 +26623,19 @@ packages: | ||||
|       react-router: 6.22.3(react@18.2.0) | ||||
|     dev: true | ||||
| 
 | ||||
|   /react-router-dom@6.23.1(react-dom@18.2.0)(react@18.2.0): | ||||
|     resolution: {integrity: sha512-utP+K+aSTtEdbWpC+4gxhdlPFwuEfDKq8ZrPFU65bbRJY+l706qjR7yaidBpo3MSeA/fzwbXWbKBI6ftOnP3OQ==} | ||||
|     engines: {node: '>=14.0.0'} | ||||
|     peerDependencies: | ||||
|       react: '>=16.8' | ||||
|       react-dom: '>=16.8' | ||||
|     dependencies: | ||||
|       '@remix-run/router': 1.16.1 | ||||
|       react: 18.2.0 | ||||
|       react-dom: 18.2.0(react@18.2.0) | ||||
|       react-router: 6.23.1(react@18.2.0) | ||||
|     dev: false | ||||
| 
 | ||||
|   /react-router-dom@6.3.0(react-dom@18.1.0)(react@18.1.0): | ||||
|     resolution: {integrity: sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==} | ||||
|     peerDependencies: | ||||
| @ -26663,6 +26687,16 @@ packages: | ||||
|       react: 18.2.0 | ||||
|     dev: true | ||||
| 
 | ||||
|   /react-router@6.23.1(react@18.2.0): | ||||
|     resolution: {integrity: sha512-fzcOaRF69uvqbbM7OhvQyBTFDVrrGlsFdS3AL+1KfIBtGETibHzi3FkoTRyiDJnWNc2VxrfvR+657ROHjaNjqQ==} | ||||
|     engines: {node: '>=14.0.0'} | ||||
|     peerDependencies: | ||||
|       react: '>=16.8' | ||||
|     dependencies: | ||||
|       '@remix-run/router': 1.16.1 | ||||
|       react: 18.2.0 | ||||
|     dev: false | ||||
| 
 | ||||
|   /react-router@6.3.0(react@18.1.0): | ||||
|     resolution: {integrity: sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==} | ||||
|     peerDependencies: | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user