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,152 @@
/* stylelint-disable declaration-no-important */
.expand-icon {
transform: rotate(90deg) !important;
display: flex !important;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin: 0 !important;
line-height: 20px;
}
.common-file-picker-wrapper {
:global {
.semi-tree-option-selected {
background-color: var(--coz-mg-hglt-hovered) !important;
&:hover {
background-color: unset;
}
}
.semi-tree-option-disable {
cursor: not-allowed;
}
.semi-tree-option-readonly {
cursor: default;
&:hover{
background: transparent !important;
}
}
.semi-tree-option {
height: 32px !important;
margin: 1px 0 !important;
line-height: 32px !important;
border-radius: 4px;
&:hover {
background-color: var(--coz-mg-secondary-hovered);
}
}
.semi-tree-option-expand-icon {
.expand-icon();
}
.expand-placeholder {
.expand-icon();
}
.semi-tree-option-list .semi-tree-option-collapsed .semi-tree-option-expand-icon {
transform: unset !important;
}
.file-selector {
width: 16px;
height: 16px;
margin: 0 12px;
.semi-checkbox-inner {
height: 16px;
}
.semi-radio {
height: 16px;
min-height: 16px;
line-height: 16px;
vertical-align: top;
.semi-radio-inner {
margin-top: 0;
}
}
}
.file-node-row-content {
display: flex;
flex: 1;
align-items: center;
height: 36px;
margin: 2px 0;
padding: 8px 13px;
border-radius: 4px;
}
.file-icon {
display: flex;
align-items: center;
align-self: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 4px;
line-height: 20px;
.semi-spin-middle>.semi-spin-wrapper svg {
width: 16px;
height: 16px;
}
img {
width: 20px;
height: 20px;
line-height: 20px;
}
}
.file-content {
display: flex;
flex: 1;
justify-content: space-between;
height: 20px;
line-height: 20px;
.file-name {
overflow: hidden;
width: 80%;
height: 20px;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: var(--coz-fg-primary);
white-space: nowrap;
}
.file-loading-info {
height: 20px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: var(--coz-fg-hglt);
}
}
}
}

View File

