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,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 { type Ref, forwardRef, type FC } from 'react';
import { type ButtonProps } from '@coze-arch/bot-semi/Button';
import { Button } from '@coze-arch/bot-semi';
export type BotDebugButtonProps = ButtonProps & {
readonly: boolean;
};
export const BotDebugButton: FC<BotDebugButtonProps> = forwardRef(
(props: BotDebugButtonProps, ref: Ref<Button>) => {
const { readonly, ...rest } = props;
if (readonly) {
return null;
}
return <Button {...rest} ref={ref} />;
},
);

View File

@@ -0,0 +1,10 @@
/* stylelint-disable declaration-no-important */
.processing-tag-process {
color: var(--Light-color-green---green-6, #32A247) !important;
background: #D2F3D5 !important;
}
.processing-tag-failed {
color: var(--Light-color-red---red-6, #DB2E13) !important;
background: #FFE0D2 !important;
}

View File

@@ -0,0 +1,58 @@
/*
* 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 FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Tag, Tooltip } from '@coze-arch/bot-semi';
import { useUploadProgress } from '../../hooks/use-upload-progress';
import { ImportFileTaskStatus } from '../../datamodel';
import styles from './index.module.less';
export interface ProcessingTagProps {
tableID: string;
botID: string;
}
export const ProcessingTag: FC<ProcessingTagProps> = props => {
const { tableID, botID } = props;
const progressInfo = useUploadProgress({ tableID, botID });
if (progressInfo?.status === ImportFileTaskStatus.Enqueue) {
return (
<Tag className={styles['processing-tag-process']}>
{I18n.t('db_table_0126_031')}: {progressInfo.progress}%
</Tag>
);
}
if (progressInfo?.status === ImportFileTaskStatus.Failed) {
return (
<Tooltip content={progressInfo?.errorMessage}>
<Tag className={styles['processing-tag-failed']}>
{I18n.t('db_table_0126_031')}
</Tag>
</Tooltip>
);
}
if (progressInfo?.status === ImportFileTaskStatus.Succeed) {
return null;
}
return null;
};

View File

@@ -0,0 +1,24 @@
.common-svg-icon(@size: 14px, @color: #3370ff) {
>svg {
width: @size;
height: @size;
>path {
fill: @color;
}
}
}
.text {
margin: 24px 0 17px;
font-size: 14px;
font-weight: 600;
font-style: normal;
line-height: 22px;
color: var(--light-usage-text-color-text-0, #1c1f23);
}
.progress-success-icon {
.common-svg-icon(20px, var(--semi-color-primary));
}

View File

@@ -0,0 +1,87 @@
/*
* 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, type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconSvgUploadCompletedIcon } from '@coze-arch/bot-icons';
import { useStepStore } from '../../store/step';
import { useInitialConfigStore } from '../../store/initial-config';
import outerStyles from '../../index.module.less';
import { useUploadProgress } from '../../hooks/use-upload-progress';
import { ImportFileTaskStatus } from '../../datamodel';
import { UploadProgress } from './upload-progress';
import styles from './index.module.less';
export const Processing: FC = () => {
const { currentState, tableStructure, upload } = useStepStore(state => ({
currentState: state.step4_processing,
setCurrentState: state.set_step4_processing,
tableStructure: state.step2_tableStructure,
upload: state.step1_upload,
}));
const { botId } = useInitialConfigStore(state => ({
botId: state.botId,
}));
const { fileList } = upload;
const { tableValue } = tableStructure;
const { tableID } = currentState;
// @ts-expect-error -- linter-disable-autofix
const progressInfo = useUploadProgress({ tableID, botID: botId });
const headerTitle = useMemo(() => {
let msg: string = I18n.t('db_table_0126_029');
if (progressInfo?.status === ImportFileTaskStatus.Succeed) {
msg = I18n.t('datasets_createFileModel_step4_Finish');
} else if (progressInfo?.status === ImportFileTaskStatus.Failed) {
msg = I18n.t('datasets_createFileModel_step4_failed');
}
return msg;
}, [progressInfo?.status]);
return (
<div className={outerStyles.stepWrapper}>
<div className={styles.text}>{headerTitle}</div>
<div className={styles['progress-list']}>
<UploadProgress
// @ts-expect-error -- linter-disable-autofix
key={fileList[0].response.upload_uri}
className={styles['dataset-progress']}
// @ts-expect-error -- linter-disable-autofix
text={tableValue.name}
percent={progressInfo?.progress || 0}
status={progressInfo?.status || ImportFileTaskStatus.Enqueue}
statusDesc={''}
format={percent =>
percent < 100 ? (
`${percent}%`
) : (
<IconSvgUploadCompletedIcon
className={styles['progress-success-icon']}
/>
)
}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,75 @@
.dataset-progress-wrap {
position: relative;
margin-bottom: 24px;
.dataset-progress {
display: flex;
align-items: center;
justify-content: space-between;
.dataset-progress-content {
position: relative;
overflow: hidden;
flex: 1;
background-color: rgba(46, 50, 56, 5%);
border-radius: 8px;
.progress-bar {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
height: 32px;
background-color: var(--semi-color-primary);
border-radius: 8px;
transition: width linear 300ms;
}
.text {
box-sizing: border-box;
height: 32px;
padding: 0 12px;
font-size: 14px;
line-height: 32px;
word-break: keep-all;
}
}
.dataset-progress-format {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
box-sizing: border-box;
width: 48px;
padding-right: 6px;
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: rgba(28, 31, 35, 60%);
}
}
.dataset-progress-error {
position: absolute;
top: 33px;
left: 4px;
font-size: 12px;
color: rgba(255, 39, 16, 100%)
}
}
.dataset-progress-wrap:last-child {
margin-bottom: 0;
}

View File

@@ -0,0 +1,88 @@
/*
* 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 { Typography } from '@coze-arch/bot-semi';
import { ImportFileTaskStatus } from '../../../datamodel';
import s from './index.module.less';
interface DatasetProgressProps {
className?: string | undefined;
style?: React.CSSProperties;
text: string;
percent: number;
format?: (percent: number) => React.ReactNode;
status?: ImportFileTaskStatus;
statusDesc?: string;
}
export const UploadProgress: React.FC<DatasetProgressProps> = ({
className,
style,
text,
percent,
format,
status,
statusDesc,
}) => {
const SUCCESSIVE_PROCESSING = 100;
return (
<div className={`${s['dataset-progress-wrap']}`}>
<div className={`${s['dataset-progress']} ${className}`} style={style}>
<div className={s['dataset-progress-content']}>
<Typography.Text
className={s.text}
strong
ellipsis={{
showTooltip: {
opts: { content: text },
},
}}
style={{ color: '#4D53E8' }}
>
{text}
</Typography.Text>
<div className={s['progress-bar']} style={{ width: `${percent}%` }}>
<Typography.Text
className={s.text}
strong
ellipsis={
percent === SUCCESSIVE_PROCESSING
? {
showTooltip: {
opts: { content: text },
},
}
: false
}
style={{ color: '#fff', width: '100%' }}
>
{text}
</Typography.Text>
</div>
</div>
<div className={s['dataset-progress-format']}>{format?.(percent)}</div>
</div>
{status && statusDesc && status === ImportFileTaskStatus.Failed ? (
<div className={s['dataset-progress-error']}>
<span>{statusDesc}</span>
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,17 @@
.table-preview-tips {
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
color: var(--Light-usage-text---color-text-3, rgba(29, 28, 35, 35%));
text-align: right;
/* 133.333% */
letter-spacing: 0.12px;
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
}

View File

@@ -0,0 +1,139 @@
/*
* 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, type FC } from 'react';
import { isNumber } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/bot-semi';
import { Step } from '../../types';
import { useStepStore } from '../../store/step';
import { useInitialConfigStore } from '../../store/initial-config';
import { useStep } from '../../hooks/use-step';
import styles from './index.module.less';
export const StepFooter: FC = () => {
const { getCallbacks } = useStep();
const { step, enableGoToNextStep, tablePreview } = useStepStore(state => ({
step: state.step,
enableGoToNextStep: state.enableGoToNextStep,
tablePreview: state.step3_tablePreview,
}));
const { onCancel } = useInitialConfigStore(state => ({
onCancel: state.onCancel,
}));
const stepList = Object.values(Step).filter(i => isNumber(i)) as Step[];
const firstStep = Math.min(...stepList);
const lastStep = Math.max(...stepList);
const isFirstStep = step === firstStep;
const isLastStep = step === lastStep;
const [submitButtonLoading, setSubmitButtonLoading] = useState(false);
const { previewData } = tablePreview;
const total = previewData?.total_rows || 0;
const previewCount = previewData?.preview_rows || 10;
const handleClickNext = async () => {
const { onValidate, onSubmit } = getCallbacks();
// onValidate
try {
const callbackResult = onValidate?.();
if (callbackResult instanceof Promise) {
setSubmitButtonLoading(true);
}
const res = await callbackResult;
// 返回 false 则直接 return
if (typeof res === 'boolean' && res === false) {
setSubmitButtonLoading(false);
return;
}
} catch (e) {
setSubmitButtonLoading(false);
throw e;
}
// onSubmit
try {
// 判断传入的 submit 函数如果是异步,则按钮 loading
const callbackResult = onSubmit?.();
if (callbackResult instanceof Promise) {
setSubmitButtonLoading(true);
}
await callbackResult;
if (isLastStep) {
//关闭
onCancel?.();
} else {
// 下一步
useStepStore.setState(state => ({
step: Math.min(state.step + 1, lastStep),
}));
}
} finally {
setSubmitButtonLoading(false);
}
};
const handleClickPrev = () => {
getCallbacks()?.onPrevious?.();
if (isFirstStep) {
// 关闭
onCancel?.();
} else {
// 上一步
useStepStore.setState(state => ({
step: Math.max(state.step - 1, firstStep),
}));
}
};
return (
<div className={styles.footer}>
{step === Step.Step3_TablePreview ? (
<div className={styles['table-preview-tips']}>
{I18n.t('db_table_0126_028', {
TotalRows: total,
ShowRows: previewCount,
})}
</div>
) : null}
{isLastStep ? null : (
<Button type="tertiary" onClick={onCancel}>
{I18n.t('db_table_0126_001')}
</Button>
)}
{isFirstStep || isLastStep ? null : (
<Button type="tertiary" onClick={handleClickPrev}>
{I18n.t('db_table_0126_004')}
</Button>
)}
<Button
theme="solid"
type="primary"
onClick={handleClickNext}
loading={submitButtonLoading}
disabled={!enableGoToNextStep}
>
{isLastStep ? I18n.t('db_table_0126_005') : I18n.t('db_table_0126_003')}
</Button>
</div>
);
};

View File

@@ -0,0 +1,42 @@
/* stylelint-disable declaration-no-important */
.table-preview-table-wrapper {
min-height: 500px;
}
.table-preview-table {
overflow: hidden;
flex: none;
background: var(--light-color-white-white, #FFF);
:global {
.semi-table-body {
max-height: 460px !important;
}
.semi-table-colgroup .semi-table-col {
min-width: 200px;
}
.semi-table-header {
overflow-y: hidden !important;
}
.semi-table-tbody>.semi-table-row>.semi-table-row-cell {
min-height: 40px;
padding: 9px 16px !important;
font-size: 14px;
}
}
}
.table-preview-tips {
margin-top: 16px;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: #1C1D2399;
text-align: left;
letter-spacing: 0;
}

View File

@@ -0,0 +1,183 @@
/*
* 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 FC } from 'react';
import classnames from 'classnames';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { type ColumnProps } from '@coze-arch/bot-semi/Table';
import { UITable } from '@coze-arch/bot-semi';
import { useStepStore } from '../../store/step';
import { useInitialConfigStore } from '../../store/initial-config';
import outerStyles from '../../index.module.less';
import { useStep } from '../../hooks/use-step';
import { type AddTableResponse } from '../../datamodel';
import { CreateType } from '../../../../types';
import styles from './index.module.less';
export const TablePreview: FC = () => {
const { onSubmit } = useStep();
const {
botId,
onSave,
// spaceId
} = useInitialConfigStore(state => ({
botId: state.botId,
onSave: state.onSave,
spaceId: state.spaceId,
}));
const {
currentState,
setProcessing,
// upload,
tableStructure,
} = useStepStore(state => ({
currentState: state.step3_tablePreview,
setProcessing: state.set_step4_processing,
tableStructure: state.step2_tableStructure,
upload: state.step1_upload,
}));
const { previewData } = currentState;
// @ts-expect-error -- linter-disable-autofix
const { headers, datas } = previewData;
// const { fileList } = upload;
const {
// excelValue,
tableValue,
} = tableStructure;
/**
* headerPart: {
* 0: 'name',
* 1: 'age',
* }
*/
const headerPart = headers.reduce<Record<number, string>>(
(acc, cur, index) => {
acc[index] = cur;
return acc;
},
{},
);
/**
* dataPart: [
* {
* 0:'Nick',
* 1: 20
* },
* {
* 0:'July',
* 1: 30
* }
* ]
*/
const dataPart = datas.map(i =>
// @ts-expect-error -- linter-disable-autofix
i.reduce<Record<number, string>>((acc, cur, index) => {
acc[index] = cur;
return acc;
}, {}),
);
// @ts-expect-error -- linter-disable-autofix
const columns: ColumnProps[] = Object.entries(headerPart).map(
([key, value]) => ({
title: value,
dataIndex: key,
}),
);
onSubmit(async () => {
sendTeaEvent(EVENT_NAMES.create_table_click, {
need_login: true,
have_access: true,
bot_id: botId,
// @ts-expect-error -- linter-disable-autofix
table_name: tableValue.name,
database_create_type: CreateType.excel,
});
let res: AddTableResponse;
try {
// TODO:此需求暂停,后端下线,后续待开放
// res = await DataModelApi.AddTable({
// file: {
// tos_uri: fileList[0].response.upload_uri,
// sheet_id: excelValue.sheetID,
// header_row: excelValue.headerRow,
// start_data_row: excelValue.dataStartRow,
// },
// table: {
// bot_id: botId,
// space_id: spaceId,
// table_name: tableValue.name,
// table_desc: tableValue.desc,
// table_meta: tableValue.tableMemoryList.map(i => ({
// name: i.name,
// desc: i.desc,
// type: i.type,
// must_required: i.must_required,
// sequence: i.id as string,
// })),
// },
// rw_mode: tableValue.readAndWriteMode as any as BotTableRWMode,
// });
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseAddFromExcel,
error: error as Error,
});
throw error;
}
// @ts-expect-error -- linter-disable-autofix
if (res) {
if (onSave) {
await onSave({
response: res,
// @ts-expect-error -- linter-disable-autofix
stateData: tableValue,
});
}
setProcessing({
tableID: res.table_id as string,
});
}
});
return (
<div
className={classnames(outerStyles.stepWrapper, styles['table-preview'])}
>
<UITable
tableProps={{
columns,
dataSource: dataPart,
pagination: false,
className: styles['table-preview-table'],
}}
wrapperClassName={styles['table-preview-table-wrapper']}
/>
</div>
);
};

