feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
.empty {
overflow: visible;
height: 100%;
.spin {
display: block;
width: 100%;
height: 100%;
:global {
.semi-spin-wrapper {
position: absolute;
display: flex;
justify-content: center;
svg {
width: 24px;
height: 24px;
}
}
.semi-tabs-content {
padding: 0;
}
.semi-spin-children {
height: 100%;
}
}
.loading-text {
margin-left: 8px;
font-size: 16px;
font-weight: 400;
line-height: 22px;
color: var(--semi-color-text-3, rgba(29, 28, 35, 35%));
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { I18n } from '@coze-arch/i18n';
import { UIEmpty, Spin } from '@coze-arch/bot-semi';
import { IllustrationFailure } from '@douyinfe/semi-illustrations';
import { type EmptyProps } from '../../type';
import s from './index.module.less';
/* Plugin header */
function Index(props: EmptyProps) {
const {
isLoading,
isSearching,
loadRetry,
isError,
renderEmpty,
text,
btn,
icon,
} = props;
return (
<div className={s.empty}>
{renderEmpty?.(props) ||
(!isError ? (
isLoading ? (
<Spin
tip={
<span className={s['loading-text']}>{I18n.t('Loading')}</span>
}
wrapperClassName={s.spin}
size="middle"
/>
) : (
<UIEmpty
isNotFound={!!isSearching}
empty={{
title: text?.emptyTitle || I18n.t('inifinit_list_empty_title'),
description: text?.emptyTitle ? text?.emptyDesc : '',
btnText: btn?.emptyText,
btnOnClick: btn?.emptyClick,
icon,
}}
notFound={{
title:
text?.searchEmptyTitle || I18n.t('inifinit_search_not_found'),
}}
/>
)
) : (
<UIEmpty
empty={{
title: I18n.t('inifinit_list_load_fail'),
icon: <IllustrationFailure />,
btnText: loadRetry && I18n.t('inifinit_list_retry'),
btnOnClick: () => {
loadRetry?.();
},
}}
/>
))}
</div>
);
}
export default Index;

View File

@@ -0,0 +1,55 @@
.footer-container {
padding: 12px 0 28px;
text-align: center;
* {
vertical-align: middle;
}
.loading,
.error-retry {
margin-left: 10px;
line-height: 20px;
color: var(--semi-color-text-3, rgba(29, 28, 35, 35%));
}
.error-retry {
cursor: pointer;
color: var(--semi-color-focus-border, #4d53e8);
}
:global {
.semi-spin-middle > .semi-spin-wrapper {
height: 16px;
svg {
width: 16px;
height: 16px;
}
}
}
.load-more-btn {
font-weight: 600;
background: #fff;
border-radius: 40px;
span {
color: #1d1c23;
}
&:hover {
background: #fff;
border: none;
}
}
&.responsive-foot-container {
padding: 0 0 16px;
.load-more-btn {
height: 40px;
padding: 16px 24px;
}
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Spin, UIButton } from '@coze-arch/bot-semi';
import { useIsResponsive } from '@coze-arch/bot-hooks';
import { type FooterProps } from '../../type';
import s from './index.module.less';
/* Plugin header */
function Index(props: FooterProps) {
const {
isLoading,
loadRetry,
isError,
renderFooter,
isNeedBtnLoadMore,
noMore,
} = props;
const isResponsive = useIsResponsive();
return (
<div
className={classNames(s['footer-container'], {
[s['responsive-foot-container']]: isResponsive,
})}
>
{renderFooter?.(props) ||
(isLoading ? (
<>
<Spin />
<span className={s.loading}>{I18n.t('Loading')}</span>
</>
) : isError ? (
<>
<Spin />
<span className={s['error-retry']} onClick={loadRetry}>
{I18n.t('inifinit_list_retry')}
</span>
</>
) : isNeedBtnLoadMore && !noMore ? (
<UIButton
onClick={loadRetry}
className={s['load-more-btn']}
theme="borderless"
>
{I18n.t('mkpl_load_btn')}
</UIButton>
) : null)}
</div>
);
}
export default Index;

View File

@@ -0,0 +1,218 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
useState,
useRef,
useEffect,
type Dispatch,
type SetStateAction,
} from 'react';
import {
useInfiniteScroll,
useUpdateEffect,
useMemoizedFn,
useDebounceFn,
} from 'ahooks';
import { type ScrollProps, type InfiniteListDataProps } from '../type';
/* 滚动Hooks */
function useForwardFunc<T>(
dataInfo: InfiniteListDataProps<T>,
mutate: Dispatch<SetStateAction<InfiniteListDataProps<T>>>,
) {
// 手动插入数据,不通过接口
const insertData = (item, index) => {
dataInfo.list.splice(index, 0, item);
mutate({
...dataInfo,
list: [...(dataInfo?.list || [])],
});
};
// 手动删除数据,不通过接口
const removeData = index => {
dataInfo.list.splice(index, 1);
mutate({
...dataInfo,
list: [...(dataInfo?.list || [])],
});
};
const getDataList = () => dataInfo?.list;
return { insertData, removeData, getDataList };
}
// eslint-disable-next-line max-lines-per-function, @coze-arch/max-line-per-function -- 看了下代码行数不太好优化
function useScroll<T>(props: ScrollProps<T>) {
const {
targetRef,
loadData,
threshold,
reloadDeps,
isNeedBtnLoadMore,
resetDataIfReload = true,
} = props;
const [isLoadingError, setIsLoadingError] = useState<boolean>(false);
const refFetchNo = useRef<number>(0);
const refResolve = useRef<(value) => void>();
const {
loading,
data: dataInfo,
loadingMore,
loadMore,
noMore,
cancel,
mutate,
reload,
} = useInfiniteScroll<InfiniteListDataProps<T>>(
async current => {
// 此处逻辑如此复杂是解决Scroll中的bug。
// useInfiniteScroll中的cancel只是取消了一次请求但是数据会根据current重新设置一遍。
const fetchNo = refFetchNo.current;
if (refResolve.current) {
// 保证顺序执行,如果有当前方法,就取消上一次的请求,防止出现由于网络原因导致数据覆盖问题
// 同时发出A1,A2,三次请求但是A1先到达然后请求了B1, 但是A1过慢导致了A1覆盖了B1的请求。
refResolve.current({
...(current || {}),
list: [],
});
}
const result = await new Promise((resolve, reject) => {
refResolve.current = resolve;
loadData(current)
.then(value => resolve(value))
.catch(err => reject(err));
});
// @ts-expect-error -- linter-disable-autofix
refResolve.current = null;
// 切换Tab的时候如果此时正在请求防止数据的残留界面显示
if (refFetchNo.current !== fetchNo) {
if (current) {
current.list = [];
}
return {
list: [],
nextPage: 1,
};
}
return result as InfiniteListDataProps<T>;
},
{
target: isLoadingError || isNeedBtnLoadMore ? null : targetRef, //失败的时候通过去掉target的事件绑定禁止滚动加载。
threshold,
onBefore: () => {
//setIsLoadingError(false);
},
isNoMore: data => data?.hasMore !== undefined && !data?.hasMore,
onSuccess: () => {
if (isLoadingError) {
setIsLoadingError(false);
}
},
onError: e => {
// 如果在请求第一页数据时发生错误并且当前列表不为空则reset数据
// 这个case只有当resetDataIfReload设置为false时才会发生
// @ts-expect-error -- linter-disable-autofix
if (dataInfo.nextPage === 1 && (dataInfo?.list?.length ?? 0) > 0) {
// @ts-expect-error -- linter-disable-autofix
mutate({
...dataInfo,
list: [],
});
}
setIsLoadingError(true);
},
},
);
const { insertData, removeData, getDataList } = useForwardFunc(
// @ts-expect-error -- linter-disable-autofix
dataInfo,
mutate,
);
useEffect(() => {
if (isNeedBtnLoadMore && !(loading || loadingMore)) {
reload();
}
}, []);
const reloadData = useMemoizedFn(() => {
mutate({
list: resetDataIfReload ? [] : (dataInfo?.list ?? []),
hasMore: undefined,
nextPage: 1,
});
cancel();
setIsLoadingError(false);
reload();
});
useUpdateEffect(() => {
refFetchNo.current++;
reloadData();
}, [...(reloadDeps || [])]);
const isLoading = loading || loadingMore || props.isLoading;
const { run: loadMoreDebounce } = useDebounceFn(
() => {
if (isLoading) {
return;
}
if (!isNeedBtnLoadMore) {
loadMore();
}
},
{ wait: 500 },
);
useEffect(() => {
const resize = () => {
loadMoreDebounce();
};
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
};
}, []);
const { list } = dataInfo || {};
return {
dataList: list,
isLoading,
loadMore: () => {
if (!isLoading) {
//如果已经有数据加载中了,需要禁止重复加载。
loadMore();
}
},
reload: reloadData,
noMore,
cancel,
isLoadingError,
mutate,
insertData,
removeData,
getDataList,
};
}
export default useScroll;

View File

@@ -0,0 +1,5 @@
.height-whole-100 {
overflow: visible;
height: 100%;
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
forwardRef,
useImperativeHandle,
type RefObject,
useEffect,
} from 'react';
import cls from 'classnames';
import { ResponsiveList } from '@coze-arch/responsive-kit';
import { List } from '@coze-arch/bot-semi';
import { type InfiniteListProps, type InfiniteListRef } from './type';
import useScroll from './hooks/use-scroll';
import Footer from './components/footer';
import Empty from './components/empty';
import s from './index.module.less';
/* Plugin header */
function Index<T extends object>(props: InfiniteListProps<T>, ref) {
const {
isSearching,
className,
emptyContent,
grid,
renderItem,
itemClassName,
renderFooter,
scrollConf,
emptyConf,
onChangeState,
canShowData = true,
isNeedBtnLoadMore = false,
isResponsive,
retryFunc,
responsiveConf,
containerClassName,
} = props;
const {
dataList,
isLoading,
loadMore,
noMore,
isLoadingError,
mutate,
reload,
insertData,
removeData,
getDataList,
} = useScroll<T>({ ...scrollConf, isNeedBtnLoadMore });
useImperativeHandle(
ref,
() => ({ mutate, reload, insertData, removeData, getDataList }),
[mutate, reload, insertData, removeData, getDataList],
);
useEffect(() => {
onChangeState?.(isLoading, dataList);
}, [dataList, isLoading]);
// 根据白名单对列表移动端进行移动端适配
return (
<div className={cls(s['height-whole-100'], containerClassName)}>
{!dataList?.length || !canShowData ? (
/** 数据为空的时候,操作如何显示空页面 */
<Empty
isError={canShowData ? isLoadingError : false}
isSearching={isSearching}
isLoading={canShowData ? isLoading : true}
loadRetry={retryFunc || loadMore}
{...emptyConf}
/>
) : isResponsive ? (
<ResponsiveList<T>
className={className}
emptyContent={isLoading ? <></> : emptyContent}
dataSource={dataList}
renderItem={(item, number) => renderItem?.(item, number)}
gridCols={responsiveConf?.gridCols}
gridGapXs={{
basic: 4,
}}
footer={
<div className="text-sm px-6 py-3">
<Footer
isError={isLoadingError}
noMore={noMore}
isLoading={isLoading}
loadRetry={retryFunc || loadMore}
renderFooter={renderFooter}
isNeedBtnLoadMore={isNeedBtnLoadMore}
/>
</div>
}
/>
) : (
<List
{...{ className, emptyContent, grid }}
emptyContent={isLoading ? <></> : emptyContent}
dataSource={dataList}
split={false}
renderItem={(item, number) => (
<List.Item
className={
typeof itemClassName === 'string'
? itemClassName
: itemClassName?.(item) // 支持动态行className
}
>
{renderItem?.(item, number)}
</List.Item>
)}
footer={
<Footer
isError={isLoadingError}
noMore={noMore}
isLoading={isLoading}
loadRetry={retryFunc || loadMore}
renderFooter={renderFooter}
isNeedBtnLoadMore={isNeedBtnLoadMore}
dataNum={dataList?.length}
/>
}
/>
)}
</div>
);
}
export const InfiniteList = forwardRef(Index) as <T>(
props: InfiniteListProps<T> & { ref?: RefObject<InfiniteListRef> },
) => JSX.Element;

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ReactElement, type RefObject } from 'react';
import {
type ResponsiveTokenMap,
type ScreenRange,
} from '@coze-arch/responsive-kit';
import { type ListProps } from '@coze-arch/bot-semi/List';
export interface EmptyProps {
isError?: boolean;
isLoading?: boolean;
isSearching?: boolean;
loadRetry?: () => void; //重试加载
text?: {
emptyTitle?: string;
emptyDesc?: string;
searchEmptyTitle?: string;
};
btn?: {
emptyClick?: () => void; //
emptyText?: string;
};
icon?: ReactElement;
renderEmpty?: (
emptyProps: Omit<EmptyProps, 'renderEmpty'>,
) => React.ReactNode | null;
}
export interface FooterProps {
isError?: boolean; // 是否加载出错
isLoading?: boolean; // 是否加载中
noMore?: boolean; //没有更多数据
isNeedBtnLoadMore?: boolean;
dataNum?: number;
loadRetry?: () => void; //重试加载
renderFooter?: (
footerProps: Omit<FooterProps, 'renderFooter'>,
) => React.ReactNode | null;
}
export interface InfiniteListDataProps<T> {
list: T[];
hasMore?: boolean;
nextPage: number;
[key: string]: unknown;
}
export interface ScrollProps<T> {
threshold?: number; //距离下方多长距离,开始加载数据
targetRef?: RefObject<HTMLDivElement>; // 监听滚动的Dom 引用
loadData: (current) => Promise<InfiniteListDataProps<T>>; // 加载更多数据
reloadDeps?: unknown[]; // 重新加载数据依赖
isNeedBtnLoadMore?: boolean;
isLoading?: boolean; // 是否加载中
resetDataIfReload?: boolean; // 当reload时是否先reset列表已存在数据默认为true
}
export interface InfiniteListProps<T>
extends Pick<
ListProps<T>,
'className' | 'emptyContent' | 'grid' | 'renderItem'
> {
containerClassName?: string;
canShowData?: boolean; //是否能够显示数据了
isSearching?: boolean; // 是否搜索中,主要是用于错误显示的时候,选择文案使用
itemClassName?: string | ((item: T) => string);
isNeedBtnLoadMore?: boolean;
isResponsive?: boolean;
emptyConf: {
renderEmpty?: EmptyProps['renderEmpty'];
text?: EmptyProps['text'];
btn?: EmptyProps['btn'];
icon?: EmptyProps['icon'];
};
renderFooter?: FooterProps['renderFooter'];
scrollConf: ScrollProps<T>;
rowKey?: string;
retryFunc?: () => void;
onChangeState?: (loading, data) => void;
responsiveConf?: {
gridCols?: ResponsiveTokenMap<ScreenRange>;
};
}
export interface InfiniteListRef {
mutate: (data) => void;
reload: () => void;
insertData: (item, index) => void;
removeData: (index) => void;
getDataList: () => unknown[]; // 获取当前列表数据
}