@@ -0,0 +1,332 @@
/*
* 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, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Tree } from '@coze-arch/coze-design';
import type {
TreeProps,
RenderFullLabelProps,
} from '@coze-arch/bot-semi/Tree';
import { CommonE2e } from '@coze-data/e2e';
import { distinctFileNodes, levelMapTreeNodesToMap } from '../utils';
import {
isFileNodeArray,
type FileNode,
type PickerRef,
type TransSelectedFilesMiddleware,
type FileId,
type CalcCurrentSelectFilesMiddleware,
} from '../types';
import { useDefaultLabelRenderer } from '../hooks/useDefaultLabelRenderer';
import {
DEFAULT_FILENODE_LEVEL_INDENT,
DEFAULT_VIRTUAL_CONTAINER_HEIGHT,
DEFAULT_VIRTUAL_ITEM_HEIGHT,
} from '../consts';
import styles from './common-file-picker.module.less';
export interface CommonFilePickerProps
extends Omit<TreeProps, 'onChange' | 'onSelect'> {
/** 渲染使用的树数据 */
treeData: FileNode[];
/** 提供一个定制化的 render tree node */
customTreeDataRenderer?: (
renderProps: RenderFullLabelProps,
) => React.ReactNode;
/** 是否只能选中叶子结点 如果传入 customRenderTreeData 则失效 */
onlySelectLeaf?: boolean;
/** 是否多选 如果自定义 customTreeDataRenderer 一定要传入, 选项会影响 customTreeDataRenderer 的入参 */
multiple?: boolean;
/**
* 是否开启虚拟化
*/
enableVirtualize?: boolean;
/** 虚拟化选项 */
/** 虚拟化容器高度 */
virtualizeHeight?: number;
/** 每个 item 高度 */
virtualizeItemSize?: number;
/** 默认已经选中的内容 可以作为 initValue 使用,也可以作为 value 的代替者使用 */
defaultValue?: FileNode[] | FileId[];
/** 样式渲染特性 */
normalLabelStyle?: React.CSSProperties;
selectedLabelStyle?: React.CSSProperties;
halfSelectedLabelStyle?: React.CSSProperties;
/** 缩进大小 默认大小 25px 如果 backgroundMode 为 position 将会反应为 left 如果为 padding 将会反应成 padding-left */
indentSize?: number;
/** 树组件展开的 icon */
expandIcon?: React.ReactNode;
/** onChange 业务层可以透传 */
onChange?: (args?: FileNode[]) => void;
/** onSelect 业务层可以透传 */
onSelect?: (key: string, selected: boolean, node: FileNode) => void;
/** 用来转换 selectedFiles, 发生在 设置选中态 到 提交给上层组件 之间 注意 处理有先后顺序,前一个中间件的返回将作为后一个的输入 */
transSelectedFilesMiddlewares?: TransSelectedFilesMiddleware[];
/** 设置选择态的钩子 发生在 点击选中框 到 设置选择态 之间 处理有先后顺序 */
selectFilesMiddlewares?: CalcCurrentSelectFilesMiddleware[];
/** disable select 禁止选择 */
disableSelect?: boolean;
/** checkRelation: 父子节点选中态是否关联 */
checkRelation?: 'related' | 'unRelated';
/** 默认展开的节点 key */
defaultExpandKeys?: FileId[];
}
function diffChangeNodes(
prevChangeNodes: FileNode[],
changeNodes: FileNode[],
): [FileNode[], FileNode[], FileNode[]] {
const addNodes: FileNode[] = [];
const removeNodes: FileNode[] = [];
const retainNodes: FileNode[] = [];
const prevChangeKeysSet = new Set(prevChangeNodes.map(node => node.key));
const changeKeysSet = new Set(changeNodes.map(node => node.key));
for (const changeNode of prevChangeNodes) {
if (!changeKeysSet.has(changeNode.key)) {
removeNodes.push(changeNode);
}
}
for (const changeNode of changeNodes) {
if (prevChangeKeysSet.has(changeNode.key)) {
retainNodes.push(changeNode);
} else {
addNodes.push(changeNode);
}
}
return [addNodes, removeNodes, retainNodes];
}
function getFirstKeyOfDefaultValue(defaultValue?: FileId[] | FileNode[]) {
if (!defaultValue || defaultValue.length === 0) {
return '';
}
if (typeof defaultValue[0] === 'string') {
return defaultValue[0];
}
return defaultValue[0].key;
}
function transDefaultValueToFileNodes(
treeData: FileNode[],
defaultValue?: FileId[] | FileNode[],
): FileNode[] {
const treeDataMap = levelMapTreeNodesToMap(treeData);
return (
defaultValue?.map(element => {
// 因为开了 onChangeWithObject 所以这里选中态要用 object 存储
if (typeof element === 'string') {
return (
treeDataMap.get(element) ?? {
key: element,
}
);
}
return element;
}) ?? []
);
}
function transDefaultValueToRenderNode(defaultValue?: FileId[] | FileNode[]) {
return (
defaultValue?.map(element => {
// 因为开了 onChangeWithObject 所以这里选中态要用 object 存储
if (typeof element === 'string') {
return {
key: element,
};
}
return element;
}) ?? []
);
}
/**
* ------------------
* common file picker
* 用于数据上传选择文件
* ------------------
* useImperativeHandle:
* search: 提供树搜索能力
* ------------------
* props: FilePickerProps
* ------------------
*/
export const CommonFilePicker = React.forwardRef(
(props: CommonFilePickerProps, ref: React.ForwardedRef<PickerRef>) => {
const {
treeData,
customTreeDataRenderer,
onlySelectLeaf,
multiple,
virtualizeHeight,
virtualizeItemSize,
indentSize,
expandIcon,
onChange,
transSelectedFilesMiddlewares,
defaultValue,
selectFilesMiddlewares = [],
disableSelect = false,
checkRelation = 'related',
defaultExpandKeys = [],
enableVirtualize = true,
} = props;
const treeRef = useRef<Tree>(null);
// 使用受控模式
const [selectValue, setSelectValue] = useState<FileNode[]>(
transDefaultValueToRenderNode(defaultValue),
);
const [expandedKeys, setExpandedKeys] =
useState<string[]>(defaultExpandKeys);
const prevChangeNodes = useRef<FileNode[]>([]);
useEffect(() => {
const defaultFileNodes = transDefaultValueToFileNodes(
treeData,
defaultValue,
);
setSelectValue(transDefaultValueToRenderNode(defaultValue));
prevChangeNodes.current = defaultFileNodes;
}, [defaultValue]);
const renderTreeData = useDefaultLabelRenderer(
!!multiple,
!!onlySelectLeaf,
{
indentSize: indentSize ?? DEFAULT_FILENODE_LEVEL_INDENT,
expandIcon,
disableSelect,
defaultSingleSelectKey: !multiple
? getFirstKeyOfDefaultValue(defaultValue)
: '',
},
);
useImperativeHandle(ref, () => ({
search: treeRef.current?.search,
}));
return (
<div className={styles['common-file-picker-wrapper']}>
<Tree
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
data-testid={CommonE2e.CommonFilePicker}
checkRelation={checkRelation}
value={selectValue}
treeData={treeData}
ref={treeRef}
renderFullLabel={
customTreeDataRenderer ??
(renderTreeData as (
renderProps: RenderFullLabelProps,
) => React.ReactNode)
}
multiple={multiple}
virtualize={
enableVirtualize
? {
height: virtualizeHeight ?? DEFAULT_VIRTUAL_CONTAINER_HEIGHT,
itemSize: virtualizeItemSize ?? DEFAULT_VIRTUAL_ITEM_HEIGHT,
}
: undefined
}
expandedKeys={expandedKeys}
onExpand={(currentExpandedKeys, current) => {
setExpandedKeys(currentExpandedKeys);
}}
onChangeWithObject
onChange={changeNodes => {
let transedChangeNodes: FileNode[];
if (multiple) {
if (
!changeNodes ||
!Array.isArray(changeNodes) ||
!isFileNodeArray(changeNodes)
) {
return;
}
transedChangeNodes = changeNodes;
} else {
if (!changeNodes) {
return;
}
transedChangeNodes = [changeNodes as unknown as FileNode];
}
// 计算 diff
const [addNodes, removeNodes, retainNodes] = diffChangeNodes(
prevChangeNodes.current,
transedChangeNodes as FileNode[],
);
// 这里的中间件更多用在定制化选中态的场景 比如想要反选所有子节点但是保持父亲节点选中
transedChangeNodes = distinctFileNodes(
selectFilesMiddlewares.reduce(
(selectedElements, middleware) =>
middleware(
selectedElements,
addNodes,
removeNodes,
retainNodes,
),
transedChangeNodes,
),
);
prevChangeNodes.current = transedChangeNodes;
setSelectValue(
transedChangeNodes.map(transedNode => ({
key: transedNode.key,
})),
);
// 虽然这里返回的是父节点带 children 但是因为都是后端一次接口的快照
// 具体上报什么数据交给业务方
// 这里的中间件主要用在定制化上报给上层组件的选中数据的场景,比如 checkRelation 'related' 模式下 上面返回的只有父亲节点的数据,但是父组件想要所有数据
// 警告:这里如果使用 loadData 时拿不到没请求的子节点(换句话说拿到的最后一层的数据不一定是叶子结点, 同样的在这里选中之后交回给后端的其实也只是一个父节点 不能保证在一个快照里
if (transSelectedFilesMiddlewares) {
transedChangeNodes = transSelectedFilesMiddlewares.reduce(
(selectedElements, middleware) => middleware(selectedElements),
transedChangeNodes,
);
}
onChange?.(transedChangeNodes);
}}
/>
</div>
);
},
);

