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,19 @@
/*
* 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 { JsonField } from './json-field';
export { Line } from './line';

View File

@@ -0,0 +1,134 @@
/*
* 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, { useMemo } from 'react';
import { last } from 'lodash-es';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconTreeTriangleRight } from '@douyinfe/semi-icons';
import { Line } from '../line';
import { type Field } from '../../types';
import { useValue } from '../../hooks/use-value';
import { useExpand } from '../../hooks';
import { LogObjSpecialKey, LogValueStyleType } from '../../constants';
import styles from './json-field.module.less';
/* JSON 类型数据渲染 */
const FieldValue: React.FC<{
value: Field['value'];
}> = ({ value }) => {
const { value: current, type } = useValue(value);
return (
<span
data-testid="json-viewer-field-value"
className={cls({
[styles['field-value-number']]: type === LogValueStyleType.Number,
[styles['field-value-boolean']]: type === LogValueStyleType.Boolean,
})}
>
{current}
</span>
);
};
const JsonField: React.FC<{ field: Field }> = ({ field }) => {
const { lines, children, path, isObj } = field;
const echoLines = useMemo(() => lines.slice(1), [lines]);
const pathStr = useMemo(() => path.join('.'), [path]);
const isError = useMemo(() => pathStr === LogObjSpecialKey.Error, [pathStr]);
const isWarning = useMemo(
() => pathStr === LogObjSpecialKey.Warning,
[pathStr],
);
const key = useMemo(() => last(path), [path]);
const keyWithColon = useMemo(() => {
if (isError) {
return I18n.t('workflow_detail_testrun_error_front');
}
if (isWarning) {
return I18n.t('workflow_detail_testrun_warning_front');
}
return key ? `${key} : ` : '';
}, [key, isError, isWarning]);
const { expand, onChange } = useExpand(path.join('.'));
return (
<>
<div className={styles['json-viewer-field']}>
{echoLines.map((l, idx) => (
<Line status={l} key={idx} />
))}
<div
data-testid="json-viewer-field-content"
className={cls('field-content', styles['field-content'], {
[styles['is-error']]: isError,
[styles['is-warning']]: isWarning,
})}
onClick={isObj ? onChange : undefined}
>
{isObj ? (
<>
<span
data-testid="json-viewer-json-field-expander"
className={cls('field-icon', styles['field-icon'], {
[styles.expand]: expand,
})}
>
<IconTreeTriangleRight size="inherit" />
</span>
<span className={cls('field-key', styles['field-key'])}>
{key}
</span>
<span className={cls('field-len', styles['field-len'])}>
{` {${children.length}}`}
</span>
</>
) : (
<>
<span
className={cls('field-block', styles['field-block'])}
></span>
{keyWithColon ? (
<span className={cls('field-key', styles['field-key'])}>
{keyWithColon}
</span>
) : null}
<span
className={cls('field-value', styles['field-value'], {
'whitespace-pre-wrap': !isObj,
})}
>
<FieldValue value={field.value} />
</span>
</>
)}
</div>
</div>
{expand
? children.map(i => <JsonField field={i} key={i.path.join('.')} />)
: null}
</>
);
};
export { JsonField };

View File

@@ -0,0 +1,59 @@
.json-viewer-field {
font-size: 14px;
font-weight: 400;
line-height: 20px;
display: flex;
.field-content {
flex: 1 1 0;
padding: 2px 0;
user-select: auto;
&.is-error .field-key,
&.is-error .field-value {
color: #FF441E;
}
&.is-warning .field-key,
&.is-warning .field-value {
color: #FF9600;
}
.field-value-number {
color: #03B6D0;
}
.field-value-boolean {
color: #BB2BC9;
}
}
.field-icon {
vertical-align: middle;
font-size: 12px;
color: #4D53E8;
margin-right: 4px;
cursor: pointer;
&.expand>span {
transform: rotate(90deg);
}
}
.field-block {
padding-left: 16px;
}
.field-key {
color: #4D53E8;
word-break: break-word;
}
.field-value {
overflow-wrap: anywhere;
}
.field-len {
color: rgba(29, 28, 35, 0.35);
}
}

View File