View File

@@ -0,0 +1,50 @@
.table-structure {
display: flex;
flex-direction: column;
:global {
.semi-form-horizontal .semi-form-field {
flex: 1;
.semi-select {
width: 100%;
border-radius: 8px;
}
}
.semi-form-field-label-text {
font-size: 14px;
font-weight: 600;
font-style: normal;
line-height: 20px;
color: #1D1C23;
}
.semi-form-field-label {
margin-bottom: 10px;
}
.semi-form-horizontal .semi-form-field:last-child {
margin-right: 0;
padding-right: 0;
}
}
}
.excel-info-form {
width: 100%;
margin-bottom: 12px;
}
.table-setting-option {
:global {
.semi-select-option-selected {
.semi-select-option-icon {
color: #4D53E8
}
}
}
}

View File

@@ -0,0 +1,350 @@
/*
* 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 { useRef, type FC, useState, useMemo } from 'react';
import { nanoid } from 'nanoid';
import classnames from 'classnames';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { Form } from '@coze-arch/bot-semi';
import {
ColumnType,
type GetTableSchemaInfoResponse,
type FieldItemType,
} from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import { type ExcelValue } from '../../types';
import { useStepStore } from '../../store/step';
import { useInitialConfigStore } from '../../store/initial-config';
import outerStyles from '../../index.module.less';
import { useStep } from '../../hooks/use-step';
import { type PreviewTableFileResponse } from '../../datamodel';
import {
DatabaseTableStructure,
type DatabaseTableStructureRef,
} from '../../../database-table-structure';
import { type TableFieldsInfo, CreateType } from '../../../../types';
import styles from './index.module.less';
export const TableStructure: FC = () => {
const { onSubmit, onValidate, computingEnableGoToNextStep, onPrevious } =
useStep();
const {
botId,
maxColumnNum,
// spaceId
} = useInitialConfigStore(state => ({
botId: state.botId,
maxColumnNum: state.maxColumnNum,
spaceId: state.spaceId,
}));
const { currentState, setCurrentState, upload, setTablePreview } =
useStepStore(state => ({
currentState: state.step2_tableStructure,
setCurrentState: state.set_step2_tableStructure,
setTablePreview: state.set_step3_tablePreview,
upload: state.step1_upload,
}));
const { excelBasicInfo, excelValue, tableValue } = currentState;
const { fileList } = upload;
const [loading, setLoading] = useState(false);
// ref
const excelInfoFormRef = useRef<Form<ExcelValue>>(null);
const tableFormRef = useRef<DatabaseTableStructureRef>(null);
// options
// @ts-expect-error -- linter-disable-autofix
const currentSheet = excelBasicInfo.find(i => i.id === excelValue.sheetID);
// @ts-expect-error -- linter-disable-autofix
const sheetOptions = excelBasicInfo.map(i => ({
label: i.sheet_name,
value: i.id,
}));
const currentSheetTotalRow = useMemo(() => {
// @ts-expect-error -- linter-disable-autofix
let totalRow = currentSheet.total_row;
if (totalRow < 2) {
totalRow = 2;
}
if (totalRow > 50) {
totalRow = 50;
}
return totalRow;
// @ts-expect-error -- linter-disable-autofix
}, [currentSheet.total_row]);
const headerRowOptions = new Array(currentSheetTotalRow - 1)
.fill(0)
.map((_, i) => ({
label: I18n.t('datasets_createFileModel_tab_dataStarRow_value', {
LineNumber: i + 1,
}),
value: i + 1,
}));
const dataStartRowOptions = new Array(currentSheetTotalRow)
.fill(0)
.map((_, i) => ({
label: I18n.t('datasets_createFileModel_tab_dataStarRow_value', {
LineNumber: i + 1,
}),
value: i + 1,
}))
// @ts-expect-error -- linter-disable-autofix
.filter(i => i.value >= excelValue.headerRow + 1);
onSubmit(() => {
const tableBasicValue =
// @ts-expect-error -- linter-disable-autofix
tableFormRef.current.tableBasicInfoFormRef.current.formApi.getValues();
let res: PreviewTableFileResponse;
try {
// TODO:此需求暂停,后端下线,后续待开放
// res = await DataModelApi.PreviewTableFile({
// file: {
// tos_uri: fileList[0].response.upload_uri,
// sheet_id: excelValue.sheetID,
// header_row: excelValue.headerRow,
// start_data_row: excelValue.dataStartRow,
// },
// table: {
// space_id: spaceId,
// bot_id: botId,
// table_name: tableBasicValue.name,
// table_desc: tableBasicValue.desc,
// table_meta: tableFormRef.current.tableFieldsList.map(i => ({
// name: i.name,
// desc: i.desc,
// type: i.type,
// must_required: i.must_required,
// sequence: i.id as string,
// })),
// },
// });
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseGetPreviewData,
error: error as Error,
});
throw error;
}
// @ts-expect-error -- linter-disable-autofix
if (res) {
setTablePreview({
previewData: res.preview_data,
});
setCurrentState({
tableValue: {
// @ts-expect-error -- linter-disable-autofix
tableMemoryList: tableFormRef.current.tableFieldsList,
...tableBasicValue,
// @ts-expect-error -- linter-disable-autofix
tableId: tableValue.tableId,
},
});
}
});
// 上一步保存当前状态
onPrevious(() => {
const tableBasicValue =
// @ts-expect-error -- linter-disable-autofix
tableFormRef.current.tableBasicInfoFormRef.current.formApi.getValues();
setCurrentState({
tableValue: {
// @ts-expect-error -- linter-disable-autofix
tableMemoryList: tableFormRef.current.tableFieldsList,
...tableBasicValue,
// @ts-expect-error -- linter-disable-autofix
tableId: tableValue.tableId,
},
});
});
// @ts-expect-error -- linter-disable-autofix
onValidate(async () => await tableFormRef.current.validate());
return (
<div
className={classnames(outerStyles.stepWrapper, styles['table-structure'])}
>
<Form<ExcelValue>
ref={excelInfoFormRef}
layout="horizontal"
// @ts-expect-error -- linter-disable-autofix
initValues={{ ...excelValue }}
className={styles['excel-info-form']}
onValueChange={async (values, changedValue) => {
const changedKeys = Object.keys(changedValue);
const reloadTableValue = async (
params: {
updateTableName?: boolean;
} = {},
) => {
const { updateTableName = false } = params;
const basicInfo =
// @ts-expect-error -- linter-disable-autofix
tableFormRef.current.tableBasicInfoFormRef.current.formApi.getValues();
setLoading(true);
let res: GetTableSchemaInfoResponse;
try {
res = await MemoryApi.GetTableSchemaInfo({
// @ts-expect-error -- linter-disable-autofix
tos_uri: fileList[0].response.upload_uri,
doc_table_info: {
sheet_id: values.sheetID,
header_line_idx: values.headerRow - 1,
start_line_idx: values.dataStartRow - 1,
} as any,
});
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseGetExcelInfo,
error: error as Error,
});
throw error;
}
if (res) {
// @ts-expect-error -- linter-disable-autofix
const newTableMemoryList: TableFieldsInfo = res.table_meta.map(
i => ({
name: i.column_name,
nanoid: nanoid(),
desc: '',
type:
i.column_type === ColumnType.Unknown
? undefined
: (i.column_type as any as FieldItemType),
must_required: false,
disableMustRequired: i.contains_empty_value,
}),
);
setCurrentState({
excelValue: { ...values },
tableValue: {
name: updateTableName
? // @ts-expect-error -- linter-disable-autofix
excelBasicInfo.find(i => i.id === values.sheetID)
?.sheet_name || basicInfo.name
: basicInfo.name,
desc: basicInfo.desc,
readAndWriteMode: basicInfo.readAndWriteMode,
tableId: '',
tableMemoryList: newTableMemoryList,
},
});
// @ts-expect-error -- linter-disable-autofix
tableFormRef.current.setTableFieldsList(newTableMemoryList);
}
setLoading(false);
};
if (changedKeys.length === 1 && changedKeys.includes('sheetID')) {
// FIXME: 此处 semi 有 bug始终是 override 更新,所以需要也添加上 sheetID 属性
// @ts-expect-error -- linter-disable-autofix
excelInfoFormRef.current.formApi.setValues({
sheetID: changedValue.sheetID,
headerRow: 1,
dataStartRow: 2,
});
}
// 切换 sheet
if (
changedKeys.length === 3 &&
changedKeys.includes('headerRow') &&
changedKeys.includes('dataStartRow') &&
changedKeys.includes('sheetID')
) {
await reloadTableValue({ updateTableName: true });
}
// 仅更新 headerRow
if (changedKeys.length === 1 && changedKeys.includes('headerRow')) {
// @ts-expect-error -- linter-disable-autofix
if (changedValue.headerRow >= values.dataStartRow) {
// @ts-expect-error -- linter-disable-autofix
excelInfoFormRef.current.formApi.setValue(
'dataStartRow',
// @ts-expect-error -- linter-disable-autofix
changedValue.headerRow + 1,
);
} else {
await reloadTableValue();
}
}
// 仅更新 dataStartRow
if (
changedKeys.length === 1 &&
changedKeys.includes('dataStartRow')
) {
await reloadTableValue();
}
}}
>
<Form.Select
field="sheetID"
label={I18n.t('datasets_createFileModel_tab_DataSheet')}
dropdownClassName={styles['table-setting-option']}
optionList={sheetOptions}
/>
<Form.Select
field="headerRow"
label={I18n.t('datasets_createFileModel_tab_header')}
dropdownClassName={styles['table-setting-option']}
optionList={headerRowOptions}
/>
<Form.Select
field="dataStartRow"
label={I18n.t('datasets_createFileModel_tab_dataStarRow')}
dropdownClassName={styles['table-setting-option']}
optionList={dataStartRowOptions}
/>
</Form>
<DatabaseTableStructure
// @ts-expect-error -- linter-disable-autofix
data={tableValue}
botId={botId}
forceEdit
ref={tableFormRef}
useComputingEnableGoToNextStep={(tableFieldsList: TableFieldsInfo) => {
computingEnableGoToNextStep(() => {
const currentLength = tableFieldsList.length;
return currentLength >= 1 && currentLength <= maxColumnNum;
});
}}
loading={loading}
enableAdd={false}
readAndWriteModeOptions="excel"
maxColumnNum={maxColumnNum}
createType={CreateType.excel}
/>
</div>
);
};

View File

@@ -0,0 +1,64 @@
/* stylelint-disable declaration-no-important */
/* stylelint-disable no-descending-specificity */
.file-name-wrapper {
display: flex;
gap: 16px;
align-items: center;
}
.file-name-label {
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: var(--Light-usage-text---color-text-0, #1D1C23);
}
.file-list-table {
:global {
.semi-table-container {
padding: 0 20px;
border: 1px solid var(--Light-usage-border---color-border-1, rgba(29, 28, 35, 12%));
border-radius: 8px;
}
// 此处不加 !important 优先级不够,会被 semi 的样式覆盖掉
.semi-table-row:hover {
cursor: default !important;
>.semi-table-row-cell {
background: none !important;
border-bottom: none !important;
}
}
// 此处不加 !important 优先级不够,会被 semi 的样式覆盖掉
.semi-table-row {
>.semi-table-row-cell {
border-bottom: none !important;
}
}
.semi-table-header {
border-radius: 8px;
}
.semi-table-body {
border-radius: 8px;
}
.semi-table-row-cell {
border: none
}
}
}
.upload {
:global {
.semi-upload-drag-area-sub-text {
color: rgba(136, 141, 146, 100%)
}
}
}

View File

@@ -0,0 +1,461 @@
/*
* 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, type FC } from 'react';
import { nanoid } from 'nanoid';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { type UploadProps, type FileItem } from '@coze-arch/bot-semi/Upload';
import {
UIIconButton,
UITable,
Popover,
Progress,
Upload as SemiUpload,
Toast,
Tooltip,
} from '@coze-arch/bot-semi';
import {
IconDeleteOutline,
IconUploadFileFail,
IconUploadFileSuccess,
} from '@coze-arch/bot-icons';
import {
ColumnType,
type GetTableSchemaInfoResponse,
BotTableRWMode,
type FieldItemType,
} from '@coze-arch/bot-api/memory';
import {
FileBizType,
type UploadFileResponse,
type UploadFileData,
} from '@coze-arch/bot-api/developer_api';
import { DeveloperApi, MemoryApi } from '@coze-arch/bot-api';
import { type SheetItem } from '../../types';
import { useStepStore } from '../../store/step';
import outerStyles from '../../index.module.less';
import { useStep } from '../../hooks/use-step';
import { getFileIcon } from '../../helpers/get-file-icon';
import { getFileExtension } from '../../helpers/get-file-extension';
import { getBase64 } from '../../helpers/get-base64';
import { ACCEPT_FILE_TYPES, ACCEPT_FILE_MAX_SIZE } from '../../const';
import styles from './index.module.less';
export const UploadStatusComp = (props: {
record: FileItem;
onRetry: (record: FileItem, index: number) => void;
index: number;
}) => {
const { record, onRetry, index } = props;
const { status } = record;
if (status === 'uploading' || status === 'validating' || status === 'wait') {
return (
<span className={styles['upload-status-wrap']}>
<span>{I18n.t('datasets_unit_upload_state')}</span>
<Progress percent={record.percent} />
</span>
);
}
if (status === 'success') {
return (
<span className={styles['upload-status-wrap']}>
<IconUploadFileSuccess />
</span>
);
}
if (status === 'validateFail') {
return (
<span className={styles['upload-status-wrap']}>
<IconUploadFileFail />
</span>
);
}
if (status === 'uploadFail') {
return (
<span
className={`${styles['upload-status-wrap']} ${styles.retry}`}
onClick={() => {
onRetry && onRetry(record, index);
}}
>
<Popover
className={styles['fail-popover']}
content={record.statusDescript}
visible
trigger="custom"
>
<IconUploadFileFail />
</Popover>
<span className={styles['retry-text']}>
{I18n.t('datasets_unit_update_retry')}
</span>
</span>
);
}
return null;
};
export const transformUnitList = ({
fileList,
data,
fileInstance,
index,
}: {
fileList: FileItem[];
data: UploadFileData | undefined;
fileInstance: File;
index: number;
}): FileItem[] => {
if (!data) {
return fileList;
}
const filteredList = fileList.map((file, i) => {
if (index === i) {
return {
...file,
uri: data.upload_uri || '',
status: 'success' as const,
percent: 100,
fileInstance,
};
}
return file;
});
return filteredList;
};
export const Upload: FC = () => {
const { onSubmit, computingEnableGoToNextStep } = useStep();
const { currentState, setCurrentState, tableStructure, setTableStructure } =
useStepStore(state => ({
currentState: state.step1_upload,
setCurrentState: state.set_step1_upload,
tableStructure: state.step2_tableStructure,
setTableStructure: state.set_step2_tableStructure,
tablePreview: state.step3_tablePreview,
}));
const { fileList = [] } = currentState;
const customRequest: UploadProps['customRequest'] = async options => {
const { onSuccess, onError, onProgress, file } = options;
if (typeof file === 'string') {
return;
}
try {
// 业务
const { name, fileInstance } = file;
if (fileInstance) {
const extension = getFileExtension(name);
const base64 = await getBase64(fileInstance);
let result: UploadFileResponse;
try {
result = await DeveloperApi.UploadFile(
{
file_head: {
file_type: extension,
biz_type: FileBizType.BIZ_BOT_DATASET,
},
data: base64,
},
{
onUploadProgress: e => {
onProgress({
total: e.total ?? fileInstance.size,
loaded: e.loaded,
});
},
},
);
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseUploadExcelFile,
error: error as Error,
});
throw error;
}
if (result) {
onSuccess(result.data);
}
} else {
throw new Error('Failed to upload database');
}
} catch (e) {
onError({
status: 0,
});
}
};
const onRetry = async (record: FileItem, index: number) => {
try {
const { fileInstance } = record;
if (fileInstance) {
const { name } = fileInstance;
const extension = getFileExtension(name);
const base64 = await getBase64(fileInstance);
const result = await DeveloperApi.UploadFile({
file_head: {
file_type: extension,
biz_type: FileBizType.BIZ_BOT_DATASET,
},
data: base64,
});
setCurrentState({
fileList: transformUnitList({
fileList,
data: result?.data,
fileInstance,
index,
}),
});
}
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseUploadExcelFile,
error: error as Error,
});
}
};
const handleDeleteFile = (index: number) => {
setCurrentState({
fileList: fileList.filter((f, i) => index !== i),
});
// reset 配置
setTableStructure({
excelBasicInfo: undefined,
excelValue: undefined,
tableValue: undefined,
});
};
const columns = [
{
title: I18n.t('db_table_0126_018'),
dataIndex: 'name',
width: 643,
render: (_, record) => {
const { name } = record;
const extension = getFileExtension(name);
return (
<div className={styles['file-name-wrapper']}>
{getFileIcon(extension)}
<span className={styles['file-name-label']}>{name}</span>
</div>
);
},
},
{
title: I18n.t('db_table_0126_019'),
dataIndex: 'status',
render: (_, record, index) => (
<UploadStatusComp onRetry={onRetry} record={record} index={index} />
),
width: 153,
},
{
title: I18n.t('db_table_0126_020'),
dataIndex: 'size',
width: 100,
render: (_, record) => <span>{record.size}</span>,
},
{
title: I18n.t('db_table_0126_021'),
dataIndex: 'action',
width: 72,
render: (_, record: FileItem, index) => {
const disabled =
record.status === 'uploading' ||
record.status === 'validating' ||
record.status === 'wait';
return (
<div
className={styles['ui-action-content']}
onClick={e => {
e.stopPropagation();
}}
>
<Tooltip spacing={12} content={I18n.t('Delete')} position="top">
<UIIconButton
disabled={disabled}
icon={<IconDeleteOutline className={styles.icon} />}
style={{
color: disabled
? 'rgba(136, 138, 142, 0.5)'
: 'rgba(136, 138, 142, 1)',
}}
onClick={() => handleDeleteFile(index)}
/>
</Tooltip>
</div>
);
},
},
];
onSubmit(async () => {
if (!tableStructure.excelBasicInfo) {
let res: GetTableSchemaInfoResponse;
try {
res = await MemoryApi.GetTableSchemaInfo({
tos_uri: fileList[0].response.upload_uri,
});
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseGetExcelInfo,
error: error as Error,
});
throw error;
}
if (res) {
const { sheet_list, table_meta } = res;
setTableStructure({
excelBasicInfo: sheet_list as SheetItem[],
excelValue: {
// @ts-expect-error -- linter-disable-autofix
sheetID: sheet_list[0]?.id as number,
headerRow: 1,
dataStartRow: 2,
},
tableValue: {
// @ts-expect-error -- linter-disable-autofix
name: sheet_list[0].sheet_name,
desc: '',
tableId: '',
readAndWriteMode: BotTableRWMode.ReadOnly,
// @ts-expect-error -- linter-disable-autofix
tableMemoryList: table_meta.map(i => ({
name: i.column_name,
nanoid: nanoid(),
desc: '',
type:
i.column_type === ColumnType.Unknown
? undefined
: (i.column_type as any as FieldItemType),
must_required: false,
id: i.sequence,
disableMustRequired: i.contains_empty_value,
})),
},
});
}
}
});
computingEnableGoToNextStep(
useCallback(
() => fileList.length > 0 && fileList.some(i => i.status === 'success'),
[fileList],
),
);
return (
<div className={outerStyles.stepWrapper}>
<SemiUpload
style={{
height: '100%',
/**
* NOTE: 此处采取 css 隐藏是为了保持 upload 过程,否则会取消上传
*/
...(fileList.length > 0
? {
display: 'none',
}
: {}),
}}
onAcceptInvalid={() => {
Toast.warning({
showClose: false,
content: I18n.t('db_table_0126_032'),
});
}}
beforeUpload={fileInfo => {
// 不通过 maxSize 属性来限制的原因是
// 只有 beforeUpload 钩子能改 validateMessage
const res = {
fileInstance: fileInfo.file.fileInstance,
status: fileInfo.file.status,
validateMessage: fileInfo.file.validateMessage,
shouldUpload: true,
autoRemove: false,
};
const { fileInstance } = fileInfo.file;
if (!fileInstance) {
return {
...res,
status: 'uploadFail',
shouldUpload: false,
};
}
if (fileInstance.size > ACCEPT_FILE_MAX_SIZE) {
Toast.warning({
showClose: false,
content: I18n.t('file_too_large', {
max_size: '20MB',
}),
});
return {
...res,
shouldUpload: false,
status: 'validateFail',
validateMessage: I18n.t('file_too_large', {
max_size: '20MB',
}),
};
}
return res;
}}
limit={1}
draggable={true}
showUploadList={false}
accept={ACCEPT_FILE_TYPES}
customRequest={customRequest}
dragMainText={I18n.t('db_table_0126_016')}
dragSubText={I18n.t('db_table_0126_017')}
onChange={({ fileList: files }) => {
// 存在校验通过的才上传
if (files.some(f => f.shouldUpload)) {
setCurrentState({
fileList: files,
});
}
}}
className={styles.upload}
/>
{fileList.length > 0 ? (
<UITable
tableProps={{
dataSource: fileList,
columns,
className: styles['file-list-table'],
}}
/>
) : null}
</div>
);
};

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.
*/
// 20MB 限制
export const ACCEPT_FILE_MAX_SIZE = 20 * 1024 * 1024;
export const ACCEPT_FILE_TYPES = ['.xlsx', '.xls', '.csv'].join(',');