View File

@@ -0,0 +1,22 @@
/*
* 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.
*/
// 缩进相关数据
export const DEFAULT_FILENODE_LEVEL_INDENT = 24;
// 虚拟化配置相关数据
export const DEFAULT_VIRTUAL_CONTAINER_HEIGHT = 540;
export const DEFAULT_VIRTUAL_ITEM_HEIGHT = 28;

View File

@@ -0,0 +1,17 @@
/*
* 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.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,389 @@
/*
* 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.
*/
/* eslint-disable complexity */
/* eslint-disable @coze-arch/max-line-per-function */
import React, { useState } from 'react';
import type { RenderFullLabelProps } from '@coze-arch/bot-semi/Tree';
import { Checkbox, Radio, Spin, Typography } from '@coze-arch/bot-semi';
import { ReactComponent as Text } from '@/assets/text-file.svg';
import { ReactComponent as Sheet } from '@/assets/sheet-file.svg';
import { ReactComponent as Folder } from '@/assets/folder.svg';
import {
TreeNodeType,
type FileSelectCheckStatus,
type FileNode,
} from '../types';
const noRealExpandClassName = 'no-real-expand';
const ActionComponent = (props: {
checkStatus: Partial<FileSelectCheckStatus>;
isLeaf: boolean;
renderExpandIcon: React.ReactElement;
onlySelectLeaf: boolean;
multiple: boolean;
disableSelect: boolean;
unCheckable: boolean;
}) => {
const {
checkStatus,
isLeaf,
renderExpandIcon,
onlySelectLeaf,
multiple,
disableSelect,
unCheckable = false,
} = props;
const getSingalSelectAction = (className: string) => (
<span
role="radio"
tabIndex={0}
aria-checked={checkStatus.checked}
className={`file-selector ${className}`}
>
<Radio checked={checkStatus.checked} disabled={disableSelect} />
</span>
);
const getMultiSelectAction = (className: string) => (
<span
role="checkbox"
tabIndex={0}
aria-checked={checkStatus.checked}
className={`file-selector ${className}`}
>
<Checkbox
indeterminate={checkStatus.halfChecked}
checked={checkStatus.checked}
disabled={disableSelect}
/>
</span>
);
const expandPlaceHolder = <span className="expand-placeholder"></span>;
const selectActionPlaceHolder = (
<span className="action-placeholder file-selector"></span>
);
// 当前整棵树是多选 并且 只能选中叶子结点
if (multiple && onlySelectLeaf) {
return isLeaf && !unCheckable ? (
<>
{expandPlaceHolder}
{getMultiSelectAction(noRealExpandClassName)}
</>
) : (
<>
{renderExpandIcon} {selectActionPlaceHolder}
</>
);
}
// 当前整棵树是多选 并且 能选中所有结点
if (multiple && !onlySelectLeaf) {
if (unCheckable) {
return (
<>
{renderExpandIcon} {selectActionPlaceHolder}
</>
);
}
return (
<>
{isLeaf ? expandPlaceHolder : renderExpandIcon}
{getMultiSelectAction(isLeaf ? noRealExpandClassName : '')}
</>
);
}
// 当前整棵树是单选 并且 只能选中叶子结点
if (!multiple && onlySelectLeaf) {
return isLeaf && !unCheckable ? (
<>
{expandPlaceHolder}
{getSingalSelectAction(noRealExpandClassName)}
</>
) : (
<>
{renderExpandIcon}
{selectActionPlaceHolder}
</>
);
}
// 当前整棵树是单选 并且 能选中所有结点
if (!multiple && !onlySelectLeaf) {
if (unCheckable) {
return (
<>
{renderExpandIcon}
{selectActionPlaceHolder}
</>
);
}
return (
<>
{isLeaf ? expandPlaceHolder : renderExpandIcon}
{getSingalSelectAction(isLeaf ? noRealExpandClassName : '')}
</>
);
}
return <></>;
};
const LabelContent = (props: {
iconUrl?: string;
label: React.ReactNode;
type?: TreeNodeType;
isLoading?: boolean;
loadingInfo?: string;
}) => {
const { iconUrl, label, type, isLoading, loadingInfo } = props;
return (
<>
<span className="file-icon">
{isLoading ? (
<Spin spinning />
) : iconUrl ? (
<img src={iconUrl} />
) : (
{
[TreeNodeType.FILE_TABLE]: <Sheet />,
[TreeNodeType.FOLDER]: <Folder />,
[TreeNodeType.FILE_TEXT]: <Text />,
}[type ?? TreeNodeType.FILE_TEXT]
)}
</span>
<span className="file-content">
<Typography.Text
ellipsis={{
showTooltip: {
type: 'tooltip',
opts: {
style: {
maxWidth: '800px',
},
},
},
}}
className="file-name"
>
{label}
</Typography.Text>
{isLoading ? (
<span className="file-loading-info">{loadingInfo}</span>
) : null}
</span>
</>
);
};
/**
* -----------------------------
* 获取默认的文件树 label renderer 这层不感知平台信息
* -----------------------------
* @param {boolean} multiple 是否多选
* @param {boolean} onlySelectLeaf 是否只能选中叶子结点
* @param {{indentSize: 树组件缩进尺寸, expandIcon: 树组件可展开节点 展开图标, disableSelect: 选择的 disable 状态}} renderOption 渲染相关的自定义选项
* @returns label render 函数
*/
export function useDefaultLabelRenderer(
multiple: boolean,
onlySelectLeaf: boolean,
renderOption: {
indentSize: number;
expandIcon?: React.ReactNode;
disableSelect?: boolean;
defaultSingleSelectKey?: string;
},
) {
const [singleSelectedKey, setSingleSelectKey] = useState(
renderOption.defaultSingleSelectKey ?? '',
);
const getExpandIcon = (
labelDefaultIcon: React.ReactElement,
customIcon?: React.ReactNode,
) => {
if (!customIcon) {
return labelDefaultIcon;
}
const {
props: { onClick, className, role },
} = labelDefaultIcon;
return (
<span
role={role}
className={`${className} semi-tree-option-expand-icon`}
onClick={onClick}
>
{customIcon}
</span>
);
};
const getFileContentClassName = (isChecked: boolean) =>
`w-full file-node-row-content flex items-center ${
isChecked ? 'file-node-selected' : 'fileNodeNormal'
}`;
/**
* 在整行点击的处理函数 不是点击 checkbox 或者 radio 或者 expandIcon 的处理函数
* @param isLeaf: 是否是叶子结点
* @param onExpand: 展开行 处理函数
* @param onCheck: 选中状态的 toggle
**/
const getItemAction = (params: {
isLeaf: boolean;
onExpand: (e: React.MouseEvent<Element, MouseEvent>) => void;
onCheck: (e: React.MouseEvent<Element, MouseEvent>) => void;
unCheckable: boolean;
}) => {
const { onExpand, onCheck, isLeaf, unCheckable } = params;
return function (e: React.MouseEvent<Element, MouseEvent>) {
// 如果只能选中叶子结点 那么无论 多选 / 单选 父节点只能展开,叶子结点可以选中
// 反之 父节点子节点 都是选中 无论多选单选,想要展开就点击 expand icon
if (onlySelectLeaf) {
if (!isLeaf) {
onExpand(e);
} else {
if (!unCheckable) {
onCheck(e);
}
}
} else {
if (!unCheckable) {
onCheck(e);
}
}
};
};
const labelRenderer = (
renderProps: RenderFullLabelProps & {
data: FileNode;
},
) => {
const {
data,
className,
level,
onCheck,
onExpand,
onClick,
checkStatus,
style,
expandIcon,
} = renderProps;
const { indentSize, disableSelect } = renderOption;
const {
label,
key,
type,
isLoading,
loadingInfo,
render,
readonly,
unCheckable = false,
} = data;
// 单选选中选项的 key
const singleSelectCheckStatus = singleSelectedKey === key;
const rowCheckStatus = multiple
? checkStatus
: {
checked: singleSelectCheckStatus,
};
// 只要 data isLeaf 不是空 永远先看 data.isLeaf
const isLeaf = data?.isLeaf ?? !(data.children && data.children.length);
const checkItem = multiple
? (e: React.MouseEvent<Element, MouseEvent>) => {
if (disableSelect) {
e.stopPropagation();
return;
}
onCheck(e);
}
: (e: React.MouseEvent<Element, MouseEvent>) => {
if (disableSelect) {
e.stopPropagation();
return;
}
onClick(e);
setSingleSelectKey(key);
};
const indent = indentSize * level;
const rowStyle = {
...style,
paddingLeft: indent,
};
const renderExpandIcon = getExpandIcon(
expandIcon as React.ReactElement,
renderOption.expandIcon,
);
return (
<li
style={{
...rowStyle,
}}
className={`${className} ${
checkStatus.checked ? 'semi-tree-option-selected' : ''
} ${disableSelect ? 'semi-tree-option-disable' : ''} ${
readonly ? 'semi-tree-option-readonly' : ''
}`}
role="treeitem"
onClick={
readonly
? undefined
: getItemAction({
isLeaf,
onExpand,
onCheck: checkItem,
unCheckable,
})
}
>
<div className={getFileContentClassName(rowCheckStatus.checked)}>
{render ? (
render()
) : (
<>
<ActionComponent
checkStatus={rowCheckStatus}
isLeaf={isLeaf}
renderExpandIcon={renderExpandIcon}
onlySelectLeaf={onlySelectLeaf}
multiple={multiple}
disableSelect={!!disableSelect}
unCheckable={unCheckable}
/>
<LabelContent
iconUrl={data.icon}
label={label}
type={type}
isLoading={isLoading}
loadingInfo={loadingInfo}
/>
</>
)}
</div>
</li>
);
};
return labelRenderer;
}

