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:
bai.zixv 2024-05-20 10:40:49 +08:00 committed by sealday
parent 519bcc703e
commit f0e51e1e89
35 changed files with 557 additions and 373 deletions

View File

@ -30,6 +30,8 @@ export const createGridCardBlockUISchema = (options: {
}, },
runWhenParamsChanged: true, runWhenParamsChanged: true,
rowKey, rowKey,
isLinkable: false,
needInfiniteScroll: false,
layoutDirection: 'column', layoutDirection: 'column',
}, },
'x-component': 'BlockItem', 'x-component': 'BlockItem',

View File

@ -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', name: 'ConvertReferenceToDuplicate',
Component: SchemaSettingsTemplate, Component: SchemaSettingsTemplate,

View File

@ -33,6 +33,7 @@ const InternalGridCardBlockProvider = (props) => {
service, service,
resource, resource,
columnCount: props.columnCount, columnCount: props.columnCount,
needInfiniteScroll: props.needInfiniteScroll,
}} }}
> >
<FormContext.Provider value={form}> <FormContext.Provider value={form}>

View File

@ -1,11 +1,14 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useCollection } from '@tachybase/client';
import { ObjectField } from '@tachybase/schema'; import { ObjectField } from '@tachybase/schema';
import { useField } from '@tachybase/schema'; import { useField, useFieldSchema } from '@tachybase/schema';
import { Card } from 'antd'; import { Card } from 'antd';
import React from 'react'; import React from 'react';
import { useCollectionParentRecordData } from '../../../data-source/collection-record/CollectionRecordProvider'; import { useCollectionParentRecordData } from '../../../data-source/collection-record/CollectionRecordProvider';
import { RecordProvider } from '../../../record-provider'; import { RecordProvider } from '../../../record-provider';
import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps'; import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps';
import { useNavigate } from 'react-router-dom';
import { useGridCardDetailUrl } from './hooks';
const itemCss = css` const itemCss = css`
display: flex; display: flex;
@ -17,13 +20,28 @@ const itemCss = css`
`; `;
export const GridCardItem = withDynamicSchemaProps((props) => { export const GridCardItem = withDynamicSchemaProps((props) => {
const collection = useCollection();
const field = useField<ObjectField>(); const field = useField<ObjectField>();
const fieldSchema = useFieldSchema();
const parentRecordData = useCollectionParentRecordData(); 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 ( return (
<Card <Card
role="button" role="button"
aria-label="grid-card-item" aria-label="grid-card-item"
className={css` className={css`
/* background-color: re/d; */
height: 100%; height: 100%;
> .ant-card-body { > .ant-card-body {
padding: 24px 24px 0px; padding: 24px 24px 0px;
@ -33,8 +51,10 @@ export const GridCardItem = withDynamicSchemaProps((props) => {
padding: 5px 0; padding: 5px 0;
} }
`} `}
onClick={handleClick}
> >
<div className={itemCss}> <div className={itemCss}>
{/* @ts-ignore */}
<RecordProvider record={field.value} parent={parentRecordData}> <RecordProvider record={field.value} parent={parentRecordData}>
{props.children} {props.children}
</RecordProvider> </RecordProvider>

View File

@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
import { ArrayField } from '@tachybase/schema'; import { ArrayField } from '@tachybase/schema';
import { RecursionField, Schema, useField, useFieldSchema } from '@tachybase/schema'; import { RecursionField, Schema, useField, useFieldSchema } from '@tachybase/schema';
import { List as AntdList, Col, PaginationProps } from 'antd'; 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 { SortableItem } from '../../common';
import { SchemaComponentOptions } from '../../core'; import { SchemaComponentOptions } from '../../core';
import { useDesigner, useProps } from '../../hooks'; import { useDesigner, useProps } from '../../hooks';
@ -12,6 +12,7 @@ import { GridCardItem } from './GridCard.Item';
import { useGridCardActionBarProps } from './hooks'; import { useGridCardActionBarProps } from './hooks';
import { defaultColumnCount, pageSizeOptions } from './options'; import { defaultColumnCount, pageSizeOptions } from './options';
import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps'; import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps';
import { InfiniteScroll } from '../../common/infinite-scroll/infinite-scroll';
const rowGutter = { const rowGutter = {
md: 12, md: 12,
@ -59,8 +60,7 @@ const designerCss = css`
const InternalGridCard = (props) => { const InternalGridCard = (props) => {
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema // 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { columnCount: columnCountProp, pagination } = useProps(props); const { columnCount: columnCountProp, pagination } = useProps(props);
const { service, columnCount: _columnCount = defaultColumnCount, needInfiniteScroll } = useGridCardBlockContext();
const { service, columnCount: _columnCount = defaultColumnCount } = useGridCardBlockContext();
const columnCount = columnCountProp || _columnCount; const columnCount = columnCountProp || _columnCount;
const { run, params } = service; const { run, params } = service;
const meta = service?.data?.meta; const meta = service?.data?.meta;
@ -68,6 +68,7 @@ const InternalGridCard = (props) => {
const field = useField<ArrayField>(); const field = useField<ArrayField>();
const Designer = useDesigner(); const Designer = useDesigner();
const [schemaMap] = useState(new Map()); const [schemaMap] = useState(new Map());
const getSchema = useCallback( const getSchema = useCallback(
(key) => { (key) => {
if (!schemaMap.has(key)) { if (!schemaMap.has(key)) {
@ -99,6 +100,31 @@ const InternalGridCard = (props) => {
[run, params], [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 ( return (
<SchemaComponentOptions <SchemaComponentOptions
scope={{ scope={{
@ -109,7 +135,7 @@ const InternalGridCard = (props) => {
<SortableItem className={cx('nb-card-list', designerCss)}> <SortableItem className={cx('nb-card-list', designerCss)}>
<AntdList <AntdList
pagination={ pagination={
!meta || meta.count <= meta.pageSize !meta || meta.count <= meta.pageSize || needInfiniteScroll
? false ? false
: { : {
...pagination, ...pagination,
@ -120,7 +146,7 @@ const InternalGridCard = (props) => {
pageSizeOptions, pageSizeOptions,
} }
} }
dataSource={field.value} dataSource={needInfiniteScroll ? data : field.value}
grid={{ grid={{
...columnCount, ...columnCount,
sm: columnCount.xs, sm: columnCount.xs,
@ -142,6 +168,7 @@ const InternalGridCard = (props) => {
}} }}
loading={service?.loading} loading={service?.loading}
/> />
{needInfiniteScroll && <InfiniteScroll loadMore={loadMore} hasMore={hasMore} />}
<Designer /> <Designer />
</SortableItem> </SortableItem>
</SchemaComponentOptions> </SchemaComponentOptions>

View File

@ -10,3 +10,13 @@ export const useGridCardActionBarProps = () => {
spaceProps, 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}`;
};

View File

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

View File

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

View File

@ -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 gridSizes = ['xs', 'md', 'lg', 'xxl'];
export const screenSizeTitleMaps = { export const screenSizeTitleMaps = {
xs: 'Phone device', xs: 'Phone device',

View File

@ -1,41 +1,20 @@
import { getScrollParent, merge } from '@tachybase/utils/client';
import { useLockFn, useThrottleFn } from 'ahooks'; import { useLockFn, useThrottleFn } from 'ahooks';
import type { FC, ReactNode } from 'react'; import { Spin } from 'antd';
import React, { useEffect, useRef, useState } from 'react'; import React, { ReactNode, useEffect, useRef, useState } from 'react';
// import { useConfig } from '../config-provider'; import { useTranslation } from 'react-i18next';
// import DotLoading from '../dot-loading'; import useStyles from './style';
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;
function isWindow(element: any | Window): element is Window { function isWindow(element: any | Window): element is Window {
return element === window; return element === window;
} }
const classPrefix = `adm-infinite-scroll`;
export type InfiniteScrollProps = { export type InfiniteScrollProps = {
loadMore: (isRetry: boolean) => Promise<void>; loadMore: (isRetry: boolean) => Promise<void>;
hasMore: boolean; hasMore: boolean;
threshold?: number; threshold?: number;
children?: ReactNode | ((hasMore: boolean, failed: boolean, retry: () => void) => ReactNode); children?: ReactNode | ((hasMore: boolean, failed: boolean, retry: () => void) => ReactNode);
} & NativeProps; };
const defaultProps: Required<Pick<InfiniteScrollProps, 'threshold' | 'children'>> = { const defaultProps: Required<Pick<InfiniteScrollProps, 'threshold' | 'children'>> = {
threshold: 250, threshold: 250,
@ -44,13 +23,17 @@ const defaultProps: Required<Pick<InfiniteScrollProps, 'threshold' | 'children'>
), ),
}; };
export const InfiniteScroll: FC<InfiniteScrollProps> = (p) => { export const InfiniteScroll = ({
const props = mergeProps(defaultProps, p); threshold = defaultProps.threshold,
loadMore,
children = defaultProps.children,
hasMore,
}: InfiniteScrollProps) => {
const { styles } = useStyles();
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const doLoadMore = useLockFn(async (isRetry: boolean) => { const doLoadMore = useLockFn(async (isRetry: boolean) => {
try { try {
await props.loadMore(isRetry); await loadMore(isRetry);
} catch (e) { } catch (e) {
setFailed(true); setFailed(true);
throw e; throw e;
@ -68,7 +51,7 @@ export const InfiniteScroll: FC<InfiniteScrollProps> = (p) => {
const { run: check } = useThrottleFn( const { run: check } = useThrottleFn(
async () => { async () => {
if (nextFlagRef.current !== flag) return; if (nextFlagRef.current !== flag) return;
if (!props.hasMore) return; if (!hasMore) return;
const element = elementRef.current; const element = elementRef.current;
if (!element) return; if (!element) return;
if (!element.offsetParent) return; if (!element.offsetParent) return;
@ -78,14 +61,14 @@ export const InfiniteScroll: FC<InfiniteScrollProps> = (p) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const elementTop = rect.top; const elementTop = rect.top;
const current = isWindow(parent) ? window.innerHeight : parent.getBoundingClientRect().bottom; const current = isWindow(parent) ? window.innerHeight : parent.getBoundingClientRect().bottom;
if (current >= elementTop - props.threshold) { if (current >= elementTop - threshold) {
const nextFlag = {}; const nextFlag = {};
nextFlagRef.current = nextFlag; nextFlagRef.current = nextFlag;
try { try {
await doLoadMore(false); await doLoadMore(false);
setFlag(nextFlag); setFlag(nextFlag);
} catch (e) { } 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); await doLoadMore(true);
setFlag(nextFlagRef.current); setFlag(nextFlagRef.current);
} catch (e) { } catch (e) {
console.log('%c Line:121 🥥 e', 'font-size:18px;color:#3f7cff;background:#2eafb0', e); console.error(e);
} }
} }
return withNativeProps( return (
props, <div className={styles.infiniteScroll} ref={elementRef}>
<div className={classPrefix} ref={elementRef}> {typeof children === 'function' ? children(hasMore, failed, retry) : children}
{typeof props.children === 'function' ? props.children(props.hasMore, failed, retry) : props.children} </div>
</div>,
); );
}; };
const InfiniteScrollContent: FC<{ const InfiniteScrollContent = (props: { hasMore: boolean; failed: boolean; retry: () => void }) => {
hasMore: boolean; const { t } = useTranslation();
failed: boolean; const { styles } = useStyles();
retry: () => void;
}> = (props) => {
const { locale } = useConfig();
if (!props.hasMore) { if (!props.hasMore) {
return <span>{locale.InfiniteScroll.noMore}</span>; return <span>{t('no more')}</span>;
} }
if (props.failed) { if (props.failed) {
return ( return (
<span> <span>
<span className={`${classPrefix}-failed-text`}>{locale.InfiniteScroll.failedToLoad}</span> <span className={`${styles.infiniteScroll}-failed-text`}>{t('failed to load')}</span>
<a <a
onClick={() => { onClick={() => {
props.retry(); props.retry();
}} }}
> >
{locale.InfiniteScroll.retry} {t('retry')}
</a> </a>
</span> </span>
); );
@ -160,14 +139,8 @@ const InfiniteScrollContent: FC<{
return ( return (
<> <>
<span>{locale.common.loading}</span> <span>{t('loading')}</span>
<DotLoading /> <Spin />
</> </>
); );
}; };
const keepComp = (props) => {
return <InfiniteScroll {...props} />;
};
keepComp({});

View File

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

View File

@ -19,3 +19,4 @@ export * from './uid';
export * from './url'; export * from './url';
export { dayjs, lodash }; export { dayjs, lodash };
export * from './parseHTML'; export * from './parseHTML';
export * from './dom';

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

View File

@ -12,6 +12,9 @@ export const HomePage: React.FC<{}> = () => {
'appends[]': 'pictures', 'appends[]': 'pictures',
}, },
}); });
console.log('%c Line:10 🥐 data', 'font-size:18px;color:#4fff4B;background:#e41a6a', data);
if (loading) { if (loading) {
return render(); return render();
} }

View File

@ -6,10 +6,12 @@
"main": "dist/server/index.js", "main": "dist/server/index.js",
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.3.6", "@ant-design/icons": "^5.3.6",
"antd-mobile": "^5.35.0" "antd-mobile": "^5.35.0",
"react-router-dom": "^6.23.1"
}, },
"peerDependencies": { "peerDependencies": {
"@tachybase/client": "0.x", "@tachybase/client": "0.x",
"@tachybase/plugin-mobile-client": "0.x",
"@tachybase/server": "0.x", "@tachybase/server": "0.x",
"@tachybase/test": "0.x" "@tachybase/test": "0.x"
}, },

View File

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

View File

@ -1,21 +1,13 @@
import { Modal, Toast, Image, Button } from 'antd-mobile';
import { ShareAltOutlined } from '@ant-design/icons'; 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 React from 'react';
import downloadImage from '../assets/download.svg'; import downloadImage from '../assets/download.svg';
import { CustomComponentType } from '@hera/plugin-core/client'; import './style.less';
export const ShareProduct = () => { export const ShareProduct = () => {
return ( return (
<Button <Button className={'m-share'} color="primary" size="small" block onClick={showModal}>
style={{
alignSelf: 'center',
fontSize: 16,
}}
color="primary"
size="small"
block
onClick={showModal}
>
<ShareAltOutlined /> <ShareAltOutlined />
</Button> </Button>
@ -26,7 +18,8 @@ ShareProduct.displayName = 'ShareProduct';
ShareProduct.__componentType = CustomComponentType.CUSTOM_FORM_ITEM; ShareProduct.__componentType = CustomComponentType.CUSTOM_FORM_ITEM;
ShareProduct.__componentLabel = '移动端-三聪头-分享产品'; ShareProduct.__componentLabel = '移动端-三聪头-分享产品';
const showModal = () => { const showModal = (event) => {
event.stopPropagation();
Modal.show({ Modal.show({
bodyStyle: { bodyStyle: {
background: 'transparent', background: 'transparent',

View File

@ -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 = '移动端-三聪头-显示详情';

View File

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

View File

@ -1,10 +1,13 @@
import { Plugin } from '@tachybase/client'; import { Plugin } from '@tachybase/client';
import { ProductDetail } from './pages/ProductDetail'; // import { ProductDetail } from './pages/ProductDetail.1';
import { ShareProduct } from './components/ShareProduct'; 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 { export class PluginSancongtouClient extends Plugin {
async afterAdd() { async afterAdd() {
// await this.app.pm.add() await this.app.pm.add(SCCardDetail);
} }
async beforeLoad() {} async beforeLoad() {}
@ -12,10 +15,18 @@ export class PluginSancongtouClient extends Plugin {
// You can get and modify the app instance here // You can get and modify the app instance here
async load() { async load() {
this.addRoutes(); this.addRoutes();
this.app.addComponents({ this.app.addComponents({
// 自定义字段组件
// GridCard: {
// ...GridCard,
// // @ts-ignore
// // Decorator: GridCardDe,
// },
ShowDetail,
ShareProduct, ShareProduct,
}); });
console.log(this.app); // console.log(this.app);
// this.app.addComponents({}) // this.app.addComponents({})
// this.app.addScopes({}) // this.app.addScopes({})
// this.app.addProvider() // this.app.addProvider()
@ -23,9 +34,18 @@ export class PluginSancongtouClient extends Plugin {
} }
addRoutes() { addRoutes() {
this.app.router.add('sancongtou/detail', { // this.app.router.add('sancongtou/detail', {
path: '/mobile/products/detail', // path: '/mobile/products/detail',
Component: ProductDetail, // Component: ProductDetail,
// });
this.app.router.add('mobile.productDetail', {
path: getPathProductDetail({
name: ':name',
dataSource: ':dataSource',
collection: ':collection',
id: ':id',
}),
Component: CardDetailSC,
}); });
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
},
];

View File

@ -1,2 +1 @@
export * from './useAction.ImageSearchItemView';
export * from './useSelect'; export * from './useSelect';

View File

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

View File

@ -3,6 +3,5 @@ export * from './ImageSearch.initializer';
export * from './ImageSearch.provider'; export * from './ImageSearch.provider';
export * from './ImageSearch.schema'; export * from './ImageSearch.schema';
export * from './ImageSearch.view'; export * from './ImageSearch.view';
export * from './data';
export * from './hooks'; export * from './hooks';
export * from './search-item'; export * from './search-item';

View File

@ -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 { Image, JumboTabs } from 'antd-mobile';
import React from 'react'; import React from 'react';
import { useActionImageSearchItemView } from '../hooks/useAction.ImageSearchItemView'; import { useTranslation } from '../../../../../locale';
// import { useActionImageSearchItemView } from '../hooks/useAction.ImageSearchItemView';
export const ImageSearchItemView = withDynamicSchemaProps( export const ImageSearchItemView = withDynamicSchemaProps(
(props) => { (props) => {
const { collectionField, Designer, items, onSelect } = useActionImageSearchItemView(props); const { collectionField, Designer, items, onSelect } = useAction(props);
if (!collectionField) { if (!collectionField) {
return null; return null;
@ -25,6 +27,63 @@ export const ImageSearchItemView = withDynamicSchemaProps(
{ displayName: 'ImageSearchItemView' }, { 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 ImageDescription = (props) => {
const { srcUrl, label } = props; const { srcUrl, label } = props;
return ( return (

View File

@ -9,6 +9,8 @@ import {
mBlockInitializers, mBlockInitializers,
mBlockInitializers_deprecated, mBlockInitializers_deprecated,
MobileFormItemInitializers, MobileFormItemInitializers,
SwiperFieldSettings,
SwiperPage,
TabSearchFieldSchemaInitializer, TabSearchFieldSchemaInitializer,
TabSearchItemFieldSettings, TabSearchItemFieldSettings,
} from './core/schema'; } from './core/schema';
@ -27,6 +29,7 @@ export class MobileClientPlugin extends Plugin {
this.app.schemaInitializerManager.add(ImageSearchConfigureFields); this.app.schemaInitializerManager.add(ImageSearchConfigureFields);
this.app.schemaInitializerManager.add(TabSearchFieldSchemaInitializer); this.app.schemaInitializerManager.add(TabSearchFieldSchemaInitializer);
this.app.schemaSettingsManager.add(SwiperFieldSettings);
this.app.schemaSettingsManager.add(ImageSearchItemFieldSettings); this.app.schemaSettingsManager.add(ImageSearchItemFieldSettings);
this.app.schemaSettingsManager.add(TabSearchItemFieldSettings); this.app.schemaSettingsManager.add(TabSearchItemFieldSettings);
this.app.schemaInitializerManager.add(MobileFormItemInitializers); this.app.schemaInitializerManager.add(MobileFormItemInitializers);
@ -82,7 +85,7 @@ export class MobileClientPlugin extends Plugin {
}); });
this.app.router.add('mobile.swiper.page', { this.app.router.add('mobile.swiper.page', {
path: '/mobile/:name/image/:collection/:field/:fieldParams', path: '/mobile/:name/image/:collection/:field/:fieldParams',
Component: 'SwiperPage', Component: SwiperPage,
}); });
} }
} }

View File

@ -1778,6 +1778,9 @@ importers:
'@tachybase/client': '@tachybase/client':
specifier: 0.x specifier: 0.x
version: link:../../../core/client version: link:../../../core/client
'@tachybase/plugin-mobile-client':
specifier: 0.x
version: link:../../@tachybase/plugin-mobile-client
'@tachybase/server': '@tachybase/server':
specifier: 0.x specifier: 0.x
version: link:../../../core/server version: link:../../../core/server
@ -1787,6 +1790,9 @@ importers:
antd-mobile: antd-mobile:
specifier: ^5.35.0 specifier: ^5.35.0
version: 5.35.0(react-dom@18.2.0)(react@18.2.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: packages/plugins/@nocobase/plugin-acl:
dependencies: dependencies:
@ -10803,6 +10809,11 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
dev: true 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): /@restart/hooks@0.4.15(react@18.2.0):
resolution: {integrity: sha512-cZFXYTxbpzYcieq/mBwSyXgqnGMHoBVh3J7MU0CCoIB4NRZxV9/TuwTBAaLMqpNhC3zTPMCgkQ5Ey07L02Xmcw==} resolution: {integrity: sha512-cZFXYTxbpzYcieq/mBwSyXgqnGMHoBVh3J7MU0CCoIB4NRZxV9/TuwTBAaLMqpNhC3zTPMCgkQ5Ey07L02Xmcw==}
peerDependencies: peerDependencies:
@ -26612,6 +26623,19 @@ packages:
react-router: 6.22.3(react@18.2.0) react-router: 6.22.3(react@18.2.0)
dev: true 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): /react-router-dom@6.3.0(react-dom@18.1.0)(react@18.1.0):
resolution: {integrity: sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==} resolution: {integrity: sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==}
peerDependencies: peerDependencies:
@ -26663,6 +26687,16 @@ packages:
react: 18.2.0 react: 18.2.0
dev: true 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): /react-router@6.3.0(react@18.1.0):
resolution: {integrity: sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==} resolution: {integrity: sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==}
peerDependencies: peerDependencies: