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