View File

@@ -0,0 +1,27 @@
/*
* 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.
*/
export {
CommonFilePicker,
type CommonFilePickerProps,
} from './components/common-file-picker';
export { appendAllAddFiles, getLeafFiles } from './utils';
export {
type PickerRef,
type FileNode,
TreeNodeType,
type FileId,
} from './types';

View File

@@ -0,0 +1,99 @@
/*
* 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 ReactNode } from 'react';
import { isObject } from 'lodash-es';
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
export enum TreeNodeType {
FILE_TEXT = 2,
FILE_TABLE = 3,
FOLDER = 1,
}
export interface PickerRef {
search?: (searchText: string) => void;
}
/**
* 文件选择树节点
*/
export interface FileNode extends TreeNodeData {
/** 独一无二的 key 标识 可以用 文件 id */
key: string;
value?: string;
label?: React.ReactNode;
type?: TreeNodeType;
// icon 的 URL
icon?: string;
children?: FileNode[];
/** 标识当前节点是不是叶子结点 loadData 时必备 */
isLeaf?: boolean;
/** 该节点是否可以选中 */
selectable?: boolean;
/** 节点的 loading 状态,开启后 loading 默认替换 icon展示 loadingInfo
* 注意这个和 semi 本身带的 loading 不一样semi 的 loading 指的是 展开的 loading 状态 */
isLoading?: boolean;
/** 节点 loading 的提示,默认是 `获取中` */
loadingInfo?: string;
/** 具体的文档类型 比如 doc docx txt 等 */
file_type?: string;
/** 三方文档链接 */
file_url?: string;
/** 【飞书场景】wiki 空间id,*/
space_id?: string;
/** 【飞书场景】wiki 叶子id,*/
obj_token?: string;
/** 自定义渲染 Item */
render?: () => ReactNode;
/** 只读,不可交互 */
readonly?: boolean;
/** 节点是否不可选择,默认为 false */
unCheckable?: boolean;
}
export type FileId = string;
/**
* 文件选择树 节点选择状态
*/
export interface FileSelectCheckStatus {
checked: boolean;
halfChecked: boolean;
}
// 三部分 当前选中的 新增选中的 较上次不选中的 较上次不变的
export type TransSelectedFilesMiddleware = (
fileNodes: FileNode[],
) => FileNode[];
export type CalcCurrentSelectFilesMiddleware = (
fileNodes: FileNode[],
addNodes?: FileNode[],
removeNodes?: FileNode[],
retainNodes?: FileNode[],
) => FileNode[];
/** 类型断言 节点是不是 fileNode */
export function isFileNode(fileNode: unknown): fileNode is FileNode {
return !!fileNode && isObject(fileNode) && !!(fileNode as FileNode).key;
}
/** 类型断言 数组是不是 fileNode 数组 */
export function isFileNodeArray(fileNodes: unknown[]): fileNodes is FileNode[] {
return fileNodes.every(fileNode => isFileNode(fileNode));
}

