feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
17
frontend/packages/data/knowledge/common/components/src/file-picker/global.d.ts
vendored
Normal file
17
frontend/packages/data/knowledge/common/components/src/file-picker/global.d.ts
vendored
Normal 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' />
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user