View File

@@ -0,0 +1,128 @@
/*
* 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 */
/* tslint:disable */
// @ts-nocheck
import * as base from './namespaces/base';
import * as table_base from './namespaces/table_base';
import * as table_import from './namespaces/table_import';
export { base, table_base, table_import };
export * from './namespaces/base';
export * from './namespaces/table_base';
export * from './namespaces/table_import';
export type Int64 = string | number;
export default class DatamodelService<T> {
private request: any = () => {
throw new Error('DatamodelService.request is undefined');
};
private baseURL: string | ((path: string) => string) = '';
constructor(options?: {
baseURL?: string | ((path: string) => string);
request?<R>(
params: {
url: string;
method: 'GET' | 'DELETE' | 'POST' | 'PUT' | 'PATCH';
data?: any;
params?: any;
headers?: any;
},
options?: T,
): Promise<R>;
}) {
this.request = options?.request || this.request;
this.baseURL = options?.baseURL || '';
}
private genBaseURL(path: string) {
return typeof this.baseURL === 'string'
? this.baseURL + path
: this.baseURL(path);
}
/**
* POST /api/datamodel/tablefile/preview
*
* [jump to BAM]()
*
* 【Table Import】导入文件数据预检查
*/
PreviewTableFile(
req: table_import.PreviewTableFileRequest,
options?: T,
): Promise<table_import.PreviewTableFileResponse> {
const url = this.genBaseURL('/api/datamodel/tablefile/preview');
const method = 'POST';
const _req = req || {};
const data = {
table: _req['table'],
file: _req['file'],
Base: _req['Base'],
};
return this.request({ url, method, data }, options);
}
/**
* POST /api/datamodel/tablefiletask/query
*
* [jump to BAM]()
*
* 【Table Import】导入文件数据任务信息查询
*/
QueryTableFileTaskStatus(
req: table_import.QueryTableFileTaskStatusRequest,
options?: T,
): Promise<table_import.QueryTableFileTaskStatusResponse> {
const url = this.genBaseURL('/api/datamodel/tablefiletask/query');
const method = 'POST';
const _req = req || {};
const data = {
table_id: _req['table_id'],
bot_id: _req['bot_id'],
task_id: _req['task_id'],
Base: _req['Base'],
};
return this.request({ url, method, data }, options);
}
/**
* POST /api/datamodel/table/add
*
* [jump to BAM]()
*
* 【Table Import】导入文件添加table
*/
AddTable(
req: table_import.AddTableRequest,
options?: T,
): Promise<table_import.AddTableResponse> {
const url = this.genBaseURL('/api/datamodel/table/add');
const method = 'POST';
const _req = req || {};
const data = {
table: _req['table'],
file: _req['file'],
rw_mode: _req['rw_mode'],
Base: _req['Base'],
};
return this.request({ url, method, data }, options);
}
}
/* eslint-enable */

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.
*/
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
export type Int64 = string | number;
export interface Base {
LogID?: string;
Caller?: string;
Addr?: string;
Client?: string;
TrafficEnv?: TrafficEnv;
Extra?: Record<string, string>;
}
export interface TrafficEnv {
Open?: boolean;
Env?: string;
}
/* eslint-enable */

View File

@@ -0,0 +1,118 @@
/*
* 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 */
/* tslint:disable */
// @ts-nocheck
export type Int64 = string | number;
export enum BotTableRWMode {
/** 单用户模式 */
LimitedReadWrite = 1,
/** 只读模式 */
ReadOnly = 2,
/** 多用户模式 */
UnlimitedReadWrite = 3,
/** Max 边界值 */
RWModeMax = 4,
}
export enum BotTableStatus {
/** 初始化(不可用) */
Init = 0,
/** 已上线 */
Online = 1,
/** 删除 */
Delete = 2,
/** 草稿态(未 publish */
Draft = 3,
}
export enum FieldItemType {
/** 文本 */
Text = 1,
/** 数字 */
Number = 2,
/** 时间 */
Date = 3,
/** float */
Float = 4,
/** bool */
Boolean = 5,
}
/** Table model相关常量结构体定义 */
export enum FieldType {
/** 文本 */
Text = 1,
/** 数字 */
Number = 2,
/** 时间 */
Date = 3,
/** float */
Float = 4,
/** bool */
Boolean = 5,
}
export enum ImportFileTaskStatus {
/** 任务初始化 */
Init = 1,
/** 任务处理中 */
Enqueue = 2,
/** 任务成功 */
Succeed = 3,
/** 任务失败 */
Failed = 4,
}
export enum Language {
/** 中文 */
Chinese = 1,
/** 英文 */
English = 2,
}
export enum TableType {
/** 草稿 */
DraftTable = 1,
/** 线上 */
OnlineTable = 2,
}
export interface FieldItem {
/** 字段名称,用户自定义,可能为中文 */
name: string;
desc?: string;
type: FieldItemType;
must_required?: boolean;
/** 字段Id服务端生成全局唯一新增为0 */
id?: Int64;
/** 字段名称语言类型 */
lang?: Language;
/** 物理字段名服务端生成单个table下唯一 */
physics_name?: string;
/** 是否主键 */
primary_key?: boolean;
/** 字段可见性1:用户自定义2:业务定义对用户可见3:业务定义,对用户隐藏 */
visibility?: number;
/** 在excel文档中使用映射到excel中对应的列 */
sequence?: string;
/** 业务自定义扩展field元数据 */
map_ext_meta?: Record<string, string>;
}
/* eslint-enable */

View File