View File

@@ -0,0 +1,120 @@
/*
* 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 { FileNode, TransSelectedFilesMiddleware } from './types';
export const getLeafFiles: TransSelectedFilesMiddleware = (
files?: FileNode[],
) => {
if (!files || files.length === 0) {
return [];
}
const leafFiles: FileNode[] = [];
const helpQueue = Array.from(files);
while (helpQueue.length > 0) {
const curFile = helpQueue.shift();
if (
curFile?.isLeaf ||
!curFile?.children ||
curFile.children.length === 0
) {
curFile && leafFiles.push(curFile);
} else {
helpQueue.push(...curFile.children);
}
}
return leafFiles;
};
function levelMapTreeNodes<
T extends {
children?: T[];
isLeaf?: boolean;
},
>(treeNodes: T[]): T[] {
const allNode: T[] = [];
const helpRemoveQueue = Array.from(treeNodes);
while (helpRemoveQueue.length > 0) {
const curFile = helpRemoveQueue.shift();
curFile && allNode.push(curFile);
if (!curFile?.isLeaf && curFile?.children && curFile.children.length > 0) {
helpRemoveQueue.push(...curFile.children);
}
}
return allNode;
}
export function levelMapTreeNodesToMap<
T extends {
key: string;
children?: T[];
isLeaf?: boolean;
},
>(treeNodes: T[]): Map<string, T> {
const allNodeMap: Map<string, T> = new Map();
const helpRemoveQueue = Array.from(treeNodes);
while (helpRemoveQueue.length > 0) {
const curFile = helpRemoveQueue.shift();
curFile && allNodeMap.set(curFile.key, curFile);
if (!curFile?.isLeaf && curFile?.children && curFile.children.length > 0) {
helpRemoveQueue.push(...curFile.children);
}
}
return allNodeMap;
}
export const appendAllAddFiles: TransSelectedFilesMiddleware = (
files?: FileNode[],
addNodes?: FileNode[],
removeNodes?: FileNode[],
retainNodes: FileNode[] = [],
// eslint-disable-next-line max-params
) => {
if (!files || files.length === 0) {
return [];
}
if (!addNodes) {
return files;
}
const allRemoveFiles: FileNode[] = levelMapTreeNodes<FileNode>(
removeNodes ?? [],
);
const removeKeys = new Set(allRemoveFiles.map(file => file.key));
const allAddFiles: FileNode[] = levelMapTreeNodes<FileNode>(addNodes ?? []);
return [...allAddFiles, ...retainNodes].filter(
file => !removeKeys.has(file.key),
);
};
export const distinctFileNodes: TransSelectedFilesMiddleware = (
files?: FileNode[],
) => {
if (!files) {
return [];
}
const distinctFiles: FileNode[] = [];
const distinctFileKey: Set<string> = new Set();
for (const file of files) {
if (distinctFileKey.has(file.key)) {
continue;
}
distinctFileKey.add(file.key);
distinctFiles.push(file);
}
return distinctFiles;
};