@@ -0,0 +1,58 @@
.json-viewer-line {
position: relative;
// 一般来说一行高度为 24px但由于支持换行所以这里只能自适应因此 line 不再具有撑高元素的能力,需要在外部去设置 minHeight
// height: 100%;
width: 24px;
display: inline-block;
// 不能被压缩也不能扩展,防止样式错乱
flex-grow: 0;
flex-shrink: 0;
&::before,
&::after {
content: "";
position: absolute;
}
// 竖线
&::before {
top: 0;
// 左边留 6px 用于和顶部 icon 对齐
left: 6px;
width: 0;
// 这里同理,集成父元素的高度
height: 100%;
border-left: 1px solid transparent;
}
// 横线
&::after {
top: 5px;
left: 6px;
width: 12px;
height: 6px;
border-bottom: 1px solid transparent;
border-left: 1px solid transparent;
border-bottom-left-radius: 6px;
}
&.visible::before,
&.visible::after,
&.last::before,
&.last::after {
border-color: #C6C6CD;
}
// 最后的元素不需要延伸的竖线,缩短竖线
&.last::before {
height: 6px;
}
&.half::before {
border-color: #C6C6CD;
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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 cls from 'classnames';
import { LineStatus } from '../../types';
import styles from './index.module.less';
export const Line: React.FC<{ status: LineStatus }> = ({ status }) => (
<div
className={cls(styles['json-viewer-line'], {
[styles.hidden]: status === LineStatus.Hidden,
[styles.visible]: status === LineStatus.Visible,
[styles.half]: status === LineStatus.Half,
[styles.last]: status === LineStatus.Last,
})}
></div>
);

View File

@@ -0,0 +1,44 @@
/*
* 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 { useMemo, useState } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Typography } from '@coze-arch/bot-semi';
import { generateStrAvoidEscape } from '../utils/generate-str-avoid-escape';
export const MAX_LENGTH = 10000;
export const LongStrValue: React.FC<{ str: string }> = ({ str }) => {
const [more, setMore] = useState(false);
const echoStr = useMemo(() => {
const current = more ? str : str.slice(0, MAX_LENGTH);
return generateStrAvoidEscape(current);
}, [str, more]);
return (
<>
{echoStr}
{!more && (
<Typography.Text link onClick={() => setMore(true)}>
{I18n.t('see_more')}
</Typography.Text>
)}
</>
);
};

View File

@@ -0,0 +1,39 @@
/*
* 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 cls from 'classnames';
const TextField: React.FC<{ text: string }> = ({ text }) => {
const paragraphs = text.split('\n');
return (
<div className={'flex'}>
<div className={cls('select-auto', 'py-[2px] px-0', 'text-sm')}>
{paragraphs.map(paragraph => (
<div className="pl-4" data-testid="json-viewer-text-field-paragraph">
<span className={'whitespace-pre-wrap'}>
<span>{paragraph}</span>
</span>
</div>
))}
</div>
</div>
);
};
export { TextField };

View File

@@ -0,0 +1,28 @@
/*
* 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.
*/
/** 预置的特殊的 key */
export enum LogObjSpecialKey {
Error = '$error',
Warning = '$warning',
}
/** log 中 value 的显示样式类型 */
export enum LogValueStyleType {
Default,
Number,
Boolean,
}

View File

@@ -0,0 +1,100 @@
/*
* 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, {
useCallback,
useState,
type PropsWithChildren,
useEffect,
} from 'react';
import { createContext } from 'use-context-selector';
import { noop } from 'lodash-es';
import { type Field } from './types';
interface JsonViewerContextType {
expand: Record<string, boolean> | null;
onExpand: (path: string, val: boolean) => void;
}
interface JsonViewerProviderProps {
fields: Field[];
defaultExpandAllFields?: boolean;
}
/**
* 根只有一项且其可以下钻时,默认展开它
*/
const generateInitialExpandValue = (fields: Field[], expandAll?: boolean) => {
if (expandAll) {
return setExpandAllFields(fields);
}
if (fields.length === 1 && fields[0]?.isObj) {
return {
[fields[0].path.join('.')]: true,
};
}
return null;
};
const setExpandAllFields = (fields: Field[]) =>
fields.reduce(
(acc, field) => ({
...acc,
[field.path.join('.')]: true,
...setExpandAllFields(field.children),
}),
{},
);
export const JsonViewerContext = createContext<JsonViewerContextType>({
expand: {},
onExpand: noop,
});
export const JsonViewerProvider: React.FC<
PropsWithChildren<JsonViewerProviderProps>
> = ({ fields, children, defaultExpandAllFields }) => {
/** 因为存在不属于单项的逻辑,所以集中管理展开折叠的状态 */
const [expand, setExpand] = useState<JsonViewerContextType['expand'] | null>(
null,
);
const handleExpand = useCallback(
(path: string, val: boolean) => setExpand(e => ({ ...e, [path]: val })),
[setExpand],
);
/**
* fields 是动态更新的,这里要注意固化 expand 数据,因为 fields 总是由少增多
* 由于存在自动展开逻辑,所以从 0 => 1 变化时需要赋值
*/
useEffect(() => {
if (!expand) {
const autoExpand = generateInitialExpandValue(
fields,
defaultExpandAllFields,
);
if (autoExpand) {
setExpand(autoExpand);
}
}
}, [expand, fields, setExpand, defaultExpandAllFields]);
return (
<JsonViewerContext.Provider value={{ expand, onExpand: handleExpand }}>
{children}
</JsonViewerContext.Provider>
);
};

View File

@@ -0,0 +1,20 @@
/*
* 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.
*/
declare module '*.less' {
const resource: { [key: string]: string };
export = resource;
}

View File

@@ -0,0 +1,18 @@
/*
* 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 { useExpand } from './use-expand';
export { useValue } from './use-value';

View File

@@ -0,0 +1,36 @@
/*
* 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 { useCallback } from 'react';
import { useContextSelector } from 'use-context-selector';
import { JsonViewerContext } from '../context';
export const useExpand = (path: string) => {
const expand = useContextSelector(
JsonViewerContext,
v => v.expand?.[path] || false,
);
const setExpand = useContextSelector(JsonViewerContext, v => v.onExpand);
const handleExpandChange = useCallback(() => {
setExpand(path, !expand);
}, [path, expand, setExpand]);
return {
expand,
onChange: handleExpandChange,
};
};

View File

@@ -0,0 +1,82 @@
/*
* 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 { useMemo } from 'react';
import { isBoolean, isNull, isNumber, isObject, isString } from 'lodash-es';
import { isBigNumber, bigNumbertoString } from '../utils/big-number';
import { generateStrAvoidEscape } from '../utils';
import { type Field } from '../types';
import { LogValueStyleType } from '../constants';
import { LongStrValue, MAX_LENGTH } from '../components/long-str-value';
export const useValue = (value: Field['value']) => {
const v = useMemo(() => {
if (isNull(value)) {
return {
value: 'null',
type: LogValueStyleType.Default,
};
} else if (isObject(value)) {
// 大数字返回数字类型,值用字符串
if (isBigNumber(value)) {
return {
value: bigNumbertoString(value),
type: LogValueStyleType.Number,
};
}
return {
value: '',
type: LogValueStyleType.Default,
};
} else if (isBoolean(value)) {
return {
value: value.toString(),
type: LogValueStyleType.Boolean,
};
} else if (isString(value)) {
if (value === '') {
return {
value: '""',
type: LogValueStyleType.Default,
};
}
if (value.length > MAX_LENGTH) {
return {
value: <LongStrValue str={value} />,
type: LogValueStyleType.Default,
};
}
return {
value: generateStrAvoidEscape(value),
// value: generateStr2Link(value, avoidEscape), 先取消做 link 解析
type: LogValueStyleType.Default,
};
} else if (isNumber(value)) {
return {
value,
type: LogValueStyleType.Number,
};
}
return {
value,
type: LogValueStyleType.Default,
};
}, [value]);
return v;
};

View File

@@ -0,0 +1,30 @@
.json-viewer-wrapper {
user-select: text;
overflow-y: auto;
width: 100%;
min-height: 32px;
/** 高度限制 */
max-height: 272px;
padding: 6px 12px;
background: rgba(46, 46, 56, 4%);
border: 1px solid rgba(29, 28, 35, 8%);
border-radius: 8px;
&::-webkit-scrollbar {
width: 6px;
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(29, 28, 35, 30%);
border-radius: 6px;
&:hover {
background: rgba(29, 28, 35, 60%);
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* 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 { isNil, isString } from 'lodash-es';
import cls from 'classnames';
import { generateFields } from './utils/generate-field';
import type { JsonValueType } from './types';
import { JsonViewerProvider } from './context';
import { TextField } from './components/text-field';
import { JsonField } from './components';
import styles from './index.module.less';
export type { JsonValueType };
export interface JsonViewerProps {
/** 支持对象或者纯文本渲染 */
data: JsonValueType;
className?: React.HTMLAttributes<HTMLDivElement>['className'];
/** 默认展开所有字段 */
defaultExpandAllFields?: boolean;
}
export const JsonViewer: React.FC<JsonViewerProps> = ({
data,
className,
defaultExpandAllFields,
}) => {
const render = () => {
// 兜底展示 null
if (isNil(data)) {
return (
<JsonField
field={{
path: [],
lines: [],
value: 'Null',
isObj: false,
children: [],
}}
key={'Null'}
/>
);
}
// 文本类结果展示
const isStr = isString(data);
if (isStr) {
return <TextField text={data} />;
}
// 其他json类型数据展示
const fields = generateFields(data);
return (
<JsonViewerProvider
fields={fields}
defaultExpandAllFields={defaultExpandAllFields}
>
{fields.map(i => (
<JsonField field={i} key={i.path.join('.')} />
))}
</JsonViewerProvider>
);
};
return (
<div
data-testid="json-viewer-wrapper"
className={cls(styles['json-viewer-wrapper'], className)}
draggable
onDragStart={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{render()}
</div>
);
};
export { LogObjSpecialKey, LogValueStyleType } from './constants';

View File

@@ -0,0 +1,50 @@
/*
* 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.
*/
/*******************************************************************************
* log 相关的类型
*/
/** 线可能存在的几种状态 */
export enum LineStatus {
/** 完全隐藏,最后一个父属性嵌套的子属性同列将不会有线 */
Hidden,
/** 完全显示,仅出现在属性相邻的线 */
Visible,
/** 半显示,非相邻的线 */
Half,
/** 最后属性的相邻线 */
Last,
}
/** JsonViewer 中的 value 可能值 */
export type JsonValueType =
| string
| null
| number
| object
| boolean
| undefined;
export interface Field {
/** 使用数组而不是 'a.b.c' 是因为可能存在 key='a.b' 会产生错误嵌套 */
path: string[];
lines: LineStatus[];
/** 这里 value 可能是任意值,这里是不完全枚举 */
value: JsonValueType;
children: Field[];
/** 是否是可下钻的对象(包含数组) */
isObj: boolean;
}

View File

@@ -0,0 +1,35 @@
/*
* 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 BigNumber from 'bignumber.js';
/**
* 是不是大数字
* @param value
* @returns
*/
export function isBigNumber(value: unknown): value is BigNumber {
return !!(value && value instanceof BigNumber);
}
/**
* 大数字转字符串
* @param value
* @returns
*/
export function bigNumbertoString(value: BigNumber): string {
return value.toFixed();
}

View File

@@ -0,0 +1,104 @@
/*
* 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 { isObject } from 'lodash-es';
import { LineStatus, type JsonValueType, type Field } from '../types';
import { isBigNumber } from './big-number';
/**
* 通过父元素的线条状态推到子元素的线条状态
*/
const getLineByParent2Child = (pLine: LineStatus): LineStatus => {
switch (pLine) {
/** 表示父节点也是从父父节点下钻而来,此处的子节点只需要把线延续下去即可 */
case LineStatus.Visible:
return LineStatus.Half;
/** 表示父节点是父父节点的最后一个节点,子节点无需再延续,渲染空白即可 */
case LineStatus.Last:
return LineStatus.Hidden;
/** 其他的情况完全继承父节点的线 */
default:
return pLine;
}
};
/**
* 将 object 解析成可以循环渲染的 fields
* 1. 若 object 非复杂类型,则返回长度为 1 的 fields 只渲染一项
* 2. 若 object = {},则返回长度为 0 的 fields渲染层需要做好兜底
*/
const generateFields = (object: JsonValueType): Field[] => {
/** 若 object 非复杂类型 */
if (!isObject(object) || isBigNumber(object)) {
return [
{
path: [],
lines: [],
value: object,
isObj: false,
children: [],
},
];
}
/** 递归计算时缓存一下计算好的线,没别的意义,降低一些时间复杂度 */
const lineMap = new Map<string[], LineStatus[]>();
/** 递归解析 object 为 fields */
const dfs = ($object: object, $parentPath: string[] = []): Field[] => {
// 如果不是对象,直接返回空数组,兜底异常情况
if (!isObject($object)) {
return [];
}
// 如果是大数字,直接返回空数组
if (isBigNumber($object)) {
return [];
}
const parentLines = lineMap.get($parentPath) || [];
const keys = Object.keys($object);
return keys.map((key, idx) => {
const value = $object[key];
const path = $parentPath.concat(key);
const last = idx === keys.length - 1;
/**
* 根据父节点的线推导子节点的线
*/
const lines = parentLines
.map<LineStatus>(getLineByParent2Child)
/**
* 最后拼接上子节点自己的线,最后一个节点和普通的节点有样式区分
*/
.concat(last ? LineStatus.Last : LineStatus.Visible);
lineMap.set(path, lines);
return {
path,
lines,
value,
children: dfs(value, path),
isObj: isObject(value) && !isBigNumber(value),
};
});
};
return dfs(object);
};
export { generateFields };

View File

@@ -0,0 +1,33 @@
/*
* 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 generateStrAvoidEscape = (str: string) => {
const characters = {
'\\': '\\\\',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
};
let next = '';
for (let i = 0; i < str.length; i++) {
const char = str[i];
next += characters[char] || char;
}
return next;
};

View File

@@ -0,0 +1,79 @@
/*
* 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 { isString } from 'lodash-es';
import { Typography } from '@coze-arch/bot-semi';
import { generateStrAvoidEscape } from './generate-str-avoid-escape';
const { Text } = Typography;
export const generateStr2Link = (str: string, avoidEscape?: boolean) => {
if (str === '') {
return [''];
}
if (avoidEscape) {
str = generateStrAvoidEscape(str);
}
/**
* 更严格的 url 匹配规则,防止过度匹配
* 协议http、https
* 域名:允许使用 -、a-z、A-Z、0-9其中 - 不能开头,每一级域名长度不会超过 63
* 端口:支持带端口 0 - 65535
* URL严格型不匹配中文等转译前的文字否则一旦命中将会识别整段字符串
*/
const urlReg = new RegExp(
'http(s)?://' +
'[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+' +
'(:[0-9]{1,5})?' +
'[-a-zA-Z0-9()@:%_\\+.~#?&//=]*',
'g',
);
const matches = [...str.matchAll(urlReg)];
/**
* 切割字符串url 嵌套为 link 的样式,切割步骤:
* 1. 匹配字符串中所有的 url
* 2. 倒序 matches从末尾开始切原因是 match.index 是从头开始计数,从头切增加计算量
* 3. 每一个 match 切两刀成三段,头尾是普通字符串,中间为 url
* 4. 按照 end、url、start 的顺序 push 到栈中,下次 match 会直接取 start 继续切
* 5. 切割完成后做一次倒序
*/
return matches
.reverse()
.reduce<ReactNode[]>(
(nodes, match) => {
const lastNode = nodes.pop();
if (!isString(lastNode)) {
return nodes.concat(lastNode);
}
const startIdx = match.index || 0;
const endIdx = startIdx + match[0].length;
const startStr = lastNode.slice(0, startIdx);
const endStr = lastNode.slice(endIdx);
return nodes.concat(
endStr,
<Text link={{ href: match[0], target: '_blank' }}>{match[0]}</Text>,
startStr,
);
},
[str],
)
.reverse();
};

View File

@@ -0,0 +1,19 @@
/*
* 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 { generateFields } from './generate-field';
export { generateStr2Link } from './generate-str-to-link';
export { generateStrAvoidEscape } from './generate-str-avoid-escape';