@@ -0,0 +1,119 @@
/*
* 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 */
/* tslint:disable */
// @ts-nocheck
import * as table_base from './table_base';
import * as base from './base';
export type Int64 = string | number;
export interface AddTableRequest {
/** table schema */
table: ImportTableInfo;
/** file source */
file: FileInfo;
rw_mode: table_base.BotTableRWMode;
Base?: base.Base;
}
export interface AddTableResponse {
/** 相关id. bot_id */
bot_id: string;
/** table_id */
table_id: string;
/** 表名 */
table_name: string;
/** 上传 TableFile 的 task id用于后期查询使用.
DataModelService 使用 GID 保证 task_id 全局唯一,后续查询时只需要 task_id.
DataModel 服务会记录 table 的 lastTaskID, 查询时可以通过 table_id 查到唯一的
task_id */
task_id: Int64;
code: number;
msg: string;
}
export interface FileInfo {
/** tos uri */
tos_uri: string;
/** Excel 行号 */
header_row: Int64;
/** Excel 数据开始行 */
start_data_row: Int64;
/** Excel sheet id, 0 for default */
sheet_id?: number;
}
export interface ImportTableInfo {
/** table 所属的 bot_id */
bot_id: string;
/** 表名 */
table_name: string;
/** 表描述 */
table_desc?: string;
/** 字段信息 */
table_meta: Array<table_base.FieldItem>;
/** 空间ID */
space_id: string;
}
export interface PreviewTableFileRequest {
table: ImportTableInfo;
file: FileInfo;
Base?: base.Base;
}
export interface PreviewTableFileResponse {
preview_data?: SheetInfo;
code: number;
msg: string;
}
export interface QueryTableFileTaskStatusRequest {
table_id: string;
bot_id?: string;
task_id?: Int64;
Base?: base.Base;
}
export interface QueryTableFileTaskStatusResponse {
table_id: string;
/** filled by server. callers may use it to do some additional business */
table_name: string;
/** filled by server. callers may use it to do some additional business */
bot_id: string;
/** filled by server. callers may use it to do some additional business */
task_id: Int64;
/** 10 for 10%, 100 for 100% */
progress: Int64;
status: table_base.ImportFileTaskStatus;
/** in json format */
summary: string;
/** tos url */
error_detail_url: string;
code: number;
msg: string;
}
export interface SheetInfo {
headers?: Array<string>;
datas?: Array<Array<string>>;
total_rows?: Int64;
preview_rows?: Int64;
}
/* eslint-enable */

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 { type Callback } from '../types';
class EventEmitter {
private eventMap = new Map<string, Callback>();
on(event: string, callback: Callback): void {
this.eventMap.set(event, callback);
}
off(event: string): void {
this.eventMap.delete(event);
}
getEventCallback(event: string): Callback | undefined {
return this.eventMap.get(event);
}
emit(event: string): Promise<void> | void {
return this.getEventCallback(event)?.() as any;
}
}
export const eventEmitter = new EventEmitter();

View File

@@ -0,0 +1,32 @@
/*
* 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 function getBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = event => {
const result = event.target?.result;
if (!result || typeof result !== 'string') {
reject(new Error('file read fail'));
return;
}
resolve(result.replace(/^.*?,/, ''));
};
fileReader.readAsDataURL(file);
});
}

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.
*/
export const getFileExtension = (name: string) => {
const index = name.lastIndexOf('.');
return name.slice(index + 1);
};

View File

@@ -0,0 +1,31 @@
/*
* 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 { Icon } from '@coze-arch/bot-semi';
import { ReactComponent as ExcelSVG } from '../../../assets/icon_wiki-excel_colorful.svg';
import { ReactComponent as CSVSVG } from '../../../assets/icon_wiki-csv_colorful.svg';
export const getFileIcon = (extension: string) => {
if (extension === 'xlsx' || extension === 'xltx') {
return <Icon svg={<ExcelSVG />} />;
}
if (extension === 'csv') {
return <Icon svg={<CSVSVG />} />;
}
// TODO
return <Icon svg={<ExcelSVG />} />;
};

View File

@@ -0,0 +1,65 @@
/*
* 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 { useEffect } from 'react';
import { type Callback } from '../types';
import { useStepStore } from '../store/step';
import { eventEmitter } from '../helpers/event-emitter';
const generateEventCallback =
(eventName: 'validate' | 'next' | 'prev') =>
(callback: Callback): void => {
const step = useStepStore(state => state.step);
useEffect(() => {
const key = `${eventName}-${step}`;
eventEmitter.on(key, callback);
return () => {
eventEmitter.off(key);
};
}, [callback, step]);
};
export const useStep = () => {
const step = useStepStore(state => state.step);
const enableGoToNextStep = useStepStore(state => state.enableGoToNextStep);
const set_enableGoToNextStep = useStepStore(
state => state.set_enableGoToNextStep,
);
const computingEnableGoToNextStep = (compute: () => boolean) => {
// eslint-disable-next-line react-hooks/rules-of-hooks -- linter-disable-autofix
useEffect(() => {
const res = compute();
if (res !== enableGoToNextStep) {
set_enableGoToNextStep(res);
}
}, [compute, enableGoToNextStep]);
};
return {
computingEnableGoToNextStep,
onValidate: generateEventCallback('validate'),
onSubmit: generateEventCallback('next'),
onPrevious: generateEventCallback('prev'),
getCallbacks: () => ({
onValidate: eventEmitter.getEventCallback(`validate-${step}`),
onSubmit: eventEmitter.getEventCallback(`next-${step}`),
onPrevious: eventEmitter.getEventCallback(`prev-${step}`),
}),
};
};

View File

@@ -0,0 +1,148 @@
/*
* 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 { useRef, useState, useEffect } from 'react';
import { useLocalStorageState } from 'ahooks';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import {
ImportFileTaskStatus,
type QueryTableFileTaskStatusResponse,
} from '../datamodel';
export interface ProgressInfo {
progress: number;
status: ImportFileTaskStatus;
errorMessage?: string;
}
export const useUploadProgress = (params: {
tableID: string;
botID: string;
}): ProgressInfo | undefined => {
const { tableID, botID } = params;
const [pollingInfo, setPollingInfo] = useState<ProgressInfo>();
const importResultRef = useRef<number>();
const [
mapOfShouldQueryDatabaseProcessStatus,
setMapOfShouldQueryDatabaseProcessStatus,
] = useLocalStorageState<string | undefined>(
'map-of-should-query-database-process-status',
{
defaultValue: '',
},
);
const writeToLocalStorage = () => {
try {
const lsMap = JSON.parse(mapOfShouldQueryDatabaseProcessStatus || '{}');
const value = lsMap?.[botID] || [];
value.push(tableID);
lsMap[botID] = value;
setMapOfShouldQueryDatabaseProcessStatus(JSON.stringify(lsMap));
} catch (error) {
console.error(error);
}
};
const readFromLocalStorage = (): Record<string, string[]> => {
let lsMap = {};
try {
lsMap = JSON.parse(mapOfShouldQueryDatabaseProcessStatus || '{}');
} catch (error) {
console.error(error);
}
return lsMap;
};
const startReceiveTimeCheck = () => {
try {
let res: QueryTableFileTaskStatusResponse;
try {
// TODO:此需求暂停,后端下线,后续待开放
// res = await DataModelApi.QueryTableFileTaskStatus({
// table_id: tableID,
// bot_id: botID,
// });
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseGetTaskInfo,
error: error as Error,
});
throw error;
}
// 不在Processing则停止轮询
// @ts-expect-error -- linter-disable-autofix
if (res?.status !== ImportFileTaskStatus.Enqueue) {
clearInterval(importResultRef.current);
importResultRef.current = undefined;
}
/**
* 成功后---写入localStorage
* 没有任务---写入localStorage
*/
if (
// @ts-expect-error -- linter-disable-autofix
!res?.status ||
res?.status === (0 as any) ||
res?.status === ImportFileTaskStatus.Succeed
) {
writeToLocalStorage();
}
// @ts-expect-error -- linter-disable-autofix
if (res) {
setPollingInfo({
progress: Number(res.progress),
status: res.status,
errorMessage: res.summary,
});
}
} catch (error) {
clearInterval(importResultRef.current);
importResultRef.current = undefined;
setPollingInfo({
progress: 0,
status: ImportFileTaskStatus.Failed,
errorMessage: (error as Error).message,
});
}
};
useEffect(() => {
// 读取localStorage减少不必要的请求次数
const lsMap = readFromLocalStorage();
const inLocaleStorage = (lsMap[botID] || []).includes(tableID);
if (tableID && !inLocaleStorage) {
importResultRef.current = setInterval(() => {
startReceiveTimeCheck();
}, 1000) as unknown as number;
}
return () => {
clearInterval(importResultRef.current);
};
}, [tableID]);
return pollingInfo;
};

View File

@@ -0,0 +1,18 @@
/* stylelint-disable selector-class-pattern */
.stepWrapper {
width: 100%;
height: 500px
}
.create-from-excel-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.steps {
width: 700px;
margin-bottom: 40px
}

View File

@@ -0,0 +1,93 @@
/*
* 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 { useEffect, type FC } from 'react';
import { type DatabaseInfo } from '@coze-studio/bot-detail-store';
import { I18n } from '@coze-arch/i18n';
import { Steps } from '@coze-arch/bot-semi';
import { Step } from './types';
import { useStepStore } from './store/step';
import { useInitialConfigStore } from './store/initial-config';
import { Upload } from './components/upload';
import { TableStructure } from './components/table-structure';
import { TablePreview } from './components/table-preview';
import { Processing } from './components/processing';
import styles from './index.module.less';
const map = {
[Step.Step1_Upload]: Upload,
[Step.Step2_TableStructure]: TableStructure,
[Step.Step3_TablePreview]: TablePreview,
[Step.Step4_Processing]: Processing,
};
export interface DatabaseCreateFromExcelProps {
onCancel: () => void;
botId: string;
spaceId: string;
maxColumnNum?: number;
onSave?: (params: {
response: any;
stateData: DatabaseInfo;
}) => Promise<void>;
}
export const DatabaseCreateFromExcel: FC<
DatabaseCreateFromExcelProps
> = props => {
const { onCancel, botId, onSave, maxColumnNum = 10, spaceId } = props;
const { step, reset } = useStepStore(state => ({
step: state.step,
reset: state.reset,
}));
// init / reset store
useEffect(() => {
useInitialConfigStore.setState(() => ({
onCancel,
botId,
spaceId,
onSave,
maxColumnNum,
}));
return () => {
reset();
};
}, []);
const Component = map[step];
return (
<div className={styles['create-from-excel-wrapper']}>
<Steps
type="basic"
size="small"
current={step - 1}
className={styles.steps}
>
<Steps.Step title={I18n.t('db_table_0126_012')} />
<Steps.Step title={I18n.t('db_table_0126_013')} />
<Steps.Step title={I18n.t('db_table_0126_014')} />
<Steps.Step title={I18n.t('db_table_0126_015')} />
</Steps>
<Component />
</div>
);
};
export { StepFooter } from './components/step-footer';

View File

@@ -0,0 +1,38 @@
/*
* 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 { create } from 'zustand';
import { noop } from 'lodash-es';
import { type DatabaseInfo } from '@coze-studio/bot-detail-store';
export interface initialConfigStore {
onCancel: () => void;
botId: string;
spaceId: string;
maxColumnNum: number;
onSave?: (params: {
response: any;
stateData: DatabaseInfo;
}) => Promise<void>;
}
// 用来存储静态状态,非初始化场景下,仅只读不可修改
export const useInitialConfigStore = create<initialConfigStore>()(set => ({
onCancel: noop,
botId: '',
spaceId: '',
maxColumnNum: 10,
}));

View File

@@ -0,0 +1,96 @@
/*
* 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 { devtools } from 'zustand/middleware';
import { create } from 'zustand';
import { type StepState, type Step } from '../types';
export interface StepStore {
step: Step;
enableGoToNextStep: boolean;
step1_upload: StepState.Upload;
step2_tableStructure: StepState.TableStructure;
step3_tablePreview: StepState.TablePreview;
step4_processing: StepState.Processing;
goToNextStep: () => void;
backToPreviousStep: () => void;
reset: () => void;
set_step1_upload: (newState: StepState.Upload) => void;
set_step2_tableStructure: (newState: StepState.TableStructure) => void;
set_step3_tablePreview: (newState: StepState.TablePreview) => void;
set_step4_processing: (newState: StepState.Processing) => void;
set_enableGoToNextStep: (newState: boolean) => void;
}
export const useStepStore = create<StepStore>()(
devtools(set => ({
step: 1,
enableGoToNextStep: true,
step1_upload: {},
step2_tableStructure: {},
step3_tablePreview: {},
step4_processing: {},
set_step1_upload: (newState: StepState.Upload) =>
set(state => ({
step1_upload: {
...state.step1_upload,
...newState,
},
})),
set_step2_tableStructure: (newState: StepState.TableStructure) =>
set(state => ({
step2_tableStructure: {
...state.step2_tableStructure,
...newState,
},
})),
set_step3_tablePreview: (newState: StepState.TablePreview) =>
set(state => ({
step3_tablePreview: {
...state.step3_tablePreview,
...newState,
},
})),
set_step4_processing: (newState: StepState.Processing) =>
set(state => ({
step4_processing: {
...state.step4_processing,
...newState,
},
})),
goToNextStep: () =>
set(state => ({
step: state.step + 1,
})),
backToPreviousStep: () =>
set(state => ({
step: state.step - 1,
})),
reset: () =>
set({
step: 1,
enableGoToNextStep: true,
step1_upload: {},
step2_tableStructure: {},
step3_tablePreview: {},
step4_processing: {},
}),
set_enableGoToNextStep: (newState: boolean) => {
set(() => ({ enableGoToNextStep: newState }));
},
})),
);

View File

@@ -0,0 +1,62 @@
/*
* 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 DatabaseInfo } from '@coze-studio/bot-detail-store';
import { type FileItem } from '@coze-arch/bot-semi/Upload';
import { type SheetInfo } from './datamodel';
export type Callback =
| (() => Promise<boolean> | boolean)
| (() => Promise<void> | void);
export enum Step {
Step1_Upload = 1,
Step2_TableStructure = 2,
Step3_TablePreview = 3,
Step4_Processing = 4,
}
export interface SheetItem {
id: number;
sheet_name: string;
total_row: number;
}
export interface ExcelValue {
sheetID: number;
headerRow: number;
dataStartRow: number;
}
export type Row = Record<number, string>;
export declare namespace StepState {
export interface Upload {
fileList?: FileItem[];
}
export interface TableStructure {
excelBasicInfo?: SheetItem[];
excelValue?: ExcelValue;
tableValue?: DatabaseInfo;
}
export interface TablePreview {
previewData?: SheetInfo;
}
export interface Processing {
tableID?: string;
}
}

View File

@@ -0,0 +1,244 @@
/* stylelint-disable selector-class-pattern */
/* stylelint-disable no-descending-specificity */
/* stylelint-disable declaration-no-important */
.modal-container {
min-width: 100%;
}
.modal-table-btn {
display: flex;
justify-content: flex-end;
}
.modal-temp {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.modal-temp-right {
position: relative;
display: flex;
flex-direction: column;
height: 409px;
margin-bottom: 24px;
padding: 15px 24px;
background: rgba(255, 255, 255, 100%);
border: 1px solid rgba(29, 28, 35, 8%);
border-radius: 12px;
.modal-temp-title {
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: rgba(29, 28, 35, 100%);
}
.modal-temp-image {
margin-top: 16px;
margin-bottom: 12px;
border-radius: 8px;
}
.modal-temp-description {
height: 64px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: rgba(29, 28, 35, 80%);
}
.modal-temp-btn-group {
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-end;
margin-top: 16px;
padding: 8px 0;
.modal-temp-btn {
width: 120px;
}
}
.modal-temp-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 329px;
padding: 16px 21px 24px;
background: rgba(241, 242, 253, 100%);
border-radius: 11px 11px 0 0;
.title {
padding-bottom: 12px;
font-size: 14px;
font-weight: 600;
line-height: 18px;
color: rgba(77, 83, 232, 100%);
}
}
}
.modal-modify-tips {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 12px;
background: var(--light-usage-warning-light-color-warning-light-default,
#fff8ea);
.tip-icon {
margin: 0 20px 0 17px;
color: rgba(252, 136, 0, 100%);
>svg {
width: 20px;
height: 20px;
}
}
.description {
display: flex;
align-items: center;
margin-right: 65px;
}
.link {
cursor: pointer;
margin-left: 20px;
color: #3370ff;
white-space: nowrap;
}
}
// 新增样式 @zhangyuanzhou.zyz
.entry {
display: flex;
gap: 64px;
align-items: center;
justify-content: center;
height: 409px;
margin-bottom: 24px;
background: rgba(255, 255, 255, 100%);
border: 1px solid rgba(29, 28, 35, 8%);
border-radius: 12px;
.entry-method {
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
color: var(--Light-usage-text---color-text-0, #1d1c23);
.entry-method-icon {
width: 24px;
height: 24px;
}
.entry-method-title {
font-size: 12px;
font-weight: 600;
font-style: normal;
line-height: 16px;
}
}
.entry-method:hover {
color: var(--Light-color-brand---brand-5, #4d53e8);
}
}
// 当窗口足够大时高度固定为641px
// 当窗口太小时高度随vh变化333px为内容区距离视窗边缘的距离
.database-table-structure-container {
overflow: auto;
width: 100%;
height: min(641px, calc(100vh - 333px));
}
.title-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right {
display: flex;
gap: 32px;
align-items: center;
}
}
.generate-ai-popover-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
width: 560px;
padding: 24px;
background-color: rgba(247, 247, 250, 100%);
border-radius: 12px;
.title {
margin-bottom: 16px;
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.button-wrapper {
display: flex;
justify-content: flex-end;
width: 100%;
}
.text-area {
:global {
.semi-input-textarea {
overflow: auto;
max-height: 191px;
}
}
}
}
.popover {
:global {
.semi-popover {
border-radius: 12px;
}
.semi-popover-content {
border-radius: 12px;
}
}
}
.modal-close-button {
height: 24px !important;
padding: 4px !important;
border-radius: 3px !important;
&:hover {
background-color: rgba(46, 50, 56, 5%) !important;
}
}

View File

@@ -0,0 +1,678 @@
/*
* 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 */
import { useRef, useMemo, useEffect, useState } from 'react';
import { nanoid } from 'nanoid';
import { useLocalStorageState } from 'ahooks';
import { IconAlertTriangle, IconClose } from '@douyinfe/semi-icons';
import { type DatabaseInfo } from '@coze-studio/bot-detail-store';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { BotE2e } from '@coze-data/e2e';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { I18n, getUnReactiveLanguage } from '@coze-arch/i18n';
import { sendTeaEvent, EVENT_NAMES } from '@coze-arch/bot-tea';
import {
Button,
Image,
Popconfirm,
Divider,
Icon,
TextArea,
Popover,
UIButton,
Modal,
Form,
Toast,
} from '@coze-arch/bot-semi';
import { IconWarningSize24 } from '@coze-arch/bot-icons';
import {
BotTableRWMode,
type RecommendDataModelResponse,
SceneType,
FieldItemType,
} from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import {
DatabaseTableStructure,
type DatabaseTableStructureRef,
} from '../database-table-structure';
import { DatabaseCreateFromExcel } from '../database-create-from-excel';
import { BotDebugButton } from '../bot-debug-button';
import { CreateType, type OnSave, type NL2DBInfo } from '../../types';
import { useCreateFromExcelFG } from '../../hooks/use-create-from-excel-fg';
import { TEMPLATE_INFO } from '../../const';
import tableTempEN from '../../assets/table-template-en.png';
import tableTempCN from '../../assets/table-template-cn.png';
import tablePreviewEN from '../../assets/table-preview-en.png';
import tablePreviewCN from '../../assets/table-preview-cn.png';
import { ReactComponent as UpArrowSVG } from '../../assets/icon_up-arrow.svg';
import { ReactComponent as DownArrowSvg } from '../../assets/icon_down-arrow.svg';
import { ReactComponent as AddSVG } from '../../assets/icon_add_outlined.svg';
import { ReactComponent as GenerateSVG } from '../../assets/generate.svg';
import { ReactComponent as FileSVG } from '../../assets/file.svg';
import s from './index.module.less';
export interface ExpertModeConfig {
isExpertMode: boolean;
maxTableNum: number;
maxColumnNum: number;
readAndWriteModes: BotTableRWMode[];
}
export interface DatabaseModalProps {
visible: boolean;
onCancel: () => void;
database: DatabaseInfo;
botId: string;
spaceId: string;
readonly: boolean;
NL2DBInfo: NL2DBInfo | null;
expertModeConfig?: ExpertModeConfig;
onSave?: OnSave;
}
export const DatabaseModal: React.FC<DatabaseModalProps> = props => {
const {
database,
botId,
spaceId,
readonly,
onCancel,
onSave,
NL2DBInfo,
expertModeConfig,
visible,
} = props;
const [generateTableLoading, setGenerateTableLoading] = useState(false);
const [contentCheckErrorMsg, setContentCheckErrorMsg] = useState<string>('');
const [isEntry, setIsEntry] = useState<boolean>(
!database.tableId && !NL2DBInfo,
);
const [isPreview, setIsPreview] = useState<boolean>(false);
const [isDeletedField, setIsDeletedField] = useState<boolean>(false);
const [
shouldHideDatabaseTableStructureTipsForCurrent,
setShouldHideDatabaseTableStructureTipsForCurrent,
] = useState<boolean>(false);
const [createType, setCreateType] = useState<CreateType>(
NL2DBInfo ? CreateType.recommend : CreateType.custom,
);
const [data, setData] = useState<DatabaseInfo>({
tableId: '',
name: '',
desc: '',
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
tableMemoryList: [],
});
const [AIPopoverVisible, setAIPopoverVisible] = useState(false);
const nlTextAreaRef = useRef<HTMLTextAreaElement>();
const tableStructureRef = useRef<DatabaseTableStructureRef>();
const enableCreateFromExcel = useCreateFromExcelFG();
const [
mapOfShouldHidingDatabaseTableStructureTips,
setMapOfShouldHidingDatabaseTableStructureTips,
] = useLocalStorageState<string | undefined>(
// FIXME: 此属性名意义不明确,处为了兼容,暂不修改此属性名,但后续需要使用更明确的命名
'use-local-storage-state-modify-tips',
{
defaultValue: '',
},
);
const language = getUnReactiveLanguage();
const isEdit = Boolean(data.tableId);
const [isReadonly, setIsReadonly] = useState(false);
const handleSave = async () => {
try {
setIsReadonly(true);
// @ts-expect-error -- linter-disable-autofix
await tableStructureRef.current.submit();
} finally {
setIsReadonly(false);
}
};
const hideTableStructureTips = useMemo(() => {
const lsMap = JSON.parse(
mapOfShouldHidingDatabaseTableStructureTips || '{}',
);
return (
!isEdit ||
lsMap?.[botId] ||
shouldHideDatabaseTableStructureTipsForCurrent
);
}, [
isEdit,
shouldHideDatabaseTableStructureTipsForCurrent,
mapOfShouldHidingDatabaseTableStructureTips,
]);
const title = useMemo(() => {
if (isEdit) {
return I18n.t('db_edit_title');
}
if (createType === CreateType.excel) {
return I18n.t('db_table_0126_011');
}
return I18n.t('db_add_table_title');
}, [isEdit, createType]);
const showEntry = isEntry && !isEdit && !NL2DBInfo;
const shouldShowAIGenerate =
/**
* 1. 入口不展示
* 2. 编辑态不展示
* 3. Excel导入时不展示
*/
!showEntry && !isEdit && createType !== CreateType.excel;
const setDataToDefault = () => {
setData({
name: '',
desc: '',
tableId: '',
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
tableMemoryList: [
{
nanoid: nanoid(),
name: '',
desc: '',
type: FieldItemType.Text,
must_required: false,
},
],
});
};
const setTableFieldsListToDefault = () => {
tableStructureRef.current?.setTableFieldsList([
{
nanoid: nanoid(),
name: '',
desc: '',
type: FieldItemType.Text,
must_required: false,
},
]);
};
const onUseTemplate = () => {
setCreateType(CreateType.template);
setIsEntry(false);
setData({
...TEMPLATE_INFO,
});
};
const onUseCustom = () => {
setCreateType(CreateType.custom);
setIsEntry(false);
setDataToDefault();
};
const onUseExcel = () => {
setCreateType(CreateType.excel);
setIsEntry(false);
};
const generateTableByNL = async (text: string, type: SceneType) => {
setGenerateTableLoading(true);
let res: RecommendDataModelResponse | undefined;
try {
res = await MemoryApi.RecommendDataModel({
bot_id: botId,
scene_type: type,
text,
});
} catch (error) {
setGenerateTableLoading(false);
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseNL2DB,
error: error as Error,
});
setDataToDefault();
setTableFieldsListToDefault();
}
if (res?.bot_table_list?.[0]) {
if (type === SceneType.BotPersona) {
setCreateType(CreateType.recommend);
}
if (type === SceneType.ModelDesc) {
setCreateType(CreateType.naturalLanguage);
}
setData({
tableId: '',
// @ts-expect-error -- linter-disable-autofix
name: res.bot_table_list[0].table_name,
// @ts-expect-error -- linter-disable-autofix
desc: res.bot_table_list[0].table_desc,
readAndWriteMode: BotTableRWMode.LimitedReadWrite,
// @ts-expect-error -- linter-disable-autofix
tableMemoryList: res.bot_table_list[0].field_list.map(i => ({
name: i.name,
desc: i.desc,
must_required: i.must_required,
type: i.type,
nanoid: nanoid(),
id: Number(i.id),
})),
});
// data 是初始值,此处需要手动 setState 更新子组件状态
// 若 Modal 已提前关闭,子组件卸载,则 ref 为空,需要加上可选链判断一下
tableStructureRef.current?.setTableFieldsList(
// @ts-expect-error -- linter-disable-autofix
res.bot_table_list[0].field_list.map(i => ({
name: i.name,
desc: i.desc,
must_required: i.must_required,
type: i.type,
nanoid: nanoid(),
id: Number(i.id),
})),
);
} else {
if (type === SceneType.BotPersona) {
Toast.info(I18n.t('recommended_failed'));
setDataToDefault();
setTableFieldsListToDefault();
}
if (type === SceneType.ModelDesc) {
Toast.warning(I18n.t('generate_failed'));
setAIPopoverVisible(true);
}
}
setGenerateTableLoading(false);
};
const handleGenerate = () => {
const generate = () => {
const { value } = nlTextAreaRef.current || {};
if (value) {
generateTableByNL(value, SceneType.ModelDesc);
}
};
sendTeaEvent(EVENT_NAMES.generate_with_ai_click, {
bot_id: botId,
need_login: true,
have_access: true,
});
setAIPopoverVisible(false);
if (
// @ts-expect-error -- linter-disable-autofix
tableStructureRef.current.tableFieldsList.filter(i => i.name).length > 0
) {
Modal.warning({
title: I18n.t('bot_database_ai_replace'),
content: I18n.t('bot_database_ai_replace_detailed'),
okButtonProps: {
type: 'warning',
},
onOk: () => {
generate();
},
maskClosable: false,
icon: <IconWarningSize24 />,
});
} else {
generate();
}
};
useEffect(() => {
setAIPopoverVisible(false);
setIsPreview(false);
setShouldHideDatabaseTableStructureTipsForCurrent(false);
setIsDeletedField(false);
setContentCheckErrorMsg('');
setIsEntry(true);
}, [visible]);
useEffect(() => {
setData(database);
}, [database]);
useEffect(() => {
setCreateType(NL2DBInfo ? CreateType.recommend : CreateType.custom);
}, [NL2DBInfo]);
useEffect(() => {
if (NL2DBInfo && visible) {
generateTableByNL(NL2DBInfo.prompt, SceneType.BotPersona);
}
}, [NL2DBInfo, visible]);
const DefaultFooter = (
<>
{contentCheckErrorMsg ? (
<Form.ErrorMessage error={contentCheckErrorMsg} />
) : null}
{hideTableStructureTips ? null : (
<div className={s['modal-modify-tips']}>
<div className={s.description}>
<IconAlertTriangle className={s['tip-icon']} />
<span style={{ textAlign: 'left' }}>{I18n.t('db_edit_tips1')}</span>
<span
className={s.link}
onClick={() => {
const lsMap = JSON.parse(
mapOfShouldHidingDatabaseTableStructureTips || '{}',
);
lsMap[botId] = true;
setMapOfShouldHidingDatabaseTableStructureTips(
JSON.stringify(lsMap),
);
}}
>
{I18n.t('db_edit_tips2')}
</span>
</div>
<IconClose
onClick={() =>
setShouldHideDatabaseTableStructureTipsForCurrent(true)
}
style={{ cursor: 'pointer' }}
/>
</div>
)}
<div className={s['modal-table-btn']}>
{isDeletedField ? (
<Popconfirm
title={I18n.t('db_del_field_confirm_title')}
content={I18n.t('db_del_field_confirm_info')}
okText={I18n.t('db_del_field_confirm_yes')}
cancelText={I18n.t('db_del_field_confirm_no')}
okType="danger"
onConfirm={handleSave}
>
<BotDebugButton
loading={isReadonly}
theme="solid"
type="primary"
readonly={readonly}
>
{I18n.t('db_edit_save')}
</BotDebugButton>
</Popconfirm>
) : (
<BotDebugButton
readonly={readonly}
loading={isReadonly}
theme="solid"
type="primary"
onClick={handleSave}
>
{I18n.t('db_edit_save')}
</BotDebugButton>
)}
</div>
</>
);
const Entry = (
<div className={s['modal-temp']}>
<div className={s.entry}>
<div
className={s['entry-method']}
onClick={onUseCustom}
data-testid={BotE2e.BotDatabaseAddModalAddCustomBtn}
>
<Icon svg={<AddSVG />} className={s['entry-method-icon']} />
<span className={s['entry-method-title']}>
{I18n.t('db_add_table_cust')}
</span>
</div>
{enableCreateFromExcel ? (
<Divider
layout="vertical"
style={{
height: '32px',
}}
/>
) : null}
{enableCreateFromExcel ? (
<div className={s['entry-method']} onClick={onUseExcel}>
<Icon svg={<FileSVG />} className={s['entry-method-icon']} />
<span className={s['entry-method-title']}>
{I18n.t('db_table_0126_010')}
</span>
</div>
) : null}
</div>
<div className={s['modal-temp-right']}>
<div
className={s['modal-temp-title']}
data-testid={BotE2e.BotDatabaseAddModalTemplateTitle}
>
{I18n.t('db_add_table_temp_title')}
</div>
<Image
className={s['modal-temp-image']}
height={201}
src={language === 'zh-CN' ? tableTempCN : tableTempEN}
/>
<div className={s['modal-temp-description']}>
💡{I18n.t('db_add_table_temp_tips')}
</div>
{isPreview ? (
<div className={s['modal-temp-preview']}>
<div className={s.title}>
{I18n.t('db_add_table_temp_preview_tips')}
</div>
<Image
height={239}
src={language === 'zh-CN' ? tablePreviewCN : tablePreviewEN}
/>
</div>
) : null}
<div className={s['modal-temp-btn-group']}>
<Button
data-testid={BotE2e.BotDatabaseAddModalPreviewTemplateBtn}
theme="light"
type="tertiary"
onClick={() => setIsPreview(state => !state)}
className={s['modal-temp-btn']}
>
{I18n.t('db_add_table_temp_preview')}
</Button>
<Button
data-testid={BotE2e.BotDatabaseAddModalUseTemplateBtn}
theme="solid"
type="primary"
onClick={onUseTemplate}
className={s['modal-temp-btn']}
>
{I18n.t('db_add_table_temp_use')}
</Button>
</div>
</div>
</div>
);
const getFooter = () => {
if (showEntry) {
return null;
}
if (createType === CreateType.excel) {
return null;
}
return DefaultFooter;
};
const getContent = () => {
if (showEntry) {
return Entry;
}
if (createType === CreateType.excel) {
return (
<DatabaseCreateFromExcel
onCancel={onCancel}
botId={botId}
onSave={onSave}
spaceId={spaceId}
// @ts-expect-error -- linter-disable-autofix
maxColumnNum={expertModeConfig.maxColumnNum}
/>
);
}
return (
<div className={s['database-table-structure-container']}>
<DatabaseTableStructure
data={data}
// @ts-expect-error -- linter-disable-autofix
ref={tableStructureRef}
loading={generateTableLoading}
loadingTips={I18n.t('bot_database_ai_waiting')}
botId={botId}
readAndWriteModeOptions={
// @ts-expect-error -- linter-disable-autofix
expertModeConfig.isExpertMode ? 'expert' : 'normal'
}
// @ts-expect-error -- linter-disable-autofix
maxColumnNum={expertModeConfig.maxColumnNum}
onSave={onSave}
onCancel={onCancel}
onDeleteField={list => {
setIsDeletedField(
!database.tableMemoryList.every(i =>
// TODO: 当前field id生成规则有问题故暂时使用 nanoid 替换
list.find(j => j.nanoid === i.nanoid),
),
);
}}
createType={createType}
setContentCheckErrorMsg={setContentCheckErrorMsg}
/>
</div>
);
};
return (
<Modal
visible={visible}
onCancel={onCancel}
closable={false}
width={1138}
centered
footer={getFooter()}
title={
<div className={s['title-wrapper']}>
<div data-testid={BotE2e.BotDatabaseAddModalTitle}>{title}</div>
<div className={s.right}>
{shouldShowAIGenerate ? (
<Popover
trigger="custom"
position="bottomRight"
content={
<div className={s['generate-ai-popover-wrapper']}>
<div
className={s.title}
data-testid={
BotE2e.BotDatabaseAddModalTitleCreateAiModalTitle
}
>
{I18n.t('bot_database_ai_create')}
</div>
<TextArea
data-testid={
BotE2e.BotDatabaseAddModalTitleCreateAiModalDesc
}
autosize
// @ts-expect-error -- linter-disable-autofix
ref={nlTextAreaRef}
rows={1}
placeholder={I18n.t('bot_database_ai_create_tip')}
className={s['text-area']}
/>
<div className={s['button-wrapper']}>
<UIButton
data-testid={
BotE2e.BotDatabaseAddModalTitleCreateAiModalCreateBtn
}
theme="borderless"
onClick={handleGenerate}
icon={<Icon svg={<GenerateSVG />} />}
>
{I18n.t('bot_database_ai_generate')}
</UIButton>
</div>
</div>
}
keepDOM
visible={AIPopoverVisible}
onVisibleChange={_v => {
setAIPopoverVisible(_v);
}}
onClickOutSide={() => {
setAIPopoverVisible(false);
}}
className={s.popover}
>
<UIButton
data-testid={BotE2e.BotDatabaseAddModalTitleCreateAiBtn}
theme="borderless"
icon={
AIPopoverVisible ? (
<Icon svg={<UpArrowSVG />} />
) : (
<Icon svg={<DownArrowSvg />} />
)
}
iconPosition="right"
onClick={() => {
sendTeaEvent(EVENT_NAMES.nl2table_create_table_click, {
bot_id: botId,
need_login: true,
have_access: true,
});
setAIPopoverVisible(true);
}}
>
{I18n.t('bot_database_ai_create')}
</UIButton>
</Popover>
) : null}
<UIButton
data-testid={BotE2e.BotDatabaseAddModalTitleCloseIcon}
icon={<IconClose />}
type="tertiary"
theme="borderless"
onClick={onCancel}
className={s['modal-close-button']}
/>
</div>
</div>
}
maskClosable={false}
>
<div className={s['modal-container']}>{getContent()}</div>
</Modal>
);
};

View File

@@ -0,0 +1,11 @@
.column-title {
display: flex;
align-items: center;
justify-content: flex-start;
}
.table-header-label-tooltip-icon {
cursor: pointer;
margin-left: 8px;
color: rgba(198, 202, 205, 100%);
}

View File

@@ -0,0 +1,43 @@
/*
* 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 FC, type ReactNode } from 'react';
import { Icon, Popover } from '@coze-arch/bot-semi';
import { ReactComponent as InfoSVG } from '../../../../assets/icon_info_outlined.svg';
import s from './index.module.less';
export const ColumnHeader: FC<{
label: string;
required: boolean;
tips: ReactNode;
}> = p => {
const { label, required, tips } = p;
return (
<div className={s['column-title']}>
<span>{label}</span>
{required ? <span style={{ color: 'red' }}>*</span> : null}
<Popover showArrow position="top" content={<div>{tips}</div>}>
<Icon
svg={<InfoSVG />}
className={s['table-header-label-tooltip-icon']}
/>
</Popover>
</div>
);
};

View File

@@ -0,0 +1,8 @@
/* stylelint-disable declaration-no-important */
.modal-key-tip {
width: 200px !important;
ul {
padding-left: 16px;
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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 { PopoverContent } from '@coze-studio/components';
import { I18n } from '@coze-arch/i18n';
import s from './index.module.less';
export const KeyTipsNode: React.FC = () => (
<PopoverContent className={s['modal-key-tip']}>{`- ${I18n.t(
'db_add_table_field_name_tips1',
)}
- ${I18n.t('db_add_table_field_name_tips2')}
- ${I18n.t('db_add_table_field_name_tips3')}
- ${I18n.t('db_add_table_field_name_tips4')}`}</PopoverContent>
);

View File

@@ -0,0 +1,185 @@
/*
* 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 */
import { type TableMemoryItem } from '@coze-studio/bot-detail-store';
import { I18n } from '@coze-arch/i18n';
import { type MapperItem, type TriggerType, VerifyType } from '../../../types';
// 校验 Table Name 和 Field Name
const namingRegexMapper = [
{
type: 1,
regex: /[^a-z0-9_]/,
errorMsg: I18n.t('db_add_table_field_name_tips2'),
},
{
type: 2,
regex: /^[^a-z]/,
errorMsg: I18n.t('db_add_table_field_name_tips3'),
},
{
type: 3,
regex: /[\s\S]{64,}/,
errorMsg: I18n.t('db_add_table_field_name_tips4'),
},
];
export const validateNaming = (str: string, errList: string[] = []) => {
let list = [...errList];
namingRegexMapper.forEach(i => {
list = list.filter(j => j !== i.errorMsg);
if (i.regex.test(str || '')) {
list.push(i.errorMsg);
}
});
return list;
};
// 校验 Table Fields
export const thMapper: MapperItem[] = [
{
label: I18n.t('db_add_table_field_name'),
key: 'name',
validator: [
{
type: VerifyType.Naming,
message: '',
},
{
type: VerifyType.Required,
message: I18n.t('db_table_save_exception_nofieldname'),
},
{
type: VerifyType.Unique,
message: I18n.t('db_table_save_exception_fieldname'),
},
],
defaultValue: '',
require: true,
},
{
label: I18n.t('db_add_table_field_desc'),
key: 'desc',
require: false,
validator: [],
defaultValue: '',
},
{
label: I18n.t('db_add_table_field_type'),
key: 'type',
require: true,
validator: [
{
type: VerifyType.Required,
message: I18n.t('db_table_save_exception_fieldtype'),
},
],
defaultValue: '',
},
{
label: I18n.t('db_add_table_field_necessary'),
key: 'must_required',
require: false,
validator: [],
defaultValue: true,
},
];
export const validateFields = (
list: TableMemoryItem[],
trigger: TriggerType,
) => {
const resList = list.map(_listItem => {
const listItem: TableMemoryItem = { ..._listItem };
thMapper.forEach(thItem => {
const thKey = thItem.key as keyof TableMemoryItem;
thItem.validator.forEach(verifyItem => {
if (!listItem?.errorMapper) {
listItem.errorMapper = {};
}
let errTarget = listItem?.errorMapper?.[thKey];
const value = listItem[thKey];
if (!errTarget) {
listItem.errorMapper[thKey] = [];
errTarget = [];
}
const msg = verifyItem.message;
switch (verifyItem.type) {
case VerifyType.Required: {
// 报错出现时机:点击保存按钮时,出现提示。表中某一行填写了数据,但是未填写必填字段时,需要报错
if (
trigger === 'save' &&
!value &&
thMapper.find(
i =>
!!listItem[i.key as keyof TableMemoryItem] && !i.defaultValue,
)
) {
listItem.errorMapper[thKey].push(msg);
}
// 报错消失时机:必填输入框输入了内容后,报错立刻消失
if (trigger === 'change' && value) {
listItem.errorMapper[thKey] = errTarget.filter(i => i !== msg);
}
break;
}
case VerifyType.Unique: {
// 报错出现时机:点击保存按钮时,出现提示。
if (
trigger === 'save' &&
value &&
list.filter(i => i[thKey] === listItem[thKey]).length !== 1
) {
listItem.errorMapper[thKey].push(msg);
}
// 报错消失时机:必填输入框输入了内容后,报错立刻消失
if (
trigger === 'change' &&
value &&
list.filter(i => i[thKey] === listItem[thKey]).length === 1
) {
listItem.errorMapper[thKey] = errTarget.filter(i => i !== msg);
}
break;
}
case VerifyType.Naming: {
// 报错出现时机:命名格式有问题,失去焦点时,立刻校验格式
if (
trigger === 'save' ||
trigger === 'blur' ||
(trigger === 'change' && errTarget.length)
) {
listItem.errorMapper[thKey] = validateNaming(
value as string,
errTarget,
);
}
break;
}
default:
break;
}
listItem.errorMapper[thKey] = Array.from(
new Set(listItem.errorMapper[thKey]),
);
});
});
return listItem;
});
return resList;
};

View File

@@ -0,0 +1,205 @@
/* stylelint-disable selector-class-pattern */
/* stylelint-disable declaration-no-important */
/* stylelint-disable no-descending-specificity */
/* stylelint-disable max-nesting-depth */
/* stylelint-disable no-duplicate-selectors */
.th-tip-name {
display: flex;
flex-direction: column;
}
.th-tip-dot {
position: relative;
padding-left: 16px;
}
.th-tip-dot::before {
content: '';
position: absolute;
top: 7px;
left: 0;
width: 5px;
height: 5px;
background-color: #000;
border-radius: 50%;
}
// 新增样式 @zhangyuanzhou.zyz
.form-input-error {
:global {
.semi-input-wrapper {
border-color: #f93920
}
.semi-input-wrapper:hover {
border-color: var(--semi-color-border, rgba(29, 28, 35, 8%))
}
}
}
.table-structure-form {
padding-bottom: 24px;
:global {
// 去除 error message icon
.semi-form-field-validate-status-icon {
display: none;
}
.semi-form-field-validate-status-icon+span {
font-size: 12px;
}
.semi-col-12 {
.semi-form-field[x-field-id="prompt_disabled"] {
display: flex;
justify-content: flex-end;
.semi-form-field-main {
display: flex;
align-items: center;
width: auto !important;
}
}
}
}
}
.max-row-banner {
margin-bottom: 24px;
}
.form-item-label-tooltip-icon {
cursor: pointer;
color: rgba(198, 202, 205, 100%);
}
.table-structure-table {
:global {
// table 圆角和边框
.semi-table-container {
padding: 0 8px;
border: 1px solid var(--Light-usage-border---color-border-1, rgba(29, 28, 35, 8%));
border-radius: 8px;
}
.semi-table-header {
padding: 0 8px;
}
// hover时去除背景色
.semi-table-body {
.semi-table-row:hover {
>.semi-table-row-cell {
background: none !important;
}
}
}
//
.semi-table-row {
cursor: default !important;
>.semi-table-row-cell {
padding: 12px 8px !important;
border-bottom: 1px solid transparent !important;
}
}
.semi-table-body {
overflow: visible;
max-height: none !important;
padding: 12px 0;
border-radius: 8px;
}
// 移除 UITable 中的 before元素影响
.semi-table-row-cell::before {
display: none;
}
}
}
.table-structure-table-wrapper {
height: auto;
}
.table-empty-tips {
padding: 8px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: @error-red;
}
.spin {
:global {
.semi-spin-wrapper div {
font-size: 16px;
color: rgba(29, 28, 35, 35%);
}
}
}
.table-setting-option {
:global {
.semi-select-option-selected {
.semi-select-option-icon {
color: #4D53E8
}
}
}
}
.table-name-form-field {
padding-bottom: 24px !important;
// semi 原有的 disable 样式不满足需求,需要更明显的文字颜色
:global {
.semi-input-wrapper-disabled {
-webkit-text-fill-color: rgba(29, 28, 35, 100%);
}
}
}
.table-desc-form-field {
padding-top: 0 !important;
}
.prompt_disabled_popover {
:global {
.semi-popover-icon-arrow {
right: 8px !important;
}
}
}
.read_mode_popover {
:global {
.semi-popover-icon-arrow {
left: 8px !important;
}
}
}

View File

@@ -0,0 +1,859 @@
/*
* 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 {
useRef,
forwardRef,
useImperativeHandle,
useState,
type FC,
type MutableRefObject,
useCallback,
useMemo,
} from 'react';
import { nanoid } from 'nanoid';
import { noop } from 'lodash-es';
import classNames from 'classnames';
import { useBoolean } from 'ahooks';
import { type DatabaseInfo } from '@coze-studio/bot-detail-store';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { BotE2e } from '@coze-data/e2e';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import {
UITable,
UITableAction,
Button,
Switch,
Tooltip,
Image,
Form,
Banner,
withField,
Popover,
Spin,
Icon,
Row,
Col,
} from '@coze-arch/bot-semi';
import { IconAdd } from '@coze-arch/bot-icons';
import { isApiError } from '@coze-arch/bot-http';
import {
type InsertBotTableRequest,
BotTableRWMode,
type FieldItemType,
} from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import { SLSelect } from '../singleline-select';
import { SLInput } from '../singleline-input';
import {
type TriggerType,
type TableFieldsInfo,
type TableBasicInfo,
type ReadAndWriteModeOptions,
type CreateType,
type OnSave,
} from '../../types';
import {
DATABASE_CONTENT_CHECK_ERROR_CODE,
DATABASE_CONTENT_CHECK_ERROR_CODE_NEW,
FIELD_TYPE_OPTIONS,
RW_MODE_OPTIONS_CONFIG,
RW_MODE_OPTIONS_MAP,
SYSTEM_FIELDS,
} from '../../const';
import keyExample from '../../assets/key-example.png';
import { ReactComponent as InfoSVG } from '../../assets/icon_info_outlined.svg';
import { validateFields, validateNaming } from './helpers/validate';
import { KeyTipsNode } from './components/KeyTipsNode';
import { ColumnHeader } from './components/ColumnHeader';
import s from './index.module.less';
const MIN_COL = 12;
const MAX_COL = 24;
const MAX_COLUMNS = 20;
export interface DatabaseTableStructureProps {
data: DatabaseInfo;
botId: string;
forceEdit?: boolean;
loading?: boolean;
loadingTips?: string;
/**
* excel: 单用户模式|只读模式
* normal: 单用户模式|只读模式
* expert: 单用户模式|只读模式|多用户模式
* undefined: 不支持读写模式
*/
readAndWriteModeOptions?: ReadAndWriteModeOptions;
enableAdd?: boolean;
maxColumnNum?: number;
useComputingEnableGoToNextStep?: (list: TableFieldsInfo) => void;
onCancel?: () => void;
onSave?: OnSave;
onDeleteField?: (list: TableFieldsInfo) => void;
setContentCheckErrorMsg?: (s: string) => void;
createType: CreateType;
}
export interface DatabaseTableStructureRef {
validate: () => Promise<boolean>;
submit: () => Promise<void>;
isReadonly: boolean;
setTableFieldsList: (list: TableFieldsInfo) => void;
tableFieldsList: TableFieldsInfo;
tableBasicInfoFormRef: MutableRefObject<Form<TableBasicInfo>>;
}
export const DatabaseTableStructure = forwardRef<
DatabaseTableStructureRef,
DatabaseTableStructureProps
>((props, ref) => {
const {
data: initialData,
botId,
onSave,
onCancel,
onDeleteField,
forceEdit = false,
maxColumnNum = MAX_COLUMNS,
useComputingEnableGoToNextStep,
readAndWriteModeOptions,
enableAdd = true,
loading = false,
setContentCheckErrorMsg = noop,
loadingTips,
createType,
} = props;
const getDefaultTableFieldsList = () => {
if (initialData.readAndWriteMode === BotTableRWMode.UnlimitedReadWrite) {
return [...SYSTEM_FIELDS, ...initialData.tableMemoryList];
}
return initialData.tableMemoryList;
};
const [tableFieldsList, setTableFieldsList] = useState<TableFieldsInfo>(
getDefaultTableFieldsList(),
);
const inputRef = useRef<{
triggerFocus?: () => void;
}>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const tableBasicInfoFormRef = useRef<Form<TableBasicInfo>>();
const isModify = Boolean(initialData.tableId);
const [isReadonly, { setTrue: enableReadonly, setFalse: disableReadonly }] =
useBoolean(false);
const isRowMaxLimit = tableFieldsList.length >= maxColumnNum;
const isExceedRowMaxLimit = tableFieldsList.length > maxColumnNum;
const isEmptyList = !tableFieldsList
.filter(i => !i.isSystemField)
.filter(i => i.name || i.desc || i.type).length;
const databaseAuditErrorCodes = [
DATABASE_CONTENT_CHECK_ERROR_CODE,
DATABASE_CONTENT_CHECK_ERROR_CODE_NEW,
];
const handleContentCheckError = (error: Error) => {
if (
isApiError(error) &&
databaseAuditErrorCodes.includes(Number(error?.code))
) {
setContentCheckErrorMsg(
error?.msg || I18n.t('knowledge_bot_update_databse_tnserr_msg'),
);
}
};
const handleAdd = (triggerFocus = true) => {
if (isReadonly) {
return;
}
const newTableFieldsList = [
...tableFieldsList,
{
nanoid: nanoid(),
name: '',
desc: '',
type: undefined as unknown as FieldItemType,
must_required: false,
},
];
setTableFieldsList(newTableFieldsList);
if (triggerFocus) {
setTimeout(() => {
inputRef.current?.triggerFocus?.();
scrollRef.current?.scrollIntoView({
block: 'end',
behavior: 'smooth',
});
}, 100);
}
};
const verifyTableFields = (trigger: TriggerType) => {
setTableFieldsList(newTableFieldsList =>
validateFields(newTableFieldsList, trigger),
);
};
const verifyAllBeforeSave = async (): Promise<boolean> => {
// 触发 tableFields 校验
const validatedTableFieldsList = validateFields(tableFieldsList, 'save');
setTableFieldsList(validatedTableFieldsList);
// 触发并校验 tableBasicInfo
try {
// @ts-expect-error -- linter-disable-autofix
await tableBasicInfoFormRef.current.formApi.validate(['name']);
} catch (error) {
return false;
}
// 校验 tableFields
if (
validatedTableFieldsList.find(i =>
Object.keys(i.errorMapper || {}).find(
j => !!i?.errorMapper?.[j]?.length,
),
)
) {
return false;
}
// 校验 tableFields 是否为空
if (isEmptyList) {
return false;
}
return true;
};
const save = async () => {
// @ts-expect-error -- linter-disable-autofix
const tableBasicInfo = tableBasicInfoFormRef.current.formApi.getValues();
if (isModify) {
sendTeaEvent(EVENT_NAMES.edit_table_click, {
need_login: true,
have_access: true,
bot_id: botId,
table_name: tableBasicInfo.name,
});
} else {
sendTeaEvent(EVENT_NAMES.create_table_click, {
need_login: true,
have_access: true,
bot_id: botId,
table_name: tableBasicInfo.name,
database_create_type: createType,
});
}
let resp;
const params: InsertBotTableRequest['bot_table'] = {
bot_id: botId,
table_name: tableBasicInfo.name,
table_desc: tableBasicInfo.desc || '',
extra_info: {
prompt_disabled: String(tableBasicInfo.prompt_disabled ? false : true),
},
field_list: tableFieldsList
.filter(i => !!i.name && !i.isSystemField)
.map(i => ({
name: i.name,
desc: i.desc || '',
type: i.type,
must_required: i.must_required,
id: i.id,
alterId: i.alterId,
})),
rw_mode: tableBasicInfo.readAndWriteMode,
};
if (!isModify) {
try {
resp = await MemoryApi.InsertBotTable({
bot_table: params,
});
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseAddTable,
error: error as Error,
});
handleContentCheckError(error as Error);
return;
}
} else {
try {
resp = await MemoryApi.AlterBotTable({
bot_table: {
...params,
id: initialData.tableId,
},
});
} catch (error) {
dataReporter.errorEvent(DataNamespace.DATABASE, {
eventName: REPORT_EVENTS.DatabaseAlterTable,
error: error as Error,
});
handleContentCheckError(error as Error);
return;
}
}
if (onSave) {
await onSave({
response: resp,
});
}
onCancel?.();
};
const handleSave = async () => {
try {
enableReadonly();
const validateRes = await verifyAllBeforeSave();
if (!validateRes) {
return;
}
await save();
} finally {
disableReadonly();
}
};
const getTableNameErrorMessage = (v: string) => {
if (!v) {
return I18n.t('db_add_table_name_tips');
}
const errList = validateNaming(v);
if (errList.length > 0) {
return errList.join('; ');
}
};
// 初始化 ref 属性
useImperativeHandle<DatabaseTableStructureRef, DatabaseTableStructureRef>(
ref,
() => ({
async submit() {
return await handleSave();
},
async validate() {
const res = await verifyAllBeforeSave();
return res;
},
setTableFieldsList,
isReadonly,
// @ts-expect-error -- linter-disable-autofix
tableBasicInfoFormRef,
tableFieldsList,
}),
[isReadonly, tableFieldsList, tableBasicInfoFormRef],
);
// 自定义表单项组件
const FormInputInner: FC<any> = useCallback(
p => {
const { onChange, value, onBlur, validateStatus } = p;
const errorMessage =
validateStatus === 'error' ? getTableNameErrorMessage(value) : '';
return (
<SLInput
value={value}
handleChange={onChange}
handleBlur={onBlur}
inputProps={{
'data-testid': BotE2e.BotDatabaseAddModalTableNameInput,
disabled: !forceEdit && (isReadonly || isModify),
placeholder: I18n.t('db_add_table_name_tips'),
}}
onFocusPopoverProps={{
position: 'left',
content: <KeyTipsNode />,
}}
errorMsgFloat
errorMsg={errorMessage}
className={classNames({
[s['form-input-error']]: validateStatus === 'error',
})}
/>
);
},
[isReadonly, isModify, forceEdit],
);
const FormInput = useMemo(
() =>
withField(FormInputInner, {
valueKey: 'value',
onKeyChangeFnName: 'onChange',
}),
[],
);
// 校验是否 disable 下一步按钮
useComputingEnableGoToNextStep?.(tableFieldsList);
const dataSource = enableAdd
? [...tableFieldsList, { operate: 'add' }]
: tableFieldsList;
const resetContentCheckErrorMsg = () => {
setContentCheckErrorMsg('');
};
return loading ? (
<Spin
style={{ height: '100%', width: '100%' }}
tip={loadingTips}
wrapperClassName={s.spin}
/>
) : (
<div className={s['table-structure-wrapper']}>
<Form<TableBasicInfo>
// @ts-expect-error -- linter-disable-autofix
ref={tableBasicInfoFormRef}
layout="vertical"
initValues={{
name: initialData.name,
desc: initialData.desc,
prompt_disabled: isModify
? initialData.extra_info?.prompt_disabled === 'true'
? false
: true
: true,
readAndWriteMode:
initialData.readAndWriteMode || BotTableRWMode.LimitedReadWrite,
}}
className={s['table-structure-form']}
onValueChange={(values, changedV) => {
if ('name' in changedV || 'desc' in changedV) {
resetContentCheckErrorMsg();
}
if (values.readAndWriteMode === BotTableRWMode.UnlimitedReadWrite) {
setTableFieldsList(state => {
if (state.some(i => i.isSystemField)) {
return state;
}
return [...SYSTEM_FIELDS, ...state];
});
} else if (
values.readAndWriteMode === BotTableRWMode.LimitedReadWrite
) {
setTableFieldsList(state => {
if (state.some(i => i.isSystemField)) {
return state.filter(i => !i.isSystemField);
}
return state;
});
}
}}
>
<FormInput
field="name"
label={{
text: I18n.t('db_add_table_name'),
required: true,
}}
// @ts-expect-error -- linter-disable-autofix
validate={v => getTableNameErrorMessage(v)}
noErrorMessage
trigger={['change', 'blur']}
fieldClassName={s['table-name-form-field']}
/>
<Form.TextArea
data-testid={BotE2e.BotDatabaseAddModalTableDescInput}
field="desc"
label={I18n.t('db_add_table_desc')}
disabled={isReadonly}
rows={2}
placeholder={I18n.t('db_add_table_desc_tips')}
fieldClassName={s['table-desc-form-field']}
/>
<Row type="flex" justify="space-between">
<Col span={12}>
{readAndWriteModeOptions ? (
<Form.Select
data-testid={BotE2e.BotDatabaseAddModalTableQueryModeSelect}
field="readAndWriteMode"
style={{ width: '267px' }}
label={{
text: I18n.t('db_table_0129_001'),
extra: (
<Popover
className={s.read_mode_popover}
content={
<div>
<article>
{RW_MODE_OPTIONS_MAP[readAndWriteModeOptions].map(
i => (
<p className={s['th-tip-dot']}>
{RW_MODE_OPTIONS_CONFIG[i].tips}
</p>
),
)}
</article>
</div>
}
position="top"
showArrow
>
<Icon
svg={<InfoSVG />}
className={s['form-item-label-tooltip-icon']}
/>
</Popover>
),
}}
dropdownClassName={s['table-setting-option']}
optionList={RW_MODE_OPTIONS_MAP[readAndWriteModeOptions].map(
i => ({
label: RW_MODE_OPTIONS_CONFIG[i].label,
value: i,
}),
)}
/>
) : null}
</Col>
<Col span={readAndWriteModeOptions ? MIN_COL : MAX_COL}>
<Form.Checkbox
style={{
width: 24,
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
className={s['form-item-checkbox']}
labelPosition="left"
field="prompt_disabled"
label={{
style: {
fontSize: '14px',
fontWeight: 400,
padding: '0 8px 0 0',
display: 'flex',
alignItems: 'center',
color: 'var(--Light-usage-text---color-text-0, #1D1C23)',
},
text: I18n.t('database_240618_01'),
extra: (
<Popover
className={s.prompt_disabled_popover}
content={I18n.t('database_240520_03')}
position="top"
showArrow
>
<Icon
svg={<InfoSVG />}
className={s['form-item-label-tooltip-icon']}
/>
</Popover>
),
}}
></Form.Checkbox>
</Col>
</Row>
</Form>
{isExceedRowMaxLimit ? (
<Banner
type="warning"
description={I18n.t('db_table_0126_027', {
ColumNum: maxColumnNum,
})}
className={s['max-row-banner']}
/>
) : null}
<UITable
tableProps={{
columns: [
{
dataIndex: 'name',
title: (
<ColumnHeader
label={I18n.t('db_add_table_field_name')}
required
tips={
<div className={s['th-tip-name']}>
<span style={{ width: 494, marginBottom: 8 }}>
{I18n.t('db_add_table_field_name_tips')}
</span>
<Image
preview={false}
width={494}
height={163}
src={keyExample}
/>
</div>
}
/>
),
render: (text, record, index) =>
record.operate !== 'add' ? (
<SLInput
style={{ position: 'static' }}
onRef={inputRef}
value={record.name}
inputProps={{
'data-testid': BotE2e.BotDatabaseAddModalFieldNameInput,
'data-dtestid': BotE2e.BotDatabaseAddModalFieldNameInput,
disabled: isReadonly || record.isSystemField,
placeholder: 'Enter Name',
}}
errorMsgFloat
onFocusPopoverProps={{
position: 'left',
content: <KeyTipsNode />,
}}
errorMsg={tableFieldsList[index]?.errorMapper?.name?.join(
'; ',
)}
handleChange={v => {
const newTableMemoryList = [...tableFieldsList];
newTableMemoryList[index].name = v;
setTableFieldsList(newTableMemoryList);
verifyTableFields('change');
resetContentCheckErrorMsg();
}}
handleBlur={() => {
verifyTableFields('blur');
}}
/>
) : (
<div
ref={scrollRef}
data-testid={BotE2e.BotDatabaseAddModalAddBtn}
>
{isRowMaxLimit ? (
<div style={{ paddingRight: 10 }}>
<Tooltip
position="top"
content={I18n.t('bot_database_add_field', {
number: maxColumnNum,
})}
>
<Button
theme="light"
style={{ width: 240 }}
type="tertiary"
disabled
icon={<IconAdd />}
>
{I18n.t('bot_userProfile_add')}
</Button>
</Tooltip>
</div>
) : (
<Button
style={{ width: 240 }}
theme="light"
type="tertiary"
disabled={isReadonly}
onClick={() => handleAdd(true)}
icon={<IconAdd />}
>
{I18n.t('bot_userProfile_add')}
</Button>
)}
</div>
),
width: 246,
},
{
dataIndex: 'desc',
title: (
<ColumnHeader
label={I18n.t('db_add_table_field_desc')}
required={false}
tips={
<article style={{ width: 327 }}>
{I18n.t('db_add_table_field_desc_tips')}
</article>
}
/>
),
render: (text, record, index) =>
record.operate !== 'add' ? (
<SLInput
value={record.desc}
maxCount={300}
inputProps={{
'data-testid': `${BotE2e.BotDatabaseAddModalFieldDescInput}.${index}.${record.name}`,
'data-dtestid': `${BotE2e.BotDatabaseAddModalFieldDescInput}.${index}.${record.name}`,
maxLength: 300,
disabled: isReadonly || record.isSystemField,
placeholder: I18n.t(
'bot_edit_variable_description_placeholder',
),
}}
errorMsgFloat
handleChange={v => {
const newTableMemoryList = [...tableFieldsList];
newTableMemoryList[index].desc = v;
setTableFieldsList(newTableMemoryList);
resetContentCheckErrorMsg();
}}
/>
) : null,
width: 376,
},
{
dataIndex: 'type',
title: (
<ColumnHeader
label={I18n.t('db_add_table_field_type')}
required={true}
tips={
<article style={{ width: 327 }}>
{I18n.t('db_add_table_field_type_tips')}
</article>
}
/>
),
render: (text, record, index) =>
record.operate !== 'add' ? (
<SLSelect
value={record.type}
selectProps={{
'data-testid': `${BotE2e.BotDatabaseAddModalFieldTypeSelect}.${index}.${record.name}`,
'data-dtestid': `${BotE2e.BotDatabaseAddModalFieldTypeSelect}.${index}.${record.name}`,
disabled:
isReadonly ||
(isModify && !!record.id) ||
record.isSystemField,
placeholder: I18n.t('db_table_save_exception_fieldtype'),
optionList: FIELD_TYPE_OPTIONS,
}}
errorMsgFloat
errorMsg={tableFieldsList[index]?.errorMapper?.type?.join(
'; ',
)}
handleChange={v => {
const newTableMemoryList = [...tableFieldsList];
newTableMemoryList[index].type = v as FieldItemType;
setTableFieldsList(newTableMemoryList);
verifyTableFields('change');
}}
/>
) : null,
width: 211,
},
{
dataIndex: 'must_required',
title: (
<ColumnHeader
label={I18n.t('db_add_table_field_necessary')}
required={false}
tips={
<article style={{ width: 327 }}>
<p className={s['th-tip-dot']}>
{I18n.t('db_add_table_field_necessary_tips1')}
</p>
<p className={s['th-tip-dot']}>
{I18n.t('db_add_table_field_necessary_tips2')}
</p>
</article>
}
/>
),
render: (text, record, index) =>
record.operate !== 'add' ? (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Switch
data-testid={`${BotE2e.BotDatabaseAddModalFieldRequiredSwitch}.${index}.${record.name}`}
data-dtestid={`${BotE2e.BotDatabaseAddModalFieldRequiredSwitch}.${index}.${record.name}`}
style={{ margin: '4px 0 4px 12px' }}
disabled={
isReadonly ||
record.disableMustRequired ||
record.isSystemField
}
checked={record.must_required}
onChange={v => {
const newTableMemoryList = [...tableFieldsList];
newTableMemoryList[index].must_required = v;
setTableFieldsList(newTableMemoryList);
}}
aria-label="a switch for semi demo"
/>
</div>
) : null,
width: 108,
},
{
dataIndex: 'operate',
title: I18n.t('db_table_0126_021'),
render: (text, record, index) =>
record.operate !== 'add' ? (
<UITableAction
deleteProps={{
handleClick: () => {
if (isReadonly) {
return;
}
const newTableMemoryList = [
...tableFieldsList.slice(0, index),
...tableFieldsList.slice(index + 1),
];
setTableFieldsList(newTableMemoryList);
verifyTableFields('change');
onDeleteField?.(newTableMemoryList);
},
popconfirm: {
defaultVisible: false,
visible: false,
},
tooltip: {
content: I18n.t('datasets_table_title_actions_delete'),
},
disabled: record.isSystemField,
}}
editProps={{
hide: true,
}}
/>
) : null,
width: 85,
},
],
dataSource,
pagination: false,
className: s['table-structure-table'],
rowKey: 'nanoid',
}}
wrapperClassName={s['table-structure-table-wrapper']}
/>
{isEmptyList ? (
<div className={s['table-empty-tips']}>
{I18n.t('db_table_save_exception_nofield')}
</div>
) : null}
</div>
);
});

View File

@@ -0,0 +1,64 @@
.input-wrapper {
position: relative;
width: 100%;
// semi 原有的 disable 样式不满足需求,需要更明显的文字颜色
:global {
.semi-input-wrapper-disabled {
-webkit-text-fill-color: rgba(29, 28, 35, 100%);
}
.semi-input-suffix {
-webkit-text-fill-color: var(--semi-color-disabled-text, rgba(56, 55, 67, 20%))
}
}
span {
width: 100%;
}
}
.error-wrapper {
:global {
.semi-input-wrapper {
border: 1px solid @error-red;
}
}
}
.error-content {
height: 20px;
}
.error-float {
position: absolute;
width: 100%;
white-space: nowrap;
}
.error-text {
position: absolute;
padding-top: 2px;
font-size: 12px;
color: @error-red;
}
.input {
input {
overflow: hidden;
text-overflow: ellipsis;
}
}
.limit-count {
overflow: hidden;
padding-right: 12px;
padding-left: 8px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
color: var(--light-usage-text-color-text-3, rgba(28, 31, 35, 35%));
}

View File

@@ -0,0 +1,197 @@
/*
* 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, {
type ComponentProps,
useRef,
useImperativeHandle,
useMemo,
useEffect,
type ForwardedRef,
} from 'react';
import isNumber from 'lodash-es/isNumber';
import cs from 'classnames';
import { useReactive } from 'ahooks';
import { type TooltipProps } from '@coze-arch/bot-semi/Tooltip';
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
import { type InputProps } from '@coze-arch/bot-semi/Input';
import { Input, Popover, Tooltip } from '@coze-arch/bot-semi';
import s from './index.module.less';
export interface SLInputRefType {
triggerFocus?: () => void;
}
export type SLInputProps = ComponentProps<typeof Input> & {
value: string | undefined;
onRef?: ForwardedRef<SLInputRefType>;
ellipsis?: boolean;
handleChange?: (v: string) => void;
handleBlur?: (v: string) => void;
handleFocus?: (v: string) => void;
ellipsisPopoverProps?: PopoverProps;
onFocusPopoverProps?: PopoverProps;
tooltipProps?: TooltipProps;
inputProps?: InputProps & { 'data-dtestid'?: string; 'data-testid'?: string };
errorMsg?: string;
errorMsgFloat?: boolean;
maxCount?: number;
className?: string;
style?: React.CSSProperties;
};
export const SLInput: React.FC<SLInputProps> = props => {
const { ellipsis = true, maxCount, errorMsgFloat } = props;
const showCount = isNumber(maxCount) && maxCount > 0;
useImperativeHandle(props.onRef, () => ({
triggerFocus,
}));
const $state = useReactive({
value: props.value,
inputOnFocus: false,
inputEle: false,
});
const inputRef = useRef<HTMLInputElement>(null);
const triggerFocus = () => {
$state.inputEle = true;
inputRef?.current?.focus();
};
const onFocus = () => {
$state.inputOnFocus = true;
$state.inputEle = true;
props?.handleFocus?.($state.value || '');
};
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
$state.inputOnFocus = false;
props?.handleBlur?.($state.value || '');
props?.onBlur?.(e);
$state.inputEle = false;
};
const onChange = (v: string) => {
$state.value = v;
props?.handleChange?.(v);
};
const onclick = () => {
if (!$state.inputEle) {
setTimeout(() => {
inputRef?.current?.focus();
}, 10);
}
$state.inputEle = true;
};
const hasEllipsis = useMemo(() => {
const clientWidth = inputRef.current?.clientWidth || 0;
const scrollWidth = inputRef.current?.scrollWidth || 0;
return clientWidth < scrollWidth - 1;
}, [
ellipsis,
$state.inputOnFocus,
$state.value,
inputRef.current?.clientWidth,
inputRef.current?.scrollWidth,
$state.inputEle,
]);
useEffect(() => {
$state.value = props.value;
}, [props.value]);
const LimitCountNode = (
<span className={s['limit-count']}>
{$state.value?.length || 0}/{maxCount}
</span>
);
return (
<div
className={cs(s['input-wrapper'], props.className)}
style={props.style}
>
{!$state.inputEle && hasEllipsis ? (
<Tooltip
content={
<article
style={{
maxWidth: 200,
wordWrap: 'break-word',
wordBreak: 'normal',
}}
>
{$state.value}
</article>
}
position={'top'}
showArrow
mouseEnterDelay={300}
{...props.tooltipProps}
>
<div
className={cs(props?.errorMsg ? s['error-wrapper'] : null)}
onClick={onclick}
>
<Input
{...props.inputProps}
ref={inputRef}
value={$state.value}
className={ellipsis ? s.input : ''}
suffix={showCount ? LimitCountNode : undefined}
></Input>
</div>
</Tooltip>
) : (
<div className={cs(props?.errorMsg ? s['error-wrapper'] : null)}>
<Popover
{...props.onFocusPopoverProps}
trigger="custom"
visible={
Boolean(props.onFocusPopoverProps?.content) && $state.inputOnFocus
}
showArrow
>
<Input
{...props.inputProps}
ref={inputRef}
value={$state.value}
className={ellipsis ? s.input : ''}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
suffix={showCount ? LimitCountNode : undefined}
></Input>
</Popover>
</div>
)}
{props?.errorMsg ? (
<div
className={cs({
[s['error-content']]: true,
[s['error-float']]: Boolean(errorMsgFloat),
})}
>
<div className={s['error-text']}>{props?.errorMsg}</div>
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,54 @@
.select-wrapper {
width: 100%;
// semi 原有的 disable 样式不满足需求,需要更明显的文字颜色
:global {
.semi-select-disabled .semi-select-selection {
color: rgba(29, 28, 35, 100%);
}
.semi-select-disabled .semi-select-arrow {
color: rgba(29, 28, 35, 60%);
}
}
span {
width: 100%;
}
}
.error-wrapper {
:global {
.semi-select {
border: 1px solid @error-red;
}
}
}
.error-content {
height: 20px;
}
.error-float {
position: absolute;
width: 100%;
}
.error-text {
position: absolute;
padding-top: 2px;
font-size: 12px;
color: @error-red;
}
.selected-option {
:global {
.semi-select-option-selected {
.semi-select-option-icon {
color: #4D53E8
}
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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 classnames from 'classnames';
import { type SelectProps } from '@coze-arch/bot-semi/Select';
import { type InputProps } from '@coze-arch/bot-semi/Input';
import { Select } from '@coze-arch/bot-semi';
import s from './index.module.less';
export interface SLSelectRefType {
triggerFocus?: () => void;
}
export type SLSelectProps = InputProps & {
value: SelectProps['value'];
handleChange?: (v: SelectProps['value']) => void;
errorMsg?: string;
errorMsgFloat?: boolean;
selectProps?: SelectProps & {
'data-testid'?: string;
'data-dtestid'?: string;
};
};
export const SLSelect: React.FC<SLSelectProps> = props => {
const { errorMsg, errorMsgFloat } = props;
return (
<div
className={classnames({
[s['select-wrapper']]: true,
[s['error-wrapper']]: Boolean(errorMsg),
})}
>
<Select
{...props.selectProps}
style={{ width: '100%' }}
value={props.value}
onChange={v => {
props?.handleChange?.(v);
}}
dropdownClassName={s['selected-option']}
/>
{errorMsg ? (
<div
className={classnames({
[s['error-content']]: true,
[s['error-float']]: Boolean(errorMsgFloat),
})}
>
<div className={s['error-text']}>{errorMsg}</div>
</div>
) : null}
</div>
);
};