feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
31
frontend/packages/workflow/components/.storybook/main.js
Normal file
31
frontend/packages/workflow/components/.storybook/main.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
|
||||
/** @type { import('@storybook/react-vite').StorybookConfig } */
|
||||
const config = {
|
||||
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
viteFinal: config =>
|
||||
mergeConfig(config, {
|
||||
plugins: [
|
||||
svgr({
|
||||
svgrOptions: {
|
||||
native: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
};
|
||||
export default config;
|
||||
14
frontend/packages/workflow/components/.storybook/preview.js
Normal file
14
frontend/packages/workflow/components/.storybook/preview.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/** @type { import('@storybook/react').Preview } */
|
||||
const preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
5
frontend/packages/workflow/components/.stylelintrc.js
Normal file
5
frontend/packages/workflow/components/.stylelintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { defineConfig } = require('@coze-arch/stylelint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
extends: [],
|
||||
});
|
||||
16
frontend/packages/workflow/components/README.md
Normal file
16
frontend/packages/workflow/components/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# @coze-workflow/components
|
||||
|
||||
> Project template for react component with storybook.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] eslint & ts
|
||||
- [x] esm bundle
|
||||
- [x] umd bundle
|
||||
- [x] storybook
|
||||
|
||||
## Commands
|
||||
|
||||
- init: `rush update`
|
||||
- dev: `npm run dev`
|
||||
- build: `npm run build`
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"operationSettings": [
|
||||
{
|
||||
"operationName": "test:cov",
|
||||
"outputFolderNames": ["coverage"]
|
||||
},
|
||||
{
|
||||
"operationName": "ts-check",
|
||||
"outputFolderNames": ["./dist"]
|
||||
}
|
||||
]
|
||||
}
|
||||
10
frontend/packages/workflow/components/eslint.config.js
Normal file
10
frontend/packages/workflow/components/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { defineConfig } = require('@coze-arch/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
packageRoot: __dirname,
|
||||
preset: 'web',
|
||||
rules: {
|
||||
'import/no-duplicates': 'off',
|
||||
},
|
||||
ignores: ['**/__tests__/**'],
|
||||
});
|
||||
116
frontend/packages/workflow/components/package.json
Normal file
116
frontend/packages/workflow/components/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "@coze-workflow/components",
|
||||
"version": "0.0.1",
|
||||
"description": "workflow 公共业务组件",
|
||||
"license": "Apache-2.0",
|
||||
"author": "lvxinsheng@bytedance.com",
|
||||
"maintainers": [],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./workflow-modal": "./src/workflow-modal/index.tsx",
|
||||
"./use-workflow-resource-action": "./src/hooks/use-workflow-resource-action/index.tsx"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"workflow-modal": [
|
||||
"./src/workflow-modal/index.tsx"
|
||||
],
|
||||
"use-workflow-resource-action": [
|
||||
"./src/hooks/use-workflow-resource-action/index.tsx"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"lint": "eslint ./ --cache",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:cov": "npm run test -- --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@coze-arch/bot-api": "workspace:*",
|
||||
"@coze-arch/bot-error": "workspace:*",
|
||||
"@coze-arch/bot-flags": "workspace:*",
|
||||
"@coze-arch/bot-semi": "workspace:*",
|
||||
"@coze-arch/bot-space-api": "workspace:*",
|
||||
"@coze-arch/bot-studio-store": "workspace:*",
|
||||
"@coze-arch/bot-tea": "workspace:*",
|
||||
"@coze-arch/bot-utils": "workspace:*",
|
||||
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
|
||||
"@coze-arch/foundation-sdk": "workspace:*",
|
||||
"@coze-arch/i18n": "workspace:*",
|
||||
"@coze-arch/idl": "workspace:*",
|
||||
"@coze-arch/logger": "workspace:*",
|
||||
"@coze-arch/report-events": "workspace:*",
|
||||
"@coze-common/assets": "workspace:*",
|
||||
"@coze-common/biz-components": "workspace:*",
|
||||
"@coze-common/loading-button": "workspace:*",
|
||||
"@coze-common/mouse-pad-selector": "workspace:*",
|
||||
"@coze-editor/editor": "0.1.0-alpha.d92d50",
|
||||
"@coze-studio/components": "workspace:*",
|
||||
"@coze-workflow/base": "workspace:*",
|
||||
"@coze-workflow/resources-adapter": "workspace:*",
|
||||
"@douyinfe/semi-icons": "^2.36.0",
|
||||
"@flowgram-adapter/free-layout-editor": "workspace:*",
|
||||
"@tanstack/react-query": "~5.13.4",
|
||||
"ahooks": "^3.7.8",
|
||||
"classnames": "^2.3.2",
|
||||
"cronstrue": "2.54.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"dequal": "^2.0.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.0.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^4.0.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"semver": "^7.3.7",
|
||||
"slate": "0.101.5",
|
||||
"slate-history": "0.100.0",
|
||||
"slate-react": "0.101.5",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coze-arch/bot-typings": "workspace:*",
|
||||
"@coze-arch/eslint-config": "workspace:*",
|
||||
"@coze-arch/stylelint-config": "workspace:*",
|
||||
"@coze-arch/tailwind-config": "workspace:*",
|
||||
"@coze-arch/ts-config": "workspace:*",
|
||||
"@coze-arch/vitest-config": "workspace:*",
|
||||
"@lezer/common": "^1.2.2",
|
||||
"@rspack/core": "0.6.0",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/lodash-es": "^4.17.10",
|
||||
"@types/node": "^18",
|
||||
"@types/react": "18.2.37",
|
||||
"@types/react-dom": "18.2.15",
|
||||
"@types/semver": "^7.3.4",
|
||||
"@vitest/coverage-v8": "~3.0.5",
|
||||
"i18next": ">= 19.0.0",
|
||||
"less": "^3.13.1",
|
||||
"react": "~18.2.0",
|
||||
"react-dom": "~18.2.0",
|
||||
"react-is": ">= 16.8.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"scheduler": ">=0.19.0",
|
||||
"styled-components": ">= 2",
|
||||
"stylelint": "^15.11.0",
|
||||
"tailwindcss": "~3.3.3",
|
||||
"typescript": "~5.8.2",
|
||||
"utility-types": "^3.10.0",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-svgr": "~3.3.0",
|
||||
"vitest": "~3.0.5",
|
||||
"webpack": "~5.91.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2.0",
|
||||
"react-dom": ">=18.2.0"
|
||||
},
|
||||
"// deps": "immer@^10.0.3 为脚本自动补齐,请勿改动"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react'
|
||||
import { Popover } from '../popover'
|
||||
|
||||
vi.mock('../popover/hooks/use-tree', () => {
|
||||
return {
|
||||
useTreeRefresh() {},
|
||||
useTreeSearch() {},
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock('@coze-arch/bot-semi', async () => {
|
||||
const { forwardRef } = await vi.importActual('react') as any;
|
||||
return {
|
||||
Popover({ content }) {
|
||||
return <div>{content}</div>
|
||||
},
|
||||
Tree: forwardRef((_, ref) => {
|
||||
return <div ref={ref}></div>
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@coze-editor/editor', () => {
|
||||
return {
|
||||
mixLanguages() {},
|
||||
astDecorator: {
|
||||
whole: {
|
||||
of() {}
|
||||
},
|
||||
fromCursor: {
|
||||
of() {}
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@coze-editor/editor/react', () => {
|
||||
return {
|
||||
Renderer() {},
|
||||
CursorMirror() {
|
||||
return null;
|
||||
},
|
||||
SelectionSide: {
|
||||
Head: 'head',
|
||||
Anchor: 'anchor',
|
||||
},
|
||||
useEditor() {
|
||||
return {
|
||||
disableKeybindings() {},
|
||||
$on() {},
|
||||
$off() {},
|
||||
replaceTextByRange() {},
|
||||
$view: {
|
||||
state: {
|
||||
selection: {
|
||||
main: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
anchor: 0,
|
||||
head: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@coze-editor/editor/preset-expression', () => {
|
||||
return {
|
||||
default: []
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/expression-editor', () => ({}));
|
||||
|
||||
describe('popover', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should render props.className correctly', () => {
|
||||
const { container } = render(<Popover variableTree={[]} className='foo' />)
|
||||
|
||||
const elements = container.querySelectorAll('.foo')
|
||||
expect(elements.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import { Renderer } from '../renderer';
|
||||
|
||||
const { SDKRenderer } = vi.hoisted(() => ({ SDKRenderer: vi.fn() }));
|
||||
|
||||
vi.mock('@coze-editor/editor', () => {
|
||||
return {
|
||||
mixLanguages() {},
|
||||
astDecorator: {
|
||||
whole: {
|
||||
of() {},
|
||||
},
|
||||
fromCursor: {
|
||||
of() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@coze-editor/editor/react', () => {
|
||||
return {
|
||||
Renderer: SDKRenderer,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@coze-editor/editor/preset-expression', () => {
|
||||
return {
|
||||
default: [],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/expression-editor', () => ({}));
|
||||
|
||||
describe('renderer', () => {
|
||||
beforeEach(() => {
|
||||
SDKRenderer.mockImplementation(({ defaultValue, didMount }) => {
|
||||
useEffect(() => {
|
||||
didMount({
|
||||
getValue() {},
|
||||
setValue() {},
|
||||
updateWholeDecorations() {},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should props.className work correctly', () => {
|
||||
render(<Renderer variableTree={[]} className="foo" />);
|
||||
|
||||
// @ts-expect-error -- mock
|
||||
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
|
||||
options: {
|
||||
contentAttributes: {
|
||||
class: 'foo flow-canvas-not-draggable',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should props.dataTestID work correctly', () => {
|
||||
render(<Renderer variableTree={[]} dataTestID="foo" />);
|
||||
|
||||
// @ts-expect-error -- mock
|
||||
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
|
||||
options: {
|
||||
contentAttributes: {
|
||||
'data-testid': 'foo',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should props.placeholder work correctly', () => {
|
||||
render(<Renderer variableTree={[]} placeholder="foo" />);
|
||||
|
||||
// @ts-expect-error -- mock
|
||||
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
|
||||
options: {
|
||||
placeholder: 'foo',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should props.value work correctly', () => {
|
||||
let value = '';
|
||||
const getValue = () => value;
|
||||
const setValue = vi.fn();
|
||||
|
||||
SDKRenderer.mockImplementation(({ defaultValue, didMount }) => {
|
||||
useEffect(() => {
|
||||
value = defaultValue;
|
||||
didMount({
|
||||
getValue,
|
||||
setValue,
|
||||
updateWholeDecorations() {},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const { rerender } = render(<Renderer variableTree={[]} value="value" />);
|
||||
|
||||
// @ts-expect-error -- mock
|
||||
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
|
||||
defaultValue: 'value',
|
||||
});
|
||||
|
||||
rerender(<Renderer variableTree={[]} value="value2" />);
|
||||
|
||||
expect(setValue).toHaveBeenCalledTimes(1);
|
||||
expect(setValue).toHaveBeenLastCalledWith('value2');
|
||||
});
|
||||
|
||||
it('Should props.onChange work correctly', () => {
|
||||
let change: ((e: { value: string }) => void) | null = null;
|
||||
SDKRenderer.mockImplementation(({ onChange, didMount }) => {
|
||||
change = onChange;
|
||||
useEffect(() => {
|
||||
didMount({
|
||||
getValue() {},
|
||||
setValue() {},
|
||||
updateWholeDecorations() {},
|
||||
});
|
||||
}, []);
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
render(<Renderer variableTree={[]} onChange={onChange} />);
|
||||
|
||||
change!({
|
||||
value: 'foo',
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenLastCalledWith('foo');
|
||||
|
||||
change!({
|
||||
value: 'bar',
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange).toHaveBeenLastCalledWith('bar');
|
||||
});
|
||||
|
||||
it('Should props.readonly work correctly', () => {
|
||||
const { rerender } = render(<Renderer variableTree={[]} readonly={true} />);
|
||||
|
||||
expect(SDKRenderer).toHaveBeenCalledTimes(1);
|
||||
// @ts-expect-error -- mock
|
||||
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
|
||||
options: {
|
||||
readOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
rerender(<Renderer variableTree={[]} readonly={false} />);
|
||||
|
||||
expect(SDKRenderer).toHaveBeenCalledTimes(2);
|
||||
// @ts-expect-error -- mock
|
||||
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
|
||||
options: {
|
||||
readOnly: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { EditorProvider } from '@coze-editor/editor/react';
|
||||
|
||||
import { Renderer } from './renderer';
|
||||
import { Popover } from './popover';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const Expression = {
|
||||
Renderer,
|
||||
Popover,
|
||||
EditorProvider,
|
||||
};
|
||||
|
||||
export { Expression };
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 { useInterpolationContent } from './use-interpolation-content';
|
||||
export { useEmptyContent } from './use-empty-content';
|
||||
export { usePrunedVariableTree } from './use-pruned-variable-tree';
|
||||
export { useFilteredVariableTree } from './use-filtered-variable-tree';
|
||||
export { useFocused } from './use-focused';
|
||||
export { useTreeRefresh, useTreeSearch } from './use-tree';
|
||||
export { useKeyboard } from './use-keyboard';
|
||||
export { useSelection } from './use-selection';
|
||||
export { useOptionsOperations } from './use-options-operations';
|
||||
export { useSelectedValue } from './use-selected-value';
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface InterpolationContent {
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
offset: number;
|
||||
textBefore: string;
|
||||
}
|
||||
|
||||
export type { InterpolationContent };
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '@/expression-editor';
|
||||
|
||||
import { type InterpolationContent } from './types';
|
||||
|
||||
function isEmpty(value: unknown) {
|
||||
return !value || !Array.isArray(value) || value.length === 0;
|
||||
}
|
||||
|
||||
function useEmptyContent(
|
||||
fullVariableTree: ExpressionEditorTreeNode[] | undefined,
|
||||
variableTree: ExpressionEditorTreeNode[] | undefined,
|
||||
interpolationContent: InterpolationContent | undefined,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!interpolationContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(fullVariableTree)) {
|
||||
if (interpolationContent.textBefore === '') {
|
||||
return I18n.t('workflow_variable_refer_no_input');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(variableTree)) {
|
||||
if (interpolationContent.text === '') {
|
||||
return I18n.t('workflow_variable_refer_no_input');
|
||||
}
|
||||
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(
|
||||
interpolationContent.textBefore,
|
||||
);
|
||||
|
||||
if (!segments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchTreeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree: fullVariableTree ?? [],
|
||||
segments,
|
||||
});
|
||||
const isMatchedButEmpty = matchTreeBranch && matchTreeBranch.length !== 0;
|
||||
|
||||
if (isMatchedButEmpty) {
|
||||
return I18n.t('workflow_variable_refer_no_sub_variable');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}, [fullVariableTree, variableTree, interpolationContent]);
|
||||
}
|
||||
|
||||
export { useEmptyContent };
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type ExpressionEditorTreeNode } from '@/expression-editor';
|
||||
|
||||
import { getSearchValue } from '../../shared';
|
||||
import { type InterpolationContent } from './types';
|
||||
|
||||
function useFilteredVariableTree(
|
||||
interpolationContent: InterpolationContent | undefined,
|
||||
prunedVariableTree: ExpressionEditorTreeNode[],
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!prunedVariableTree) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!interpolationContent) {
|
||||
return prunedVariableTree;
|
||||
}
|
||||
|
||||
const searchValue = getSearchValue(interpolationContent.textBefore);
|
||||
|
||||
return prunedVariableTree.filter(variable =>
|
||||
variable.label.startsWith(searchValue),
|
||||
);
|
||||
}, [interpolationContent, prunedVariableTree]);
|
||||
}
|
||||
|
||||
export { useFilteredVariableTree };
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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, useEffect } from 'react';
|
||||
|
||||
function useFocused(editor) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
setFocused(true);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
setFocused(false);
|
||||
}
|
||||
|
||||
editor.$on('focus', handleFocus);
|
||||
editor.$on('blur', handleBlur);
|
||||
|
||||
return () => {
|
||||
editor.$off('focus', handleFocus);
|
||||
editor.$off('blur', handleBlur);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return focused;
|
||||
}
|
||||
|
||||
export { useFocused };
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { type EditorView } from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
import { type InterpolationContent } from './types';
|
||||
|
||||
function getInterpolationContentAtPos(view: EditorView, pos: number) {
|
||||
const tree = syntaxTree(view.state);
|
||||
const cursor = tree.cursorAt(pos);
|
||||
|
||||
do {
|
||||
if (
|
||||
cursor.node.type.name === 'Interpolation' &&
|
||||
cursor.node.firstChild &&
|
||||
cursor.node.lastChild &&
|
||||
pos >= cursor.node.firstChild.to &&
|
||||
pos <= cursor.node.lastChild.from
|
||||
) {
|
||||
const text = view.state.sliceDoc(
|
||||
cursor.node.firstChild.to,
|
||||
cursor.node.lastChild.from,
|
||||
);
|
||||
const offset = pos - cursor.node.firstChild.to;
|
||||
return {
|
||||
from: cursor.node.firstChild.to,
|
||||
to: cursor.node.lastChild.from,
|
||||
text,
|
||||
offset,
|
||||
textBefore: text.slice(0, offset),
|
||||
};
|
||||
}
|
||||
} while (cursor.parent());
|
||||
}
|
||||
|
||||
function useInterpolationContent(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
pos: number | undefined,
|
||||
): InterpolationContent | undefined {
|
||||
return useMemo(() => {
|
||||
if (!editor || typeof pos === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = editor.$view;
|
||||
return getInterpolationContentAtPos(view, pos);
|
||||
}, [editor, pos]);
|
||||
}
|
||||
|
||||
export { useInterpolationContent };
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 { useLatest } from '../../shared';
|
||||
|
||||
type Keymap = Record<string, (e: KeyboardEvent) => void>;
|
||||
|
||||
function useKeyboard(enable: boolean, keymap: Keymap) {
|
||||
const keymapRef = useLatest(keymap);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
const callback = keymapRef.current[e.key];
|
||||
if (typeof callback === 'function') {
|
||||
callback(e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, false);
|
||||
};
|
||||
}, [enable]);
|
||||
}
|
||||
|
||||
export { useKeyboard };
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
|
||||
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from '../shared';
|
||||
import { useLatest } from '../../shared';
|
||||
import { type InterpolationContent } from './types';
|
||||
|
||||
// eslint-disable-next-line max-params
|
||||
function useOptionsOperations(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
interpolationContent: InterpolationContent | undefined,
|
||||
treeContainerRef,
|
||||
treeRef,
|
||||
) {
|
||||
const editorRef = useLatest(editor);
|
||||
const interpolationContentRef = useLatest(interpolationContent);
|
||||
|
||||
return useMemo(() => {
|
||||
function prev() {
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { elements, selectedIndex } = optionsInfo;
|
||||
|
||||
if (elements.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex =
|
||||
selectedIndex - 1 < 0 ? elements.length - 1 : selectedIndex - 1;
|
||||
selectNodeByIndex(elements, newIndex);
|
||||
}
|
||||
|
||||
function next() {
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { elements, selectedIndex } = optionsInfo;
|
||||
|
||||
const newIndex =
|
||||
selectedIndex + 1 >= elements.length ? 0 : selectedIndex + 1;
|
||||
selectNodeByIndex(elements, newIndex);
|
||||
}
|
||||
|
||||
function apply() {
|
||||
if (!interpolationContentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedElement } = optionsInfo;
|
||||
|
||||
const selectedDataKey = selectedElement?.getAttribute('data-key');
|
||||
|
||||
if (!selectedDataKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variableTreeNode =
|
||||
treeRef.current?.state?.keyEntities?.[selectedDataKey]?.data;
|
||||
if (!variableTreeNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyNode(
|
||||
editorRef.current,
|
||||
variableTreeNode,
|
||||
interpolationContentRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
prev,
|
||||
next,
|
||||
apply,
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export { useOptionsOperations };
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '@/expression-editor';
|
||||
|
||||
import { type InterpolationContent } from './types';
|
||||
|
||||
function usePrunedVariableTree(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
variableTree: ExpressionEditorTreeNode[],
|
||||
interpolationContent: InterpolationContent | undefined,
|
||||
): ExpressionEditorTreeNode[] {
|
||||
return useMemo(() => {
|
||||
if (!editor || !interpolationContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(
|
||||
interpolationContent.textBefore,
|
||||
);
|
||||
|
||||
if (!segments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
|
||||
tree: variableTree,
|
||||
segments,
|
||||
});
|
||||
|
||||
return prunedVariableTree;
|
||||
}, [editor, variableTree, interpolationContent]);
|
||||
}
|
||||
|
||||
export { usePrunedVariableTree };
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '@/expression-editor';
|
||||
|
||||
function useSelectedValue(
|
||||
interpolationText: string | undefined,
|
||||
variableTree: ExpressionEditorTreeNode[],
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!interpolationText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segments =
|
||||
ExpressionEditorParserBuiltin.toSegments(interpolationText);
|
||||
|
||||
if (!segments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const treeBrach = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree: variableTree,
|
||||
segments,
|
||||
});
|
||||
|
||||
if (!treeBrach) {
|
||||
return;
|
||||
}
|
||||
|
||||
return treeBrach[treeBrach.length - 1];
|
||||
}, [interpolationText, variableTree]);
|
||||
}
|
||||
|
||||
export { useSelectedValue };
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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, useEffect } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import { isSkipSelectionChangeUserEvent } from '../shared';
|
||||
import { useLatest } from '../../shared';
|
||||
|
||||
interface Selection {
|
||||
anchor: number;
|
||||
head: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
function isSameSelection(a?: Selection, b?: Selection) {
|
||||
if (!a && !b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
a &&
|
||||
b &&
|
||||
a.anchor === b.anchor &&
|
||||
a.head === b.head &&
|
||||
a.from === b.from &&
|
||||
a.to === b.to
|
||||
);
|
||||
}
|
||||
|
||||
function useSelection(editor: ExpressionEditorAPI | undefined) {
|
||||
const [selection, setSelection] = useState<Selection | undefined>();
|
||||
|
||||
const selectionRef = useLatest(selection);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = editor.$view;
|
||||
|
||||
function updateSelection(update?: ViewUpdate) {
|
||||
// 忽略 replaceTextByRange 导致的 selection change(效果:不触发 selection 变更,进而不显示推荐面板)
|
||||
if (update?.transactions.some(tr => isSkipSelectionChangeUserEvent(tr))) {
|
||||
setSelection(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, anchor, head } = view.state.selection.main;
|
||||
const newSelection = { from, to, anchor, head };
|
||||
if (isSameSelection(newSelection, selectionRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection({ from, to, anchor, head });
|
||||
}
|
||||
|
||||
function handleSelectionChange(e: { update: ViewUpdate }) {
|
||||
updateSelection(e.update);
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
editor.$on('selectionChange', handleSelectionChange);
|
||||
editor.$on('focus', handleFocus);
|
||||
|
||||
return () => {
|
||||
editor.$off('selectionChange', handleSelectionChange);
|
||||
editor.$off('focus', handleFocus);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
export { useSelection };
|
||||
@@ -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 { useState, useEffect, type MutableRefObject } from 'react';
|
||||
|
||||
import { type Tree } from '@coze-arch/bot-semi';
|
||||
|
||||
import { type ExpressionEditorTreeNode } from '@/expression-editor';
|
||||
|
||||
import { generateUniqueId, getSearchValue, useLatest } from '../../shared';
|
||||
import { type InterpolationContent } from './types';
|
||||
|
||||
// 在数据更新后,强制 Tree 组件重新渲染
|
||||
function useTreeRefresh(filteredVariableTree: ExpressionEditorTreeNode[]) {
|
||||
const [treeRefreshKey, setTreeRefreshKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setTreeRefreshKey(generateUniqueId());
|
||||
}, [filteredVariableTree]);
|
||||
|
||||
return treeRefreshKey;
|
||||
}
|
||||
|
||||
// Tree 组件重新渲染后进行搜索
|
||||
// eslint-disable-next-line max-params
|
||||
function useTreeSearch(
|
||||
treeRefreshKey: string,
|
||||
treeRef: MutableRefObject<Tree | null>,
|
||||
interpolationContent: InterpolationContent | undefined,
|
||||
callback: () => void,
|
||||
) {
|
||||
const interpolationContentRef = useLatest(interpolationContent);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeRef.current && interpolationContentRef.current) {
|
||||
const searchValue = getSearchValue(
|
||||
interpolationContentRef.current.textBefore,
|
||||
);
|
||||
treeRef.current.search(searchValue);
|
||||
callback();
|
||||
}
|
||||
}, [treeRefreshKey, interpolationContent]);
|
||||
}
|
||||
|
||||
export { useTreeRefresh, useTreeSearch };
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Popover } from './popover';
|
||||
@@ -0,0 +1,146 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable max-nesting-depth */
|
||||
.expression-editor-suggestion {
|
||||
z-index: 1000;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
width: 272px;
|
||||
max-height: 236px;
|
||||
|
||||
background: var(--light-usage-bg-color-bg-3, #FFF);
|
||||
border: 0.5px solid rgba(153, 182, 255, 12%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-empty {
|
||||
z-index: 1000;
|
||||
|
||||
background: #FFF;
|
||||
border: 0.5px solid rgba(153, 182, 255, 12%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
|
||||
|
||||
p {
|
||||
margin: 4px 6px;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
color: var(--light-usage-text-color-text-2, rgba(29, 28, 35, 60%));
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-tree {
|
||||
:global {
|
||||
.semi-tree-search-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.semi-tree-option-list {
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
padding: 4px;
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.semi-tree-option {
|
||||
pointer-events: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.semi-tree-option-label {
|
||||
pointer-events: auto;
|
||||
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
padding: 0 4px;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
|
||||
}
|
||||
|
||||
.semi-tree-option-label-text {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.semi-tree-option-highlight {
|
||||
color: var(--light-usage-warning-color-warning, #FF9600)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.semi-tree-option-selected {
|
||||
font-weight: 600;
|
||||
color: var(--light-usage-primary-color-primary, #4D53E8);
|
||||
}
|
||||
|
||||
.semi-tree-option-disabled {
|
||||
.semi-tree-option-label {
|
||||
cursor: not-allowed;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.semi-icon+.semi-tree-option-label {
|
||||
color: var(--light-usage-text-color-text-0, #1D1C23);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.semi-tree-option-empty-icon {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.semi-tree-option-expand-icon {
|
||||
pointer-events: auto;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0;
|
||||
padding: 4px;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-keyboard-selected {
|
||||
:global {
|
||||
.semi-tree-option-label {
|
||||
background: var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 8%)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* 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 ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
CursorMirror,
|
||||
useEditor,
|
||||
SelectionSide,
|
||||
} from '@coze-editor/editor/react';
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { type TreeNodeData } from '@coze-arch/bot-semi/Tree';
|
||||
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
|
||||
import { Popover as SemiPopover, Tree } from '@coze-arch/bot-semi';
|
||||
|
||||
import { type ExpressionEditorTreeNode } from '@/expression-editor';
|
||||
|
||||
import { generateUniqueId, useDeepEqualMemo, useLatest } from '../shared';
|
||||
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from './shared';
|
||||
import {
|
||||
useEmptyContent,
|
||||
useFilteredVariableTree,
|
||||
useFocused,
|
||||
useInterpolationContent,
|
||||
usePrunedVariableTree,
|
||||
useSelection,
|
||||
useTreeRefresh,
|
||||
useTreeSearch,
|
||||
useKeyboard,
|
||||
useOptionsOperations,
|
||||
useSelectedValue,
|
||||
} from './hooks';
|
||||
|
||||
import styles from './popover.module.less';
|
||||
|
||||
interface Props {
|
||||
getPopupContainer?: PopoverProps['getPopupContainer'];
|
||||
variableTree: ExpressionEditorTreeNode[];
|
||||
className?: string;
|
||||
onVisibilityChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @coze-arch/max-line-per-function
|
||||
function Popover({
|
||||
getPopupContainer,
|
||||
variableTree: vTree,
|
||||
className,
|
||||
onVisibilityChange,
|
||||
}: Props) {
|
||||
const variableTree = useDeepEqualMemo(vTree);
|
||||
const treeRef = useRef<Tree | null>(null);
|
||||
const treeContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const onVisibilityChangeRef = useLatest(onVisibilityChange);
|
||||
const [posKey, setPosKey] = useState('');
|
||||
const editor = useEditor<ExpressionEditorAPI | undefined>();
|
||||
const editorRef = useLatest(editor);
|
||||
const selection = useSelection(editor);
|
||||
const focused = useFocused(editor);
|
||||
const interpolationContent = useInterpolationContent(
|
||||
editor,
|
||||
selection?.anchor,
|
||||
);
|
||||
const prunedVariableTree = usePrunedVariableTree(
|
||||
editor,
|
||||
variableTree,
|
||||
interpolationContent,
|
||||
);
|
||||
const filteredVariableTree = useFilteredVariableTree(
|
||||
interpolationContent,
|
||||
prunedVariableTree,
|
||||
);
|
||||
const emptyContent = useEmptyContent(
|
||||
variableTree,
|
||||
prunedVariableTree,
|
||||
interpolationContent,
|
||||
);
|
||||
const treeRefreshKey = useTreeRefresh(filteredVariableTree);
|
||||
useTreeSearch(treeRefreshKey, treeRef, interpolationContent, () => {
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
const { elements } = optionsInfo;
|
||||
selectNodeByIndex(elements, 0);
|
||||
});
|
||||
// selected 仅用于 Tree 组件对应项展示蓝色选中效果,无其他用途
|
||||
const selected = useSelectedValue(interpolationContent?.text, variableTree);
|
||||
|
||||
// 基于用户选中项,替换所在 {{}} 中的内容
|
||||
const handleSelect = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
(_, __, node: TreeNodeData) => {
|
||||
if (!editor || !interpolationContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyNode(editor, node as ExpressionEditorTreeNode, interpolationContent);
|
||||
},
|
||||
[editor, interpolationContent],
|
||||
);
|
||||
|
||||
const internalVisible =
|
||||
focused &&
|
||||
((Boolean(interpolationContent) && filteredVariableTree.length > 0) ||
|
||||
Boolean(emptyContent));
|
||||
|
||||
const [allowVisible, setAllowVisible] = useState(false);
|
||||
// 选区变化时,清除锁定效果
|
||||
useEffect(() => {
|
||||
setAllowVisible(true);
|
||||
}, [selection]);
|
||||
|
||||
const visible = internalVisible && allowVisible;
|
||||
|
||||
const { prev, next, apply } = useOptionsOperations(
|
||||
editor,
|
||||
interpolationContent,
|
||||
treeContainerRef,
|
||||
treeRef,
|
||||
);
|
||||
|
||||
// 上下键切换推荐项,回车填入
|
||||
useKeyboard(visible, {
|
||||
ArrowUp: prev,
|
||||
ArrowDown: next,
|
||||
Enter: apply,
|
||||
});
|
||||
|
||||
// ESC 关闭
|
||||
useKeyboard(visible, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Escape() {
|
||||
setAllowVisible(false);
|
||||
},
|
||||
});
|
||||
|
||||
// 推荐面板出现时,禁用 ArrowUp/ArrowDown/Enter 的默认行为(行为改为上下键切换推荐项 & 回车插入)
|
||||
useEffect(() => {
|
||||
if (visible === true) {
|
||||
editorRef.current?.disableKeybindings(['ArrowUp', 'ArrowDown', 'Enter']);
|
||||
} else {
|
||||
editorRef.current?.disableKeybindings([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onVisibilityChangeRef.current === 'function') {
|
||||
onVisibilityChangeRef.current(visible);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<SemiPopover
|
||||
trigger="custom"
|
||||
visible={visible}
|
||||
keepDOM={true}
|
||||
rePosKey={posKey}
|
||||
getPopupContainer={getPopupContainer}
|
||||
content={
|
||||
<div
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
style={{ display: visible ? 'block' : 'none' }}
|
||||
// The data-attribute is used for other components to ignore some click outside event
|
||||
data-expression-popover
|
||||
>
|
||||
<EmptyContent visible={!!emptyContent} content={emptyContent} />
|
||||
<TreeContainer
|
||||
ref={treeContainerRef}
|
||||
visible={!emptyContent}
|
||||
className={className}
|
||||
>
|
||||
<Tree
|
||||
// key={treeRefreshKey}
|
||||
className={styles['expression-editor-suggestion-tree']}
|
||||
showFilteredOnly
|
||||
filterTreeNode
|
||||
onChangeWithObject
|
||||
ref={treeRef}
|
||||
treeData={prunedVariableTree}
|
||||
searchRender={false}
|
||||
value={selected}
|
||||
emptyContent={null}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</TreeContainer>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CursorMirror
|
||||
side={SelectionSide.Anchor}
|
||||
onChange={() => setPosKey(generateUniqueId())}
|
||||
/>
|
||||
</SemiPopover>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyContentProps {
|
||||
visible: boolean;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
function EmptyContent({ visible, content }: EmptyContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={styles['expression-editor-suggestion-empty']}
|
||||
style={{ display: visible ? 'block' : 'none' }}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeContainerProps {
|
||||
visible: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const TreeContainer = forwardRef<HTMLDivElement, TreeContainerProps>(
|
||||
function TreeContainer({ visible, className, children }, ref) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles['expression-editor-suggestion'],
|
||||
)}
|
||||
style={{ display: visible ? 'block' : 'none' }}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { Popover };
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '@/expression-editor';
|
||||
|
||||
import styles from './popover.module.less';
|
||||
|
||||
const SKIP_SELECTION_CHANGE_USER_EVENT = 'api.skip-selection-change';
|
||||
const SELECTED_OPTION_CLASSNAME =
|
||||
styles['expression-editor-suggestion-keyboard-selected'];
|
||||
|
||||
// modified from:
|
||||
// file: packages/workflow/components/src/expression-editor/components/suggestion/hooks.ts
|
||||
// method: computeUIOptions
|
||||
interface OptionsInfo {
|
||||
elements: Element[];
|
||||
selectedIndex: number;
|
||||
selectedElement?: Element;
|
||||
}
|
||||
const getOptionInfoFromDOM = (
|
||||
root: Element | null,
|
||||
): OptionsInfo | undefined => {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有的选项元素
|
||||
const foundNodes = root.querySelectorAll(
|
||||
'.semi-tree-option-list .semi-tree-option',
|
||||
);
|
||||
|
||||
if (foundNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionElements = [...foundNodes];
|
||||
|
||||
// 找到当前高亮的选项
|
||||
const selectedIndex = optionElements.findIndex(element =>
|
||||
element.classList.contains(SELECTED_OPTION_CLASSNAME),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: optionElements,
|
||||
selectedIndex,
|
||||
selectedElement: optionElements[selectedIndex],
|
||||
};
|
||||
};
|
||||
|
||||
function selectNodeByIndex(elements: Element[], index: number) {
|
||||
const newSelectedElement = elements[index];
|
||||
|
||||
if (!newSelectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove old selected class
|
||||
elements.forEach(element => {
|
||||
if (element.classList.contains(SELECTED_OPTION_CLASSNAME)) {
|
||||
element.classList.remove(SELECTED_OPTION_CLASSNAME);
|
||||
}
|
||||
});
|
||||
|
||||
newSelectedElement.classList.add(SELECTED_OPTION_CLASSNAME);
|
||||
|
||||
newSelectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
|
||||
interface ApplyNodeOptions {
|
||||
from: number;
|
||||
to: number;
|
||||
textBefore: string;
|
||||
}
|
||||
|
||||
function isLeafNode(node: ExpressionEditorTreeNode) {
|
||||
return !node.variable?.children || node.variable.children.length === 0;
|
||||
}
|
||||
|
||||
function applyNode(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
node: ExpressionEditorTreeNode,
|
||||
options: ApplyNodeOptions,
|
||||
) {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, textBefore } = options;
|
||||
|
||||
const text = getInsertTextFromNode(node, textBefore);
|
||||
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.replaceTextByRange({
|
||||
from,
|
||||
to,
|
||||
text,
|
||||
cursorOffset: isLeafNode(node) ? '}}'.length : 0,
|
||||
userEvent: SKIP_SELECTION_CHANGE_USER_EVENT,
|
||||
});
|
||||
}
|
||||
|
||||
function isSkipSelectionChangeUserEvent(tr) {
|
||||
return tr.isUserEvent(SKIP_SELECTION_CHANGE_USER_EVENT);
|
||||
}
|
||||
|
||||
function getInsertTextFromNode(
|
||||
node: ExpressionEditorTreeNode,
|
||||
textBefore: string,
|
||||
) {
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore) ?? [];
|
||||
return ExpressionEditorTreeHelper.concatFullPath({
|
||||
node,
|
||||
segments,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
getOptionInfoFromDOM,
|
||||
applyNode,
|
||||
selectNodeByIndex,
|
||||
isSkipSelectionChangeUserEvent,
|
||||
};
|
||||
@@ -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 { type MutableRefObject, useMemo } from 'react';
|
||||
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { mixLanguages, astDecorator } from '@coze-editor/editor';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { type ExpressionEditorTreeNode } from '@/expression-editor/type';
|
||||
|
||||
import { validateExpression } from './validate';
|
||||
|
||||
function useInputRules(apiRef: MutableRefObject<EditorAPI | null>) {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
type: 'character' as const,
|
||||
triggerCharacter: '{',
|
||||
handler({ from, to }) {
|
||||
apiRef.current?.replaceTextByRange({
|
||||
from,
|
||||
to,
|
||||
text: '{{}}',
|
||||
cursorOffset: -2,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
type Extension =
|
||||
| {
|
||||
extension: Extension;
|
||||
}
|
||||
| readonly Extension[];
|
||||
|
||||
function useExtensions(
|
||||
variableTreeRef: MutableRefObject<ExpressionEditorTreeNode[] | undefined>,
|
||||
): Extension[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
mixLanguages({}),
|
||||
EditorView.baseTheme({
|
||||
'& .cm-line': {
|
||||
padding: 0,
|
||||
},
|
||||
'& .cm-placeholder': {
|
||||
color: 'inherit',
|
||||
opacity: 0.333,
|
||||
},
|
||||
'& .cm-content': {
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
}),
|
||||
[
|
||||
astDecorator.whole.of((cursor, state) => {
|
||||
if (!variableTreeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cursor.node.type.name === 'Interpolation' &&
|
||||
// 由于 parser 存在容错能力
|
||||
// 可能出现缺少右花括号也被正常解析为 Interpolation 的情况
|
||||
// 如:{{variable
|
||||
cursor.node.firstChild?.type.name === '{{' &&
|
||||
cursor.node.lastChild?.type.name === '}}'
|
||||
) {
|
||||
const source = state.sliceDoc(
|
||||
cursor.node.firstChild.to,
|
||||
cursor.node.lastChild.from,
|
||||
);
|
||||
|
||||
const isValid = validateExpression(source, variableTreeRef.current);
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'cm-decoration-interpolation-valid',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'cm-decoration-interpolation-invalid',
|
||||
};
|
||||
}
|
||||
}),
|
||||
EditorView.baseTheme({
|
||||
'& .cm-decoration-interpolation-valid': {
|
||||
color: '#6675D9',
|
||||
caretColor: '#6675D9',
|
||||
},
|
||||
// '& .cm-decoration-interpolation-invalid': {
|
||||
// },
|
||||
}),
|
||||
],
|
||||
[
|
||||
astDecorator.fromCursor.of((cursor, state) => {
|
||||
const { anchor } = state.selection.main;
|
||||
|
||||
const pos = anchor;
|
||||
if (
|
||||
cursor.node.type.name === 'Interpolation' &&
|
||||
cursor.node.firstChild &&
|
||||
cursor.node.lastChild &&
|
||||
pos >= cursor.node.firstChild.to &&
|
||||
pos <= cursor.node.lastChild.from
|
||||
) {
|
||||
return {
|
||||
type: 'background',
|
||||
className: 'cm-decoration-interpolation-active',
|
||||
from: cursor.node.firstChild.from,
|
||||
to: cursor.node.lastChild.to,
|
||||
};
|
||||
}
|
||||
}),
|
||||
EditorView.baseTheme({
|
||||
'& .cm-decoration-interpolation-active': {
|
||||
borderRadius: '2px',
|
||||
backgroundColor:
|
||||
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export { useInputRules, useExtensions };
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Renderer } from './renderer';
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { Renderer as SDKRenderer } from '@coze-editor/editor/react';
|
||||
import expression, {
|
||||
type EditorAPI as ExpressionEditorAPI,
|
||||
} from '@coze-editor/editor/preset-expression';
|
||||
|
||||
import { type ExpressionEditorTreeNode } from '@/expression-editor';
|
||||
|
||||
import { useDeepEqualMemo, useLatest } from '../shared';
|
||||
import { useInputRules, useExtensions } from './hooks';
|
||||
|
||||
interface RendererProps {
|
||||
value?: string;
|
||||
className?: string;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
dataTestID?: string;
|
||||
variableTree: ExpressionEditorTreeNode[];
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function Renderer({
|
||||
value,
|
||||
variableTree,
|
||||
className,
|
||||
readonly,
|
||||
placeholder,
|
||||
dataTestID,
|
||||
onChange,
|
||||
}: RendererProps) {
|
||||
const apiRef = useRef<ExpressionEditorAPI | null>(null);
|
||||
const variableTreeRef = useLatest<ExpressionEditorTreeNode[] | undefined>(
|
||||
variableTree,
|
||||
);
|
||||
const changedVariableTree = useDeepEqualMemo(variableTree);
|
||||
const inputRules = useInputRules(apiRef);
|
||||
const extensions = useExtensions(variableTreeRef);
|
||||
const contentAttributes = useMemo(
|
||||
() => ({
|
||||
class: `${className ?? ''} flow-canvas-not-draggable`,
|
||||
'data-testid': dataTestID ?? '',
|
||||
'data-flow-editor-selectable': 'false',
|
||||
}),
|
||||
[className, dataTestID],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: { value: string }) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(e.value);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Note: changedVariableTree 这里只用来进行性能优化
|
||||
// useVariableTree 的触发时机仍然存在问题,缩放画布也会频繁触发 variableTree 的变更
|
||||
useEffect(() => {
|
||||
const editor = apiRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.updateWholeDecorations();
|
||||
}, [changedVariableTree]);
|
||||
|
||||
function handleFocus() {
|
||||
const editor = apiRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.updateWholeDecorations();
|
||||
}
|
||||
|
||||
// 值受控
|
||||
useEffect(() => {
|
||||
const editor = apiRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value !== editor.getValue()) {
|
||||
editor.setValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<SDKRenderer
|
||||
plugins={expression}
|
||||
defaultValue={value ?? ''}
|
||||
options={{
|
||||
fontSize: 14,
|
||||
inputRules,
|
||||
readOnly: readonly,
|
||||
placeholder,
|
||||
contentAttributes,
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
didMount={api => (apiRef.current = api)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Renderer };
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
|
||||
import {
|
||||
ExpressionEditorSegmentType,
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '@/expression-editor';
|
||||
|
||||
function validateExpression(source: string, tree: ExpressionEditorTreeNode[]) {
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(source);
|
||||
|
||||
if (!segments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
segments[segments.length - 1].type === ExpressionEditorSegmentType.EndEmpty
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. segments mix variable tree, match tree branch
|
||||
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree,
|
||||
segments,
|
||||
});
|
||||
if (!treeBranch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. if full segments path could match one tree branch, the pattern is valid
|
||||
return true;
|
||||
}
|
||||
|
||||
export { validateExpression };
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
type MutableRefObject,
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
|
||||
import { dequal } from 'dequal';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
|
||||
import { ExpressionEditorSegmentType } from '@/expression-editor';
|
||||
|
||||
function useLatest<T>(value: T): MutableRefObject<T> {
|
||||
const ref = useRef(value);
|
||||
ref.current = value;
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
// 解除 parent 导致的循环依赖(否则无法深比较)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function cloneWithout(target: any, keys: string[]) {
|
||||
// target 为 undefined 时会抛错
|
||||
try {
|
||||
return JSON.parse(
|
||||
JSON.stringify(target, function (key, value) {
|
||||
if (keys.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
function useDeepEqualMemo<T>(value: T): T {
|
||||
const [state, setState] = useState<T>(value);
|
||||
const lastValueRef = useRef<T>(value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
!dequal(
|
||||
cloneWithout(value, ['parent']),
|
||||
cloneWithout(lastValueRef.current, ['parent']),
|
||||
)
|
||||
) {
|
||||
setState(value);
|
||||
lastValueRef.current = value;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function generateUniqueId(): string {
|
||||
return Math.floor(Math.random() * 2e6).toString(36);
|
||||
}
|
||||
|
||||
function getSearchValue(textBefore: string) {
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore);
|
||||
|
||||
if (!segments) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lastSegment =
|
||||
segments[segments.length - 1].type ===
|
||||
ExpressionEditorSegmentType.ArrayIndex
|
||||
? segments[segments.length - 2] // 数组索引属于上一层级,需要去除防止影响到搜索值
|
||||
: segments[segments.length - 1];
|
||||
if (
|
||||
!lastSegment ||
|
||||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return lastSegment.objectKey;
|
||||
}
|
||||
|
||||
export { useLatest, useDeepEqualMemo, getSearchValue, generateUniqueId };
|
||||
@@ -0,0 +1,23 @@
|
||||
.expression-editor-counter {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
color: var(--semi-color-text-2);
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
min-height: 24px;
|
||||
padding: 3px 12px 5px;
|
||||
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&-error {
|
||||
color: var(--semi-color-danger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 classNames from 'classnames';
|
||||
|
||||
import { type ExpressionEditorModel } from '../../model';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface ExpressionEditorCounterProps {
|
||||
className?: string;
|
||||
model: ExpressionEditorModel;
|
||||
maxLength?: number;
|
||||
disabled?: boolean;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 长度计数器
|
||||
*/
|
||||
export const ExpressionEditorCounter: FC<
|
||||
ExpressionEditorCounterProps
|
||||
> = props => {
|
||||
const { className, model, maxLength, disabled, isError } = props;
|
||||
|
||||
const { visible, count, max } = useMemo(() => {
|
||||
if (typeof model.value.length !== 'number') {
|
||||
return {
|
||||
visible: false,
|
||||
};
|
||||
}
|
||||
if (typeof maxLength !== 'number') {
|
||||
return {
|
||||
visible: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
visible: true,
|
||||
count: model.value.length,
|
||||
max: maxLength,
|
||||
};
|
||||
}, [model.value.length, maxLength]);
|
||||
|
||||
if (disabled || !visible) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles['expression-editor-counter'], className, {
|
||||
[styles['expression-editor-counter-error']]: isError,
|
||||
})}
|
||||
>
|
||||
<p>
|
||||
{count} / {max}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 { ExpressionEditorCounter } from './counter';
|
||||
export { ExpressionEditorLeaf } from './leaf';
|
||||
export { ExpressionEditorRender } from './render';
|
||||
export { ExpressionEditorSuggestion } from './suggestion';
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 CSSProperties } from 'react';
|
||||
|
||||
import type { RenderLeafProps } from 'slate-react';
|
||||
|
||||
import { ExpressionEditorSignal } from '../../constant';
|
||||
|
||||
const LeafStyle: Partial<Record<ExpressionEditorSignal, CSSProperties>> = {
|
||||
[ExpressionEditorSignal.Valid]: {
|
||||
color: '#6675D9',
|
||||
},
|
||||
[ExpressionEditorSignal.Invalid]: {
|
||||
color: 'inherit',
|
||||
},
|
||||
[ExpressionEditorSignal.SelectedValid]: {
|
||||
color: '#6675D9',
|
||||
borderRadius: 2,
|
||||
backgroundColor:
|
||||
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
|
||||
},
|
||||
[ExpressionEditorSignal.SelectedInvalid]: {
|
||||
color: 'inherit',
|
||||
borderRadius: 2,
|
||||
backgroundColor:
|
||||
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpressionEditorLeaf = (props: RenderLeafProps) => {
|
||||
const { type } = props.leaf as {
|
||||
type?: ExpressionEditorSignal;
|
||||
};
|
||||
return (
|
||||
<span style={type && LeafStyle[type]} {...props.attributes}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/* stylelint-disable declaration-no-important */
|
||||
/* stylelint-disable property-no-vendor-prefix */
|
||||
|
||||
.slate-editable {
|
||||
// WARNING: 别删,有人在全局加了 user-select: none 删了会在低版本 safari 浏览器上出现无法输入文本
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
-o-user-select: text !important;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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 CompositionEventHandler } from 'react';
|
||||
|
||||
import { Slate, Editable } from 'slate-react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ExpressionEditorLeaf } from '../leaf';
|
||||
import { type ExpressionEditorLine } from '../../type';
|
||||
import { type ExpressionEditorModel } from '../../model';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface ExpressionEditorRenderProps {
|
||||
model: ExpressionEditorModel;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
dataTestID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应当只包含编辑器逻辑,业务无关
|
||||
*/
|
||||
export const ExpressionEditorRender: React.FC<
|
||||
ExpressionEditorRenderProps
|
||||
> = props => {
|
||||
const {
|
||||
model,
|
||||
className,
|
||||
placeholder,
|
||||
onFocus,
|
||||
onBlur,
|
||||
readonly = false,
|
||||
dataTestID,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Slate
|
||||
editor={model.editor}
|
||||
initialValue={model.lines}
|
||||
onChange={value => {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- 防止阻塞 slate 渲染
|
||||
const asyncOnChange = async () => {
|
||||
const lines = value as ExpressionEditorLine[];
|
||||
model.change(lines);
|
||||
model.select(lines);
|
||||
};
|
||||
asyncOnChange();
|
||||
}}
|
||||
>
|
||||
<Editable
|
||||
data-testid={dataTestID}
|
||||
className={classNames(
|
||||
styles.slateEditable,
|
||||
'flow-canvas-not-draggable',
|
||||
)}
|
||||
data-flow-editor-selectable="false"
|
||||
readOnly={readonly}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
renderLeaf={ExpressionEditorLeaf}
|
||||
decorate={model.decorate}
|
||||
onKeyDown={e => model.keydown(e)}
|
||||
onCompositionStart={e =>
|
||||
model.compositionStart(
|
||||
e as unknown as CompositionEventHandler<HTMLDivElement>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,693 @@
|
||||
/*
|
||||
* 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 @typescript-eslint/no-namespace */
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers */
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Range, type Selection, Transforms } from 'slate';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
import type {
|
||||
ExpressionEditorEventParams,
|
||||
ExpressionEditorParseData,
|
||||
ExpressionEditorTreeNode,
|
||||
} from '../../type';
|
||||
import { ExpressionEditorTreeHelper } from '../../tree-helper';
|
||||
import { ExpressionEditorParser } from '../../parser';
|
||||
import type { ExpressionEditorModel } from '../../model';
|
||||
import {
|
||||
ExpressionEditorEvent,
|
||||
ExpressionEditorSegmentType,
|
||||
ExpressionEditorToken,
|
||||
} from '../../constant';
|
||||
import {
|
||||
type SuggestionReducer,
|
||||
SuggestionActionType,
|
||||
type SuggestionState,
|
||||
} from './type';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
/** 内置函数 */
|
||||
namespace SuggestionViewUtils {
|
||||
/** 编辑器选中事件处理 */
|
||||
export const editorSelectHandler = (params: {
|
||||
reducer: SuggestionReducer;
|
||||
payload: ExpressionEditorEventParams<ExpressionEditorEvent.Select>;
|
||||
}) => {
|
||||
const { reducer, payload } = params;
|
||||
const [state, dispatch] = reducer;
|
||||
|
||||
// 设置解析数据
|
||||
const parseData = ExpressionEditorParser.parse({
|
||||
lineContent: payload.content,
|
||||
lineOffset: payload.offset,
|
||||
});
|
||||
if (!parseData) {
|
||||
dispatch({
|
||||
type: SuggestionActionType.ClearParseDataAndEditorPath,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: false,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetRect,
|
||||
payload: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetParseDataAndEditorPath,
|
||||
payload: {
|
||||
parseData,
|
||||
editorPath: payload.path,
|
||||
},
|
||||
});
|
||||
|
||||
// 重置UI组件内部状态
|
||||
const shouldRefresh = parseData.content.reachable === '';
|
||||
if (shouldRefresh) {
|
||||
dispatch({
|
||||
type: SuggestionActionType.Refresh,
|
||||
});
|
||||
}
|
||||
|
||||
// 设置选中值
|
||||
const selected = SuggestionViewUtils.computeSelected({
|
||||
model: state.model,
|
||||
parseData,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetSelected,
|
||||
payload: selected,
|
||||
});
|
||||
|
||||
// 设置可见变量树
|
||||
const variableTree = SuggestionViewUtils.computeVariableTree({
|
||||
model: state.model,
|
||||
parseData,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVariableTree,
|
||||
payload: variableTree,
|
||||
});
|
||||
|
||||
// 设置匹配树枝
|
||||
const matchTreeBranch: ExpressionEditorTreeNode[] | undefined =
|
||||
ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree: state.model.variableTree,
|
||||
segments: parseData.segments.reachable,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetMatchTreeBranch,
|
||||
payload: matchTreeBranch,
|
||||
});
|
||||
|
||||
// 设置空内容
|
||||
const emptyContent = SuggestionViewUtils.computeEmptyContent({
|
||||
parseData,
|
||||
fullVariableTree: state.model.variableTree,
|
||||
variableTree,
|
||||
matchTreeBranch,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetEmptyContent,
|
||||
payload: emptyContent,
|
||||
});
|
||||
|
||||
// 设置UI相对坐标
|
||||
const rect = SuggestionViewUtils.computeRect(state);
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetRect,
|
||||
payload: rect,
|
||||
});
|
||||
if (!rect) {
|
||||
dispatch({
|
||||
type: SuggestionActionType.ClearParseDataAndEditorPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: 设置搜索值,很hack的逻辑,后面建议重构不用semi组件,自己写一个
|
||||
if (!state.ref.tree.current) {
|
||||
// 不设为可见获取不到ref
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: true,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SuggestionActionType.SearchEffectStart,
|
||||
});
|
||||
};
|
||||
|
||||
export const getFinalScale = (state: SuggestionState): number => {
|
||||
if (state.entities.playgroundConfig) {
|
||||
return state.entities.playgroundConfig.finalScale;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
/** 计算可见时相对父容器坐标 */
|
||||
export const computeRect = (
|
||||
state: SuggestionState,
|
||||
):
|
||||
| {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
| undefined => {
|
||||
const borderTopOffset = 5;
|
||||
const containerRect = state.ref.container.current?.getBoundingClientRect();
|
||||
if (!state.model.editor.selection || !containerRect) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rect = ReactEditor.toDOMRange(
|
||||
state.model.editor,
|
||||
state.model.editor.selection,
|
||||
).getBoundingClientRect();
|
||||
return {
|
||||
top:
|
||||
(rect.top - containerRect.top) / getFinalScale(state) +
|
||||
borderTopOffset,
|
||||
left: (rect.left - containerRect.left) / getFinalScale(state),
|
||||
};
|
||||
} catch (e) {
|
||||
// slate DOM 计算报错可忽略
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/** 计算当前选中变量 */
|
||||
export const computeSelected = (params: {
|
||||
model: ExpressionEditorModel;
|
||||
parseData: ExpressionEditorParseData;
|
||||
}): ExpressionEditorTreeNode | undefined => {
|
||||
const { model, parseData } = params;
|
||||
if (!parseData?.segments.inline) {
|
||||
return;
|
||||
}
|
||||
const treeBrach = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree: model.variableTree,
|
||||
segments: parseData.segments.inline,
|
||||
});
|
||||
if (!treeBrach) {
|
||||
return;
|
||||
}
|
||||
return treeBrach[treeBrach.length - 1];
|
||||
};
|
||||
|
||||
/** 计算当前搜索值 */
|
||||
export const computeSearch = (
|
||||
parseData: ExpressionEditorParseData,
|
||||
): string => {
|
||||
if (!parseData) {
|
||||
return '';
|
||||
}
|
||||
const segments = parseData.segments.reachable;
|
||||
const lastSegment =
|
||||
segments[segments.length - 1].type ===
|
||||
ExpressionEditorSegmentType.ArrayIndex
|
||||
? segments[segments.length - 2] // 数组索引属于上一层级,需要去除防止影响到搜索值
|
||||
: segments[segments.length - 1];
|
||||
if (
|
||||
!lastSegment ||
|
||||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return lastSegment.objectKey;
|
||||
};
|
||||
|
||||
/** 计算裁剪层级的变量树 */
|
||||
export const computeVariableTree = (params: {
|
||||
model: ExpressionEditorModel;
|
||||
parseData: ExpressionEditorParseData;
|
||||
}): ExpressionEditorTreeNode[] => {
|
||||
const { model, parseData } = params;
|
||||
if (!parseData?.segments.reachable) {
|
||||
return [];
|
||||
}
|
||||
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
|
||||
tree: model.variableTree,
|
||||
segments: parseData.segments.reachable,
|
||||
});
|
||||
return prunedVariableTree;
|
||||
};
|
||||
|
||||
export const computeEmptyContent = (params: {
|
||||
parseData: ExpressionEditorParseData;
|
||||
fullVariableTree: ExpressionEditorTreeNode[];
|
||||
variableTree: ExpressionEditorTreeNode[];
|
||||
matchTreeBranch: ExpressionEditorTreeNode[] | undefined;
|
||||
}): string | undefined => {
|
||||
const { parseData, fullVariableTree, variableTree, matchTreeBranch } =
|
||||
params;
|
||||
if (
|
||||
!fullVariableTree ||
|
||||
!Array.isArray(fullVariableTree) ||
|
||||
fullVariableTree.length === 0
|
||||
) {
|
||||
if (parseData.content.reachable === '') {
|
||||
return I18n.t('workflow_variable_refer_no_input');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!variableTree ||
|
||||
!Array.isArray(variableTree) ||
|
||||
variableTree.length === 0
|
||||
) {
|
||||
if (parseData.content.inline === '') {
|
||||
return I18n.t('workflow_variable_refer_no_input');
|
||||
}
|
||||
if (matchTreeBranch && matchTreeBranch.length !== 0) {
|
||||
return I18n.t('workflow_variable_refer_no_sub_variable');
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
export const keyboardSelectedClassName = () =>
|
||||
styles['expression-editor-suggestion-keyboard-selected'];
|
||||
|
||||
/** 将选中项设为高亮 */
|
||||
export const setUIOptionSelected = (uiOption: Element): void => {
|
||||
if (
|
||||
!uiOption?.classList?.add ||
|
||||
!uiOption?.classList?.contains ||
|
||||
uiOption.classList.contains('semi-tree-option-empty')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
uiOption.classList.add(SuggestionViewUtils.keyboardSelectedClassName());
|
||||
};
|
||||
|
||||
/** 获取所有选项UI元素 */
|
||||
export const computeUIOptions = (
|
||||
state: SuggestionState,
|
||||
):
|
||||
| {
|
||||
optionList: Element[];
|
||||
selectedIndex: number;
|
||||
selectedOption?: Element;
|
||||
}
|
||||
| undefined => {
|
||||
// 获取所有的选项元素
|
||||
const optionListDom =
|
||||
state.ref.suggestion.current?.children?.[0]?.children?.[1]?.children;
|
||||
if (!optionListDom) {
|
||||
return;
|
||||
}
|
||||
const optionList = Array.from(optionListDom);
|
||||
// 找到当前高亮的选项
|
||||
const selectedIndex = optionList.findIndex(element =>
|
||||
element.classList.contains(keyboardSelectedClassName()),
|
||||
);
|
||||
return {
|
||||
optionList,
|
||||
selectedIndex,
|
||||
selectedOption: optionList[selectedIndex],
|
||||
};
|
||||
};
|
||||
|
||||
/** 禁止变更 visible 防止 ui 抖动 */
|
||||
export const preventVisibleJitter = (
|
||||
reducer: SuggestionReducer,
|
||||
time = 150,
|
||||
) => {
|
||||
const [state, dispatch] = reducer;
|
||||
if (!state.allowVisibleChange) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetAllowVisibleChange,
|
||||
payload: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetAllowVisibleChange,
|
||||
payload: true,
|
||||
});
|
||||
}, time);
|
||||
};
|
||||
|
||||
/** 清空键盘UI选项 */
|
||||
export const clearSelectedUIOption = (state: SuggestionState) => {
|
||||
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
|
||||
if (uiOptions?.selectedOption) {
|
||||
// 清空键盘选中状态
|
||||
uiOptions.selectedOption.classList.remove(
|
||||
SuggestionViewUtils.keyboardSelectedClassName(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** 默认键盘UI选项为第一项 */
|
||||
export const selectFirstUIOption = (state: SuggestionState) => {
|
||||
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
|
||||
if (!uiOptions?.optionList) {
|
||||
return;
|
||||
}
|
||||
clearSelectedUIOption(state);
|
||||
if (!uiOptions?.optionList?.[0]?.classList?.add) {
|
||||
return;
|
||||
}
|
||||
// 默认首项高亮
|
||||
SuggestionViewUtils.setUIOptionSelected(uiOptions.optionList[0]);
|
||||
};
|
||||
}
|
||||
|
||||
/** 选中节点 */
|
||||
export const useSelectNode = (reducer: SuggestionReducer) => {
|
||||
const [state] = reducer;
|
||||
return useCallback(
|
||||
(node: ExpressionEditorTreeNode) => {
|
||||
const fullPath: string = ExpressionEditorTreeHelper.concatFullPath({
|
||||
node,
|
||||
segments: state.parseData?.segments.reachable ?? [],
|
||||
});
|
||||
if (!state.parseData || !state.editorPath) {
|
||||
return;
|
||||
}
|
||||
const selection: Selection = {
|
||||
anchor: {
|
||||
path: state.editorPath,
|
||||
offset: state.parseData.offset.lastStart - 1,
|
||||
},
|
||||
focus: {
|
||||
path: state.editorPath,
|
||||
offset: state.parseData.offset.firstEnd + 2,
|
||||
},
|
||||
};
|
||||
const insertText = `${ExpressionEditorToken.FullStart}${fullPath}${ExpressionEditorToken.FullEnd}`;
|
||||
// 替换文本
|
||||
Transforms.insertText(state.model.editor, insertText, {
|
||||
at: selection,
|
||||
});
|
||||
},
|
||||
[state],
|
||||
);
|
||||
};
|
||||
|
||||
/** 挂载监听器 */
|
||||
export const useListeners = (reducer: SuggestionReducer) => {
|
||||
const [state, dispatch] = reducer;
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载监听: 鼠标点击事件
|
||||
const mouseHandler = (e: MouseEvent) => {
|
||||
if (!state.visible || !state.ref.suggestion.current) {
|
||||
return;
|
||||
}
|
||||
if (state.ref.suggestion.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: false,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetRect,
|
||||
payload: undefined,
|
||||
});
|
||||
};
|
||||
window.addEventListener('mousedown', mouseHandler);
|
||||
const mouseDisposer = () => {
|
||||
window.removeEventListener('mousedown', mouseHandler);
|
||||
};
|
||||
return () => {
|
||||
// 销毁时卸载监听器防止内存泄露
|
||||
mouseDisposer();
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载监听: 编辑器选择事件
|
||||
const editorSelectDisposer = state.model.on<ExpressionEditorEvent.Select>(
|
||||
ExpressionEditorEvent.Select,
|
||||
payload =>
|
||||
SuggestionViewUtils.editorSelectHandler({
|
||||
reducer,
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
// 销毁时卸载监听器防止内存泄露
|
||||
editorSelectDisposer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载监听: 编辑器拼音输入事件
|
||||
const compositionStartDisposer =
|
||||
state.model.on<ExpressionEditorEvent.CompositionStart>(
|
||||
ExpressionEditorEvent.CompositionStart,
|
||||
payload =>
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: false,
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
// 销毁时卸载监听器防止内存泄露
|
||||
compositionStartDisposer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化前首次渲染激活DOM
|
||||
if (state.initialized) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化前监听到DOM激活后隐藏
|
||||
if (state.initialized || state.visible) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetHiddenDOM,
|
||||
payload: false,
|
||||
});
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetInitialized,
|
||||
});
|
||||
}, [state]);
|
||||
};
|
||||
|
||||
/** 键盘上下回车键选中节点 */
|
||||
export const useKeyboardSelect = (
|
||||
reducer: SuggestionReducer,
|
||||
selectNode: (node: ExpressionEditorTreeNode) => void,
|
||||
) => {
|
||||
const [state, dispatch] = reducer;
|
||||
|
||||
// 键盘上下
|
||||
useEffect(() => {
|
||||
const keyboardArrowHandler = event => {
|
||||
if (
|
||||
!state.visible ||
|
||||
!state.ref.suggestion.current ||
|
||||
!['ArrowDown', 'ArrowUp'].includes(event.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
|
||||
if (!uiOptions) {
|
||||
return;
|
||||
}
|
||||
const { optionList, selectedIndex } = uiOptions;
|
||||
if (optionList.length === 1) {
|
||||
// 仅有一项可选项的时候不做处理
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
let newIndex = selectedIndex;
|
||||
if (event.key === 'ArrowDown') {
|
||||
// 如果当前没有高亮的选项或者是最后一个选项,则高亮第一个选项
|
||||
newIndex =
|
||||
selectedIndex === -1 || selectedIndex === optionList.length - 1
|
||||
? 0
|
||||
: selectedIndex + 1;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
// 如果当前没有高亮的选项或者是第一个选项,则高亮最后一个选项
|
||||
newIndex =
|
||||
selectedIndex <= 0 ? optionList.length - 1 : selectedIndex - 1;
|
||||
}
|
||||
const selectedOption = optionList[newIndex];
|
||||
// 更新高亮选项
|
||||
if (selectedIndex !== -1) {
|
||||
optionList[selectedIndex].classList.remove(
|
||||
SuggestionViewUtils.keyboardSelectedClassName(),
|
||||
);
|
||||
}
|
||||
SuggestionViewUtils.setUIOptionSelected(selectedOption);
|
||||
// 将新选中的选项滚动到视图中
|
||||
selectedOption.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动
|
||||
block: 'nearest', // 最接近的视图边界,可能是顶部或底部
|
||||
});
|
||||
};
|
||||
document.addEventListener('keydown', keyboardArrowHandler);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keyboardArrowHandler);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
// 键盘回车
|
||||
useEffect(() => {
|
||||
const keyboardEnterHandler = event => {
|
||||
if (
|
||||
!state.visible ||
|
||||
!state.ref.suggestion.current ||
|
||||
event.key !== 'Enter'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
|
||||
if (!uiOptions?.selectedOption) {
|
||||
return;
|
||||
}
|
||||
const { selectedOption } = uiOptions;
|
||||
const selectedDataKey = selectedOption.getAttribute('data-key');
|
||||
if (!selectedDataKey) {
|
||||
return;
|
||||
}
|
||||
const variableTreeNode =
|
||||
state.ref.tree.current?.state?.keyEntities?.[selectedDataKey]?.data;
|
||||
if (!variableTreeNode) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: false,
|
||||
});
|
||||
selectNode(variableTreeNode as ExpressionEditorTreeNode);
|
||||
if (
|
||||
!variableTreeNode.variable?.children ||
|
||||
variableTreeNode.variable?.children.length === 0
|
||||
) {
|
||||
// 叶子节点
|
||||
return;
|
||||
}
|
||||
// 非叶子节点,光标向前移动两格
|
||||
const { selection } = state.model.editor;
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
// 向前移动两个字符的光标
|
||||
Transforms.move(state.model.editor, { distance: 2, reverse: true });
|
||||
}
|
||||
SuggestionViewUtils.preventVisibleJitter(reducer);
|
||||
};
|
||||
document.addEventListener('keydown', keyboardEnterHandler);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keyboardEnterHandler);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
// 键盘 ESC 取消弹窗
|
||||
useEffect(() => {
|
||||
const keyboardESCHandler = event => {
|
||||
if (
|
||||
!state.visible ||
|
||||
!state.ref.suggestion.current ||
|
||||
event.key !== 'Escape'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: false,
|
||||
});
|
||||
};
|
||||
document.addEventListener('keydown', keyboardESCHandler);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keyboardESCHandler);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
// 默认选中首项
|
||||
useEffect(() => {
|
||||
SuggestionViewUtils.selectFirstUIOption(state);
|
||||
}, [state]);
|
||||
};
|
||||
|
||||
/** 等待semi组件数据更新后的副作用 */
|
||||
export const useRenderEffect = (reducer: SuggestionReducer) => {
|
||||
const [state, dispatch] = reducer;
|
||||
|
||||
// 组件树状数据更新后设置搜索值
|
||||
useEffect(() => {
|
||||
if (!state.renderEffect.search || !state.parseData) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.SearchEffectEnd,
|
||||
});
|
||||
const searchValue = SuggestionViewUtils.computeSearch(state.parseData);
|
||||
state.ref.tree.current?.search(searchValue);
|
||||
if (!searchValue && state.matchTreeBranch) {
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.FilteredEffectStart,
|
||||
});
|
||||
}, [state]);
|
||||
|
||||
// 搜索过滤后是否为空
|
||||
useEffect(() => {
|
||||
if (!state.renderEffect.filtered) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.FilteredEffectEnd,
|
||||
});
|
||||
const filteredKeys = Array.from(
|
||||
state.ref.tree.current?.state.filteredKeys || [],
|
||||
);
|
||||
if (!state.emptyContent && filteredKeys.length === 0) {
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: SuggestionActionType.SetVisible,
|
||||
payload: true,
|
||||
});
|
||||
}, [state]);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable declaration-no-important */
|
||||
/* stylelint-disable max-nesting-depth */
|
||||
.expression-editor-suggestion-pin {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 1.5rem;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
.expression-editor-suggestion {
|
||||
max-height: 236px;
|
||||
width: 272px;
|
||||
z-index: 1000;
|
||||
background: var(--light-usage-bg-color-bg-3, #FFF);
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid rgba(153, 182, 255, 12%);
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-empty {
|
||||
z-index: 1000;
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid rgba(153, 182, 255, 12%);
|
||||
background: #FFF;
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
|
||||
|
||||
p {
|
||||
margin: 4px 6px;
|
||||
color: var(--light-usage-text-color-text-2, rgba(29, 28, 35, 60%));
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-tree {
|
||||
:global {
|
||||
.semi-tree-search-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.semi-tree-option-list {
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
padding: 4px;
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.semi-tree-option {
|
||||
background-color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.semi-tree-option-label {
|
||||
height: 24px;
|
||||
padding: 0 4px;
|
||||
margin-right: 4px;
|
||||
border-radius: 4px;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
|
||||
}
|
||||
|
||||
.semi-tree-option-label-text {
|
||||
width: fit-content;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
|
||||
& span {
|
||||
width: fit-content;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.semi-tree-option-highlight {
|
||||
color: var(--light-usage-warning-color-warning, #FF9600)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.semi-tree-option-selected {
|
||||
color: var(--light-usage-primary-color-primary, #4D53E8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.semi-tree-option-disabled {
|
||||
.semi-tree-option-label {
|
||||
cursor: not-allowed;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.semi-icon+.semi-tree-option-label {
|
||||
color: var(--light-usage-text-color-text-0, #1D1C23);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.semi-tree-option-empty-icon {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.semi-tree-option-expand-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 4px;
|
||||
margin-right: 0;
|
||||
border-radius: 4px;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-keyboard-selected {
|
||||
:global {
|
||||
.semi-tree-option-label {
|
||||
background: var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 8%)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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, useRef, type RefObject } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
|
||||
import { Popover, Tree } from '@coze-arch/bot-semi';
|
||||
import type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
|
||||
import type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
|
||||
|
||||
import { type ExpressionEditorTreeNode } from '../../type';
|
||||
import { type ExpressionEditorModel } from '../../model';
|
||||
import { useSuggestionReducer } from './state';
|
||||
import {
|
||||
useListeners,
|
||||
useSelectNode,
|
||||
useKeyboardSelect,
|
||||
useRenderEffect,
|
||||
} from './hooks';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface ExpressionEditorSuggestionProps {
|
||||
className?: string;
|
||||
model: ExpressionEditorModel;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
getPopupContainer?: PopoverProps['getPopupContainer'];
|
||||
playgroundConfig?: PlaygroundConfigEntity;
|
||||
selectorBoxConfig?: SelectorBoxConfigEntity;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动提示
|
||||
*/
|
||||
export const ExpressionEditorSuggestion: FC<
|
||||
ExpressionEditorSuggestionProps
|
||||
> = props => {
|
||||
const {
|
||||
model,
|
||||
containerRef,
|
||||
className,
|
||||
playgroundConfig,
|
||||
selectorBoxConfig,
|
||||
disabled = false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPopupContainer = () => containerRef.current!,
|
||||
} = props;
|
||||
const suggestionRef = useRef<HTMLDivElement>(null);
|
||||
const treeRef = useRef<Tree>(null);
|
||||
|
||||
const suggestionReducer = useSuggestionReducer({
|
||||
model,
|
||||
entities: {
|
||||
playgroundConfig,
|
||||
selectorBoxConfig,
|
||||
},
|
||||
ref: {
|
||||
container: containerRef,
|
||||
suggestion: suggestionRef,
|
||||
tree: treeRef,
|
||||
},
|
||||
});
|
||||
const [state] = suggestionReducer;
|
||||
const selectNode = useSelectNode(suggestionReducer);
|
||||
useRenderEffect(suggestionReducer);
|
||||
useListeners(suggestionReducer);
|
||||
useKeyboardSelect(suggestionReducer, selectNode);
|
||||
|
||||
if (disabled) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="custom"
|
||||
visible={state.visible}
|
||||
keepDOM={true}
|
||||
getPopupContainer={getPopupContainer}
|
||||
content={
|
||||
<>
|
||||
<div
|
||||
className={styles['expression-editor-suggestion-empty']}
|
||||
style={{
|
||||
display:
|
||||
!state.visible || !state.emptyContent ? 'none' : 'inherit',
|
||||
}}
|
||||
>
|
||||
<p>{state.emptyContent}</p>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles['expression-editor-suggestion'],
|
||||
)}
|
||||
ref={suggestionRef}
|
||||
style={{
|
||||
display:
|
||||
!state.visible || state.emptyContent || state.hiddenDOM
|
||||
? 'none'
|
||||
: 'inherit',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Tree
|
||||
key={state.key}
|
||||
className={styles['expression-editor-suggestion-tree']}
|
||||
showFilteredOnly
|
||||
filterTreeNode
|
||||
onChangeWithObject
|
||||
ref={treeRef}
|
||||
treeData={state.variableTree}
|
||||
searchRender={false}
|
||||
value={state.selected}
|
||||
emptyContent={<></>}
|
||||
onSelect={(key, selected, node) => {
|
||||
selectNode(node as ExpressionEditorTreeNode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles['expression-editor-suggestion-pin']}
|
||||
style={{
|
||||
top: state.rect?.top,
|
||||
left: state.rect?.left,
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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 { useReducer } from 'react';
|
||||
|
||||
import { type ExpressionEditorTreeNode } from '../../type';
|
||||
import {
|
||||
type SuggestionState,
|
||||
type SuggestionAction,
|
||||
SuggestionActionType,
|
||||
type SuggestionActionPayload,
|
||||
type SuggestionReducer,
|
||||
} from './type';
|
||||
|
||||
const updateState = (
|
||||
state: SuggestionState,
|
||||
snapshot: Partial<SuggestionState>,
|
||||
): SuggestionState => ({
|
||||
...state,
|
||||
version: state.version + 1,
|
||||
...snapshot,
|
||||
});
|
||||
|
||||
export const suggestionReducer = (
|
||||
state: SuggestionState,
|
||||
action: SuggestionAction,
|
||||
) => {
|
||||
if (action.type === SuggestionActionType.SetInitialized) {
|
||||
return updateState(state, {
|
||||
initialized: true,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.Refresh) {
|
||||
return updateState(state, {
|
||||
key: state.key + 1,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetParseDataAndEditorPath) {
|
||||
const { parseData, editorPath } =
|
||||
(action.payload as SuggestionActionPayload<SuggestionActionType.SetParseDataAndEditorPath>) ||
|
||||
{};
|
||||
return updateState(state, {
|
||||
parseData,
|
||||
editorPath,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.ClearParseDataAndEditorPath) {
|
||||
return updateState(state, {
|
||||
parseData: undefined,
|
||||
editorPath: undefined,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetVariableTree) {
|
||||
const variableTree = action.payload as ExpressionEditorTreeNode[];
|
||||
return updateState(state, {
|
||||
variableTree,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetAllowVisibleChange) {
|
||||
const allowVisibleChange =
|
||||
action.payload as SuggestionActionPayload<SuggestionActionType.SetAllowVisibleChange>;
|
||||
return updateState(state, {
|
||||
allowVisibleChange,
|
||||
});
|
||||
}
|
||||
if (
|
||||
action.type === SuggestionActionType.SetVisible &&
|
||||
state.allowVisibleChange
|
||||
) {
|
||||
const visible =
|
||||
action.payload as SuggestionActionPayload<SuggestionActionType.SetVisible>;
|
||||
if (state.entities.selectorBoxConfig) {
|
||||
if (visible) {
|
||||
state.entities.selectorBoxConfig.disabled = true; // 防止鼠标拖选不触发点击
|
||||
}
|
||||
if (!visible) {
|
||||
state.entities.selectorBoxConfig.disabled = false;
|
||||
}
|
||||
}
|
||||
return updateState(state, {
|
||||
visible,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetHiddenDOM) {
|
||||
const hiddenDOM =
|
||||
action.payload as SuggestionActionPayload<SuggestionActionType.SetHiddenDOM>;
|
||||
return updateState(state, {
|
||||
hiddenDOM,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetRect) {
|
||||
const rect = action.payload as {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
return updateState(state, {
|
||||
rect,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetSelected) {
|
||||
const selected = action.payload as ExpressionEditorTreeNode;
|
||||
return updateState(state, {
|
||||
selected,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetEmptyContent) {
|
||||
const emptyContent = action.payload as string;
|
||||
return updateState(state, {
|
||||
emptyContent,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SetMatchTreeBranch) {
|
||||
const matchTreeBranch = action.payload as
|
||||
| ExpressionEditorTreeNode[]
|
||||
| undefined;
|
||||
return updateState(state, {
|
||||
matchTreeBranch,
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SearchEffectStart) {
|
||||
return updateState(state, {
|
||||
renderEffect: {
|
||||
...state.renderEffect,
|
||||
search: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.SearchEffectEnd) {
|
||||
return updateState(state, {
|
||||
renderEffect: {
|
||||
...state.renderEffect,
|
||||
search: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.FilteredEffectStart) {
|
||||
return updateState(state, {
|
||||
renderEffect: {
|
||||
...state.renderEffect,
|
||||
filtered: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (action.type === SuggestionActionType.FilteredEffectEnd) {
|
||||
return updateState(state, {
|
||||
renderEffect: {
|
||||
...state.renderEffect,
|
||||
filtered: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
/** 获取状态 */
|
||||
export const useSuggestionReducer = (
|
||||
initialState: Omit<
|
||||
SuggestionState,
|
||||
| 'initialized'
|
||||
| 'version'
|
||||
| 'key'
|
||||
| 'visible'
|
||||
| 'allowVisibleChange'
|
||||
| 'hiddenDOM'
|
||||
| 'variableTree'
|
||||
| 'renderEffect'
|
||||
>,
|
||||
): SuggestionReducer => {
|
||||
const [state, dispatch]: SuggestionReducer = useReducer(suggestionReducer, {
|
||||
...initialState,
|
||||
initialized: false, // 初始化
|
||||
version: 0, // 更新状态计数
|
||||
key: 0, // 用于触发 react 重新渲染组件
|
||||
variableTree: [], // 用于展示的组件树
|
||||
visible: true, // 默认显示,让ref能访问到DOM
|
||||
hiddenDOM: true, // 默认隐藏,让用户看不到UI
|
||||
allowVisibleChange: true, // 允许visible变更
|
||||
renderEffect: {
|
||||
// 渲染副作用
|
||||
search: false,
|
||||
filtered: false,
|
||||
},
|
||||
});
|
||||
return [state, dispatch];
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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 { Dispatch, RefObject } from 'react';
|
||||
|
||||
import type { Tree } from '@coze-arch/bot-semi';
|
||||
import type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
|
||||
import type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
|
||||
|
||||
import type {
|
||||
ExpressionEditorParseData,
|
||||
ExpressionEditorTreeNode,
|
||||
} from '../../type';
|
||||
import type { ExpressionEditorModel } from '../../model';
|
||||
|
||||
export interface SuggestionState {
|
||||
initialized: boolean;
|
||||
version: number;
|
||||
model: ExpressionEditorModel;
|
||||
entities: {
|
||||
playgroundConfig?: PlaygroundConfigEntity;
|
||||
selectorBoxConfig?: SelectorBoxConfigEntity;
|
||||
};
|
||||
ref: {
|
||||
container: RefObject<HTMLDivElement>;
|
||||
suggestion: RefObject<HTMLDivElement>;
|
||||
tree: RefObject<Tree>;
|
||||
};
|
||||
key: number;
|
||||
variableTree: ExpressionEditorTreeNode[];
|
||||
visible: boolean;
|
||||
allowVisibleChange: boolean;
|
||||
hiddenDOM: boolean;
|
||||
renderEffect: {
|
||||
search: boolean;
|
||||
filtered: boolean;
|
||||
};
|
||||
rect?: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
selected?: ExpressionEditorTreeNode;
|
||||
parseData?: ExpressionEditorParseData;
|
||||
editorPath?: number[];
|
||||
emptyContent?: string;
|
||||
matchTreeBranch?: ExpressionEditorTreeNode[];
|
||||
}
|
||||
|
||||
export enum SuggestionActionType {
|
||||
SetInitialized = 'set_initialized',
|
||||
Refresh = 'refresh',
|
||||
SetParseDataAndEditorPath = 'set_parse_data_and_editor_path',
|
||||
ClearParseDataAndEditorPath = 'clear_parse_data_and_editor_path',
|
||||
SetVariableTree = 'set_variable_tree',
|
||||
SetVisible = 'set_visible',
|
||||
SetAllowVisibleChange = 'set_allow_visible_change',
|
||||
SetHiddenDOM = 'set_hidden_dom',
|
||||
SetRect = 'set_rect',
|
||||
SetSelected = 'set_selected',
|
||||
SetEmptyContent = 'set_empty_content',
|
||||
SetMatchTreeBranch = 'set_match_tree_branch',
|
||||
SearchEffectStart = 'search_effect_start',
|
||||
SearchEffectEnd = 'search_effect_end',
|
||||
FilteredEffectStart = 'filtered_effect_start',
|
||||
FilteredEffectEnd = 'filtered_effect_end',
|
||||
}
|
||||
|
||||
export type SuggestionActionPayload<T extends SuggestionActionType> = {
|
||||
[SuggestionActionType.SetInitialized]?: undefined;
|
||||
[SuggestionActionType.Refresh]?: undefined;
|
||||
[SuggestionActionType.SetParseDataAndEditorPath]?: {
|
||||
parseData: ExpressionEditorParseData;
|
||||
editorPath: number[];
|
||||
};
|
||||
[SuggestionActionType.ClearParseDataAndEditorPath]?: undefined;
|
||||
[SuggestionActionType.SetVariableTree]: ExpressionEditorTreeNode[];
|
||||
[SuggestionActionType.SetVisible]: boolean;
|
||||
[SuggestionActionType.SetAllowVisibleChange]: boolean;
|
||||
[SuggestionActionType.SetHiddenDOM]: boolean;
|
||||
[SuggestionActionType.SetRect]: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
[SuggestionActionType.SetSelected]?: ExpressionEditorTreeNode;
|
||||
[SuggestionActionType.SetEmptyContent]?: string;
|
||||
[SuggestionActionType.SetMatchTreeBranch]:
|
||||
| ExpressionEditorTreeNode[]
|
||||
| undefined;
|
||||
[SuggestionActionType.SearchEffectStart]?: undefined;
|
||||
[SuggestionActionType.SearchEffectEnd]?: undefined;
|
||||
[SuggestionActionType.FilteredEffectStart]?: undefined;
|
||||
[SuggestionActionType.FilteredEffectEnd]?: undefined;
|
||||
}[T];
|
||||
|
||||
export interface SuggestionAction<
|
||||
T extends SuggestionActionType = SuggestionActionType,
|
||||
> {
|
||||
type: SuggestionActionType;
|
||||
payload?: SuggestionActionPayload<T>;
|
||||
}
|
||||
|
||||
export type SuggestionReducer = [SuggestionState, Dispatch<SuggestionAction>];
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 enum ExpressionEditorEvent {
|
||||
Change = 'change',
|
||||
Select = 'select',
|
||||
Dispose = 'dispose',
|
||||
CompositionStart = 'compositionStart',
|
||||
}
|
||||
|
||||
export enum ExpressionEditorToken {
|
||||
Start = '{',
|
||||
End = '}',
|
||||
FullStart = '{{',
|
||||
FullEnd = '}}',
|
||||
Separator = '.',
|
||||
ArrayStart = '[',
|
||||
ArrayEnd = ']',
|
||||
}
|
||||
|
||||
export enum ExpressionEditorSegmentType {
|
||||
ObjectKey = 'object_key',
|
||||
ArrayIndex = 'array_index',
|
||||
EndEmpty = 'end_empty',
|
||||
}
|
||||
|
||||
export enum ExpressionEditorSignal {
|
||||
Line = 'paragraph',
|
||||
Valid = 'valid',
|
||||
Invalid = 'invalid',
|
||||
SelectedValid = 'selectedValid',
|
||||
SelectedInvalid = 'selectedInvalid',
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 {
|
||||
ExpressionEditorEvent,
|
||||
ExpressionEditorToken,
|
||||
ExpressionEditorSegmentType,
|
||||
ExpressionEditorSignal,
|
||||
} from './constant';
|
||||
export {
|
||||
ExpressionEditorEventParams,
|
||||
ExpressionEditorEventDisposer,
|
||||
ExpressionEditorSegment,
|
||||
ExpressionEditorVariable,
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorParseData,
|
||||
ExpressionEditorLine,
|
||||
ExpressionEditorValidateData,
|
||||
ExpressionEditorRange,
|
||||
} from './type';
|
||||
|
||||
export type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
|
||||
export type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
|
||||
|
||||
export {
|
||||
ExpressionEditorLeaf,
|
||||
ExpressionEditorSuggestion,
|
||||
ExpressionEditorRender,
|
||||
ExpressionEditorCounter,
|
||||
} from './components';
|
||||
export { ExpressionEditorModel } from './model';
|
||||
export { ExpressionEditorParser } from './parser';
|
||||
export { ExpressionEditorTreeHelper } from './tree-helper';
|
||||
export { ExpressionEditorValidator } from './validator';
|
||||
|
||||
export { useSuggestionReducer } from './components/suggestion/state';
|
||||
export {
|
||||
useListeners,
|
||||
useSelectNode,
|
||||
useKeyboardSelect,
|
||||
useRenderEffect,
|
||||
} from './components/suggestion/hooks';
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* 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 { CompositionEventHandler, KeyboardEventHandler } from 'react';
|
||||
|
||||
import { type ReactEditor, withReact } from 'slate-react';
|
||||
import { withHistory } from 'slate-history';
|
||||
import {
|
||||
Text,
|
||||
type NodeEntry,
|
||||
Transforms,
|
||||
createEditor,
|
||||
Range,
|
||||
type BaseEditor,
|
||||
Editor,
|
||||
} from 'slate';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
import { ExpressionEditorValidator } from '../validator';
|
||||
import type {
|
||||
ExpressionEditorEventParams,
|
||||
ExpressionEditorRange,
|
||||
ExpressionEditorEventDisposer,
|
||||
ExpressionEditorLine,
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorValidateData,
|
||||
} from '../type';
|
||||
import { ExpressionEditorParser } from '../parser';
|
||||
import {
|
||||
ExpressionEditorEvent,
|
||||
ExpressionEditorToken,
|
||||
ExpressionEditorSignal,
|
||||
} from '../constant';
|
||||
|
||||
export class ExpressionEditorModel {
|
||||
public readonly editor: BaseEditor & ReactEditor;
|
||||
protected innerValue: string;
|
||||
protected innerFocus: boolean;
|
||||
protected innerLines: ExpressionEditorLine[];
|
||||
protected innerVariableTree: ExpressionEditorTreeNode[];
|
||||
protected emitter: EventEmitter;
|
||||
|
||||
constructor(initialValue: string) {
|
||||
this.emitter = new EventEmitter();
|
||||
this.editor = withReact(withHistory(createEditor()));
|
||||
this.innerValue = initialValue;
|
||||
this.innerLines = ExpressionEditorParser.deserialize(initialValue);
|
||||
}
|
||||
|
||||
/** 设置变量树 */
|
||||
public setVariableTree(variableTree: ExpressionEditorTreeNode[]): void {
|
||||
this.innerVariableTree = variableTree;
|
||||
}
|
||||
|
||||
/** 获取变量树 */
|
||||
public get variableTree(): ExpressionEditorTreeNode[] {
|
||||
return this.innerVariableTree;
|
||||
}
|
||||
|
||||
/** 获取行数据 */
|
||||
public get lines(): ExpressionEditorLine[] {
|
||||
return this.innerLines;
|
||||
}
|
||||
|
||||
/** 获取序列化值 */
|
||||
public get value(): string {
|
||||
return this.innerValue;
|
||||
}
|
||||
|
||||
/** 外部设置模型值 */
|
||||
public setValue(value: string): void {
|
||||
if (value === this.innerValue) {
|
||||
return;
|
||||
}
|
||||
this.innerValue = value;
|
||||
this.innerLines = ExpressionEditorParser.deserialize(value);
|
||||
this.syncEditorValue();
|
||||
}
|
||||
|
||||
/** 同步选中状态 */
|
||||
public setFocus(focus: boolean): void {
|
||||
if (this.innerFocus === focus) {
|
||||
return;
|
||||
}
|
||||
this.innerFocus = focus;
|
||||
if (focus) {
|
||||
// 首次选中时主动触发选区事件,主动触发变量推荐
|
||||
this.select(this.lines);
|
||||
} else if (this.innerValue !== '' && this.editor.children.length !== 0) {
|
||||
// 触发失焦且编辑器内容不为空,则重置选区
|
||||
Transforms.select(this.editor, Editor.start(this.editor, []));
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册事件 */
|
||||
public on<T extends ExpressionEditorEvent>(
|
||||
event: T,
|
||||
callback: (params: ExpressionEditorEventParams<T>) => void,
|
||||
): ExpressionEditorEventDisposer {
|
||||
this.emitter.on(event, callback);
|
||||
return () => {
|
||||
this.emitter.off(event, callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据变更事件 */
|
||||
public change(lines: ExpressionEditorLine[]): void {
|
||||
const isAstChange = this.editor.operations.some(
|
||||
op => 'set_selection' !== op.type,
|
||||
);
|
||||
if (!isAstChange) {
|
||||
return;
|
||||
}
|
||||
this.innerLines = lines;
|
||||
this.innerValue = ExpressionEditorParser.serialize(lines);
|
||||
this.emitter.emit(ExpressionEditorEvent.Change, {
|
||||
lines,
|
||||
value: this.innerValue,
|
||||
});
|
||||
}
|
||||
|
||||
/** 选中事件 */
|
||||
public select(lines: ExpressionEditorLine[]): void {
|
||||
const { selection } = this.editor;
|
||||
if (!selection || !Range.isCollapsed(selection)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selection.anchor.offset !== selection.focus.offset ||
|
||||
selection.anchor.path[0] !== selection.focus.path[0] ||
|
||||
selection.anchor.path[1] !== selection.focus.path[1]
|
||||
) {
|
||||
// 框选
|
||||
this.emitter.emit(ExpressionEditorEvent.Select, {
|
||||
content: '',
|
||||
offset: -1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cursorOffset = selection.anchor.offset;
|
||||
const lineIndex = selection.anchor.path[0];
|
||||
const contentIndex = selection.anchor.path[1];
|
||||
const line = lines[lineIndex];
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
const content = line.children[contentIndex];
|
||||
const cursorContent = content?.text;
|
||||
if (typeof cursorContent !== 'string') {
|
||||
return;
|
||||
}
|
||||
this.emitter.emit(ExpressionEditorEvent.Select, {
|
||||
content: cursorContent,
|
||||
offset: cursorOffset,
|
||||
path: selection.anchor.path,
|
||||
});
|
||||
}
|
||||
|
||||
/** 键盘事件 */
|
||||
public keydown(
|
||||
event: Parameters<KeyboardEventHandler<HTMLDivElement>>[0],
|
||||
): void {
|
||||
if (event.key === ExpressionEditorToken.Start) {
|
||||
event.preventDefault();
|
||||
Transforms.insertText(
|
||||
this.editor,
|
||||
ExpressionEditorToken.FullStart + ExpressionEditorToken.FullEnd,
|
||||
);
|
||||
Transforms.move(this.editor, {
|
||||
distance: 2,
|
||||
reverse: true,
|
||||
});
|
||||
setTimeout(() => {
|
||||
// slate UI 渲染滞后
|
||||
this.select(this.innerLines);
|
||||
}, 0);
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (event.key) {
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
Transforms.select(this.editor, {
|
||||
anchor: Editor.start(this.editor, []),
|
||||
focus: Editor.end(this.editor, []),
|
||||
});
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 开始输入拼音 */
|
||||
public compositionStart(
|
||||
event: CompositionEventHandler<HTMLDivElement>,
|
||||
): void {
|
||||
this.emitter.emit(ExpressionEditorEvent.CompositionStart, {
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
/** 装饰叶子节点 */
|
||||
public get decorate(): ([node, path]: NodeEntry) => ExpressionEditorRange[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const self = this;
|
||||
const decorateFn = ([node, path]: NodeEntry): ExpressionEditorRange[] => {
|
||||
const ranges: ExpressionEditorRange[] = [];
|
||||
if (!Text.isText(node)) {
|
||||
return ranges;
|
||||
}
|
||||
// 计算表达式合法/非法
|
||||
const validateList = ExpressionEditorValidator.lineTextValidate({
|
||||
lineText: node.text,
|
||||
tree: self.innerVariableTree,
|
||||
});
|
||||
validateList.forEach(validateData => {
|
||||
const { start, end, valid } = validateData;
|
||||
const rangePath = {
|
||||
anchor: { path, offset: start },
|
||||
focus: { path, offset: end },
|
||||
};
|
||||
if (valid) {
|
||||
ranges.push({
|
||||
type: ExpressionEditorSignal.Valid,
|
||||
...rangePath,
|
||||
});
|
||||
} else {
|
||||
ranges.push({
|
||||
type: ExpressionEditorSignal.Invalid,
|
||||
...rangePath,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!this.innerFocus) {
|
||||
return ranges;
|
||||
}
|
||||
// 以下是计算当前选中表达式逻辑
|
||||
const selectedItem = self.isValidateSelectPath([node, path]);
|
||||
const selectedValidItem = validateList.find(
|
||||
validateData =>
|
||||
validateData.valid &&
|
||||
validateData.start === selectedItem?.start &&
|
||||
validateData.end === selectedItem?.end,
|
||||
);
|
||||
if (selectedItem && selectedValidItem) {
|
||||
ranges.push({
|
||||
type: ExpressionEditorSignal.SelectedValid,
|
||||
anchor: { path, offset: selectedItem.start },
|
||||
focus: { path, offset: selectedItem.end },
|
||||
});
|
||||
} else if (selectedItem && !selectedValidItem) {
|
||||
ranges.push({
|
||||
type: ExpressionEditorSignal.SelectedInvalid,
|
||||
anchor: { path, offset: selectedItem.start },
|
||||
focus: { path, offset: selectedItem.end },
|
||||
});
|
||||
}
|
||||
return ranges;
|
||||
};
|
||||
return decorateFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步编辑器实例内容
|
||||
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
|
||||
*/
|
||||
private syncEditorValue(): void {
|
||||
// 删除编辑器内所有行
|
||||
this.editor.children.forEach((line, index) => {
|
||||
Transforms.removeNodes(this.editor, {
|
||||
at: [index],
|
||||
});
|
||||
});
|
||||
// 重新在编辑器插入当前行内容
|
||||
this.lines.forEach((line, index) => {
|
||||
Transforms.insertNodes(this.editor, line, {
|
||||
at: [this.editor.children.length],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isValidateSelectPath([node, path]: NodeEntry):
|
||||
| ExpressionEditorValidateData
|
||||
| undefined {
|
||||
if (!Text.isText(node)) {
|
||||
return;
|
||||
}
|
||||
const { selection } = this.editor;
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const cursorOffset = selection.anchor.offset;
|
||||
const lineIndex = selection.anchor.path[0];
|
||||
const contentIndex = selection.anchor.path[1];
|
||||
if (lineIndex !== path[0] || contentIndex !== path[1]) {
|
||||
return;
|
||||
}
|
||||
const lineContent = node.text;
|
||||
const lineOffset = cursorOffset;
|
||||
const parsedData = ExpressionEditorParser.parse({
|
||||
lineContent,
|
||||
lineOffset,
|
||||
});
|
||||
if (!parsedData) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
start: parsedData.offset.lastStart - 1,
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
end: parsedData.offset.firstEnd + 2,
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
/*
|
||||
* 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 'reflect-metadata';
|
||||
import { type ExpressionEditorParseData } from '../type';
|
||||
import { ExpressionEditorSegmentType } from '../constant';
|
||||
import { ExpressionEditorParser, ExpressionEditorParserBuiltin } from '.';
|
||||
|
||||
describe('ExpressionEditorParserBuiltin', () => {
|
||||
it('tokenIndex', () => {
|
||||
const result = ExpressionEditorParserBuiltin.tokenOffset({
|
||||
lineContent: 'test input: {{Earth.Asia.China.Hangzhou}}',
|
||||
lineOffset: 39,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
lastStartTokenOffset: 13,
|
||||
firstEndTokenOffset: 39,
|
||||
});
|
||||
});
|
||||
it('extractContent', () => {
|
||||
const result = ExpressionEditorParserBuiltin.extractContent({
|
||||
lineContent: 'test input: {{Earth.Asia.China.Hangzhou}}',
|
||||
lineOffset: 39,
|
||||
lastStartTokenOffset: 13,
|
||||
firstEndTokenOffset: 39,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
content: 'Earth.Asia.China.Hangzhou',
|
||||
offset: 25,
|
||||
});
|
||||
});
|
||||
it('sliceReachable', () => {
|
||||
const result = ExpressionEditorParserBuiltin.sliceReachable({
|
||||
content: 'China.Hangzhou',
|
||||
offset: 6,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
reachable: 'China.',
|
||||
unreachable: 'Hangzhou',
|
||||
});
|
||||
});
|
||||
describe('splitPath', () => {
|
||||
it('pure object keys', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText(
|
||||
'Earth.Asia.China.Hangzhou',
|
||||
);
|
||||
expect(result).toEqual(['Earth', 'Asia', 'China', 'Hangzhou']);
|
||||
});
|
||||
it('pure object keys with number type', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText(
|
||||
'Earth.Asia.China.0.Hangzhou',
|
||||
);
|
||||
expect(result).toEqual(['Earth', 'Asia', 'China', '0', 'Hangzhou']);
|
||||
});
|
||||
it('object keys and array indexes', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText(
|
||||
'Earth.Asia.China[0].Hangzhou',
|
||||
);
|
||||
expect(result).toEqual(['Earth', 'Asia', 'China', '[0]', 'Hangzhou']);
|
||||
});
|
||||
it('object keys and individual array indexes', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText(
|
||||
'Earth.Asia.China.[0].Hangzhou',
|
||||
);
|
||||
expect(result).toEqual(['Earth', 'Asia', 'China', '', '[0]', 'Hangzhou']);
|
||||
});
|
||||
it('continues array index', () => {
|
||||
const result =
|
||||
ExpressionEditorParserBuiltin.splitText('Hangzhou[0][0][0]');
|
||||
expect(result).toEqual(['Hangzhou[0][0]', '[0]']);
|
||||
});
|
||||
it('continues array index start with separator', () => {
|
||||
const result =
|
||||
ExpressionEditorParserBuiltin.splitText('Hangzhou.[0][0][0]');
|
||||
expect(result).toEqual(['Hangzhou', '[0][0]', '[0]']);
|
||||
});
|
||||
it('start with array index', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText('[0].Hangzhou');
|
||||
expect(result).toEqual(['', '[0]', 'Hangzhou']);
|
||||
});
|
||||
it('object keys with empty', () => {
|
||||
const result =
|
||||
ExpressionEditorParserBuiltin.splitText('Earth...Hangzhou');
|
||||
expect(result).toEqual(['Earth', '', '', 'Hangzhou']);
|
||||
});
|
||||
it('object keys and end with empty', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText('China.Hangzhou.');
|
||||
expect(result).toEqual(['China', 'Hangzhou', '']);
|
||||
});
|
||||
it('object keys and start with empty', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText('.China.Hangzhou');
|
||||
expect(result).toEqual(['', 'China', 'Hangzhou']);
|
||||
});
|
||||
it('all empty', () => {
|
||||
const result = ExpressionEditorParserBuiltin.splitText('..');
|
||||
expect(result).toEqual(['', '', '']);
|
||||
});
|
||||
});
|
||||
describe('textToPath', () => {
|
||||
it('pure object keys', () => {
|
||||
const result = ExpressionEditorParserBuiltin.toSegments('China.Hangzhou');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'China',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'Hangzhou',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('pure object keys with number type', () => {
|
||||
const result =
|
||||
ExpressionEditorParserBuiltin.toSegments('China.0.Hangzhou');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'China',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: '0',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: 'Hangzhou',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('object keys and array indexes', () => {
|
||||
const result =
|
||||
ExpressionEditorParserBuiltin.toSegments('China[0].Hangzhou');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'China',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 0,
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: 'Hangzhou',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('object keys and end with empty', () => {
|
||||
const result = ExpressionEditorParserBuiltin.toSegments(
|
||||
'China_Zhejiang.Hangzhou.',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'China_Zhejiang',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'Hangzhou',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.EndEmpty,
|
||||
index: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should be undefined', () => {
|
||||
const invalidPatterns = [
|
||||
'foo..bar',
|
||||
'..',
|
||||
'.foo',
|
||||
'foo[]',
|
||||
'foo.[]',
|
||||
'foo.[0]',
|
||||
'foo[0',
|
||||
'foo[0.',
|
||||
'foo[0].{a}',
|
||||
'foo[0][0]',
|
||||
'foo[0].[0]',
|
||||
'foo.[0].[0]',
|
||||
'[]foo',
|
||||
'.[]foo',
|
||||
'[.]foo',
|
||||
'[].foo',
|
||||
'[0].foo',
|
||||
'.[0].foo',
|
||||
'{a}',
|
||||
'foo-bar',
|
||||
'😊[0]',
|
||||
];
|
||||
invalidPatterns.forEach(pattern => {
|
||||
const result = ExpressionEditorParserBuiltin.toSegments(pattern);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('textToPath unicode', () => {
|
||||
it('pure object keys', () => {
|
||||
const result = ExpressionEditorParserBuiltin.toSegments('主题.名称');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: '主题',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: '名称',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('pure object keys with number type', () => {
|
||||
const result = ExpressionEditorParserBuiltin.toSegments('主题.0.名称');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: '主题',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: '0',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: '名称',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('object keys and array indexes', () => {
|
||||
const result = ExpressionEditorParserBuiltin.toSegments('主题[0].名称');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: '主题',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 0,
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: '名称',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('object keys and end with empty', () => {
|
||||
const result = ExpressionEditorParserBuiltin.toSegments('主题.名称.');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: '主题',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: '名称',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.EndEmpty,
|
||||
index: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExpressionEditorParser parse should be successful', () => {
|
||||
it('parse object keys', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: 'test: {{foo.bar}}',
|
||||
lineOffset: 15,
|
||||
});
|
||||
const expected: ExpressionEditorParseData = {
|
||||
content: {
|
||||
line: 'test: {{foo.bar}}',
|
||||
inline: 'foo.bar',
|
||||
reachable: 'foo.bar',
|
||||
unreachable: '',
|
||||
},
|
||||
offset: {
|
||||
line: 15,
|
||||
inline: 7,
|
||||
lastStart: 7,
|
||||
firstEnd: 15,
|
||||
},
|
||||
segments: {
|
||||
inline: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
],
|
||||
reachable: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
it('parse array indexes', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: 'test: {{foo[0].bar}}',
|
||||
lineOffset: 18,
|
||||
});
|
||||
const expected: ExpressionEditorParseData = {
|
||||
content: {
|
||||
line: 'test: {{foo[0].bar}}',
|
||||
inline: 'foo[0].bar',
|
||||
reachable: 'foo[0].bar',
|
||||
unreachable: '',
|
||||
},
|
||||
offset: {
|
||||
line: 18,
|
||||
inline: 10,
|
||||
lastStart: 7,
|
||||
firstEnd: 18,
|
||||
},
|
||||
segments: {
|
||||
inline: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 0,
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
],
|
||||
reachable: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 0,
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
it('parse end with empty', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: 'test: {{foo.bar}}',
|
||||
lineOffset: 12,
|
||||
});
|
||||
const expected: ExpressionEditorParseData = {
|
||||
content: {
|
||||
line: 'test: {{foo.bar}}',
|
||||
inline: 'foo.bar',
|
||||
reachable: 'foo.',
|
||||
unreachable: 'bar',
|
||||
},
|
||||
offset: { line: 12, inline: 4, lastStart: 7, firstEnd: 15 },
|
||||
segments: {
|
||||
inline: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
],
|
||||
reachable: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{ type: ExpressionEditorSegmentType.EndEmpty, index: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
it('empty {{content}}', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: 'test: {{}}',
|
||||
lineOffset: 8,
|
||||
});
|
||||
const expected: ExpressionEditorParseData = {
|
||||
content: {
|
||||
line: 'test: {{}}',
|
||||
inline: '',
|
||||
reachable: '',
|
||||
unreachable: '',
|
||||
},
|
||||
offset: { line: 8, inline: 0, lastStart: 7, firstEnd: 8 },
|
||||
segments: {
|
||||
inline: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
|
||||
reachable: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
|
||||
},
|
||||
};
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
it('only empty {{content}}', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: '{{}}',
|
||||
lineOffset: 2,
|
||||
});
|
||||
const expected: ExpressionEditorParseData = {
|
||||
content: { line: '{{}}', inline: '', reachable: '', unreachable: '' },
|
||||
offset: { line: 2, inline: 0, lastStart: 1, firstEnd: 2 },
|
||||
segments: {
|
||||
inline: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
|
||||
reachable: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
|
||||
},
|
||||
};
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExpressionEditorParser parse should be fail', () => {
|
||||
it('out of bucket', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: 'test: {{foo.bar}}',
|
||||
lineOffset: 7,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
it('dangling null pointer', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: '{{foo.bar}}',
|
||||
lineOffset: 12,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
it('empty content with not zero offset', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: '',
|
||||
lineOffset: 1,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
it('invalid char', () => {
|
||||
const result = ExpressionEditorParser.parse({
|
||||
lineContent: '{{foo(0).bar}}',
|
||||
lineOffset: 12,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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 @typescript-eslint/no-namespace */
|
||||
import { Node } from 'slate';
|
||||
|
||||
import type {
|
||||
ExpressionEditorLine,
|
||||
ExpressionEditorParseData,
|
||||
ExpressionEditorSegment,
|
||||
} from '../type';
|
||||
import {
|
||||
ExpressionEditorSegmentType,
|
||||
ExpressionEditorSignal,
|
||||
ExpressionEditorToken,
|
||||
} from '../constant';
|
||||
|
||||
export namespace ExpressionEditorParserBuiltin {
|
||||
/** 计算开始和结束标识的序号 */
|
||||
export const tokenOffset = (line: {
|
||||
lineContent: string;
|
||||
lineOffset: number;
|
||||
}):
|
||||
| {
|
||||
lastStartTokenOffset: number;
|
||||
firstEndTokenOffset: number;
|
||||
}
|
||||
| undefined => {
|
||||
const { lineContent: content, lineOffset: offset } = line;
|
||||
|
||||
const firstEndTokenOffset = content.indexOf(
|
||||
ExpressionEditorToken.End,
|
||||
offset,
|
||||
);
|
||||
|
||||
const endChars = content.slice(
|
||||
firstEndTokenOffset,
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
firstEndTokenOffset + 2,
|
||||
);
|
||||
if (endChars !== ExpressionEditorToken.FullEnd) {
|
||||
// 结束符号 "}}" 不完整
|
||||
return;
|
||||
}
|
||||
const lastStartTokenOffset = content.lastIndexOf(
|
||||
ExpressionEditorToken.Start,
|
||||
offset - 1,
|
||||
);
|
||||
const startChars = content.slice(
|
||||
lastStartTokenOffset - 1,
|
||||
lastStartTokenOffset + 1,
|
||||
);
|
||||
if (startChars !== ExpressionEditorToken.FullStart) {
|
||||
// 开始符号 "{{" 不完整
|
||||
return;
|
||||
}
|
||||
return {
|
||||
lastStartTokenOffset,
|
||||
firstEndTokenOffset,
|
||||
};
|
||||
};
|
||||
|
||||
/** 从行内容提取内容 */
|
||||
export const extractContent = (params: {
|
||||
lineContent: string;
|
||||
lineOffset: number;
|
||||
lastStartTokenOffset: number;
|
||||
firstEndTokenOffset: number;
|
||||
}):
|
||||
| {
|
||||
content: string;
|
||||
offset: number;
|
||||
}
|
||||
| undefined => {
|
||||
const {
|
||||
lineContent,
|
||||
lineOffset,
|
||||
lastStartTokenOffset,
|
||||
firstEndTokenOffset,
|
||||
} = params;
|
||||
const content = lineContent.slice(
|
||||
lastStartTokenOffset + 1,
|
||||
firstEndTokenOffset,
|
||||
);
|
||||
const offset = lineOffset - lastStartTokenOffset - 1;
|
||||
return {
|
||||
content,
|
||||
offset,
|
||||
};
|
||||
};
|
||||
|
||||
/** 根据 offset 将文本内容切分为可用与不可用 */
|
||||
export const sliceReachable = (params: {
|
||||
content: string;
|
||||
offset: number;
|
||||
}): {
|
||||
reachable: string;
|
||||
unreachable: string;
|
||||
} => {
|
||||
const { content, offset } = params;
|
||||
const reachable = content.slice(0, offset);
|
||||
const unreachable = content.slice(offset, content.length);
|
||||
return {
|
||||
reachable,
|
||||
unreachable,
|
||||
};
|
||||
};
|
||||
|
||||
/** 切分文本 */
|
||||
export const splitText = (pathString: string): string[] => {
|
||||
// 得到的分割数组,初始为原字符串以"."分割的结果
|
||||
const segments = pathString.split(ExpressionEditorToken.Separator);
|
||||
|
||||
// 定义结果数组,并处理连续的"."导致的空字符串
|
||||
const result: string[] = [];
|
||||
|
||||
segments.forEach(segment => {
|
||||
if (!segment.match(/\[\d+\]/)) {
|
||||
// 如果不是数组索引,直接加入结果数组,即使是空字符串也加入以保持正确的分割
|
||||
result.push(segment);
|
||||
return;
|
||||
}
|
||||
// 如果当前段是数组索引,将前面的字符串和当前数组索引分别加入结果数组
|
||||
const lastSegmentIndex = segment.lastIndexOf(
|
||||
ExpressionEditorToken.ArrayStart,
|
||||
);
|
||||
const key = segment.substring(0, lastSegmentIndex);
|
||||
const index = segment.substring(lastSegmentIndex);
|
||||
// {{array[0]}} 中的 array
|
||||
result.push(key);
|
||||
// {{array[0]}} 中的 [0]
|
||||
result.push(index);
|
||||
return;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/** 字符串解析为路径 */
|
||||
export const toSegments = (
|
||||
text: string,
|
||||
): ExpressionEditorSegment[] | undefined => {
|
||||
const textSegments = ExpressionEditorParserBuiltin.splitText(text);
|
||||
const segments: ExpressionEditorSegment[] = [];
|
||||
const validate = textSegments.every((textSegment, index) => {
|
||||
// 数组下标
|
||||
if (
|
||||
textSegment.startsWith(ExpressionEditorToken.ArrayStart) &&
|
||||
textSegment.endsWith(ExpressionEditorToken.ArrayEnd)
|
||||
) {
|
||||
const arrayIndexString = textSegment.slice(1, -1);
|
||||
const arrayIndex = Number(arrayIndexString);
|
||||
if (arrayIndexString === '' || Number.isNaN(arrayIndex)) {
|
||||
// index 必须是数字
|
||||
return false;
|
||||
}
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
if (
|
||||
!lastSegment ||
|
||||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
|
||||
) {
|
||||
// 数组索引必须在 key 之后
|
||||
return false;
|
||||
}
|
||||
segments.push({
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index,
|
||||
arrayIndex,
|
||||
});
|
||||
}
|
||||
// 最后一行空文本
|
||||
else if (index === textSegments.length - 1 && textSegment === '') {
|
||||
segments.push({
|
||||
type: ExpressionEditorSegmentType.EndEmpty,
|
||||
index,
|
||||
});
|
||||
} else {
|
||||
if (!textSegment || !/^[\u4e00-\u9fa5_a-zA-Z0-9]*$/.test(textSegment)) {
|
||||
return false;
|
||||
}
|
||||
segments.push({
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index,
|
||||
objectKey: textSegment,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!validate) {
|
||||
return undefined;
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
}
|
||||
|
||||
export namespace ExpressionEditorParser {
|
||||
/** 序列化 */
|
||||
export const serialize = (value: ExpressionEditorLine[]) =>
|
||||
value.map(n => Node.string(n)).join('\n');
|
||||
|
||||
/** 反序列化 */
|
||||
export const deserialize = (text: string): ExpressionEditorLine[] => {
|
||||
const lines = text.split('\n');
|
||||
return lines.map(line => ({
|
||||
type: ExpressionEditorSignal.Line,
|
||||
children: [{ text: line || '' }],
|
||||
}));
|
||||
};
|
||||
|
||||
export const parse = (line: {
|
||||
lineContent: string;
|
||||
lineOffset: number;
|
||||
}): ExpressionEditorParseData | undefined => {
|
||||
const { lineContent, lineOffset } = line;
|
||||
const tokenOffsets = ExpressionEditorParserBuiltin.tokenOffset(line);
|
||||
if (!tokenOffsets) {
|
||||
return;
|
||||
}
|
||||
const { lastStartTokenOffset, firstEndTokenOffset } = tokenOffsets;
|
||||
const extractedContent = ExpressionEditorParserBuiltin.extractContent({
|
||||
...line,
|
||||
...tokenOffsets,
|
||||
});
|
||||
if (!extractedContent) {
|
||||
return;
|
||||
}
|
||||
const { content, offset } = extractedContent;
|
||||
const slicedReachable =
|
||||
ExpressionEditorParserBuiltin.sliceReachable(extractedContent);
|
||||
if (!slicedReachable) {
|
||||
return;
|
||||
}
|
||||
const reachableSegments = ExpressionEditorParserBuiltin.toSegments(
|
||||
slicedReachable.reachable,
|
||||
);
|
||||
const inlineSegments = ExpressionEditorParserBuiltin.toSegments(content);
|
||||
if (!reachableSegments) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
content: {
|
||||
line: lineContent,
|
||||
inline: content,
|
||||
reachable: slicedReachable.reachable,
|
||||
unreachable: slicedReachable.unreachable,
|
||||
},
|
||||
offset: {
|
||||
line: lineOffset,
|
||||
inline: offset,
|
||||
lastStart: lastStartTokenOffset,
|
||||
firstEnd: firstEndTokenOffset,
|
||||
},
|
||||
segments: {
|
||||
inline: inlineSegments,
|
||||
reachable: reachableSegments,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
/*
|
||||
* 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 @typescript-eslint/consistent-type-assertions */
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { ViewVariableType } from '@coze-workflow/base';
|
||||
|
||||
import type {
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorSegment,
|
||||
ExpressionEditorVariable,
|
||||
} from '../type';
|
||||
import { ExpressionEditorSegmentType } from '../constant';
|
||||
import { ExpressionEditorTreeHelper } from '.';
|
||||
|
||||
vi.mock('@coze-workflow/base', () => {
|
||||
enum VariableType {
|
||||
String = 1,
|
||||
Integer,
|
||||
Boolean,
|
||||
Number,
|
||||
Object = 6,
|
||||
ArrayString = 99,
|
||||
ArrayInteger,
|
||||
ArrayBoolean,
|
||||
ArrayNumber,
|
||||
ArrayObject,
|
||||
}
|
||||
|
||||
return {
|
||||
ViewVariableType: {
|
||||
...VariableType,
|
||||
isArrayType: (type: VariableType): boolean => {
|
||||
const arrayTypes = [
|
||||
VariableType.ArrayString,
|
||||
VariableType.ArrayInteger,
|
||||
VariableType.ArrayBoolean,
|
||||
VariableType.ArrayNumber,
|
||||
VariableType.ArrayObject,
|
||||
];
|
||||
return arrayTypes.includes(type);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExpressionEditorTreeHelper pruning', () => {
|
||||
let defaultTree: ExpressionEditorTreeNode[];
|
||||
let defaultSegments: ExpressionEditorSegment[];
|
||||
beforeEach(() => {
|
||||
defaultTree = [
|
||||
{
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as ExpressionEditorVariable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.Object,
|
||||
} as ExpressionEditorVariable,
|
||||
children: [
|
||||
{
|
||||
label: 'baz',
|
||||
value: 'baz',
|
||||
key: 'baz',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as ExpressionEditorVariable,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
defaultSegments = [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
{ type: ExpressionEditorSegmentType.EndEmpty, index: 2 },
|
||||
];
|
||||
});
|
||||
|
||||
it('should pruning', () => {
|
||||
const result = ExpressionEditorTreeHelper.pruning({
|
||||
tree: defaultTree,
|
||||
segments: defaultSegments,
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
label: 'baz',
|
||||
value: 'baz',
|
||||
key: 'baz',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as ExpressionEditorVariable,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not pruning', () => {
|
||||
const result = ExpressionEditorTreeHelper.pruning({
|
||||
tree: defaultTree,
|
||||
segments: [],
|
||||
});
|
||||
expect(result).toEqual(defaultTree);
|
||||
});
|
||||
|
||||
it('should be empty', () => {
|
||||
const result = ExpressionEditorTreeHelper.pruning({
|
||||
tree: defaultTree,
|
||||
segments: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'trs',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual(defaultTree);
|
||||
});
|
||||
|
||||
it('should pruning and ignore array index segments', () => {
|
||||
const result = ExpressionEditorTreeHelper.pruning({
|
||||
tree: defaultTree,
|
||||
segments: [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual(defaultTree);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExpressionEditorTreeHelper fullPath without segments', () => {
|
||||
it('return node full path', () => {
|
||||
const node = {
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {} as ExpressionEditorVariable,
|
||||
parent: {
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {} as ExpressionEditorVariable,
|
||||
},
|
||||
};
|
||||
const fullString = ExpressionEditorTreeHelper.concatFullPath({
|
||||
node,
|
||||
segments: [],
|
||||
});
|
||||
expect(fullString).toEqual('foo.bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExpressionEditorTreeHelper fullPath with segments', () => {
|
||||
it('return node full path', () => {
|
||||
const node = {
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {} as ExpressionEditorVariable,
|
||||
parent: {
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as ExpressionEditorVariable,
|
||||
},
|
||||
};
|
||||
const segments: ExpressionEditorSegment[] = [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 10,
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
|
||||
];
|
||||
const fullString = ExpressionEditorTreeHelper.concatFullPath({
|
||||
node,
|
||||
segments,
|
||||
});
|
||||
expect(fullString).toEqual('foo[10].bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExpressionEditorTreeHelper matchBranch', () => {
|
||||
it('match tree branch', () => {
|
||||
const tree: ExpressionEditorTreeNode[] = [
|
||||
{
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {} as ExpressionEditorVariable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as ExpressionEditorVariable,
|
||||
children: [
|
||||
{
|
||||
label: 'baz',
|
||||
value: 'baz',
|
||||
key: 'baz',
|
||||
variable: {} as ExpressionEditorVariable,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const segments: ExpressionEditorSegment[] = [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 2,
|
||||
arrayIndex: 10,
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 1,
|
||||
objectKey: 'baz',
|
||||
},
|
||||
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
|
||||
];
|
||||
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree,
|
||||
segments,
|
||||
});
|
||||
expect(treeBranch).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('match tree branch failed with incorrect array index', () => {
|
||||
const tree: ExpressionEditorTreeNode[] = [
|
||||
{
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as ExpressionEditorVariable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as ExpressionEditorVariable,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const segments: ExpressionEditorSegment[] = [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 10,
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
|
||||
];
|
||||
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree,
|
||||
segments,
|
||||
});
|
||||
expect(treeBranch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('match tree branch failed, array object without index before sub item', () => {
|
||||
const tree: ExpressionEditorTreeNode[] = [
|
||||
{
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as ExpressionEditorVariable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as ExpressionEditorVariable,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const segments: ExpressionEditorSegment[] = [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 2,
|
||||
objectKey: 'bar',
|
||||
},
|
||||
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
|
||||
];
|
||||
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree,
|
||||
segments,
|
||||
});
|
||||
expect(treeBranch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('match tree branch failed with constant follow array index', () => {
|
||||
const tree: ExpressionEditorTreeNode[] = [
|
||||
{
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
},
|
||||
];
|
||||
const segments: ExpressionEditorSegment[] = [
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ObjectKey,
|
||||
index: 0,
|
||||
objectKey: 'foo',
|
||||
},
|
||||
{
|
||||
type: ExpressionEditorSegmentType.ArrayIndex,
|
||||
index: 1,
|
||||
arrayIndex: 10,
|
||||
},
|
||||
];
|
||||
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree,
|
||||
segments,
|
||||
});
|
||||
expect(treeBranch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExpressionEditorTreeHelper findAvailableVariables & createVariableTree', () => {
|
||||
let variables: ExpressionEditorVariable[];
|
||||
let inputs: {
|
||||
name: string;
|
||||
keyPath?: string[];
|
||||
}[];
|
||||
let availableVariables: ExpressionEditorTreeHelper.AvailableVariable[];
|
||||
|
||||
beforeEach(() => {
|
||||
variables = [
|
||||
{
|
||||
key: 'G3UiXFzKjTefY_iu8U59Z',
|
||||
type: 6,
|
||||
name: 'obj',
|
||||
children: [
|
||||
{
|
||||
key: 'klAhNVg0xasVuN-l3bZdw',
|
||||
type: 1,
|
||||
name: 'str',
|
||||
},
|
||||
{
|
||||
key: 'j8Hp-0mQhGfW618h35Pql',
|
||||
type: 4,
|
||||
name: 'num',
|
||||
},
|
||||
],
|
||||
nodeTitle: 'Code',
|
||||
nodeId: '112561',
|
||||
},
|
||||
{
|
||||
key: 'UVxP2tcAXIe2DXIeT1C-o',
|
||||
type: 103,
|
||||
name: 'arr_obj',
|
||||
children: [
|
||||
{
|
||||
key: '7-id-zYuO7aBPiC48Jkk4',
|
||||
type: 1,
|
||||
name: 'str',
|
||||
},
|
||||
{
|
||||
key: 'QHB4k7Z3k2VyTipg8rjlL',
|
||||
type: 4,
|
||||
name: 'num',
|
||||
},
|
||||
],
|
||||
nodeTitle: 'Code',
|
||||
nodeId: '112561',
|
||||
},
|
||||
{
|
||||
key: 'GX1IupmKt-gaMKC54d1a4',
|
||||
type: 99,
|
||||
name: 'arr_str',
|
||||
nodeTitle: 'Code',
|
||||
nodeId: '112561',
|
||||
},
|
||||
];
|
||||
inputs = [
|
||||
{
|
||||
name: 'ref_obj',
|
||||
keyPath: ['112561', 'G3UiXFzKjTefY_iu8U59Z'],
|
||||
},
|
||||
{
|
||||
name: 'ref_arr_obj',
|
||||
keyPath: ['112561', 'UVxP2tcAXIe2DXIeT1C-o'],
|
||||
},
|
||||
{
|
||||
name: 'test_ref',
|
||||
keyPath: ['112561', 'G3UiXFzKjTefY_iu8U59Z', 'klAhNVg0xasVuN-l3bZdw'],
|
||||
},
|
||||
{
|
||||
name: 'ref_arr_str',
|
||||
keyPath: ['112561', 'GX1IupmKt-gaMKC54d1a4'],
|
||||
},
|
||||
{
|
||||
name: 'constant',
|
||||
keyPath: [],
|
||||
},
|
||||
];
|
||||
availableVariables = [
|
||||
{
|
||||
name: 'ref_obj',
|
||||
keyPath: ['G3UiXFzKjTefY_iu8U59Z'],
|
||||
variable: {
|
||||
key: 'G3UiXFzKjTefY_iu8U59Z',
|
||||
type: 6,
|
||||
name: 'obj',
|
||||
children: [
|
||||
{
|
||||
key: 'klAhNVg0xasVuN-l3bZdw',
|
||||
type: 1,
|
||||
name: 'str',
|
||||
},
|
||||
{
|
||||
key: 'j8Hp-0mQhGfW618h35Pql',
|
||||
type: 4,
|
||||
name: 'num',
|
||||
},
|
||||
],
|
||||
nodeTitle: 'Code',
|
||||
nodeId: '112561',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ref_arr_obj',
|
||||
keyPath: ['UVxP2tcAXIe2DXIeT1C-o'],
|
||||
variable: {
|
||||
key: 'UVxP2tcAXIe2DXIeT1C-o',
|
||||
type: 103,
|
||||
name: 'arr_obj',
|
||||
children: [
|
||||
{
|
||||
key: '7-id-zYuO7aBPiC48Jkk4',
|
||||
type: 1,
|
||||
name: 'str',
|
||||
},
|
||||
{
|
||||
key: 'QHB4k7Z3k2VyTipg8rjlL',
|
||||
type: 4,
|
||||
name: 'num',
|
||||
},
|
||||
],
|
||||
nodeTitle: 'Code',
|
||||
nodeId: '112561',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'test_ref',
|
||||
keyPath: ['G3UiXFzKjTefY_iu8U59Z', 'klAhNVg0xasVuN-l3bZdw'],
|
||||
variable: {
|
||||
key: 'klAhNVg0xasVuN-l3bZdw',
|
||||
type: 1,
|
||||
name: 'str',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ref_arr_str',
|
||||
keyPath: ['GX1IupmKt-gaMKC54d1a4'],
|
||||
variable: {
|
||||
key: 'GX1IupmKt-gaMKC54d1a4',
|
||||
type: 99,
|
||||
name: 'arr_str',
|
||||
nodeTitle: 'Code',
|
||||
nodeId: '112561',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'constant',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it('find available variables', () => {
|
||||
const results = ExpressionEditorTreeHelper.findAvailableVariables({
|
||||
variables,
|
||||
inputs,
|
||||
});
|
||||
expect(results).toEqual(availableVariables);
|
||||
});
|
||||
|
||||
it('create variable tree', () => {
|
||||
const results =
|
||||
ExpressionEditorTreeHelper.createVariableTree(availableVariables);
|
||||
expect(results.length).toEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* 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 @typescript-eslint/no-namespace */
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers */
|
||||
import { ViewVariableType } from '@coze-workflow/base/types';
|
||||
|
||||
import type {
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorSegment,
|
||||
ExpressionEditorVariable,
|
||||
} from '../type';
|
||||
import { ExpressionEditorSegmentType } from '../constant';
|
||||
|
||||
export namespace ExpressionEditorTreeHelper {
|
||||
export interface Input {
|
||||
name: string;
|
||||
keyPath?: string[];
|
||||
children?: Input[];
|
||||
}
|
||||
|
||||
export interface AvailableVariable extends Input {
|
||||
variable?: ExpressionEditorVariable;
|
||||
children?: AvailableVariable[];
|
||||
}
|
||||
|
||||
const findAvailableVariable = (params: {
|
||||
variables: ExpressionEditorVariable[];
|
||||
input: Input;
|
||||
}): AvailableVariable => {
|
||||
const { variables, input } = params;
|
||||
|
||||
if (!input.keyPath) {
|
||||
return {
|
||||
name: input.name,
|
||||
};
|
||||
}
|
||||
const nodeId = input.keyPath.shift();
|
||||
const nodePath = input.keyPath;
|
||||
const nodeVariables = variables.filter(
|
||||
variable => variable.nodeId === nodeId,
|
||||
);
|
||||
let variable: ExpressionEditorVariable | undefined;
|
||||
nodePath.reduce(
|
||||
(
|
||||
availableVariables: ExpressionEditorVariable[],
|
||||
path: string,
|
||||
index: number,
|
||||
) => {
|
||||
const targetVariable = availableVariables.find(
|
||||
availableVariable => availableVariable.key === path,
|
||||
);
|
||||
if (index === nodePath.length - 1) {
|
||||
variable = targetVariable;
|
||||
}
|
||||
if (targetVariable && targetVariable.children) {
|
||||
return targetVariable.children;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
nodeVariables,
|
||||
);
|
||||
if (!variable) {
|
||||
return {
|
||||
name: input.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
keyPath: input.keyPath,
|
||||
variable,
|
||||
};
|
||||
};
|
||||
|
||||
export const findAvailableVariables = (params: {
|
||||
variables: ExpressionEditorVariable[];
|
||||
inputs: Input[];
|
||||
}): AvailableVariable[] => {
|
||||
const { variables, inputs } = params;
|
||||
return inputs.map(input => {
|
||||
const availableVariable = findAvailableVariable({ input, variables });
|
||||
|
||||
if (input.children?.length) {
|
||||
availableVariable.children = findAvailableVariables({
|
||||
variables,
|
||||
inputs: input.children || [],
|
||||
});
|
||||
}
|
||||
|
||||
return availableVariable;
|
||||
});
|
||||
};
|
||||
|
||||
const createVariableLeaves = (
|
||||
variables: ExpressionEditorVariable[],
|
||||
parent: ExpressionEditorTreeNode,
|
||||
): ExpressionEditorTreeNode[] =>
|
||||
variables.map(
|
||||
(variable: ExpressionEditorVariable): ExpressionEditorTreeNode => {
|
||||
const node: ExpressionEditorTreeNode = {
|
||||
label: variable.name,
|
||||
value: `${parent.value}.${variable.key}`,
|
||||
key: `${parent.value}.${variable.key}`,
|
||||
variable,
|
||||
parent,
|
||||
};
|
||||
node.children = createVariableLeaves(variable.children || [], node);
|
||||
return node;
|
||||
},
|
||||
);
|
||||
|
||||
export const createVariableTree = (
|
||||
availableVariables: AvailableVariable[],
|
||||
parent?: ExpressionEditorTreeNode,
|
||||
): ExpressionEditorTreeNode[] =>
|
||||
availableVariables.map(
|
||||
(availableVariable: AvailableVariable): ExpressionEditorTreeNode => {
|
||||
const path = parent
|
||||
? `${parent.key}.${availableVariable.name}`
|
||||
: availableVariable.name;
|
||||
|
||||
const node: ExpressionEditorTreeNode = {
|
||||
label: availableVariable.name,
|
||||
value: availableVariable.keyPath?.join('.') || path,
|
||||
key: path,
|
||||
keyPath: availableVariable.keyPath,
|
||||
variable: availableVariable.variable,
|
||||
parent,
|
||||
};
|
||||
|
||||
if (availableVariable.children?.length) {
|
||||
node.children = createVariableTree(availableVariable.children, node);
|
||||
} else {
|
||||
node.children = createVariableLeaves(
|
||||
availableVariable.variable?.children || [],
|
||||
node,
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
);
|
||||
|
||||
export const pruning = (params: {
|
||||
tree: ExpressionEditorTreeNode[];
|
||||
segments: ExpressionEditorSegment[];
|
||||
}): ExpressionEditorTreeNode[] => {
|
||||
const { tree, segments } = params;
|
||||
if (segments.length === 0) {
|
||||
return tree;
|
||||
}
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
const segmentsRemovedLast =
|
||||
lastSegment.type === ExpressionEditorSegmentType.ArrayIndex
|
||||
? segments.slice(0, segments.length - 2) // 数组索引属于上一层级,需要去除两层
|
||||
: segments.slice(0, segments.length - 1);
|
||||
let treeLayer = tree;
|
||||
segmentsRemovedLast.forEach(segment => {
|
||||
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
|
||||
return;
|
||||
}
|
||||
const treeChild = treeLayer.find(
|
||||
node => node.label === segment.objectKey,
|
||||
);
|
||||
if (treeChild) {
|
||||
treeLayer = treeChild.children || [];
|
||||
} else {
|
||||
treeLayer = [];
|
||||
}
|
||||
});
|
||||
return treeLayer;
|
||||
};
|
||||
|
||||
export const concatFullPath = (params: {
|
||||
node: ExpressionEditorTreeNode;
|
||||
segments: ExpressionEditorSegment[];
|
||||
}): string => {
|
||||
const { node, segments } = params;
|
||||
let current: ExpressionEditorTreeNode | undefined = node;
|
||||
const pathList: { objectKey: string; arrayIndex?: number }[] = [];
|
||||
while (current) {
|
||||
if (current.variable?.type === ViewVariableType.ArrayObject) {
|
||||
// 默认第0个
|
||||
pathList.unshift({
|
||||
objectKey: current.label,
|
||||
arrayIndex: 0,
|
||||
});
|
||||
} else {
|
||||
pathList.unshift({
|
||||
objectKey: current.label,
|
||||
});
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
let pathIndex = 0;
|
||||
segments.find((segment, index) => {
|
||||
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
|
||||
return false;
|
||||
}
|
||||
const pathItem = pathList[pathIndex];
|
||||
pathIndex++;
|
||||
if (pathItem.objectKey !== segment.objectKey) {
|
||||
// 退出循环
|
||||
return true;
|
||||
}
|
||||
const nextSegment = segments[index + 1];
|
||||
if (
|
||||
typeof pathItem.arrayIndex === 'number' &&
|
||||
nextSegment?.type === ExpressionEditorSegmentType.ArrayIndex
|
||||
) {
|
||||
pathItem.arrayIndex = nextSegment.arrayIndex;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return pathList
|
||||
.map((pathItem, index) => {
|
||||
const isLastPathItem = index === pathList.length - 1;
|
||||
if (typeof pathItem.arrayIndex === 'number' && !isLastPathItem) {
|
||||
return `${pathItem.objectKey}[${pathItem.arrayIndex}]`;
|
||||
}
|
||||
return pathItem.objectKey;
|
||||
})
|
||||
.join('.');
|
||||
};
|
||||
|
||||
export const matchTreeBranch = (params: {
|
||||
tree: ExpressionEditorTreeNode[];
|
||||
segments: ExpressionEditorSegment[];
|
||||
}): ExpressionEditorTreeNode[] | undefined => {
|
||||
const { tree, segments } = params;
|
||||
const treeBranch: (ExpressionEditorTreeNode | null)[] = [];
|
||||
let treeLayer = tree;
|
||||
const invalid = segments.find((segment, index) => {
|
||||
const itemInvalid = (): boolean => {
|
||||
treeBranch.push(null);
|
||||
return true;
|
||||
};
|
||||
const itemValid = (treeNode?: ExpressionEditorTreeNode): boolean => {
|
||||
treeBranch.push(treeNode || null);
|
||||
return false;
|
||||
};
|
||||
const beforeTreeNode = treeBranch[treeBranch.length - 1];
|
||||
// 确认非法情况:是否对非数组类型使用数组索引
|
||||
if (
|
||||
segment.type === ExpressionEditorSegmentType.ArrayIndex &&
|
||||
beforeTreeNode &&
|
||||
(!beforeTreeNode.variable ||
|
||||
!ViewVariableType.isArrayType(beforeTreeNode.variable.type))
|
||||
) {
|
||||
return itemInvalid();
|
||||
}
|
||||
// 确认非法情况:数组只能跟随数组下标
|
||||
if (
|
||||
beforeTreeNode?.variable?.type &&
|
||||
ViewVariableType.isArrayType(beforeTreeNode.variable.type) &&
|
||||
segment.type !== ExpressionEditorSegmentType.ArrayIndex
|
||||
) {
|
||||
return itemInvalid();
|
||||
}
|
||||
// 忽略
|
||||
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
|
||||
return itemValid();
|
||||
}
|
||||
const treeNode = treeLayer.find(node => node.label === segment.objectKey);
|
||||
// 确认非法情况:每一个 object key 必须对应一个 variable node
|
||||
if (!treeNode) {
|
||||
return itemInvalid();
|
||||
}
|
||||
treeLayer = treeNode.children || [];
|
||||
return itemValid(treeNode);
|
||||
});
|
||||
const filteredTreeBranch = treeBranch.filter(
|
||||
Boolean,
|
||||
) as ExpressionEditorTreeNode[];
|
||||
const filteredSegments = segments.filter(
|
||||
segment => segment.type === ExpressionEditorSegmentType.ObjectKey,
|
||||
);
|
||||
if (invalid || filteredSegments.length !== filteredTreeBranch.length) {
|
||||
return;
|
||||
}
|
||||
return filteredTreeBranch;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 { CompositionEventHandler } from 'react';
|
||||
|
||||
import type { BaseElement, BaseRange } from 'slate';
|
||||
import {
|
||||
type StandardNodeType,
|
||||
type ViewVariableMeta,
|
||||
} from '@coze-workflow/base';
|
||||
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
|
||||
|
||||
import type {
|
||||
ExpressionEditorEvent,
|
||||
ExpressionEditorSegmentType,
|
||||
ExpressionEditorSignal,
|
||||
} from './constant';
|
||||
|
||||
export type ExpressionEditorEventParams<T extends ExpressionEditorEvent> = {
|
||||
[ExpressionEditorEvent.Change]: {
|
||||
lines: ExpressionEditorLine[];
|
||||
value: string;
|
||||
};
|
||||
[ExpressionEditorEvent.Select]: {
|
||||
content: string;
|
||||
offset: number;
|
||||
path: number[];
|
||||
};
|
||||
[ExpressionEditorEvent.Dispose]: undefined;
|
||||
[ExpressionEditorEvent.CompositionStart]: {
|
||||
event: CompositionEventHandler<HTMLDivElement>;
|
||||
};
|
||||
}[T];
|
||||
|
||||
export type ExpressionEditorEventDisposer = () => void;
|
||||
|
||||
export type ExpressionEditorSegment<
|
||||
T extends ExpressionEditorSegmentType = ExpressionEditorSegmentType,
|
||||
> = {
|
||||
[ExpressionEditorSegmentType.ObjectKey]: {
|
||||
type: ExpressionEditorSegmentType.ObjectKey;
|
||||
index: number;
|
||||
objectKey: string;
|
||||
};
|
||||
[ExpressionEditorSegmentType.ArrayIndex]: {
|
||||
type: ExpressionEditorSegmentType.ArrayIndex;
|
||||
index: number;
|
||||
arrayIndex: number;
|
||||
};
|
||||
[ExpressionEditorSegmentType.EndEmpty]: {
|
||||
type: ExpressionEditorSegmentType.EndEmpty;
|
||||
index: number;
|
||||
};
|
||||
}[T];
|
||||
|
||||
export interface ExpressionEditorVariable extends ViewVariableMeta {
|
||||
nodeTitle?: string;
|
||||
nodeId?: string;
|
||||
nodeType?: StandardNodeType;
|
||||
}
|
||||
|
||||
export interface ExpressionEditorTreeNode extends TreeNodeData {
|
||||
label: string;
|
||||
value: string;
|
||||
key: string;
|
||||
keyPath?: string[];
|
||||
variable?: ExpressionEditorVariable;
|
||||
children?: ExpressionEditorTreeNode[];
|
||||
parent?: ExpressionEditorTreeNode;
|
||||
}
|
||||
|
||||
export interface ExpressionEditorParseData {
|
||||
content: {
|
||||
line: string;
|
||||
inline: string;
|
||||
reachable: string;
|
||||
unreachable: string;
|
||||
};
|
||||
offset: {
|
||||
line: number;
|
||||
inline: number;
|
||||
lastStart: number;
|
||||
firstEnd: number;
|
||||
};
|
||||
segments: {
|
||||
inline?: ExpressionEditorSegment[];
|
||||
reachable: ExpressionEditorSegment[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExpressionEditorLine extends BaseElement {
|
||||
type: ExpressionEditorSignal.Line;
|
||||
children: {
|
||||
text: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ExpressionEditorValidateData {
|
||||
start: number;
|
||||
end: number;
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ExpressionEditorRange extends BaseRange {
|
||||
type:
|
||||
| ExpressionEditorSignal.Valid
|
||||
| ExpressionEditorSignal.Invalid
|
||||
| ExpressionEditorSignal.SelectedValid
|
||||
| ExpressionEditorSignal.SelectedInvalid;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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 @typescript-eslint/consistent-type-assertions */
|
||||
import 'reflect-metadata';
|
||||
|
||||
import type {
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorVariable,
|
||||
} from '../type';
|
||||
import { ExpressionEditorValidator } from './index';
|
||||
|
||||
enum ViewVariableType {
|
||||
String = 1,
|
||||
Integer,
|
||||
Boolean,
|
||||
Number,
|
||||
Object = 6,
|
||||
ArrayString = 99,
|
||||
ArrayInteger,
|
||||
ArrayBoolean,
|
||||
ArrayNumber,
|
||||
ArrayObject,
|
||||
}
|
||||
|
||||
vi.mock('@coze-arch/logger', () => ({
|
||||
logger: {
|
||||
createLoggerWith: vi.fn(),
|
||||
},
|
||||
reporter: {
|
||||
createReporterWithPreset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@coze-workflow/base', () => {
|
||||
enum VariableType {
|
||||
String = 1,
|
||||
Integer,
|
||||
Boolean,
|
||||
Number,
|
||||
Object = 6,
|
||||
ArrayString = 99,
|
||||
ArrayInteger,
|
||||
ArrayBoolean,
|
||||
ArrayNumber,
|
||||
ArrayObject,
|
||||
}
|
||||
|
||||
return {
|
||||
ViewVariableType: {
|
||||
...VariableType,
|
||||
isArrayType: (type: VariableType): boolean => {
|
||||
const arrayTypes = [
|
||||
VariableType.ArrayString,
|
||||
VariableType.ArrayInteger,
|
||||
VariableType.ArrayBoolean,
|
||||
VariableType.ArrayNumber,
|
||||
VariableType.ArrayObject,
|
||||
];
|
||||
return arrayTypes.includes(type);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExpressionEditorValidatorBuiltin', () => {
|
||||
describe('findPatterns', () => {
|
||||
it('findPatterns should work', () => {
|
||||
const patterns = ExpressionEditorValidator.findPatterns(
|
||||
'first {{foo1.bar1}} second {{foo2.bar2}}',
|
||||
);
|
||||
expect(patterns).toEqual([
|
||||
{
|
||||
start: 6,
|
||||
end: 19,
|
||||
content: 'foo1.bar1',
|
||||
},
|
||||
{
|
||||
start: 27,
|
||||
end: 40,
|
||||
content: 'foo2.bar2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('findPatterns with empty content', () => {
|
||||
const patterns = ExpressionEditorValidator.findPatterns('{{}}');
|
||||
expect(patterns).toEqual([
|
||||
{
|
||||
start: 0,
|
||||
end: 4,
|
||||
content: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('findPatterns satisfies length', () => {
|
||||
const lengthTests = {
|
||||
'first {{foo1.bar1}}': 1,
|
||||
'first {{foo1.bar1}} second {{foo2.bar2}}': 2,
|
||||
'first {{foo1.bar1}} second {{foo2.bar2}} third {{foo3.bar3}}': 3,
|
||||
'first| {{foo1.bar1}}': 1,
|
||||
'first{} {{foo1.bar1}}': 1,
|
||||
'first{} {{foo1.bar1}} {}': 1,
|
||||
'{} {} {} {{ {{}} }{}{}{': 1,
|
||||
};
|
||||
for (const [input, expected] of Object.entries(lengthTests)) {
|
||||
const patterns = ExpressionEditorValidator.findPatterns(input);
|
||||
expect(patterns.length).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExpressionEditorValidator lineTextValidator', () => {
|
||||
let tree: ExpressionEditorTreeNode[];
|
||||
beforeEach(() => {
|
||||
tree = [
|
||||
{
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as ExpressionEditorVariable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as ExpressionEditorVariable,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
it('line text validator correctly', () => {
|
||||
const validateList = ExpressionEditorValidator.lineTextValidate({
|
||||
lineText: 'first {{foo[0].bar}} second {{foo[1].bar}}',
|
||||
tree,
|
||||
});
|
||||
expect(validateList).toEqual([
|
||||
{ start: 6, end: 20, valid: true },
|
||||
{ start: 28, end: 42, valid: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 @typescript-eslint/no-namespace */
|
||||
/* eslint-disable no-cond-assign */
|
||||
import { Node } from 'slate';
|
||||
|
||||
import type {
|
||||
ExpressionEditorLine,
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorValidateData,
|
||||
} from '../type';
|
||||
import { ExpressionEditorTreeHelper } from '../tree-helper';
|
||||
import { ExpressionEditorParserBuiltin } from '../parser';
|
||||
import { ExpressionEditorSegmentType } from '../constant';
|
||||
|
||||
export namespace ExpressionEditorValidator {
|
||||
interface ExpressionEditorPattern {
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
}
|
||||
export const findPatterns = (text: string): ExpressionEditorPattern[] => {
|
||||
const matches: ExpressionEditorPattern[] = [];
|
||||
const regex = /{{(.*?)}}/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const start: number = match.index;
|
||||
const end: number = regex.lastIndex;
|
||||
const content: string = match[1];
|
||||
matches.push({ start, end, content });
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
const patternValidate = (params: {
|
||||
pattern: ExpressionEditorPattern;
|
||||
tree: ExpressionEditorTreeNode[];
|
||||
}) => {
|
||||
const { pattern, tree } = params;
|
||||
// 1. content to segments
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(pattern.content);
|
||||
if (!segments) {
|
||||
return {
|
||||
start: pattern.start,
|
||||
end: pattern.end,
|
||||
valid: false,
|
||||
message: 'invalid variable path',
|
||||
};
|
||||
}
|
||||
if (
|
||||
segments[segments.length - 1].type ===
|
||||
ExpressionEditorSegmentType.EndEmpty
|
||||
) {
|
||||
return {
|
||||
start: pattern.start,
|
||||
end: pattern.end,
|
||||
valid: false,
|
||||
message: 'empty with empty',
|
||||
};
|
||||
}
|
||||
// 2. segments mix variable tree, match tree branch
|
||||
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree,
|
||||
segments,
|
||||
});
|
||||
if (!treeBranch) {
|
||||
return {
|
||||
start: pattern.start,
|
||||
end: pattern.end,
|
||||
valid: false,
|
||||
message: 'no match variable path',
|
||||
};
|
||||
}
|
||||
// 3. if full segments path could match one tree branch, the pattern is valid
|
||||
return {
|
||||
start: pattern.start,
|
||||
end: pattern.end,
|
||||
valid: true,
|
||||
};
|
||||
};
|
||||
export const lineTextValidate = (params: {
|
||||
lineText: string;
|
||||
tree: ExpressionEditorTreeNode[];
|
||||
}): ExpressionEditorValidateData[] => {
|
||||
const { lineText, tree } = params;
|
||||
// find patterns {{content}}, record start / end offset
|
||||
const patterns: ExpressionEditorPattern[] = findPatterns(lineText);
|
||||
const validateList: ExpressionEditorValidateData[] = patterns.map(pattern =>
|
||||
patternValidate({ pattern, tree }),
|
||||
);
|
||||
return validateList;
|
||||
};
|
||||
export const validate = (params: {
|
||||
lines: ExpressionEditorLine[];
|
||||
tree: ExpressionEditorTreeNode[];
|
||||
}): ExpressionEditorValidateData[] => {
|
||||
const { lines, tree } = params;
|
||||
const textLines: string[] = lines.map(n => Node.string(n));
|
||||
const validateList: ExpressionEditorValidateData[] = textLines
|
||||
.map((lineText: string, lineIndex: number) =>
|
||||
ExpressionEditorValidator.lineTextValidate({
|
||||
lineText,
|
||||
tree,
|
||||
}),
|
||||
)
|
||||
.flat();
|
||||
return validateList;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
|
||||
export const SHORTCUTS = {
|
||||
CTRL: isMacOS ? '⌘' : 'Ctrl',
|
||||
SHIFT: isMacOS ? '⇧' : '⇧',
|
||||
ALT: isMacOS ? '⌥' : 'Alt',
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
.item {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
color: #1D1C23;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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 ReactNode } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozCross } from '@coze-arch/coze-design/icons';
|
||||
import { Divider, Typography, Tag } from '@coze-arch/coze-design';
|
||||
import { InteractiveType } from '@coze-common/mouse-pad-selector';
|
||||
|
||||
import { getIsIPad } from '../utils';
|
||||
import { SHORTCUTS } from './constants';
|
||||
|
||||
import s from './index.module.less';
|
||||
|
||||
interface ShortcutItemProps {
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function ShortcutItem({ title, children }: ShortcutItemProps) {
|
||||
return (
|
||||
<div className={s.item}>
|
||||
<div className={s.itemTitle}>{title}</div>
|
||||
<div className={s.itemContent}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DividerWithMargin() {
|
||||
return <Divider style={{ margin: '12px 0' }} />;
|
||||
}
|
||||
|
||||
function ShortcutTag({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Tag className="mx-1 text-[14px]" prefixIcon={null} color="primary">
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
interface FlowShortcutsHelpProps {
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
isAgentFlow?: boolean;
|
||||
interactiveType?: InteractiveType;
|
||||
}
|
||||
|
||||
const isIPad = getIsIPad();
|
||||
|
||||
function FlowShortcutsHelp(props: FlowShortcutsHelpProps) {
|
||||
const {
|
||||
closable = false,
|
||||
onClose,
|
||||
isAgentFlow = false,
|
||||
interactiveType,
|
||||
} = props;
|
||||
|
||||
const isMouseFriendly = interactiveType === InteractiveType.Mouse;
|
||||
return (
|
||||
<>
|
||||
{closable ? (
|
||||
<div className={s.close} onClick={() => onClose?.()}>
|
||||
<IconCozCross />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Typography.Title heading={5} style={{ marginBottom: 16 }}>
|
||||
{I18n.t('flowcanvas_shortcuts_shortcuts')}
|
||||
</Typography.Title>
|
||||
|
||||
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_move_canvas')}>
|
||||
{isMouseFriendly ? (
|
||||
<>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_space')}</ShortcutTag>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
|
||||
</>
|
||||
)}
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
<ShortcutItem title={I18n.t('workflow_area_select')}>
|
||||
{isMouseFriendly ? (
|
||||
<>
|
||||
<ShortcutTag>{SHORTCUTS.SHIFT}</ShortcutTag>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
|
||||
</>
|
||||
)}
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
<ShortcutItem
|
||||
title={
|
||||
<>
|
||||
{I18n.t('flowcanvas_shortcuts_multiple_select')}/
|
||||
{I18n.t('flowcanvas_shortcuts_multiple_deselect')}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ShortcutTag>
|
||||
{SHORTCUTS.CTRL}/{SHORTCUTS.SHIFT}
|
||||
</ShortcutTag>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_click')}</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_zoom_in')}>
|
||||
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
|
||||
<ShortcutTag>+</ShortcutTag>
|
||||
<span style={{ margin: '0 6px' }}>
|
||||
{I18n.t('flowcanvas_shortcuts_or')}
|
||||
</span>
|
||||
{!isMouseFriendly ? <ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag> : null}
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_scroll')}</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_zoom_out')}>
|
||||
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
|
||||
<ShortcutTag>-</ShortcutTag>
|
||||
<span style={{ margin: '0 6px' }}>
|
||||
{I18n.t('flowcanvas_shortcuts_or')}
|
||||
</span>
|
||||
{!isMouseFriendly ? <ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag> : null}
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_scroll')}</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
{isIPad || isAgentFlow ? null : (
|
||||
<>
|
||||
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_duplicate')}>
|
||||
<ShortcutTag>{SHORTCUTS.ALT}</ShortcutTag>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
<DividerWithMargin />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_copy')}>
|
||||
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
|
||||
<ShortcutTag>C</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_paste')}>
|
||||
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
|
||||
<ShortcutTag>V</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
<UndoRedoShortcuts />
|
||||
|
||||
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_delete')}>
|
||||
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_backspace')}</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UndoRedoShortcuts() {
|
||||
return (
|
||||
<>
|
||||
<ShortcutItem title={I18n.t('workflow_detail_undo_tooltip')}>
|
||||
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
|
||||
<ShortcutTag>Z</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
|
||||
<ShortcutItem title={I18n.t('workflow_detail_redo_tooltip')}>
|
||||
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
|
||||
<ShortcutTag>{SHORTCUTS.SHIFT}</ShortcutTag>
|
||||
<ShortcutTag>Z</ShortcutTag>
|
||||
</ShortcutItem>
|
||||
|
||||
<DividerWithMargin />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { FlowShortcutsHelp };
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 { DynamicParams } from '@coze-arch/bot-typings/teamspace';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export const useOpenWorkflowDetail = () => {
|
||||
const { bot_id: botId } = useParams<DynamicParams>();
|
||||
|
||||
/** 打开流程详情页 */
|
||||
const openWorkflowDetailPage = ({
|
||||
workflowId,
|
||||
spaceId,
|
||||
projectId,
|
||||
ideNavigate,
|
||||
}: {
|
||||
workflowId: string;
|
||||
spaceId: string;
|
||||
projectId?: string;
|
||||
ideNavigate?: (uri: string) => void;
|
||||
}) => {
|
||||
if (projectId && ideNavigate) {
|
||||
ideNavigate(`/workflow/${workflowId}?from=createSuccess`);
|
||||
} else {
|
||||
const query = new URLSearchParams();
|
||||
botId && query.append('bot_id', botId);
|
||||
query.append('space_id', spaceId ?? '');
|
||||
query.append('workflow_id', workflowId);
|
||||
query.append('from', 'createSuccess');
|
||||
window.open(`/work_flow?${query.toString()}`, '_blank');
|
||||
}
|
||||
};
|
||||
return openWorkflowDetailPage;
|
||||
};
|
||||
@@ -0,0 +1,535 @@
|
||||
/*
|
||||
* 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 max-lines-per-function */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { omit } from 'lodash-es';
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
type UseInfiniteQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
WorkflowMode,
|
||||
type GetWorkFlowListRequest,
|
||||
type GetExampleWorkFlowListRequest,
|
||||
type GetWorkFlowListResponse,
|
||||
type GetExampleWorkFlowListResponse,
|
||||
WorkFlowType,
|
||||
DeleteType,
|
||||
workflowApi,
|
||||
DeleteAction,
|
||||
type WorkflowListByBindBizRequest,
|
||||
SchemaType,
|
||||
BindBizType,
|
||||
CheckType,
|
||||
} from '@coze-workflow/base/api';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { CustomError } from '@coze-arch/bot-error';
|
||||
import { Toast } from '@coze-arch/coze-design';
|
||||
|
||||
import { reporter, wait } from '../utils';
|
||||
import { type WorkflowInfo, WorkflowModalFrom } from '../types';
|
||||
|
||||
interface FetchWorkflowListResult {
|
||||
total: number;
|
||||
workflow_list: WorkflowInfo[];
|
||||
}
|
||||
|
||||
interface WorkflowListReturn {
|
||||
flowType: WorkFlowType;
|
||||
setFlowType: Dispatch<SetStateAction<WorkFlowType>>;
|
||||
flowMode: WorkflowMode;
|
||||
setFlowMode: Dispatch<SetStateAction<WorkflowMode>>;
|
||||
spaceId: string;
|
||||
setSpaceId: Dispatch<SetStateAction<string>>;
|
||||
status: GetWorkFlowListRequest['status'] | undefined;
|
||||
setStatus: Dispatch<
|
||||
SetStateAction<GetWorkFlowListRequest['status'] | undefined>
|
||||
>;
|
||||
name: GetWorkFlowListRequest['name'] | undefined;
|
||||
setName: Dispatch<SetStateAction<GetWorkFlowListRequest['name'] | undefined>>;
|
||||
tags: GetWorkFlowListRequest['tags'] | undefined;
|
||||
setTags: Dispatch<SetStateAction<GetWorkFlowListRequest['tags'] | undefined>>;
|
||||
orderBy: GetWorkFlowListRequest['order_by'] | undefined;
|
||||
setOrderBy: Dispatch<
|
||||
SetStateAction<GetWorkFlowListRequest['order_by'] | undefined>
|
||||
>;
|
||||
loginUserCreate: boolean | undefined;
|
||||
setLoginUserCreate: Dispatch<SetStateAction<boolean | undefined>>;
|
||||
updatePageParam: (
|
||||
newParam: Partial<GetWorkFlowListRequest & WorkflowListByBindBizRequest>,
|
||||
) => void;
|
||||
workflowList: WorkflowInfo[];
|
||||
total: number;
|
||||
queryError: UseInfiniteQueryResult['error'];
|
||||
fetchNextPage: UseInfiniteQueryResult['fetchNextPage'];
|
||||
hasNextPage: UseInfiniteQueryResult['hasNextPage'];
|
||||
isFetching: UseInfiniteQueryResult['isFetching'];
|
||||
isFetchingNextPage: UseInfiniteQueryResult['isFetchingNextPage'];
|
||||
loadingStatus: UseInfiniteQueryResult['status'];
|
||||
refetch: UseInfiniteQueryResult['refetch'];
|
||||
handleCopy: (item: WorkflowInfo) => Promise<void>;
|
||||
handleDelete: (item: WorkflowInfo) => Promise<{
|
||||
canDelete: boolean;
|
||||
deleteType: DeleteType;
|
||||
handleDelete:
|
||||
| ((params?: { needDeleteBlockwise: boolean }) => Promise<void>)
|
||||
| undefined;
|
||||
}>;
|
||||
}
|
||||
|
||||
const defaultPageSize = 20;
|
||||
|
||||
/**
|
||||
* 流程列表
|
||||
*/
|
||||
export function useWorkflowList({
|
||||
pageSize = defaultPageSize,
|
||||
enabled = false,
|
||||
from,
|
||||
fetchWorkflowListApi = workflowApi.GetWorkFlowList.bind(workflowApi),
|
||||
}: {
|
||||
pageSize?: number;
|
||||
/** 是否开启数据获取 */
|
||||
enabled?: boolean;
|
||||
from?: WorkflowModalFrom;
|
||||
fetchWorkflowListApi?: (
|
||||
params: GetWorkFlowListRequest | GetExampleWorkFlowListRequest,
|
||||
) => Promise<GetWorkFlowListResponse | GetExampleWorkFlowListResponse>;
|
||||
} = {}): Readonly<WorkflowListReturn> {
|
||||
const [flowMode, setFlowMode] = useState<WorkflowMode>(WorkflowMode.All);
|
||||
const [flowType, setFlowType] = useState<WorkFlowType>(WorkFlowType.User);
|
||||
const [spaceId, setSpaceId] = useState<string>('');
|
||||
const [name, setName] = useState<GetWorkFlowListRequest['name']>();
|
||||
const [status, setStatus] = useState<GetWorkFlowListRequest['status']>();
|
||||
const [orderBy, setOrderBy] = useState<GetWorkFlowListRequest['order_by']>();
|
||||
const [tags, setTags] = useState<GetWorkFlowListRequest['tags']>();
|
||||
const [bindBizId, setBindBizId] =
|
||||
useState<WorkflowListByBindBizRequest['bind_biz_id']>();
|
||||
const [bindBizType, setBindBizType] =
|
||||
useState<WorkflowListByBindBizRequest['bind_biz_type']>();
|
||||
const [loginUserCreate, setLoginUserCreate] =
|
||||
useState<GetWorkFlowListRequest['login_user_create']>();
|
||||
const [projectId, setProjectId] =
|
||||
useState<GetWorkFlowListRequest['project_id']>('');
|
||||
const initialPageParam = useMemo<GetWorkFlowListRequest>(
|
||||
() => ({
|
||||
page: 1,
|
||||
size: pageSize,
|
||||
type: flowType,
|
||||
name,
|
||||
space_id: spaceId,
|
||||
status,
|
||||
tags,
|
||||
order_by: orderBy,
|
||||
login_user_create: loginUserCreate,
|
||||
flow_mode: flowMode,
|
||||
bind_biz_id: bindBizId,
|
||||
bind_biz_type: bindBizType,
|
||||
project_id: projectId,
|
||||
}),
|
||||
[
|
||||
flowType,
|
||||
status,
|
||||
name,
|
||||
flowMode,
|
||||
orderBy,
|
||||
spaceId,
|
||||
loginUserCreate,
|
||||
tags,
|
||||
bindBizId,
|
||||
bindBizType,
|
||||
projectId,
|
||||
],
|
||||
);
|
||||
|
||||
const updatePageParam = useCallback(
|
||||
(newParam: Partial<GetWorkFlowListRequest>) => {
|
||||
[
|
||||
{ key: 'type', func: setFlowType, defaultValue: WorkFlowType.User },
|
||||
{ key: 'name', func: setName },
|
||||
{ key: 'space_id', func: setSpaceId, defaultValue: '' },
|
||||
{ key: 'status', func: setStatus },
|
||||
{ key: 'tags', func: setTags },
|
||||
{ key: 'order_by', func: setOrderBy },
|
||||
{ key: 'login_user_create', func: setLoginUserCreate },
|
||||
{
|
||||
key: 'flow_mode',
|
||||
func: setFlowMode,
|
||||
defaultValue: WorkflowMode.All,
|
||||
},
|
||||
{ key: 'bind_biz_id', func: setBindBizId },
|
||||
{ key: 'bind_biz_type', func: setBindBizType },
|
||||
{ key: 'project_id', func: setProjectId },
|
||||
]
|
||||
.filter(({ key }) => key in newParam)
|
||||
.forEach(({ key, defaultValue, func }) =>
|
||||
func?.(newParam[key] ?? defaultValue),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchWorkflowList = async (
|
||||
params: GetWorkFlowListRequest & WorkflowListByBindBizRequest,
|
||||
): Promise<FetchWorkflowListResult> => {
|
||||
try {
|
||||
reporter.info({
|
||||
message: 'workflow_list_get_list',
|
||||
});
|
||||
|
||||
const result: FetchWorkflowListResult = {
|
||||
total: 0,
|
||||
workflow_list: [],
|
||||
};
|
||||
|
||||
if (params.bind_biz_type === BindBizType.Scene && params.bind_biz_id) {
|
||||
const resp = await workflowApi.WorkflowListByBindBiz(params);
|
||||
result.total = (resp.data.total as number) ?? 0;
|
||||
// 设置流程权限
|
||||
result.workflow_list = (resp.data.workflow_list ?? []).map(
|
||||
(item): WorkflowInfo => {
|
||||
const authInfo = {
|
||||
can_edit: true,
|
||||
can_copy: true,
|
||||
can_delete: !!item?.creator?.self,
|
||||
};
|
||||
return {
|
||||
...item,
|
||||
authInfo,
|
||||
};
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 多人协作场景,DEV 模式需要展示 Blockwise workflow(除了流程列表引用)
|
||||
Object.assign(params, {
|
||||
schema_type_list: [SchemaType.FDL],
|
||||
checker:
|
||||
from === WorkflowModalFrom.WorkflowAgent
|
||||
? [CheckType.BotAgent]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const isDouyinBot = params.bind_biz_type === BindBizType.DouYinBot;
|
||||
// 如果不是抖音分身模式,搜索参数不携带 bind_biz_id 参数
|
||||
// 否则会导致某个工作流关联到 Agent 后0,之后在该工作流添加子工作流时看不到工作流列表
|
||||
const fetchParams = isDouyinBot
|
||||
? params
|
||||
: omit(params, ['bind_biz_id']);
|
||||
|
||||
const resp = await fetchWorkflowListApi(fetchParams);
|
||||
result.total = (resp.data.total as number) ?? 0;
|
||||
// 设置流程权限
|
||||
result.workflow_list = (resp.data.workflow_list ?? []).map(
|
||||
(item): WorkflowInfo => {
|
||||
let authInfo = {
|
||||
can_edit: true,
|
||||
can_copy: true,
|
||||
can_delete: !!item?.creator?.self,
|
||||
};
|
||||
const authItem = (resp.data.auth_list ?? []).find(
|
||||
it => it.workflow_id === item.workflow_id,
|
||||
);
|
||||
if (authItem) {
|
||||
authInfo = { ...authInfo, ...authItem.auth };
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
authInfo,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_get_list_success',
|
||||
meta: {
|
||||
currentPage: params.page,
|
||||
pageSize: params.size,
|
||||
order_by: params.order_by,
|
||||
name: params.name,
|
||||
total: result.total,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
reporter.error({
|
||||
message: 'workflow_list_get_list_fail',
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
data: pageData,
|
||||
error: queryError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
status: loadingStatus,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
enabled: enabled && !!spaceId,
|
||||
queryKey: ['space_workflow_list', 'vcs', JSON.stringify(initialPageParam)],
|
||||
queryFn: ({ pageParam }) => fetchWorkflowList(pageParam),
|
||||
initialPageParam,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||
if ((lastPageParam.page ?? 1) * pageSize > lastPage.total) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...lastPageParam,
|
||||
page: (lastPageParam.page ?? 1) + 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const workflowList = useMemo(() => {
|
||||
const result: WorkflowInfo[] = [];
|
||||
const idMap: Record<string, boolean> = {};
|
||||
pageData?.pages.forEach(page => {
|
||||
page.workflow_list.forEach(workflow => {
|
||||
if (!workflow.workflow_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idMap[workflow.workflow_id]) {
|
||||
result.push(workflow);
|
||||
}
|
||||
idMap[workflow.workflow_id] = true;
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}, [pageData]);
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (
|
||||
!pageData?.pages ||
|
||||
!Array.isArray(pageData.pages) ||
|
||||
pageData.pages.length === 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return pageData.pages[pageData.pages.length - 1].total ?? 0;
|
||||
}, [pageData]);
|
||||
|
||||
// 复制
|
||||
const handleCopy = async (item: WorkflowInfo) => {
|
||||
if (!item.workflow_id || !spaceId) {
|
||||
throw new CustomError('normal_error', 'miss workflowId or spaceID');
|
||||
}
|
||||
|
||||
// 检查复制权限
|
||||
if (!item.authInfo.can_copy) {
|
||||
throw new CustomError('normal_error', 'no copy permission');
|
||||
}
|
||||
reporter.info({
|
||||
message: 'workflow_list_copy_row',
|
||||
meta: {
|
||||
workflowId: item.workflow_id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let isError = false;
|
||||
const { data } = await workflowApi.CopyWorkflow({
|
||||
space_id: spaceId,
|
||||
workflow_id: item.workflow_id,
|
||||
});
|
||||
isError = !data?.workflow_id;
|
||||
|
||||
if (isError) {
|
||||
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
|
||||
reporter.error({
|
||||
message: 'workflow_list_copy_row_fail',
|
||||
error: new CustomError('normal_error', 'result no workflow'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.success({
|
||||
content:
|
||||
flowMode === WorkflowMode.Imageflow
|
||||
? I18n.t('imageflow_detail_toast_createcopy_succeed')
|
||||
: I18n.t('workflow_detail_toast_createcopy_succeed'),
|
||||
showClose: false,
|
||||
});
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_copy_row_success',
|
||||
meta: {
|
||||
workflowId: item.workflow_id,
|
||||
},
|
||||
});
|
||||
|
||||
// 兜底服务主从延迟
|
||||
await wait(300);
|
||||
|
||||
// 刷新列表
|
||||
refetch();
|
||||
} catch (error) {
|
||||
reporter.error({
|
||||
message: 'workflow_list_copy_row_fail',
|
||||
error,
|
||||
});
|
||||
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (item: WorkflowInfo) => {
|
||||
if (!item.workflow_id || !spaceId) {
|
||||
throw new CustomError('normal_error', 'miss workflowId or spaceID');
|
||||
}
|
||||
|
||||
// 先检查删除权限
|
||||
if (!item.authInfo.can_delete) {
|
||||
throw new CustomError('normal_error', 'no delete permission');
|
||||
}
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_delete_row',
|
||||
meta: {
|
||||
workflowId: item.workflow_id,
|
||||
},
|
||||
});
|
||||
|
||||
let deleteType = DeleteType.CanDelete;
|
||||
|
||||
// 从服务端查询删除模式
|
||||
const resp = await workflowApi.GetDeleteStrategy({
|
||||
space_id: spaceId,
|
||||
workflow_id: item.workflow_id,
|
||||
});
|
||||
deleteType = resp.data;
|
||||
|
||||
const canDelete = [
|
||||
DeleteType.CanDelete,
|
||||
DeleteType.RejectProductDraft,
|
||||
].includes(deleteType);
|
||||
|
||||
const deleteFuc = async (deleteParams?: {
|
||||
needDeleteBlockwise: boolean;
|
||||
}) => {
|
||||
const needDeleteBlockwise = deleteParams?.needDeleteBlockwise;
|
||||
const action = needDeleteBlockwise
|
||||
? DeleteAction.BlockwiseDelete
|
||||
: DeleteAction.BlockwiseUnbind;
|
||||
|
||||
if (!item.workflow_id || !spaceId) {
|
||||
throw new CustomError('normal_error', 'miss workflowId or spaceID');
|
||||
}
|
||||
try {
|
||||
await workflowApi.DeleteWorkflow({
|
||||
space_id: spaceId,
|
||||
workflow_id: item.workflow_id,
|
||||
action,
|
||||
});
|
||||
|
||||
Toast.success({
|
||||
content: I18n.t('workflow_add_delete_success'),
|
||||
showClose: false,
|
||||
});
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_delete_row_success',
|
||||
});
|
||||
|
||||
// 兜底服务主从延迟
|
||||
await wait(300);
|
||||
|
||||
// 刷新列表
|
||||
refetch();
|
||||
} catch (error) {
|
||||
reporter.error({
|
||||
message: 'workflow_list_delete_row_fail',
|
||||
error,
|
||||
});
|
||||
Toast.error({
|
||||
content: I18n.t('workflow_add_delete_fail'),
|
||||
showClose: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
/** 是否可删除 */
|
||||
canDelete,
|
||||
/** 删除策略 */
|
||||
deleteType,
|
||||
/** 删除方法 */
|
||||
handleDelete: canDelete ? deleteFuc : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// 列表筛选状态
|
||||
flowType,
|
||||
setFlowType,
|
||||
flowMode,
|
||||
setFlowMode,
|
||||
spaceId,
|
||||
setSpaceId,
|
||||
status,
|
||||
setStatus,
|
||||
name,
|
||||
setName,
|
||||
tags,
|
||||
setTags,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
loginUserCreate,
|
||||
setLoginUserCreate,
|
||||
/** 更新筛选参数 */
|
||||
updatePageParam,
|
||||
// 列表获取
|
||||
/** 流程列表数据 */
|
||||
workflowList,
|
||||
/** 流程总数 */
|
||||
total,
|
||||
/** 获取列表请求错误 */
|
||||
queryError,
|
||||
/** 拉取下一页数据 */
|
||||
fetchNextPage,
|
||||
/** 是否有下一页 */
|
||||
hasNextPage,
|
||||
/** 获取数据中 */
|
||||
isFetching,
|
||||
/** 获取下一页数据中 */
|
||||
isFetchingNextPage,
|
||||
/** 加载状态 */
|
||||
loadingStatus,
|
||||
/** 重新加载 */
|
||||
refetch,
|
||||
// 列表操作
|
||||
/** 复制流程 */
|
||||
handleCopy,
|
||||
// /** 删除流程 */
|
||||
handleDelete,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { useBoolean } from 'ahooks';
|
||||
|
||||
import { type WorkFlowModalModeProps } from '../workflow-modal/type';
|
||||
import WorkflowModal from '../workflow-modal';
|
||||
|
||||
interface UseWorkFlowListReturnValue {
|
||||
node: ReactNode;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useWorkflowModal = (
|
||||
props?: WorkFlowModalModeProps,
|
||||
): UseWorkFlowListReturnValue => {
|
||||
const { onClose, ...restProps } = props || {};
|
||||
const [visible, { setTrue: showModal, setFalse: hideModal }] =
|
||||
useBoolean(false);
|
||||
const closeModal = () => {
|
||||
onClose?.();
|
||||
hideModal();
|
||||
};
|
||||
return {
|
||||
node: visible ? (
|
||||
<WorkflowModal visible onClose={closeModal} {...restProps} />
|
||||
) : null,
|
||||
close: closeModal,
|
||||
open: showModal,
|
||||
};
|
||||
};
|
||||
@@ -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 { useWorkflowResourceMenuActions } from './use-workflow-resource-menu-actions';
|
||||
import { useWorkflowResourceClick } from './use-workflow-resource-click';
|
||||
import { useCreateWorkflowModal } from './use-create-workflow-modal';
|
||||
import {
|
||||
type UseWorkflowResourceAction,
|
||||
type WorkflowResourceActionProps,
|
||||
type WorkflowResourceActionReturn,
|
||||
} from './type';
|
||||
export { useWorkflowPublishEntry } from './use-workflow-publish-entry';
|
||||
export const useWorkflowResourceAction: UseWorkflowResourceAction = props => {
|
||||
const { spaceId, userId, getCommonActions } = props;
|
||||
const { handleWorkflowResourceClick, goWorkflowDetail } =
|
||||
useWorkflowResourceClick(spaceId);
|
||||
const {
|
||||
openCreateModal,
|
||||
workflowModal,
|
||||
createWorkflowModal,
|
||||
handleEditWorkflow,
|
||||
} = useCreateWorkflowModal({ ...props, goWorkflowDetail });
|
||||
const { renderWorkflowResourceActions, modals } =
|
||||
useWorkflowResourceMenuActions({
|
||||
...props,
|
||||
userId,
|
||||
onEditWorkflowInfo: handleEditWorkflow,
|
||||
getCommonActions,
|
||||
});
|
||||
|
||||
return {
|
||||
workflowResourceModals: [createWorkflowModal, workflowModal, ...modals],
|
||||
openCreateModal,
|
||||
handleWorkflowResourceClick,
|
||||
renderWorkflowResourceActions,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
type WorkflowResourceActionProps,
|
||||
type WorkflowResourceActionReturn,
|
||||
useCreateWorkflowModal,
|
||||
useWorkflowResourceClick,
|
||||
useWorkflowResourceMenuActions,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
type WorkflowMode,
|
||||
type ProductDraftStatus,
|
||||
type SchemaType,
|
||||
} from '@coze-workflow/base';
|
||||
import { type TableActionProps } from '@coze-arch/coze-design';
|
||||
import { type ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
|
||||
export { type ResourceInfo };
|
||||
|
||||
export interface WorkflowResourceActionProps {
|
||||
/* 刷新列表函数 */
|
||||
refreshPage?: () => void;
|
||||
spaceId?: string;
|
||||
/* 当前登录用户 id */
|
||||
userId?: string;
|
||||
getCommonActions?: (
|
||||
libraryResource: ResourceInfo,
|
||||
) => NonNullable<TableActionProps['actionList']>;
|
||||
}
|
||||
export interface WorkflowResourceActionReturn {
|
||||
/* 打开 workflow 创建弹窗 */
|
||||
openCreateModal: (flowMode?: WorkflowMode) => void;
|
||||
/* 创建、删除等操作的全局弹窗,直接挂载到列表父容器上 */
|
||||
workflowResourceModals: ReactNode[];
|
||||
/* 在 Table 组件的 columns 的 render 里调用,返回 Table.TableAction 组件 */
|
||||
renderWorkflowResourceActions: (record: ResourceInfo) => ReactNode;
|
||||
/* 资源 item 点击 */
|
||||
handleWorkflowResourceClick: (record: ResourceInfo) => void;
|
||||
}
|
||||
|
||||
export type UseWorkflowResourceAction = (
|
||||
props: WorkflowResourceActionProps,
|
||||
) => WorkflowResourceActionReturn;
|
||||
|
||||
export interface WorkflowResourceBizExtend {
|
||||
product_draft_status: ProductDraftStatus;
|
||||
external_flow_info?: string;
|
||||
schema_type: SchemaType;
|
||||
plugin_id?: string;
|
||||
icon_uri: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DeleteModalConfig {
|
||||
title: string;
|
||||
desc: string;
|
||||
okText: string;
|
||||
okHandle: () => void;
|
||||
cancelText: string;
|
||||
}
|
||||
|
||||
export interface CommonActionProps extends WorkflowResourceActionProps {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CommonActionReturn {
|
||||
actionHandler: (record: ResourceInfo) => void;
|
||||
}
|
||||
export interface DeleteActionReturn extends CommonActionReturn {
|
||||
deleteModal?: ReactNode;
|
||||
}
|
||||
|
||||
export interface PublishActionReturn extends CommonActionReturn {
|
||||
publishModal: ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { workflowApi } from '@coze-workflow/base';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Toast } from '@coze-arch/coze-design';
|
||||
import { WorkflowMode } from '@coze-arch/bot-api/workflow_api';
|
||||
import { type ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
|
||||
export const useChatflowSwitch = ({
|
||||
spaceId,
|
||||
refreshPage,
|
||||
}: {
|
||||
spaceId: string;
|
||||
refreshPage?: () => void;
|
||||
}) => {
|
||||
const changeFlowMode = async (flowMode: WorkflowMode, workflowId: string) => {
|
||||
await workflowApi.UpdateWorkflowMeta({
|
||||
space_id: spaceId,
|
||||
workflow_id: workflowId,
|
||||
flow_mode: flowMode,
|
||||
});
|
||||
Toast.success(
|
||||
I18n.t('wf_chatflow_123', {
|
||||
Chatflow: I18n.t(
|
||||
flowMode === WorkflowMode.ChatFlow ? 'wf_chatflow_76' : 'Workflow',
|
||||
),
|
||||
}),
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
refreshPage?.();
|
||||
};
|
||||
const switchToWorkflow = async (record: ResourceInfo) =>
|
||||
changeFlowMode(WorkflowMode.Workflow, record.res_id ?? '');
|
||||
const switchToChatflow = async (record: ResourceInfo) =>
|
||||
changeFlowMode(WorkflowMode.ChatFlow, record.res_id ?? '');
|
||||
return { switchToWorkflow, switchToChatflow };
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { workflowApi } from '@coze-workflow/base';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Toast } from '@coze-arch/coze-design';
|
||||
import { CustomError } from '@coze-arch/bot-error';
|
||||
import { type ResourceInfo, ResType } from '@coze-arch/bot-api/plugin_develop';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { reporter, wait } from '@/utils';
|
||||
|
||||
import { type CommonActionProps, type CommonActionReturn } from './type';
|
||||
|
||||
export const useCopyAction = (props: CommonActionProps): CommonActionReturn => {
|
||||
const { spaceId } = props;
|
||||
const navigate = useNavigate();
|
||||
// 复制
|
||||
const handleCopy = async (item: ResourceInfo) => {
|
||||
if (!item.res_id || !spaceId) {
|
||||
throw new CustomError('normal_error', 'miss workflowId or spaceID');
|
||||
}
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_copy_row',
|
||||
meta: {
|
||||
workflowId: item.res_id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let isError = false;
|
||||
const { data } = await workflowApi.CopyWorkflow({
|
||||
space_id: spaceId,
|
||||
workflow_id: item.res_id,
|
||||
});
|
||||
isError = !data?.workflow_id;
|
||||
|
||||
if (isError) {
|
||||
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
|
||||
reporter.error({
|
||||
message: 'workflow_list_copy_row_fail',
|
||||
error: new CustomError('normal_error', 'result no workflow'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.success({
|
||||
content:
|
||||
item.res_type === ResType.Imageflow
|
||||
? I18n.t('imageflow_detail_toast_createcopy_succeed')
|
||||
: I18n.t('workflow_detail_toast_createcopy_succeed'),
|
||||
showClose: false,
|
||||
});
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_copy_row_success',
|
||||
meta: {
|
||||
workflowId: item.res_id,
|
||||
},
|
||||
});
|
||||
|
||||
// 兜底服务主从延迟
|
||||
await wait(300);
|
||||
// 复制后跳转到详情页
|
||||
navigate(
|
||||
`/work_flow?workflow_id=${data.workflow_id}&space_id=${spaceId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
reporter.error({
|
||||
message: 'workflow_list_copy_row_fail',
|
||||
error,
|
||||
});
|
||||
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
|
||||
}
|
||||
};
|
||||
return { actionHandler: handleCopy };
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useBoolean } from 'ahooks';
|
||||
import {
|
||||
type FrontWorkflowInfo,
|
||||
WorkflowMode,
|
||||
isGeneralWorkflow,
|
||||
type BindBizType,
|
||||
} from '@coze-workflow/base';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { useFlags } from '@coze-arch/bot-flags';
|
||||
import { CustomError } from '@coze-arch/bot-error';
|
||||
|
||||
import { DataSourceType, WorkflowModalFrom } from '@/workflow-modal';
|
||||
import { CreateWorkflowModal, type RuleItem } from '@/workflow-edit';
|
||||
import { reporter } from '@/utils';
|
||||
import { useWorkflowModal } from '@/hooks/use-workflow-modal';
|
||||
|
||||
import { type WorkflowResourceActionProps } from './type';
|
||||
|
||||
export const useCreateWorkflowModal = ({
|
||||
from = WorkflowModalFrom.SpaceWorkflowList,
|
||||
bindBizType,
|
||||
bindBizId,
|
||||
refreshPage,
|
||||
spaceId,
|
||||
goWorkflowDetail,
|
||||
projectId,
|
||||
onCreateSuccess,
|
||||
hiddenTemplateEntry,
|
||||
nameValidators,
|
||||
}: WorkflowResourceActionProps & {
|
||||
from?: WorkflowModalFrom;
|
||||
/** 当前项目 id,只在项目内的 workflow 有该字段 */
|
||||
projectId?: string;
|
||||
bindBizType?: BindBizType;
|
||||
bindBizId?: string;
|
||||
onCreateSuccess?: ({ workflowId }: { workflowId: string }) => void;
|
||||
goWorkflowDetail?: (workflowId?: string, spaceId?: string) => void;
|
||||
/** 隐藏通过模板创建入口 */
|
||||
hiddenTemplateEntry?: boolean;
|
||||
nameValidators?: RuleItem[];
|
||||
}) => {
|
||||
const [currentWorkflow, setCurrentWorkflow] = useState<FrontWorkflowInfo>();
|
||||
const [formMode, setFormMode] = useState<'add' | 'update'>('add');
|
||||
const [flowMode, setFlowMode] = useState<WorkflowMode>(WorkflowMode.Workflow);
|
||||
const [createModalVisible, { setTrue, setFalse: closeCreateModal }] =
|
||||
useBoolean(false);
|
||||
|
||||
const [FLAGS] = useFlags();
|
||||
|
||||
const openCreateModal = (mode?: WorkflowMode) => {
|
||||
setFormMode('add');
|
||||
setFlowMode(mode || WorkflowMode.Workflow);
|
||||
reporter.info({
|
||||
message: 'workflow_list_open_create_modal',
|
||||
});
|
||||
setTrue();
|
||||
};
|
||||
|
||||
const openEditModal = () => {
|
||||
setFormMode('update');
|
||||
reporter.info({
|
||||
message: 'workflow_list_open_create_modal',
|
||||
});
|
||||
setTrue();
|
||||
};
|
||||
|
||||
const handleEditWorkflow = (partialWorkflowInfo: FrontWorkflowInfo) => {
|
||||
setCurrentWorkflow(partialWorkflowInfo);
|
||||
setFlowMode(partialWorkflowInfo.flow_mode || WorkflowMode.Workflow);
|
||||
openEditModal();
|
||||
};
|
||||
|
||||
const workflowModalInitState = useMemo(() => {
|
||||
// 社区版暂不支持该功能
|
||||
if (isWorkflowMode || FLAGS['bot.community.store_imageflow']) {
|
||||
return {
|
||||
productCategory: 'all',
|
||||
isSpaceWorkflow: false,
|
||||
dataSourceType: DataSourceType.Product,
|
||||
};
|
||||
}
|
||||
return {
|
||||
workflowTag: 1,
|
||||
isSpaceWorkflow: false,
|
||||
dataSource: DataSourceType.Workflow,
|
||||
};
|
||||
}, [FLAGS, flowMode]);
|
||||
|
||||
const { node: workflowModal } = useWorkflowModal({
|
||||
from,
|
||||
flowMode,
|
||||
dupText: I18n.t('Copy'),
|
||||
hiddenCreate: true,
|
||||
hiddenSpaceList: true,
|
||||
initState: workflowModalInitState,
|
||||
projectId,
|
||||
onAdd: () => {
|
||||
refreshPage?.();
|
||||
},
|
||||
onDupSuccess: val => {
|
||||
window.open(
|
||||
`/work_flow?space_id=${spaceId}&workflow_id=${val.workflow_id}&from=dupSuccess`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const isWorkflowMode = useMemo(() => isGeneralWorkflow(flowMode), [flowMode]);
|
||||
|
||||
return {
|
||||
openCreateModal,
|
||||
handleEditWorkflow,
|
||||
workflowModal,
|
||||
createWorkflowModal: (
|
||||
<CreateWorkflowModal
|
||||
initConfirmDisabled
|
||||
mode={formMode}
|
||||
flowMode={flowMode}
|
||||
projectId={projectId}
|
||||
visible={createModalVisible}
|
||||
onCancel={closeCreateModal}
|
||||
bindBizType={bindBizType}
|
||||
bindBizId={bindBizId}
|
||||
workFlow={formMode === 'update' ? currentWorkflow : undefined}
|
||||
getLatestWorkflowJson={undefined}
|
||||
customTitleRender={undefined}
|
||||
onSuccess={({ workflowId }) => {
|
||||
closeCreateModal();
|
||||
if (!workflowId) {
|
||||
throw new CustomError(
|
||||
'[Workflow] create failed',
|
||||
'create workflow failed, no workflow id',
|
||||
);
|
||||
}
|
||||
|
||||
if (onCreateSuccess && formMode === 'add') {
|
||||
onCreateSuccess({ workflowId });
|
||||
return;
|
||||
}
|
||||
// 编辑模式,不跳转,刷新当前列表
|
||||
if (formMode === 'update') {
|
||||
refreshPage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const navigateDelay = 500;
|
||||
// 由于后端数据同步慢,这里delay 500 ms
|
||||
setTimeout(() => {
|
||||
goWorkflowDetail?.(workflowId, spaceId);
|
||||
}, navigateDelay);
|
||||
}}
|
||||
spaceID={spaceId}
|
||||
nameValidators={nameValidators}
|
||||
/>
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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, { useState } from 'react';
|
||||
|
||||
import { DeleteAction, DeleteType, workflowApi } from '@coze-workflow/base';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { CustomError } from '@coze-arch/bot-error';
|
||||
import { type ResourceInfo, ResType } from '@coze-arch/bot-api/plugin_develop';
|
||||
import { Modal, Toast } from '@coze-arch/coze-design';
|
||||
|
||||
import { reporter, wait } from '@/utils';
|
||||
|
||||
import {
|
||||
type CommonActionProps,
|
||||
type DeleteActionReturn,
|
||||
type DeleteModalConfig,
|
||||
} from './type';
|
||||
|
||||
// eslint-disable-next-line @coze-arch/max-line-per-function
|
||||
export const useDeleteAction = (
|
||||
props: CommonActionProps,
|
||||
): DeleteActionReturn => {
|
||||
const { spaceId, userId, refreshPage } = props;
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [deleteModalConfig, setDeleteModalConfig] =
|
||||
useState<DeleteModalConfig>();
|
||||
/**
|
||||
* 逻辑来自 useWorkflowList(@coze-workflow/components),由于入参变了,不再复用
|
||||
* @param item
|
||||
*/
|
||||
const handleDeleteWorkflowResource = async (item: ResourceInfo) => {
|
||||
if (!item.res_id || !spaceId) {
|
||||
throw new CustomError('normal_error', 'miss workflowId or spaceID');
|
||||
}
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_delete_row',
|
||||
meta: {
|
||||
workflowId: item.res_id,
|
||||
},
|
||||
});
|
||||
|
||||
let deleteType = DeleteType.CanDelete;
|
||||
|
||||
// 从服务端查询删除模式
|
||||
const resp = await workflowApi.GetDeleteStrategy({
|
||||
space_id: spaceId,
|
||||
workflow_id: item.res_id,
|
||||
});
|
||||
deleteType = resp.data;
|
||||
|
||||
const canDelete = [
|
||||
DeleteType.CanDelete,
|
||||
DeleteType.RejectProductDraft,
|
||||
].includes(deleteType);
|
||||
|
||||
const deleteFuc = async (deleteParams?: {
|
||||
needDeleteBlockwise: boolean;
|
||||
}) => {
|
||||
const needDeleteBlockwise = deleteParams?.needDeleteBlockwise;
|
||||
const action = needDeleteBlockwise
|
||||
? DeleteAction.BlockwiseDelete
|
||||
: DeleteAction.BlockwiseUnbind;
|
||||
|
||||
if (!item.res_id || !spaceId) {
|
||||
throw new CustomError('normal_error', 'miss workflowId or spaceID');
|
||||
}
|
||||
try {
|
||||
await workflowApi.DeleteWorkflow({
|
||||
space_id: spaceId,
|
||||
workflow_id: item.res_id,
|
||||
action,
|
||||
});
|
||||
|
||||
Toast.success({
|
||||
content: I18n.t('workflow_add_delete_success'),
|
||||
showClose: false,
|
||||
});
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_list_delete_row_success',
|
||||
});
|
||||
|
||||
// 兜底服务主从延迟
|
||||
await wait(300);
|
||||
|
||||
// 刷新列表
|
||||
refreshPage?.();
|
||||
} catch (error) {
|
||||
reporter.error({
|
||||
message: 'workflow_list_delete_row_fail',
|
||||
error,
|
||||
});
|
||||
Toast.error({
|
||||
content: I18n.t('workflow_add_delete_fail'),
|
||||
showClose: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
canDelete,
|
||||
deleteType,
|
||||
handleDelete: canDelete ? deleteFuc : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteAction = async (record: ResourceInfo) => {
|
||||
const isSelfCreator = record.creator_id === userId;
|
||||
const deleteConfig = await handleDeleteWorkflowResource(record);
|
||||
let title = I18n.t('delete_title');
|
||||
if (deleteConfig.deleteType === DeleteType.UnListProduct) {
|
||||
title = I18n.t('workflowstore_unable_to_delete_workflow');
|
||||
}
|
||||
|
||||
let desc = I18n.t('library_delete_desc');
|
||||
if (deleteConfig.deleteType === DeleteType.UnListProduct) {
|
||||
if (isSelfCreator) {
|
||||
desc = I18n.t('workflowstore_the_workflow_has_been');
|
||||
} else {
|
||||
desc = I18n.t('workflowstore_delete_permission');
|
||||
}
|
||||
}
|
||||
|
||||
let okText = deleteConfig.canDelete
|
||||
? I18n.t('confirm')
|
||||
: I18n.t('workflowstore_remove_wf');
|
||||
if (
|
||||
deleteConfig.deleteType === DeleteType.UnListProduct &&
|
||||
!isSelfCreator
|
||||
) {
|
||||
okText = '';
|
||||
}
|
||||
|
||||
let cancelText = I18n.t('cancel');
|
||||
if (
|
||||
deleteConfig.deleteType === DeleteType.UnListProduct &&
|
||||
!isSelfCreator
|
||||
) {
|
||||
cancelText = I18n.t('confirm');
|
||||
}
|
||||
|
||||
setDeleteModalConfig({
|
||||
title,
|
||||
desc,
|
||||
cancelText,
|
||||
okText,
|
||||
okHandle: () => {
|
||||
if (deleteConfig.canDelete) {
|
||||
deleteConfig?.handleDelete?.({
|
||||
needDeleteBlockwise: false,
|
||||
});
|
||||
} else {
|
||||
if (
|
||||
deleteConfig.deleteType === DeleteType.UnListProduct &&
|
||||
isSelfCreator
|
||||
) {
|
||||
window.open(
|
||||
`/store/${
|
||||
record.res_type === ResType.Workflow ? 'workflow' : 'imageflow'
|
||||
}/${record.res_id}?entity_id=true`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
const deleteModal = (
|
||||
<Modal
|
||||
maskClosable={false}
|
||||
centered={true}
|
||||
visible={modalVisible}
|
||||
title={deleteModalConfig?.title ?? ''}
|
||||
onOk={() => {
|
||||
setModalVisible(false);
|
||||
deleteModalConfig?.okHandle();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
}}
|
||||
closeOnEsc={true}
|
||||
cancelText={deleteModalConfig?.cancelText}
|
||||
okText={deleteModalConfig?.okText}
|
||||
okButtonColor={'red'}
|
||||
>
|
||||
<div className="coz-common-content">{deleteModalConfig?.desc ?? ''}</div>
|
||||
</Modal>
|
||||
);
|
||||
return { deleteModal, actionHandler: deleteAction };
|
||||
};
|
||||
@@ -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 { useState } from 'react';
|
||||
|
||||
import { WorkflowMode } from '@coze-workflow/base';
|
||||
import { AddWorkflowToStoreEntry } from '@coze-arch/bot-tea';
|
||||
import { type ResourceInfo, ResType } from '@coze-arch/bot-api/plugin_develop';
|
||||
import {
|
||||
PublishWorkflowModal,
|
||||
usePublishWorkflowModal,
|
||||
} from '@coze-workflow/resources-adapter';
|
||||
|
||||
import { type CommonActionProps, type PublishActionReturn } from './type';
|
||||
|
||||
export const usePublishAction = ({
|
||||
spaceId = '',
|
||||
refreshPage,
|
||||
}: CommonActionProps): PublishActionReturn => {
|
||||
const [flowMode, setFlowMode] = useState<WorkflowMode>(WorkflowMode.Workflow);
|
||||
const publishWorkflowModalHook = usePublishWorkflowModal({
|
||||
onPublishSuccess: () => {
|
||||
refreshPage?.();
|
||||
},
|
||||
fromSpace: true,
|
||||
flowMode,
|
||||
});
|
||||
|
||||
/**
|
||||
* NOTICE: 此函数由商店侧维护, 可联系 @gaoding
|
||||
* 发布/更新流程商品
|
||||
*/
|
||||
const onPublishStore = (item: ResourceInfo) => {
|
||||
setFlowMode(
|
||||
item.res_type === ResType.Imageflow
|
||||
? WorkflowMode.Imageflow
|
||||
: WorkflowMode.Workflow,
|
||||
);
|
||||
// 商店渲染流程需要 spaceId 信息, 在这个场景需要手动设置对应信息
|
||||
publishWorkflowModalHook.setSpace(spaceId);
|
||||
publishWorkflowModalHook.showModal({
|
||||
type: PublishWorkflowModal.WORKFLOW_INFO,
|
||||
product: {
|
||||
meta_info: {
|
||||
entity_id: item.res_id,
|
||||
name: item.name,
|
||||
},
|
||||
},
|
||||
source: AddWorkflowToStoreEntry.WORKFLOW_PERSONAL_LIST,
|
||||
});
|
||||
};
|
||||
return {
|
||||
actionHandler: onPublishStore,
|
||||
publishModal: publishWorkflowModalHook.ModalComponent,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { useWorkflowPublishEntry } from '@coze-workflow/resources-adapter';
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { reporter } from '@/utils';
|
||||
|
||||
export const useWorkflowResourceClick = (spaceId?: string) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onEditWorkFlow = (workflowId?: string) => {
|
||||
reporter.info({
|
||||
message: 'workflow_list_edit_row',
|
||||
meta: {
|
||||
workflowId,
|
||||
},
|
||||
});
|
||||
goWorkflowDetail(workflowId, spaceId);
|
||||
};
|
||||
|
||||
/** 打开流程编辑页 */
|
||||
const goWorkflowDetail = (workflowId?: string, sId?: string) => {
|
||||
if (!workflowId || !sId) {
|
||||
return;
|
||||
}
|
||||
reporter.info({
|
||||
message: 'workflow_list_navigate_to_detail',
|
||||
meta: {
|
||||
workflowId,
|
||||
},
|
||||
});
|
||||
|
||||
navigate(`/work_flow?workflow_id=${workflowId}&space_id=${sId}`);
|
||||
};
|
||||
const handleWorkflowResourceClick = (record: ResourceInfo) => {
|
||||
reporter.info({
|
||||
message: 'workflow_list_click_row',
|
||||
});
|
||||
onEditWorkFlow(record?.res_id);
|
||||
};
|
||||
|
||||
return { handleWorkflowResourceClick, goWorkflowDetail };
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
ProductDraftStatus,
|
||||
type FrontWorkflowInfo,
|
||||
} from '@coze-workflow/base';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Table, type TableActionProps } from '@coze-arch/coze-design';
|
||||
import { useFlags } from '@coze-arch/bot-flags';
|
||||
import {
|
||||
resource_resource_common,
|
||||
type ResourceInfo,
|
||||
ResType,
|
||||
} from '@coze-arch/bot-api/plugin_develop';
|
||||
|
||||
import {
|
||||
parseWorkflowResourceBizExtend,
|
||||
transformResourceToWorkflowEditInfo,
|
||||
} from './utils';
|
||||
import { useWorkflowPublishEntry } from './use-workflow-publish-entry';
|
||||
import { usePublishAction } from './use-publish-action';
|
||||
import { useDeleteAction } from './use-delete-action';
|
||||
import { useCopyAction } from './use-copy-action';
|
||||
import { useChatflowSwitch } from './use-chatflow-switch';
|
||||
import {
|
||||
type WorkflowResourceActionProps,
|
||||
type WorkflowResourceActionReturn,
|
||||
} from './type';
|
||||
|
||||
const { ActionKey } = resource_resource_common;
|
||||
|
||||
type ActionItemProps = NonNullable<TableActionProps['actionList']>[number];
|
||||
|
||||
export const useWorkflowResourceMenuActions = (
|
||||
props: WorkflowResourceActionProps & {
|
||||
userId?: string;
|
||||
onEditWorkflowInfo: (partialWorkflowInfo: FrontWorkflowInfo) => void;
|
||||
},
|
||||
): Pick<WorkflowResourceActionReturn, 'renderWorkflowResourceActions'> & {
|
||||
modals: ReactNode[];
|
||||
} => {
|
||||
const [FLAGS] = useFlags();
|
||||
const { userId, onEditWorkflowInfo, getCommonActions } = props;
|
||||
const { actionHandler: deleteAction, deleteModal } = useDeleteAction(props);
|
||||
const { actionHandler: copyAction } = useCopyAction(props);
|
||||
const { actionHandler: publishAction, publishModal } =
|
||||
usePublishAction(props);
|
||||
const { switchToChatflow, switchToWorkflow } = useChatflowSwitch({
|
||||
spaceId: props.spaceId ?? '',
|
||||
refreshPage: props.refreshPage,
|
||||
});
|
||||
const actionMap = {
|
||||
[ActionKey.Copy]: copyAction,
|
||||
[ActionKey.Delete]: deleteAction,
|
||||
[ActionKey.Edit]: (record: ResourceInfo) => {
|
||||
const workflowPartialInfo = transformResourceToWorkflowEditInfo(record);
|
||||
onEditWorkflowInfo(workflowPartialInfo as FrontWorkflowInfo);
|
||||
},
|
||||
[ActionKey.SwitchToFuncflow]: switchToWorkflow,
|
||||
[ActionKey.SwitchToChatflow]: switchToChatflow,
|
||||
};
|
||||
|
||||
const { enablePublishEntry } = useWorkflowPublishEntry();
|
||||
// eslint-disable-next-line complexity
|
||||
const renderWorkflowResourceActions = (record: ResourceInfo): ReactNode => {
|
||||
const bizExtend = parseWorkflowResourceBizExtend(record.biz_extend);
|
||||
const productDraftStatus = bizExtend?.product_draft_status;
|
||||
const isImageFlow = record.res_type === ResType.Imageflow;
|
||||
const { actions } = record;
|
||||
const deleteActionConfig = actions?.find(
|
||||
action => action.key === ActionKey.Delete,
|
||||
);
|
||||
const copyActionConfig = actions?.find(
|
||||
action => action.key === ActionKey.Copy,
|
||||
);
|
||||
const editConfig = actions?.find(action => action.key === ActionKey.Edit);
|
||||
const chatflowConfig = actions?.find(
|
||||
action => action.key === ActionKey.SwitchToChatflow,
|
||||
);
|
||||
const workflowConfig = actions?.find(
|
||||
action => action.key === ActionKey.SwitchToFuncflow,
|
||||
);
|
||||
|
||||
const isSelfCreator = record.creator_id === userId;
|
||||
const extraActions: ActionItemProps[] = [
|
||||
{
|
||||
hide: !editConfig,
|
||||
disabled: editConfig?.enable === false,
|
||||
actionKey: 'edit',
|
||||
actionText: I18n.t('Edit'),
|
||||
handler: () => actionMap?.[ActionKey.Edit]?.(record),
|
||||
},
|
||||
{
|
||||
hide: !chatflowConfig,
|
||||
disabled: chatflowConfig?.enable === false,
|
||||
actionKey: 'switchChatflow',
|
||||
actionText: I18n.t('wf_chatflow_121', {
|
||||
flowMode: I18n.t('wf_chatflow_76'),
|
||||
}),
|
||||
handler: () => actionMap?.[ActionKey.SwitchToChatflow]?.(record),
|
||||
},
|
||||
{
|
||||
hide: !workflowConfig,
|
||||
disabled: workflowConfig?.enable === false,
|
||||
actionKey: 'switchWorkflow',
|
||||
actionText: I18n.t('wf_chatflow_121', { flowMode: I18n.t('Workflow') }),
|
||||
handler: () => actionMap?.[ActionKey.SwitchToFuncflow]?.(record),
|
||||
},
|
||||
...(getCommonActions?.(record) ?? []),
|
||||
{
|
||||
hide:
|
||||
!enablePublishEntry || // 上架入口加白
|
||||
// 社区版暂不支持该功能
|
||||
(!FLAGS['bot.community.store_imageflow'] && isImageFlow) || // Imageflow 不支持商店
|
||||
!isSelfCreator ||
|
||||
bizExtend?.plugin_id === '0',
|
||||
actionKey: 'publishWorkflowProduct',
|
||||
actionText:
|
||||
productDraftStatus === ProductDraftStatus.Default
|
||||
? I18n.t('workflowstore_submit')
|
||||
: I18n.t('workflowstore_submit_update'),
|
||||
handler: () => {
|
||||
publishAction?.(record);
|
||||
},
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Table.TableAction
|
||||
deleteProps={{
|
||||
hide: !deleteActionConfig,
|
||||
disabled: deleteActionConfig?.enable === false,
|
||||
disableConfirm: true,
|
||||
handler: () => actionMap[ActionKey.Delete]?.(record),
|
||||
}}
|
||||
copyProps={{
|
||||
hide: !copyActionConfig,
|
||||
disabled: copyActionConfig?.enable === false,
|
||||
handler: () => actionMap[ActionKey.Copy]?.(record),
|
||||
}}
|
||||
actionList={extraActions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return { renderWorkflowResourceActions, modals: [deleteModal, publishModal] };
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 {
|
||||
ProductDraftStatus,
|
||||
SchemaType,
|
||||
type FrontWorkflowInfo,
|
||||
} from '@coze-workflow/base';
|
||||
import { type ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
|
||||
|
||||
import { type WorkflowResourceBizExtend } from './type';
|
||||
|
||||
export const parseWorkflowResourceBizExtend = (
|
||||
bizExtend?: Record<string, string>,
|
||||
): WorkflowResourceBizExtend | undefined => {
|
||||
if (!bizExtend) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
product_draft_status:
|
||||
bizExtend.product_draft_status !== undefined
|
||||
? parseInt(bizExtend.product_draft_status || '0')
|
||||
: ProductDraftStatus.Default,
|
||||
external_flow_info: bizExtend.external_flow_info,
|
||||
schema_type:
|
||||
bizExtend.schema_type !== undefined
|
||||
? parseInt(bizExtend.schema_type || '0')
|
||||
: SchemaType.DAG,
|
||||
plugin_id: bizExtend.plugin_id,
|
||||
icon_uri: bizExtend.icon_uri,
|
||||
url: bizExtend.url,
|
||||
};
|
||||
};
|
||||
/**
|
||||
* 转换 ResourceInfo 为编辑 workflow 所需的 WorkflowInfoLocal 结构
|
||||
* @param resource
|
||||
*/
|
||||
export const transformResourceToWorkflowEditInfo = (
|
||||
resource: ResourceInfo,
|
||||
): Pick<
|
||||
FrontWorkflowInfo,
|
||||
| 'workflow_id'
|
||||
| 'url'
|
||||
| 'icon_uri'
|
||||
| 'name'
|
||||
| 'desc'
|
||||
| 'schema_type'
|
||||
| 'external_flow_info'
|
||||
| 'space_id'
|
||||
> => {
|
||||
const bizExtend = parseWorkflowResourceBizExtend(resource.biz_extend);
|
||||
return {
|
||||
workflow_id: resource.res_id,
|
||||
url: bizExtend?.url,
|
||||
icon_uri: bizExtend?.icon_uri,
|
||||
name: resource.name,
|
||||
desc: resource.desc,
|
||||
schema_type: bizExtend?.schema_type,
|
||||
external_flow_info: bizExtend?.external_flow_info,
|
||||
space_id: resource.space_id,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* 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 { workflowApi } from '@coze-workflow/base/api';
|
||||
import { REPORT_EVENTS } from '@coze-arch/report-events';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { upLoadFile } from '@coze-arch/bot-utils';
|
||||
import { CustomError } from '@coze-arch/bot-error';
|
||||
import { FileBizType } from '@coze-arch/bot-api/developer_api';
|
||||
import { DeveloperApi } from '@coze-arch/bot-api';
|
||||
|
||||
/** 图片上传错误码 */
|
||||
export enum ImgUploadErrNo {
|
||||
Success = 0,
|
||||
/** 缺少文件 */
|
||||
NoFile,
|
||||
/** 上传失败 */
|
||||
UploadFail,
|
||||
/** 上传超时 */
|
||||
UploadTimeout,
|
||||
/** 获取 URL 失败 */
|
||||
GetUrlFail,
|
||||
/** 校验异常, 但是不明确具体异常 */
|
||||
ValidateError,
|
||||
/** 文件尺寸超出限制 */
|
||||
MaxSizeError,
|
||||
/** 文件类型不支持 */
|
||||
SuffixError,
|
||||
/** 最大宽度限制 */
|
||||
MaxWidthError,
|
||||
/** 最大高度限制 */
|
||||
MaxHeightError,
|
||||
/** 最小宽度限制 */
|
||||
MinWidthError,
|
||||
/** 最小高度限制 */
|
||||
MinHeightError,
|
||||
/** 固定宽高比 */
|
||||
AspectRatioError,
|
||||
}
|
||||
|
||||
export interface ImageRule {
|
||||
/** 文件大小限制, 单位 b, 1M = 1 * 1024 * 1024 */
|
||||
maxSize?: number;
|
||||
/** 文件后缀 */
|
||||
suffix?: string[];
|
||||
/** 最大宽度限制 */
|
||||
maxWidth?: number;
|
||||
/** 最大高度限制 */
|
||||
maxHeight?: number;
|
||||
/** 最小宽度限制 */
|
||||
minWidth?: number;
|
||||
/** 最小高度限制 */
|
||||
minHeight?: number;
|
||||
/** 固定宽高比 */
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
type UploadResult =
|
||||
| {
|
||||
isSuccess: false;
|
||||
errNo: ImgUploadErrNo;
|
||||
msg: string;
|
||||
}
|
||||
| {
|
||||
isSuccess: true;
|
||||
errNo: ImgUploadErrNo.Success;
|
||||
uri: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow 图片上传
|
||||
*/
|
||||
class ImageUploader {
|
||||
/** 任务 ID, 用于避免 ABA 问题 */
|
||||
private taskId = 0;
|
||||
/**
|
||||
* 上传模式
|
||||
* - api 直接使用接口上传
|
||||
* - uploader 上传到视频云服务, 走 workflow 服务. !海外版未经过测试
|
||||
*/
|
||||
mode: 'uploader' | 'api' = 'uploader';
|
||||
/** 校验规则 */
|
||||
rules?: ImageRule;
|
||||
/** 上传的文件 */
|
||||
file?: File;
|
||||
/** 展示 Url, 添加文件后生成, 用于预览 */
|
||||
displayUrl?: string;
|
||||
/** 上传状态 */
|
||||
isUploading = false;
|
||||
/** 超时时间 */
|
||||
timeout?: number;
|
||||
/** 校验结果 */
|
||||
validateResult?: {
|
||||
isSuccess: boolean;
|
||||
errNo: ImgUploadErrNo;
|
||||
msg?: string;
|
||||
};
|
||||
/** 上传结果 */
|
||||
uploadResult?: UploadResult;
|
||||
|
||||
constructor(config?: {
|
||||
rules?: ImageRule;
|
||||
mode?: ImageUploader['mode'];
|
||||
timeout?: number;
|
||||
}) {
|
||||
this.rules = config?.rules ?? this.rules;
|
||||
this.mode = config?.mode ?? this.mode;
|
||||
this.timeout = config?.timeout ?? this.timeout;
|
||||
}
|
||||
|
||||
/** 选择待上传文件 */
|
||||
async select(file: File) {
|
||||
if (!file) {
|
||||
throw new CustomError('normal_error', '选择文件为空');
|
||||
}
|
||||
this.reset();
|
||||
this.file = file;
|
||||
this.displayUrl = URL.createObjectURL(this.file);
|
||||
|
||||
await this.validate().catch(() => {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.ValidateError,
|
||||
msg: I18n.t('imageflow_upload_error'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 上传图片 */
|
||||
async upload() {
|
||||
// 未选择文件或文件不符合要求
|
||||
if (!this.file || !this.validateResult?.isSuccess || this.isUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUploading = true;
|
||||
|
||||
// 添加任务 ID,避免 ABA 问题
|
||||
this.taskId += 1;
|
||||
const currentId = this.taskId;
|
||||
|
||||
let uploadResult: UploadResult;
|
||||
if (this.mode === 'api') {
|
||||
uploadResult = await this.uploadByApi(this.file);
|
||||
} else if (this.mode === 'uploader') {
|
||||
uploadResult = await this.uploadByUploader(this.file);
|
||||
} else {
|
||||
throw new CustomError('normal_error', 'ImageUploader mode error');
|
||||
}
|
||||
|
||||
if (currentId !== this.taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadResult = uploadResult;
|
||||
this.isUploading = false;
|
||||
}
|
||||
|
||||
private uploadByUploader(file: File): Promise<UploadResult> {
|
||||
return new Promise(resolve => {
|
||||
const timer =
|
||||
this.timeout &&
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.UploadTimeout,
|
||||
msg: I18n.t('imageflow_upload_error7'),
|
||||
}),
|
||||
this.timeout,
|
||||
);
|
||||
|
||||
const doUpload = async () => {
|
||||
const uri = await upLoadFile({
|
||||
biz: 'workflow',
|
||||
file,
|
||||
fileType: 'image',
|
||||
})
|
||||
.then(result => {
|
||||
if (!result) {
|
||||
throw new CustomError('normal_error', 'no uri');
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
resolve({
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.UploadFail,
|
||||
msg: I18n.t('imageflow_upload_error'),
|
||||
});
|
||||
return '';
|
||||
});
|
||||
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
// 获取 url
|
||||
const resp = await workflowApi
|
||||
.SignImageURL(
|
||||
{
|
||||
uri,
|
||||
},
|
||||
{
|
||||
__disableErrorToast: true,
|
||||
},
|
||||
)
|
||||
.catch(() => null);
|
||||
const url = resp?.url || '';
|
||||
|
||||
if (url) {
|
||||
resolve({
|
||||
isSuccess: true,
|
||||
errNo: ImgUploadErrNo.Success,
|
||||
uri,
|
||||
url,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.GetUrlFail,
|
||||
msg: I18n.t('imageflow_upload_error'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
doUpload().finally(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private uploadByApi(file: File): Promise<UploadResult> {
|
||||
return new Promise(resolve => {
|
||||
const timer =
|
||||
this.timeout &&
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.UploadTimeout,
|
||||
msg: I18n.t('imageflow_upload_error7'),
|
||||
}),
|
||||
this.timeout,
|
||||
);
|
||||
|
||||
const doUpload = async function () {
|
||||
const base64 = await getBase64(file).catch(() => '');
|
||||
|
||||
if (!base64) {
|
||||
resolve({
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.UploadFail,
|
||||
msg: I18n.t('imageflow_upload_error'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await DeveloperApi.UploadFile({
|
||||
file_head: {
|
||||
file_type: getFileExtension(file.name),
|
||||
biz_type: FileBizType.BIZ_BOT_WORKFLOW,
|
||||
},
|
||||
data: base64,
|
||||
})
|
||||
.then(result => {
|
||||
resolve({
|
||||
isSuccess: true,
|
||||
errNo: ImgUploadErrNo.Success,
|
||||
uri: result.data?.upload_uri || '',
|
||||
url: result.data?.upload_url || '',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
resolve({
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.UploadFail,
|
||||
msg: I18n.t('imageflow_upload_error'),
|
||||
});
|
||||
});
|
||||
};
|
||||
doUpload().finally(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.file = undefined;
|
||||
if (this.displayUrl) {
|
||||
// 是内部链接
|
||||
URL.revokeObjectURL(this.displayUrl);
|
||||
this.displayUrl = undefined;
|
||||
}
|
||||
this.isUploading = false;
|
||||
this.uploadResult = undefined;
|
||||
this.validateResult = undefined;
|
||||
this.taskId += 1;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
private async validate() {
|
||||
if (!this.file || !this.displayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = this.rules || {};
|
||||
|
||||
// 文件尺寸
|
||||
if (rules.maxSize) {
|
||||
if (this.file.size > rules.maxSize) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.MaxSizeError,
|
||||
msg: I18n.t('imageflow_upload_exceed', {
|
||||
size: formatBytes(rules.maxSize),
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件后缀
|
||||
if (Array.isArray(rules.suffix) && rules.suffix.length > 0) {
|
||||
const fileExtension = getFileExtension(this.file.name);
|
||||
if (!rules.suffix.includes(fileExtension)) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.SuffixError,
|
||||
msg: I18n.t('imageflow_upload_error_type', {
|
||||
type: `${rules.suffix.filter(Boolean).join('/')}`,
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 图片尺寸
|
||||
const { width, height } = await getImageSize(this.displayUrl);
|
||||
|
||||
if (!width || !height) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.ValidateError,
|
||||
msg: I18n.t('imageflow_upload_error6'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (rules.maxWidth) {
|
||||
if (width > rules.maxWidth) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.MaxWidthError,
|
||||
msg: I18n.t('imageflow_upload_error5', {
|
||||
value: `${rules.maxWidth}px`,
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (rules.maxHeight) {
|
||||
if (height > rules.maxHeight) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.MaxHeightError,
|
||||
msg: I18n.t('imageflow_upload_error4', {
|
||||
value: `${rules.maxHeight}px`,
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (rules.minWidth) {
|
||||
if (width < rules.minWidth) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.MinWidthError,
|
||||
msg: I18n.t('imageflow_upload_error3', {
|
||||
value: `${rules.minWidth}px`,
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (rules.minHeight) {
|
||||
if (height < rules.minHeight) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.MinHeightError,
|
||||
msg: I18n.t('imageflow_upload_error2', {
|
||||
value: `${rules.minHeight}px`,
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (rules.aspectRatio) {
|
||||
if (width / height - rules.aspectRatio > Number.MIN_VALUE) {
|
||||
this.validateResult = {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.AspectRatioError,
|
||||
msg: I18n.t('imageflow_upload_error1'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.validateResult = {
|
||||
isSuccess: true,
|
||||
errNo: ImgUploadErrNo.Success,
|
||||
msg: 'success',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageUploader;
|
||||
|
||||
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 CustomError(REPORT_EVENTS.parmasValidation, 'file read fail'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(result.replace(/^.*?,/, ''));
|
||||
};
|
||||
fileReader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取文件名后缀 */
|
||||
function getFileExtension(name: string) {
|
||||
const index = name.lastIndexOf('.');
|
||||
return name.slice(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param url 获取图片宽高
|
||||
*/
|
||||
function getImageSize(url: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
img.onload = () =>
|
||||
resolve({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
img.onerror = e => reject(e);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param bytes 文件大小
|
||||
* @param decimals 小数位数, 默认 2 位
|
||||
* @example
|
||||
* formatBytes(1024); // 1KB
|
||||
* formatBytes('1024'); // 1KB
|
||||
* formatBytes(1234); // 1.21KB
|
||||
* formatBytes(1234, 3); // 1.205KB
|
||||
*/
|
||||
function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes';
|
||||
}
|
||||
const k = 1024,
|
||||
dm = decimals,
|
||||
sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
.image-uploader {
|
||||
&:global(.semi-input-wrapper) {
|
||||
&:hover {
|
||||
background-color: var(--semi-color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.can-action:global(.semi-input-wrapper) {
|
||||
transition: all 0.1s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(46, 46, 56, 8%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(46, 46, 56, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
font-size: 12px;
|
||||
color: rgba(29, 28, 35, 60%);
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(46, 46, 56, 8%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgba(46, 46, 56, 12%);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: rgba(46, 46, 56, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.input-img-thumb {
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
border-radius: 0.125rem;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
:global {
|
||||
.semi-image-status {
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
width: 17px;
|
||||
color: rgba(6, 7, 9, 30%);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.img-popover-content {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
* 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 @coze-arch/max-line-per-function */
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useBoolean } from 'ahooks';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozTrashCan,
|
||||
IconCozRefresh,
|
||||
IconCozUpload,
|
||||
IconCozImageBroken,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { type SelectProps } from '@coze-arch/bot-semi/Select';
|
||||
import {
|
||||
Image,
|
||||
ImagePreview,
|
||||
Popover,
|
||||
Space,
|
||||
Spin,
|
||||
} from '@coze-arch/bot-semi';
|
||||
|
||||
import useImageUploader from './use-image-uploader';
|
||||
import { type ImageRule, ImgUploadErrNo } from './image-uploader';
|
||||
|
||||
import s from './index.module.less';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 图片上传限制 */
|
||||
rules?: ImageRule;
|
||||
value?: { url: string; uri: string } | undefined;
|
||||
validateStatus?: SelectProps['validateStatus'];
|
||||
onChange?: (value?: { uri: string; url: string }) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
interface ImagePopoverWrapperProps {
|
||||
/** 图片地址 */
|
||||
url?: string;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
/** 是否支持预览 */
|
||||
enablePreview?: boolean;
|
||||
children?: React.ReactElement;
|
||||
}
|
||||
|
||||
const ImagePopoverWrapper: FC<ImagePopoverWrapperProps> = ({
|
||||
url,
|
||||
children,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
minWidth,
|
||||
minHeight,
|
||||
enablePreview,
|
||||
}) => {
|
||||
const [visible, { setTrue: showImagePreview, setFalse: closeImagePreview }] =
|
||||
useBoolean(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadError(false);
|
||||
}, [url]);
|
||||
|
||||
if (!url) {
|
||||
return children || null;
|
||||
}
|
||||
const content = loadError ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center"
|
||||
style={{ width: 225, height: 125 }}
|
||||
>
|
||||
<IconCozImageBroken className="w-8 coz-fg-dim" />
|
||||
<div className="mt-1 coz-fg-primary text-sm font-medium">
|
||||
{I18n.t('inifinit_list_load_fail')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col items-center justify-center rounded-lg overflow-hidden',
|
||||
enablePreview && !loadError ? 'cursor-zoom-in' : 'cursor-default',
|
||||
)}
|
||||
style={{
|
||||
minWidth,
|
||||
minHeight,
|
||||
background: 'rgba(46, 46, 56, 0.08)',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (loadError) {
|
||||
return;
|
||||
}
|
||||
showImagePreview();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={classNames('object-contain object-center rounded-sm')}
|
||||
style={{ maxWidth, maxHeight }}
|
||||
src={url}
|
||||
alt=""
|
||||
onLoad={() => {
|
||||
setLoadError(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setLoadError(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
className={s['img-popover-content']}
|
||||
content={content}
|
||||
showArrow
|
||||
position="top"
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
{enablePreview ? (
|
||||
<ImagePreview
|
||||
src={url}
|
||||
visible={visible}
|
||||
onVisibleChange={closeImagePreview}
|
||||
getPopupContainer={() => document.body}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageUploaderBtn: FC<{
|
||||
visible?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactElement;
|
||||
onClick?: () => void;
|
||||
}> = ({ visible = true, disabled = false, onClick, children }) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classNames(s.action, disabled && s.disabled)}
|
||||
onClick={e => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageUploader: FC<ImageUploaderProps> = ({
|
||||
className,
|
||||
style,
|
||||
value,
|
||||
rules,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
validateStatus,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
uri,
|
||||
url,
|
||||
fileName,
|
||||
isError,
|
||||
loading,
|
||||
setImgValue,
|
||||
uploadImg,
|
||||
clearImg,
|
||||
retryUploadImg,
|
||||
} = useImageUploader({
|
||||
rules,
|
||||
});
|
||||
const acceptAttr = useMemo(() => {
|
||||
if ((rules?.suffix || []).length > 0) {
|
||||
return (rules?.suffix || []).map(item => `.${item}`).join(',');
|
||||
}
|
||||
return 'image/*';
|
||||
}, [rules?.suffix]);
|
||||
|
||||
/** 整体区域支持交互 */
|
||||
const wrapCanAction = useMemo(
|
||||
() => !uri && !loading && !isError && !disabled && !readonly,
|
||||
[uri, loading, isError, disabled, readonly],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setImgValue({ uri: value?.uri, url: value?.url });
|
||||
}, [value?.uri, value?.url]);
|
||||
|
||||
const selectImage = () => {
|
||||
if (loading || disabled || !inputRef.current || readonly || isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current.click();
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Spin
|
||||
style={{ width: 20, height: 20, lineHeight: '20px' }}
|
||||
spinning
|
||||
/>
|
||||
<span
|
||||
className="truncate min-w-0 ml-1"
|
||||
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
|
||||
>
|
||||
{I18n.t('datasets_unit_upload_state')}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<span
|
||||
className="truncate min-w-0"
|
||||
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
|
||||
title={fileName}
|
||||
>
|
||||
{fileName || I18n.t('Upload_failed')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (url) {
|
||||
return (
|
||||
<>
|
||||
{/* <div
|
||||
className="inline-flex items-center justify-center flex-shrink-0 flex-grow-0 overflow-hidden rounded-sm"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<img
|
||||
className="object-contain object-center rounded-sm max-w-full max-h-full"
|
||||
src={url}
|
||||
alt="img"
|
||||
/>
|
||||
</div> */}
|
||||
<Image
|
||||
className={classNames(s['input-img-thumb'])}
|
||||
src={url}
|
||||
alt="img"
|
||||
preview={false}
|
||||
fallback={<IconCozImageBroken />}
|
||||
/>
|
||||
<div className="truncate min-w-0 ml-1" title={fileName}>
|
||||
{fileName}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="truncate min-w-0"
|
||||
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
|
||||
>
|
||||
{I18n.t('imageflow_input_upload_placeholder')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
s['image-uploader'],
|
||||
'semi-input-wrapper semi-input-wrapper-default',
|
||||
'min-w-0 cursor-default',
|
||||
(isError || validateStatus === 'error') && 'semi-input-wrapper-error',
|
||||
wrapCanAction && s['can-action'],
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<ImagePopoverWrapper
|
||||
url={url}
|
||||
minWidth={100}
|
||||
minHeight={75}
|
||||
maxWidth={400}
|
||||
maxHeight={300}
|
||||
enablePreview
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'semi-input',
|
||||
'flex items-center h-full',
|
||||
!uri &&
|
||||
!loading &&
|
||||
!isError &&
|
||||
!disabled &&
|
||||
!readonly &&
|
||||
'cursor-pointer',
|
||||
)}
|
||||
style={{ paddingRight: 6 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (wrapCanAction) {
|
||||
selectImage();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>{renderContent()}</>
|
||||
<div className="flex-1" />
|
||||
{!readonly && (
|
||||
<Space spacing={4}>
|
||||
<ImageUploaderBtn
|
||||
visible={!uri && !loading && !isError}
|
||||
disabled={disabled}
|
||||
onClick={selectImage}
|
||||
>
|
||||
<IconCozUpload />
|
||||
</ImageUploaderBtn>
|
||||
|
||||
<ImageUploaderBtn
|
||||
visible={isError}
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
const result = await retryUploadImg();
|
||||
if (result?.isSuccess) {
|
||||
onChange?.({ uri: result.uri, url: result.url });
|
||||
}
|
||||
onBlur?.();
|
||||
}}
|
||||
>
|
||||
<IconCozRefresh />
|
||||
</ImageUploaderBtn>
|
||||
|
||||
<ImageUploaderBtn
|
||||
visible={Boolean(uri || url)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
clearImg();
|
||||
onChange?.();
|
||||
onBlur?.();
|
||||
}}
|
||||
>
|
||||
<IconCozTrashCan />
|
||||
</ImageUploaderBtn>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</ImagePopoverWrapper>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
onChange={async e => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) {
|
||||
const result = await uploadImg(file);
|
||||
if (result?.isSuccess) {
|
||||
onChange?.({ uri: result.uri, url: result.url });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
export { ImgUploadErrNo, ImageRule, useImageUploader, ImageUploader };
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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 @coze-arch/max-line-per-function */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useUnmount } from 'ahooks';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Toast } from '@coze-arch/coze-design';
|
||||
|
||||
import ImageUploader, { ImgUploadErrNo } from './image-uploader';
|
||||
|
||||
interface UseImageUploaderParams {
|
||||
/** 图片限制条件 */
|
||||
rules?: ImageUploader['rules'];
|
||||
/** 上传模式 */
|
||||
mode?: ImageUploader['mode'];
|
||||
/** 上传配置 */
|
||||
timeout?: ImageUploader['timeout'];
|
||||
}
|
||||
|
||||
interface UseImageUploaderReturn {
|
||||
/** 图片标识, 用于提交给服务 */
|
||||
uri: string;
|
||||
/** 图片展示地址 */
|
||||
url: string;
|
||||
/** 文件名 */
|
||||
fileName: string;
|
||||
/** 上传中状态 */
|
||||
loading: boolean;
|
||||
/** 上传失败状态 */
|
||||
isError: boolean;
|
||||
/** 上传图片 */
|
||||
uploadImg: (file: File) => Promise<ImageUploader['uploadResult']>;
|
||||
/** 清除已上传图片 */
|
||||
clearImg: () => void;
|
||||
/** 上传失败后重试 */
|
||||
retryUploadImg: () => Promise<ImageUploader['uploadResult']>;
|
||||
/**
|
||||
* 设置初始状态, 用于回显服务下发的数据
|
||||
*
|
||||
* @param val 对应值
|
||||
* @param isMerge 是否 merge 模式, merge 模式仅更新传入字段. 默认 false
|
||||
*/
|
||||
setImgValue: (
|
||||
val: { uri?: string; url?: string; fileName?: string },
|
||||
isMerge?: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** 缓存文件名 */
|
||||
const fileNameCache: Record<string, string> = Object.create(null);
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export default function useImageUploader(
|
||||
params?: UseImageUploaderParams,
|
||||
): UseImageUploaderReturn {
|
||||
const { rules, mode, timeout } = params || {};
|
||||
const uploaderRef = useRef<ImageUploader>(
|
||||
new ImageUploader({ rules, mode, timeout }),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [uri, setUri] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
|
||||
useUnmount(() => {
|
||||
uploaderRef.current?.reset();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
uploaderRef.current.rules = rules;
|
||||
uploaderRef.current.mode = mode ?? uploaderRef.current.mode;
|
||||
}, [rules, mode]);
|
||||
|
||||
const setImgValue: UseImageUploaderReturn['setImgValue'] = useCallback(
|
||||
(
|
||||
{ url: targetDisplayUrl, uri: targetUri, fileName: targetFileName },
|
||||
isMerge = false,
|
||||
) => {
|
||||
if (typeof targetUri !== 'undefined') {
|
||||
setUri(targetUri);
|
||||
}
|
||||
if (typeof targetDisplayUrl !== 'undefined') {
|
||||
setUrl(targetDisplayUrl);
|
||||
}
|
||||
if (typeof targetFileName !== 'undefined') {
|
||||
setFileName(targetFileName);
|
||||
}
|
||||
|
||||
// 非 Merge 模式, 未设置的值清空
|
||||
if (!isMerge) {
|
||||
setUrl(targetDisplayUrl ?? '');
|
||||
setUri(targetUri ?? '');
|
||||
setFileName(targetFileName ?? '');
|
||||
}
|
||||
|
||||
// 文件名特殊逻辑, 根据 uri 从缓存重映射文件名
|
||||
if (!targetFileName) {
|
||||
if (targetUri && fileNameCache[targetUri]) {
|
||||
setFileName(fileNameCache[targetUri]);
|
||||
} else if (!targetUri) {
|
||||
setFileName('');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof targetUri !== 'undefined' || !isMerge) {
|
||||
setLoading(false);
|
||||
setIsError(false);
|
||||
uploaderRef.current?.reset();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const uploadImg = useCallback(
|
||||
async (file: File): Promise<ImageUploader['uploadResult'] | undefined> => {
|
||||
await uploaderRef.current.select(file);
|
||||
// 图片校验不通过
|
||||
if (!uploaderRef.current.validateResult?.isSuccess) {
|
||||
Toast.error(
|
||||
uploaderRef.current.validateResult?.msg || '图片不符合要求',
|
||||
);
|
||||
// @ts-expect-error 此处 validateResult.isSuccess 为 false
|
||||
return uploaderRef.current.validateResult;
|
||||
}
|
||||
|
||||
setIsError(false);
|
||||
setLoading(true);
|
||||
setUrl(uploaderRef.current.displayUrl || '');
|
||||
setFileName(file.name || '');
|
||||
await uploaderRef.current.upload();
|
||||
setLoading(false);
|
||||
|
||||
// 上传结果
|
||||
const { uploadResult } = uploaderRef.current;
|
||||
|
||||
// 无上传结果说明上传取消
|
||||
if (!uploadResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsError(!uploadResult.isSuccess);
|
||||
|
||||
if (uploadResult.isSuccess) {
|
||||
Toast.success(I18n.t('file_upload_success'));
|
||||
setUri(uploadResult.uri);
|
||||
// FIXME: 合理的设计应该用 uri 进行缓存, 但是 Imageflow 初期只存储了 url, 使用 url 作为临时方案
|
||||
fileNameCache[uploadResult.url] = `${file.name}`;
|
||||
} else {
|
||||
Toast.error(uploadResult.msg);
|
||||
}
|
||||
return uploadResult;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const retryUploadImg = useCallback(async (): Promise<
|
||||
ImageUploader['uploadResult']
|
||||
> => {
|
||||
// 重传前置检查, 有文件且校验通过
|
||||
if (
|
||||
!uploaderRef.current?.file ||
|
||||
!uploaderRef.current?.validateResult?.isSuccess
|
||||
) {
|
||||
Toast.error(I18n.t('imageflow_upload_action'));
|
||||
return {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.NoFile,
|
||||
msg: '请选择文件',
|
||||
};
|
||||
}
|
||||
setLoading(true);
|
||||
setIsError(false);
|
||||
await uploaderRef.current.upload();
|
||||
setLoading(false);
|
||||
|
||||
// 上传结果
|
||||
const uploadResult = uploaderRef.current.uploadResult || {
|
||||
isSuccess: false,
|
||||
errNo: ImgUploadErrNo.UploadFail,
|
||||
msg: '无上传结果',
|
||||
};
|
||||
|
||||
setIsError(!uploadResult.isSuccess);
|
||||
if (uploadResult.isSuccess) {
|
||||
Toast.success(I18n.t('file_upload_success'));
|
||||
setUri(uploadResult.uri);
|
||||
fileNameCache[uploadResult.url] = uploaderRef.current.file.name;
|
||||
} else {
|
||||
Toast.error(uploadResult.msg);
|
||||
}
|
||||
return uploadResult;
|
||||
}, []);
|
||||
|
||||
const clearImg = useCallback(() => {
|
||||
setUri('');
|
||||
setUrl('');
|
||||
setFileName('');
|
||||
setLoading(false);
|
||||
setIsError(false);
|
||||
uploaderRef.current?.reset();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uri,
|
||||
url,
|
||||
fileName,
|
||||
loading,
|
||||
isError,
|
||||
uploadImg,
|
||||
clearImg,
|
||||
retryUploadImg,
|
||||
setImgValue,
|
||||
};
|
||||
}
|
||||
66
frontend/packages/workflow/components/src/index.ts
Normal file
66
frontend/packages/workflow/components/src/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 @coze-arch/no-batch-import-or-export */
|
||||
export { CreateWorkflowModal } from './workflow-edit';
|
||||
export { FlowShortcutsHelp } from './flow-shortcuts-help';
|
||||
export { WorkflowCommitList } from './workflow-commit-list';
|
||||
export * from './expression-editor';
|
||||
export { useWorkflowModal } from './hooks/use-workflow-modal';
|
||||
export { useWorkflowList } from './hooks/use-workflow-list';
|
||||
import WorkflowModalContext from './workflow-modal/workflow-modal-context';
|
||||
import { type WorkflowModalContextValue } from './workflow-modal/workflow-modal-context';
|
||||
import { type BotPluginWorkFlowItem } from './workflow-modal/type';
|
||||
import WorkflowModal from './workflow-modal';
|
||||
|
||||
export { WorkflowModal, BotPluginWorkFlowItem };
|
||||
export {
|
||||
useWorkflowModalParts,
|
||||
DataSourceType,
|
||||
MineActiveEnum,
|
||||
WorkflowModalFrom,
|
||||
WorkflowModalProps,
|
||||
WorkFlowModalModeProps,
|
||||
WorkflowModalState,
|
||||
WORKFLOW_LIST_STATUS_ALL,
|
||||
isSelectProjectCategory,
|
||||
WorkflowCategory,
|
||||
} from './workflow-modal';
|
||||
|
||||
export * from './utils';
|
||||
export * from './image-uploader';
|
||||
export { SizeSelect, type SizeSelectProps } from './size-select';
|
||||
export { Text } from './text';
|
||||
|
||||
export { Expression } from './expression-editor-next';
|
||||
export {
|
||||
useWorkflowResourceAction,
|
||||
useWorkflowPublishEntry,
|
||||
useCreateWorkflowModal,
|
||||
useWorkflowResourceClick,
|
||||
useWorkflowResourceMenuActions,
|
||||
} from './hooks/use-workflow-resource-action';
|
||||
export {
|
||||
WorkflowResourceActionProps,
|
||||
WorkflowResourceActionReturn,
|
||||
WorkflowResourceBizExtend,
|
||||
} from './hooks/use-workflow-resource-action/type';
|
||||
|
||||
export { useWorkflowProductList } from './workflow-modal/hooks/use-workflow-product-list';
|
||||
export { useWorkflowAction } from './workflow-modal/hooks/use-workflow-action';
|
||||
export { WorkflowModalContext, WorkflowModalContextValue };
|
||||
export { useOpenWorkflowDetail } from './hooks/use-open-workflow-detail';
|
||||
export { VoiceSelect } from './voice-select';
|
||||
209
frontend/packages/workflow/components/src/size-select/index.tsx
Normal file
209
frontend/packages/workflow/components/src/size-select/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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, useState, type FC } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { CozInputNumber, Select } from '@coze-arch/coze-design';
|
||||
|
||||
export interface SizeSelectProps {
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
value?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
defaultValue?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
onChange: (value: { width?: number; height?: number }) => void;
|
||||
readonly?: boolean;
|
||||
options?: {
|
||||
label: string;
|
||||
value: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
selectClassName?: string;
|
||||
layoutStyle?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
interface SizeOption {
|
||||
label: string;
|
||||
originValue?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function isNumber(value) {
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
}
|
||||
|
||||
export const SizeSelect: FC<SizeSelectProps> = props => {
|
||||
const {
|
||||
options = [],
|
||||
value,
|
||||
defaultValue,
|
||||
minWidth = 0,
|
||||
maxWidth = Infinity,
|
||||
minHeight = 0,
|
||||
maxHeight = Infinity,
|
||||
onChange,
|
||||
readonly,
|
||||
selectClassName = '',
|
||||
layoutStyle = 'horizontal',
|
||||
} = props;
|
||||
|
||||
const width = value?.width ?? defaultValue?.width ?? 0;
|
||||
const height = value?.height ?? defaultValue?.height ?? 0;
|
||||
|
||||
const [sizeOptionList, setSizeOptionList] = useState<SizeOption[]>([]);
|
||||
const [sizeValue, setSizeValue] = useState<string>();
|
||||
|
||||
const stringToWidthHeight = (str: string) => ({
|
||||
width: Number(str.split('x')[0]),
|
||||
height: Number(str.split('x')[1]),
|
||||
});
|
||||
|
||||
const widthHeightToString = (v: { width: number; height: number }) =>
|
||||
`${v.width}x${v.height}`;
|
||||
|
||||
useEffect(() => {
|
||||
const _sizeValue = widthHeightToString({ width, height });
|
||||
const _options: SizeOption[] = options.map(d => ({
|
||||
...d,
|
||||
originValue: d.value,
|
||||
value: widthHeightToString(d.value),
|
||||
}));
|
||||
const selected = _options.find(d => d.value === _sizeValue);
|
||||
|
||||
if (!selected) {
|
||||
_options.push({
|
||||
label: I18n.t('customize_key_1'),
|
||||
value: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
setSizeValue(selected?.value ?? 'custom');
|
||||
setSizeOptionList(_options);
|
||||
}, [width, height, options]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-[12px]">
|
||||
<Select
|
||||
onChange={v => {
|
||||
if (v === 'custom') {
|
||||
return;
|
||||
}
|
||||
|
||||
const wh = stringToWidthHeight(v as string);
|
||||
onChange(wh);
|
||||
}}
|
||||
disabled={readonly}
|
||||
className={`${selectClassName} ${layoutStyle === 'horizontal' ? '' : 'w-full'}`}
|
||||
value={sizeValue}
|
||||
optionList={sizeOptionList}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex items-center">
|
||||
<CozInputNumber
|
||||
size="small"
|
||||
prefix={I18n.t('imageflow_canvas_width')}
|
||||
hideButtons
|
||||
onNumberChange={w => {
|
||||
if (isNaN(w as number)) {
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
width: Number(w),
|
||||
height,
|
||||
});
|
||||
}}
|
||||
onBlur={e => {
|
||||
if (
|
||||
e.target.value === '' ||
|
||||
(isNumber(minWidth) && Number(e.target.value) < minWidth)
|
||||
) {
|
||||
onChange({
|
||||
width: minWidth ?? 0,
|
||||
height,
|
||||
});
|
||||
} else if (Number(e.target.value) > maxWidth) {
|
||||
onChange({
|
||||
width: maxWidth,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={width}
|
||||
disabled={readonly}
|
||||
min={minWidth}
|
||||
max={maxWidth}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center">
|
||||
<CozInputNumber
|
||||
prefix={I18n.t('imageflow_canvas_height')}
|
||||
size="small"
|
||||
hideButtons
|
||||
onNumberChange={h => {
|
||||
if (isNaN(h as number)) {
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
height: Number(h),
|
||||
width,
|
||||
});
|
||||
}}
|
||||
onBlur={e => {
|
||||
if (
|
||||
e.target.value === '' ||
|
||||
(isNumber(minHeight) && Number(e.target.value) < minHeight)
|
||||
) {
|
||||
onChange({
|
||||
width,
|
||||
height: minHeight ?? 0,
|
||||
});
|
||||
} else if (
|
||||
isNumber(maxHeight) &&
|
||||
Number(e.target.value) > maxHeight
|
||||
) {
|
||||
onChange({
|
||||
width,
|
||||
height: maxHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={height}
|
||||
disabled={readonly}
|
||||
min={minHeight}
|
||||
max={maxHeight}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
frontend/packages/workflow/components/src/text/index.tsx
Normal file
51
frontend/packages/workflow/components/src/text/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** Text 组件,超出自动 ... 并且展示 tooltip */
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { Typography } from '@coze-arch/coze-design';
|
||||
import { type Position } from '@coze-arch/bot-semi/Tooltip';
|
||||
|
||||
interface IText {
|
||||
text?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
tooltipPosition?: Position;
|
||||
}
|
||||
export const Text: FC<IText> = props => {
|
||||
const { text = '', rows = 1, className, tooltipPosition } = props;
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
rows,
|
||||
showTooltip: {
|
||||
type: 'tooltip',
|
||||
opts: {
|
||||
style: {
|
||||
width: '100%',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
position: tooltipPosition,
|
||||
},
|
||||
},
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{text}
|
||||
</Typography.Paragraph>
|
||||
);
|
||||
};
|
||||
18
frontend/packages/workflow/components/src/types/index.ts
Normal file
18
frontend/packages/workflow/components/src/types/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @coze-arch/no-batch-import-or-export
|
||||
export * from './workflow-list';
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 ResourceActionAuth,
|
||||
type Workflow,
|
||||
} from '@coze-workflow/base/api';
|
||||
|
||||
interface Auth {
|
||||
authInfo: ResourceActionAuth;
|
||||
}
|
||||
|
||||
export type WorkflowInfo = Workflow & Auth;
|
||||
|
||||
/**
|
||||
* 打开弹窗场景, 主要用于 log
|
||||
*
|
||||
* WorkflowAddNode 场景有特殊处理
|
||||
*/
|
||||
export enum WorkflowModalFrom {
|
||||
/** 流程详情添加子流程 */
|
||||
WorkflowAddNode = 'workflow_addNode',
|
||||
/** 在 bot skills 打开 */
|
||||
BotSkills = 'bot_skills',
|
||||
/** 在抖音分身场景的 ide 打开 */
|
||||
BotSkillsDouyin = 'bot_skills_douyin_ide',
|
||||
/** 在 bot 多 agent skills 打开 */
|
||||
BotMultiSkills = 'bot_multi_skills',
|
||||
/** 在 bot triggers 打开 */
|
||||
BotTrigger = 'bot_trigger',
|
||||
/** bot 快捷方式打开 */
|
||||
BotShortcut = 'bot_shortcut',
|
||||
/** 空间下流程列表 */
|
||||
SpaceWorkflowList = 'space_workflow_list',
|
||||
/** 来自 workflow as agent */
|
||||
WorkflowAgent = 'workflow_agent',
|
||||
/** 社会场景 workflow 列表 */
|
||||
SocialSceneHost = 'social_scene_host',
|
||||
/** 项目引入资源库文件 */
|
||||
ProjectImportLibrary = 'project_import_library',
|
||||
/** 项目内 workflow 画布添加子流程 */
|
||||
ProjectWorkflowAddNode = 'project_workflow_addNode',
|
||||
/**
|
||||
* 项目内 workflow 资源列表添加 workflow 资源
|
||||
*/
|
||||
ProjectAddWorkflowResource = 'project_add_workflow_resource',
|
||||
}
|
||||
17
frontend/packages/workflow/components/src/typings.d.ts
vendored
Normal file
17
frontend/packages/workflow/components/src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/// <reference types='@coze-arch/bot-typings' />
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 cronstrue from 'cronstrue/i18n';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
const langMap = {
|
||||
// 简体中文
|
||||
'zh-CN': 'zh_CN',
|
||||
zh: 'zh-CN',
|
||||
// 繁体中文
|
||||
zh_TW: 'zh_TW',
|
||||
// 英语
|
||||
'en-US': 'en',
|
||||
en: 'en',
|
||||
// 日语
|
||||
'ja-JP': 'ja',
|
||||
ja: 'ja',
|
||||
// 韩语
|
||||
'ko-KR': 'ko',
|
||||
ko: 'ko',
|
||||
// 法语
|
||||
'fr-FR': 'fr',
|
||||
fr: 'fr',
|
||||
// 德语
|
||||
'de-DE': 'de',
|
||||
de: 'de',
|
||||
// 意大利语
|
||||
'it-IT': 'it',
|
||||
it: 'it',
|
||||
// 西班牙语
|
||||
'es-ES': 'es',
|
||||
es: 'es',
|
||||
};
|
||||
|
||||
// 校验使用 cronjob 翻译结果
|
||||
export const isCronJobVerify = cronJob => {
|
||||
// 仅支持 6 位 cronjob(后端限制)
|
||||
const parts = cronJob?.split(' ');
|
||||
if (parts?.length !== 6) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const rs = cronstrue.toString(cronJob, {
|
||||
locale: langMap['zh-CN'],
|
||||
throwExceptionOnParseError: true,
|
||||
});
|
||||
|
||||
// 额外校验一下字符串是否包含 null undefined
|
||||
// 1 2 3 31 1- 1 在上午 03:02:01, 限每月 31 号, 或者为星期一, 一月至undefined
|
||||
// 1 2 3 31 1 1#6 在上午 03:02:01, 限每月 31 号, 限每月的null 星期一, 仅于一月份
|
||||
if (rs.includes('null') || rs.includes('undefined')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const cronJobTranslator = (
|
||||
cronJob?: string,
|
||||
errorMsg?: string,
|
||||
): string => {
|
||||
if (!cronJob) {
|
||||
return '';
|
||||
}
|
||||
const lang = I18n.getLanguages();
|
||||
|
||||
if (isCronJobVerify(cronJob)) {
|
||||
return cronstrue.toString(cronJob, {
|
||||
locale: langMap[lang[0]] ?? langMap['zh-CN'],
|
||||
use24HourTimeFormat: true,
|
||||
});
|
||||
}
|
||||
return (
|
||||
errorMsg ??
|
||||
I18n.t('workflow_trigger_param_unvalid_cron', {}, '参数为非法 cron 表达式')
|
||||
);
|
||||
};
|
||||
20
frontend/packages/workflow/components/src/utils/index.ts
Normal file
20
frontend/packages/workflow/components/src/utils/index.ts
Normal 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/no-batch-import-or-export */
|
||||
export * from './utils';
|
||||
|
||||
export { cronJobTranslator, isCronJobVerify } from './cronjob-translator';
|
||||
75
frontend/packages/workflow/components/src/utils/utils.ts
Normal file
75
frontend/packages/workflow/components/src/utils/utils.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 dayjs from 'dayjs';
|
||||
|
||||
let getIsIPadCache: boolean | undefined;
|
||||
/**
|
||||
* gpt-4 提供的代码
|
||||
*/
|
||||
export const getIsIPad = () => {
|
||||
if (typeof getIsIPadCache === 'undefined') {
|
||||
const { userAgent } = navigator;
|
||||
const isIPadDevice = /iPad/.test(userAgent);
|
||||
const isIPadOS =
|
||||
userAgent.includes('Macintosh') &&
|
||||
'ontouchstart' in document.documentElement;
|
||||
|
||||
getIsIPadCache = isIPadDevice || isIPadOS;
|
||||
}
|
||||
|
||||
return getIsIPadCache;
|
||||
};
|
||||
|
||||
/* 时间戳转文本,并省略年份或日期*/
|
||||
export const formatOmittedDateTime = time => {
|
||||
if (!time) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const day = dayjs(time);
|
||||
const today = dayjs();
|
||||
|
||||
let formatStr: string;
|
||||
|
||||
if (!today.isSame(day, 'year')) {
|
||||
// 不是当年,展示年份
|
||||
formatStr = 'YYYY-MM-DD HH:mm';
|
||||
} else if (!today.isSame(day, 'day')) {
|
||||
// 不是当天, 展示日期
|
||||
formatStr = 'MM-DD HH:mm';
|
||||
} else {
|
||||
// 当天只展示时间
|
||||
formatStr = 'HH:mm';
|
||||
}
|
||||
|
||||
return day.format(formatStr);
|
||||
};
|
||||
|
||||
/** 等待 */
|
||||
export const wait = (ms: number) =>
|
||||
new Promise(r => {
|
||||
setTimeout(r, ms);
|
||||
});
|
||||
|
||||
import { reporter as infraReporter } from '@coze-arch/logger';
|
||||
|
||||
/**
|
||||
* 流程使用的 slardar 上报实例
|
||||
*/
|
||||
export const reporter = infraReporter.createReporterWithPreset({
|
||||
namespace: 'workflow',
|
||||
});
|
||||
201
frontend/packages/workflow/components/src/voice-select/index.tsx
Normal file
201
frontend/packages/workflow/components/src/voice-select/index.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useRequest } from 'ahooks';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozPauseFill,
|
||||
IconCozPlus,
|
||||
IconCozTrashCan,
|
||||
IconCozVolume,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Avatar, Button, IconButton } from '@coze-arch/coze-design';
|
||||
import { useSpaceStore } from '@coze-arch/bot-studio-store';
|
||||
import { type VoiceDetail } from '@coze-arch/bot-api/multimedia_api';
|
||||
import { MultimediaApi } from '@coze-arch/bot-api';
|
||||
import {
|
||||
useSelectVoiceModal,
|
||||
useAudioPlayer,
|
||||
} from '@coze-workflow/resources-adapter';
|
||||
|
||||
interface CardProps {
|
||||
voice: VoiceDetail | null;
|
||||
onDelete?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const VoiceCard = ({ voice, onDelete, disabled }: CardProps) => {
|
||||
const { isPlaying, togglePlayPause } = useAudioPlayer(voice?.preview_audio);
|
||||
|
||||
if (!voice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--coz-mg-card-hovered)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
height: '44px',
|
||||
border: '1px solid var(--coz-stroke-primary)',
|
||||
padding: '6px',
|
||||
borderRadius: 'var(--coze-8)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Avatar shape={'square'} size="small" src={voice.icon_url} />
|
||||
|
||||
<span style={{ marginLeft: '8px', marginRight: '2px' }}>
|
||||
{voice.voice_name}
|
||||
</span>
|
||||
|
||||
{isPlaying ? (
|
||||
<IconButton
|
||||
theme={'borderless'}
|
||||
disabled={disabled}
|
||||
onClick={togglePlayPause}
|
||||
size="small"
|
||||
color="secondary"
|
||||
icon={<IconCozPauseFill color="#4E40E5" />}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
theme={'borderless'}
|
||||
disabled={disabled}
|
||||
onClick={togglePlayPause}
|
||||
size="small"
|
||||
color="secondary"
|
||||
icon={<IconCozVolume color="#4E40E5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
size="small"
|
||||
color="secondary"
|
||||
icon={<IconCozTrashCan />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useVoiceSource = (id?: string) => {
|
||||
const [voice, setVoice] = useState<VoiceDetail | null | undefined>(null);
|
||||
|
||||
const { loading } = useRequest(
|
||||
() => {
|
||||
if (!id) {
|
||||
return Promise.resolve(null).then(() => {
|
||||
setVoice(null);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
return MultimediaApi.APIMGetVoice({
|
||||
voice_ids: [id],
|
||||
})
|
||||
.then(data => {
|
||||
const v = data?.data?.voices?.[0];
|
||||
setVoice(data?.data?.voices?.[0]);
|
||||
return v;
|
||||
})
|
||||
.catch(() => {
|
||||
setVoice(null);
|
||||
return null;
|
||||
});
|
||||
},
|
||||
{
|
||||
refreshDeps: [id],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
voice,
|
||||
setVoice,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (v?: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const VoiceSelect: React.FC<Props> = props => {
|
||||
const { value, onChange, disabled } = props;
|
||||
const [voiceId, setVoiceId] = useState<string | undefined>(value);
|
||||
const spaceId = useSpaceStore(store => store.space.id) || '';
|
||||
|
||||
const { voice, setVoice } = useVoiceSource(voiceId);
|
||||
|
||||
useEffect(() => {
|
||||
setVoiceId(value);
|
||||
}, [value]);
|
||||
|
||||
const { open: openSelectVoiceModal, modal: selectVoiceModal } =
|
||||
useSelectVoiceModal({
|
||||
spaceId,
|
||||
onSelectVoice: v => {
|
||||
setVoice(v);
|
||||
onChange?.(v.voice_id);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!voice?.voice_id ? (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', fontWeight: '500' }}
|
||||
size={'small'}
|
||||
icon={<IconCozPlus />}
|
||||
color="primary"
|
||||
onClick={openSelectVoiceModal}
|
||||
>
|
||||
{I18n.t('workflow_variable_select_voice')}
|
||||
</Button>
|
||||
) : (
|
||||
<VoiceCard
|
||||
disabled={disabled}
|
||||
voice={voice}
|
||||
onDelete={() => {
|
||||
setVoice(null);
|
||||
onChange?.(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectVoiceModal}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { VoiceSelect };
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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 @coze-arch/max-line-per-function */
|
||||
import { useMemo, type FC } from 'react';
|
||||
|
||||
import semver from 'semver';
|
||||
import dayjs from 'dayjs';
|
||||
import classNames from 'classnames';
|
||||
import { type VersionMetaInfo, OperateType } from '@coze-workflow/base/api';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozMore } from '@coze-arch/coze-design/icons';
|
||||
import {
|
||||
Avatar,
|
||||
IconButton,
|
||||
Menu,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
import { type WorkflowCommitListProps } from './type';
|
||||
|
||||
export interface CommitItemProps {
|
||||
className?: string;
|
||||
data: VersionMetaInfo;
|
||||
/** 是否选中 */
|
||||
isActive?: boolean;
|
||||
readonly?: WorkflowCommitListProps['readonly'];
|
||||
enablePublishPPE?: WorkflowCommitListProps['enablePublishPPE'];
|
||||
onClick?: WorkflowCommitListProps['onItemClick'];
|
||||
onPublishPPE?: WorkflowCommitListProps['onPublishPPE'];
|
||||
onResetToCommit?: WorkflowCommitListProps['onResetToCommit'];
|
||||
onShowCommit?: WorkflowCommitListProps['onShowCommit'];
|
||||
/** 隐藏操作下拉菜单 */
|
||||
hiddenActionMenu?: boolean;
|
||||
/** 隐藏 commitId */
|
||||
hideCommitId?: boolean;
|
||||
}
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
export const CommitItem: FC<CommitItemProps> = ({
|
||||
className,
|
||||
data,
|
||||
readonly,
|
||||
isActive,
|
||||
hiddenActionMenu,
|
||||
enablePublishPPE,
|
||||
onClick,
|
||||
onPublishPPE,
|
||||
onResetToCommit,
|
||||
onShowCommit,
|
||||
hideCommitId,
|
||||
}) => {
|
||||
const action = hiddenActionMenu ? null : (
|
||||
<Menu
|
||||
className="min-w-[96px] mb-2px flex-shrink-0"
|
||||
trigger="hover"
|
||||
stopPropagation={true}
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Menu.SubMenu mode="menu">
|
||||
<Menu.Item
|
||||
onClick={(_, e) => {
|
||||
e.stopPropagation();
|
||||
onShowCommit?.(data);
|
||||
}}
|
||||
>
|
||||
{I18n.t('bmv_view_version')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={(_, e) => {
|
||||
e.stopPropagation();
|
||||
onResetToCommit?.(data);
|
||||
}}
|
||||
>
|
||||
{I18n.t('bmv_load_to_draft')}
|
||||
</Menu.Item>
|
||||
{enablePublishPPE ? (
|
||||
<Menu.Item
|
||||
onClick={(_, e) => {
|
||||
e.stopPropagation();
|
||||
onPublishPPE?.(data);
|
||||
}}
|
||||
>
|
||||
{I18n.t('bmv_pre_release_to_lane')}
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.SubMenu>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
iconSize="small"
|
||||
icon={
|
||||
<>
|
||||
<IconCozMore className="rotate-90" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const time = useMemo(
|
||||
() =>
|
||||
data.create_time
|
||||
? dayjs(data.create_time).format('YYYY-MM-DD HH:mm:ss')
|
||||
: '',
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'commit-item',
|
||||
isActive && 'active',
|
||||
'p-2 rounded-mini',
|
||||
isActive && 'coz-mg-hglt',
|
||||
!readonly && !isActive && 'hover:coz-mg-secondary',
|
||||
!readonly && 'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
onClick={() => onClick?.(data)}
|
||||
>
|
||||
<div className="mb-2">
|
||||
{data.type === OperateType.SubmitOperate && (
|
||||
<Tag size="small">
|
||||
{I18n.t('workflow_publish_multibranch_submitted_title')}
|
||||
</Tag>
|
||||
)}
|
||||
{data.type === OperateType.PubPPEOperate && (
|
||||
<Tag size="small" color={data.offline ? 'primary' : 'green'}>
|
||||
{data.env}
|
||||
</Tag>
|
||||
)}
|
||||
{data.type === OperateType.PublishOperate && (
|
||||
<Tag size="small" color={data.offline ? 'primary' : 'green'}>
|
||||
{semver.valid(data.version)
|
||||
? data.version
|
||||
: I18n.t('bmv_official_version')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{!hideCommitId && (
|
||||
<Space className="w-full items-start" vertical spacing={4}>
|
||||
<Text
|
||||
ellipsis={{
|
||||
showTooltip: {
|
||||
opts: { content: data.submit_commit_id || data.commit_id },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span className="font-bold mr-2">{I18n.t('bmv_submit_id')}:</span>
|
||||
{data.submit_commit_id || data.commit_id}
|
||||
</Text>
|
||||
{data.type !== OperateType.SubmitOperate &&
|
||||
data.offline &&
|
||||
data.update_time ? (
|
||||
<Text
|
||||
ellipsis={{
|
||||
showTooltip: {
|
||||
opts: {
|
||||
content: dayjs(data.update_time as number).format(
|
||||
'YYYY-MM-DD HH:mm',
|
||||
),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span className="font-bold mr-2">
|
||||
{I18n.t('bmv_offline_time')}:
|
||||
</span>
|
||||
{dayjs(data.update_time as number).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{data.type === OperateType.PublishOperate && data.desc ? (
|
||||
<div>
|
||||
<Text ellipsis={{ rows: 4, showTooltip: true }}>{data.desc}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end mt-2">
|
||||
<div>
|
||||
<div className="min-w-0 flex items-center mb-1">
|
||||
<Avatar
|
||||
className="mr-2 flex-shrink-0"
|
||||
size="extra-extra-small"
|
||||
src={data.user?.user_avatar}
|
||||
alt="avatar"
|
||||
/>
|
||||
<Text ellipsis fontSize="12px">
|
||||
{data.user?.user_name}
|
||||
</Text>
|
||||
</div>
|
||||
<Text type="secondary" fontSize="12px">
|
||||
{time}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.history-item {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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 @coze-arch/max-line-per-function */
|
||||
|
||||
import { useEffect, type FC, useRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useInViewport, useUpdateEffect } from 'ahooks';
|
||||
import {
|
||||
OperateType,
|
||||
type VersionMetaInfo,
|
||||
withQueryClient,
|
||||
} from '@coze-workflow/base';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozFocus } from '@coze-arch/coze-design/icons';
|
||||
import {
|
||||
EmptyState,
|
||||
Spin,
|
||||
Timeline,
|
||||
Typography,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
import { useVersionHistory } from './use-version-history';
|
||||
import { type WorkflowCommitListProps } from './type';
|
||||
import { CommitItem } from './commit-item';
|
||||
|
||||
import css from './history-list.module.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const currentKey = 'current';
|
||||
|
||||
const WorkflowCommitListComp: FC<WorkflowCommitListProps> = withQueryClient(
|
||||
({
|
||||
className,
|
||||
spaceId,
|
||||
value,
|
||||
workflowId,
|
||||
readonly,
|
||||
type,
|
||||
enablePublishPPE,
|
||||
showCurrent,
|
||||
onItemClick,
|
||||
onPublishPPE,
|
||||
onResetToCommit,
|
||||
onShowCommit,
|
||||
onCurrentClick,
|
||||
hideCommitId,
|
||||
}) => {
|
||||
const {
|
||||
queryParams,
|
||||
updatePageParam,
|
||||
list,
|
||||
loadingStatus,
|
||||
fetchNextPage,
|
||||
isFetching,
|
||||
hasNextPage,
|
||||
} = useVersionHistory({
|
||||
spaceId,
|
||||
workflowId,
|
||||
type,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
/** scroll的container */
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
/** 监听触底的observer */
|
||||
const intersectionObserverDom = useRef<HTMLDivElement>(null);
|
||||
// 是否触底
|
||||
const [inViewPort] = useInViewport(intersectionObserverDom, {
|
||||
root: () => scrollContainerRef.current,
|
||||
threshold: 0.8,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updatePageParam({ type });
|
||||
}, [type, updatePageParam]);
|
||||
|
||||
// 首次effect不执行,这个是切换状态的effect
|
||||
useUpdateEffect(() => {
|
||||
// 当筛选项改变时,回到顶部
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: 0,
|
||||
});
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
// 获取下一页逻辑
|
||||
useEffect(() => {
|
||||
if (!inViewPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingStatus !== 'success' || isFetching || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchNextPage();
|
||||
}, [inViewPort, loadingStatus, isFetching, hasNextPage]);
|
||||
|
||||
if (loadingStatus === 'error') {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[300px] w-full">
|
||||
<EmptyState
|
||||
title="An error occurred"
|
||||
description="Please try again later"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadingStatus === 'pending') {
|
||||
return (
|
||||
<Spin wrapperClassName="flex justify-center items-center min-h-[300px] w-full" />
|
||||
);
|
||||
}
|
||||
|
||||
if (loadingStatus === 'success' && !list.length && !showCurrent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[300px] w-full">
|
||||
<EmptyState
|
||||
title={I18n.t('query_data_empty')}
|
||||
description={I18n.t('bwc_no_version_record')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timelineType = (data: VersionMetaInfo, index) => {
|
||||
// PPE 历史, 在线激活
|
||||
if (type === OperateType.PubPPEOperate) {
|
||||
return !data.offline ? 'ongoing' : 'default';
|
||||
}
|
||||
|
||||
// 提交历史和发布历史, 最新的激活
|
||||
return index === 0 ? 'ongoing' : 'default';
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className={className}>
|
||||
<Timeline>
|
||||
{showCurrent ? (
|
||||
<Timeline.Item
|
||||
className={css['history-item']}
|
||||
type="warning"
|
||||
dot={value === currentKey ? <IconCozFocus /> : undefined}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'relative top-[-8px] p-2 rounded-mini',
|
||||
value === currentKey
|
||||
? 'coz-mg-hglt'
|
||||
: 'hover:coz-mg-secondary',
|
||||
!readonly && 'cursor-pointer',
|
||||
)}
|
||||
onClick={() => onCurrentClick?.(currentKey)}
|
||||
>
|
||||
<Text className="font-bold">
|
||||
{I18n.t('devops_publish_multibranch_Current')}
|
||||
</Text>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
) : null}
|
||||
{list.map((item, index) => (
|
||||
<Timeline.Item
|
||||
className={css['history-item']}
|
||||
key={item.commit_id}
|
||||
type={timelineType(item, index)}
|
||||
dot={value === item.commit_id ? <IconCozFocus /> : undefined}
|
||||
>
|
||||
<CommitItem
|
||||
className="relative top-[-8px]"
|
||||
data={item}
|
||||
readonly={readonly}
|
||||
isActive={value === item.commit_id}
|
||||
enablePublishPPE={enablePublishPPE}
|
||||
onClick={!readonly ? onItemClick : undefined}
|
||||
onPublishPPE={onPublishPPE}
|
||||
onResetToCommit={onResetToCommit}
|
||||
onShowCommit={onShowCommit}
|
||||
hideCommitId={hideCommitId}
|
||||
/>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
{hasNextPage ? (
|
||||
<div
|
||||
className="flex justify-center py-1"
|
||||
ref={intersectionObserverDom}
|
||||
>
|
||||
<Spin spinning wrapperClassName="mr-2" />
|
||||
<div className="coz-fg-primary">{I18n.t('Loading')}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const WorkflowCommitList = Object.assign(WorkflowCommitListComp, {
|
||||
Item: CommitItem,
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 VersionMetaInfo,
|
||||
type OperateType,
|
||||
} from '@coze-workflow/base/api';
|
||||
|
||||
/** 流程提交历史列表组件 */
|
||||
export interface WorkflowCommitListProps {
|
||||
className?: string;
|
||||
spaceId: string;
|
||||
workflowId: string;
|
||||
/** 操作类型 */
|
||||
type: OperateType;
|
||||
/** 只读模式, 只读历史卡片不可点, 不影响 action */
|
||||
readonly?: boolean;
|
||||
/** 每页拉取数量, 默认 10 */
|
||||
limit?: number;
|
||||
/** 当前选中项 */
|
||||
value?: string;
|
||||
/** 是否展示当前节点 */
|
||||
showCurrent?: boolean;
|
||||
/** 是否支持发布到 PPE 功能 */
|
||||
enablePublishPPE?: boolean;
|
||||
/** 隐藏 commitId (commitId可读性较差,非专业用户不需要感知) */
|
||||
hideCommitId?: boolean;
|
||||
/** 卡片点击 */
|
||||
onItemClick?: (item: VersionMetaInfo) => void;
|
||||
/** 恢复到某版本点击 */
|
||||
onResetToCommit?: (item: VersionMetaInfo) => void;
|
||||
/** 查看某版本点击 */
|
||||
onShowCommit?: (item: VersionMetaInfo) => void;
|
||||
/** 发布到多环境点击 */
|
||||
onPublishPPE?: (item: VersionMetaInfo) => void;
|
||||
/** 点击[现在] */
|
||||
onCurrentClick?: (currentKey: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type UseInfiniteQueryResult,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
type VersionMetaInfo,
|
||||
type VersionHistoryListRequest,
|
||||
type OperateType,
|
||||
workflowApi,
|
||||
} from '@coze-workflow/base/api';
|
||||
|
||||
type VersionHistoryQueryParams = Omit<
|
||||
VersionHistoryListRequest,
|
||||
'limit' | 'cursor'
|
||||
>;
|
||||
|
||||
interface VersionHistoryParams {
|
||||
spaceId: string;
|
||||
workflowId: string;
|
||||
type: OperateType;
|
||||
/** 每页请求数量, 默认 10 */
|
||||
pageSize?: number;
|
||||
/** 是否启动请求, 默认 false */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface VersionHistoryReturn {
|
||||
queryParams: VersionHistoryQueryParams;
|
||||
updatePageParam: (newParam: Partial<VersionHistoryListRequest>) => void;
|
||||
list: VersionMetaInfo[];
|
||||
queryError: UseInfiniteQueryResult['error'];
|
||||
loadingStatus: UseInfiniteQueryResult['status'];
|
||||
refetch: UseInfiniteQueryResult['refetch'];
|
||||
fetchNextPage: UseInfiniteQueryResult['fetchNextPage'];
|
||||
isFetching: UseInfiniteQueryResult['isFetching'];
|
||||
isFetchingNextPage: UseInfiniteQueryResult['isFetchingNextPage'];
|
||||
hasNextPage: UseInfiniteQueryResult['hasNextPage'];
|
||||
}
|
||||
|
||||
export function useVersionHistory({
|
||||
spaceId,
|
||||
workflowId,
|
||||
type,
|
||||
pageSize = 10,
|
||||
enabled = false,
|
||||
}: VersionHistoryParams): Readonly<VersionHistoryReturn> {
|
||||
const [queryParams, setQueryParams] = useState<VersionHistoryQueryParams>({
|
||||
space_id: spaceId,
|
||||
workflow_id: workflowId,
|
||||
type,
|
||||
});
|
||||
const initialPageParam = useMemo<VersionHistoryListRequest>(
|
||||
() => ({
|
||||
...queryParams,
|
||||
limit: pageSize,
|
||||
last_commit_id: '',
|
||||
}),
|
||||
[queryParams, pageSize],
|
||||
);
|
||||
|
||||
const updatePageParam = useCallback(
|
||||
(newParam: Partial<VersionHistoryQueryParams>) => {
|
||||
setQueryParams(prevParams => ({
|
||||
...prevParams,
|
||||
...newParam,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => ['workflowApi_OperateList', JSON.stringify(initialPageParam)],
|
||||
[initialPageParam],
|
||||
);
|
||||
|
||||
const fetchList = async (params: VersionHistoryListRequest) => {
|
||||
const resp = await workflowApi.VersionHistoryList(params);
|
||||
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
const {
|
||||
data: pageData,
|
||||
error: queryError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
status: loadingStatus,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
enabled: Boolean(spaceId && workflowId && enabled),
|
||||
queryKey,
|
||||
queryFn: ({ pageParam }) => fetchList(pageParam),
|
||||
initialPageParam,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||
if (!lastPage?.has_more) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...lastPageParam,
|
||||
cursor: lastPage.cursor || '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const targetList = useMemo(() => {
|
||||
const result: VersionMetaInfo[] = [];
|
||||
const idMap: Record<string, boolean> = {};
|
||||
|
||||
pageData?.pages.forEach(page => {
|
||||
page?.version_list?.forEach(item => {
|
||||
const key = item.commit_id || '';
|
||||
if (!idMap[key]) {
|
||||
result.push(item);
|
||||
}
|
||||
idMap[key] = true;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [pageData]);
|
||||
|
||||
return {
|
||||
queryParams,
|
||||
updatePageParam,
|
||||
|
||||
list: targetList,
|
||||
queryError,
|
||||
loadingStatus,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* stylelint-disable block-no-empty */
|
||||
.upload-form {
|
||||
.textarea-single-line {
|
||||
:global {
|
||||
.semi-input-textarea-counter {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-multi-line {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:global {
|
||||
.semi-input-textarea-counter {}
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.upload-field {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.schema_type {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-form-item {
|
||||
:global {
|
||||
.semi-form-field-label-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-card {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.add-card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* 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 @coze-arch/max-line-per-function */
|
||||
/* eslint-disable max-lines-per-function -- refactor later */
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { type WorkflowJSON } from '@flowgram-adapter/free-layout-editor';
|
||||
import { type FrontWorkflowInfo } from '@coze-workflow/base/types';
|
||||
import {
|
||||
type BindBizType,
|
||||
type SchemaType,
|
||||
workflowApi,
|
||||
WorkflowMode,
|
||||
} from '@coze-workflow/base/api';
|
||||
import {
|
||||
WORKFLOW_NAME_MAX_LEN,
|
||||
WORKFLOW_NAME_REGEX,
|
||||
} from '@coze-workflow/base';
|
||||
import { PictureUpload } from '@coze-common/biz-components/picture-upload';
|
||||
import { type UploadValue } from '@coze-common/biz-components';
|
||||
import { REPORT_EVENTS } from '@coze-arch/report-events';
|
||||
import { reporter } from '@coze-arch/logger';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { useUserInfo } from '@coze-arch/foundation-sdk';
|
||||
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
|
||||
import {
|
||||
Button,
|
||||
LoadingButton,
|
||||
Space,
|
||||
Toast,
|
||||
Tooltip,
|
||||
} from '@coze-arch/coze-design';
|
||||
import { useSpaceStore } from '@coze-arch/bot-studio-store';
|
||||
import { Form, Typography, UIFormTextArea, UIModal } from '@coze-arch/bot-semi';
|
||||
import { CustomError } from '@coze-arch/bot-error';
|
||||
import { FileBizType, IconType } from '@coze-arch/bot-api/developer_api';
|
||||
|
||||
import s from './index.module.less';
|
||||
/** 输入合规校验异常的错误码 */
|
||||
const sensitiveWordsErrorCode = ['702095075', '702095081'];
|
||||
|
||||
const { Checkbox } = Form;
|
||||
|
||||
export interface RuleItem {
|
||||
validator: (rules: unknown[], value: string) => boolean | Error;
|
||||
}
|
||||
|
||||
interface EditWorkFlowPropsInner {
|
||||
/** 流程类型 */
|
||||
flowMode?: WorkflowMode;
|
||||
mode: 'update' | 'add';
|
||||
visible: boolean;
|
||||
// 默认confirm的disabled
|
||||
|
||||
/** 自定义弹窗标题 */
|
||||
customTitleRender?: (title: React.ReactNode) => React.ReactNode;
|
||||
|
||||
initConfirmDisabled?: boolean;
|
||||
workFlow?: FrontWorkflowInfo;
|
||||
onSuccess?: (val: {
|
||||
workflowId?: string;
|
||||
flowMode?: EditWorkFlowPropsInner['flowMode'];
|
||||
}) => void;
|
||||
onCancel?: () => void;
|
||||
/** @deprecated 未使用 */
|
||||
spaceID?: string;
|
||||
getLatestWorkflowJson?: () => Promise<WorkflowJSON>;
|
||||
bindBizId?: string;
|
||||
bindBizType?: BindBizType;
|
||||
/** 当前项目 id,只在项目内的 workflow 有该字段 */
|
||||
projectId?: string;
|
||||
nameValidators?: RuleItem[];
|
||||
}
|
||||
|
||||
/** 表单值 */
|
||||
interface FormValue {
|
||||
icon_uri: UploadValue;
|
||||
name: string;
|
||||
target: string;
|
||||
schema_type: SchemaType;
|
||||
create_conversation?: boolean;
|
||||
}
|
||||
|
||||
/** 获取弹窗标题 */
|
||||
function getModalTitle(
|
||||
mode: EditWorkFlowPropsInner['mode'],
|
||||
flowMode: EditWorkFlowPropsInner['flowMode'],
|
||||
): string {
|
||||
switch (flowMode) {
|
||||
case WorkflowMode.Imageflow:
|
||||
return mode === 'add'
|
||||
? I18n.t('imageflow_create')
|
||||
: I18n.t('imageflow_edit');
|
||||
case WorkflowMode.Workflow:
|
||||
return mode === 'add'
|
||||
? I18n.t('workflow_list_create_modal_title')
|
||||
: I18n.t('workflow_list_edit_modal_title');
|
||||
case WorkflowMode.ChatFlow:
|
||||
return mode === 'add'
|
||||
? I18n.t('wf_chatflow_81')
|
||||
: I18n.t('wf_chatflow_84');
|
||||
default:
|
||||
return mode === 'add'
|
||||
? I18n.t('workflow_list_create_modal_title')
|
||||
: I18n.t('workflow_list_edit_modal_title');
|
||||
}
|
||||
}
|
||||
|
||||
const getPictureUploadInitValue = (
|
||||
workFlow?: FrontWorkflowInfo,
|
||||
): UploadValue | undefined => {
|
||||
if (!workFlow) {
|
||||
return;
|
||||
}
|
||||
return [
|
||||
{
|
||||
url: workFlow.url || '',
|
||||
uid: workFlow.icon_uri || '',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function CreateWorkflowModal({
|
||||
flowMode = WorkflowMode.Workflow,
|
||||
mode,
|
||||
bindBizId,
|
||||
bindBizType,
|
||||
projectId,
|
||||
visible,
|
||||
workFlow,
|
||||
initConfirmDisabled = false,
|
||||
customTitleRender,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
nameValidators = [],
|
||||
}: EditWorkFlowPropsInner) {
|
||||
const formRef = useRef<Form<Partial<FormValue>>>(null);
|
||||
const [confirmDisabled, setConfirmDisabled] = useState(initConfirmDisabled);
|
||||
const [sensitiveTip, setSensitiveTip] = useState<string | undefined>();
|
||||
const userInfo = useUserInfo();
|
||||
const currentLocale = userInfo?.locale ?? navigator.language ?? 'en-US';
|
||||
|
||||
const getValues = async () => {
|
||||
const formApi = formRef.current?.formApi;
|
||||
await formApi?.validate(['name']);
|
||||
return formApi?.getValues() as Partial<FormValue> | undefined;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reporter.info({
|
||||
message: 'workflow_info_modal_cancel',
|
||||
namespace: 'workflow',
|
||||
});
|
||||
setSensitiveTip(undefined);
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
const handleError = (error: Error & { code?: string; msg?: string }) => {
|
||||
if (sensitiveWordsErrorCode.includes(error.code || '')) {
|
||||
setConfirmDisabled(true);
|
||||
setSensitiveTip(error.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
handleCancel();
|
||||
throw error;
|
||||
};
|
||||
|
||||
const handleUpdateWorkflow = async () => {
|
||||
const workflowId = workFlow?.workflow_id;
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_info_modal_confirm_update',
|
||||
namespace: 'workflow',
|
||||
meta: {
|
||||
workflowId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflowId) {
|
||||
const msg = I18n.t('workflow_list_create_modal_workflow_id_empty');
|
||||
throw new CustomError(REPORT_EVENTS.parmasValidation, msg);
|
||||
}
|
||||
const values = await getValues();
|
||||
const updateParams = {
|
||||
workflow_id: workflowId,
|
||||
icon_uri: values?.icon_uri?.[0].uid || '',
|
||||
name: values?.name,
|
||||
desc: values?.target ? values.target : '',
|
||||
space_id: workFlow.space_id || '',
|
||||
// 更新头像等信息不需要重新test run
|
||||
ignore_status_transfer: true,
|
||||
schema_type: values?.schema_type || workFlow?.schema_type,
|
||||
};
|
||||
|
||||
try {
|
||||
await workflowApi.UpdateWorkflowMeta(updateParams);
|
||||
|
||||
reporter.info({
|
||||
message: 'workflow_info_modal_update_success',
|
||||
namespace: 'workflow',
|
||||
});
|
||||
Toast.success({
|
||||
content: I18n.t('workflow_list_update_success'),
|
||||
showClose: false,
|
||||
});
|
||||
await onSuccess?.({
|
||||
workflowId,
|
||||
flowMode,
|
||||
});
|
||||
} catch (error) {
|
||||
reporter.error({
|
||||
message: 'workflow_info_modal_update_fail',
|
||||
namespace: 'workflow',
|
||||
error,
|
||||
});
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
reporter.info({
|
||||
message: 'workflow_info_modal_confirm_create',
|
||||
namespace: 'workflow',
|
||||
});
|
||||
const values = await getValues();
|
||||
try {
|
||||
const reqParams = {
|
||||
...values,
|
||||
space_id: useSpaceStore.getState().getSpaceId(),
|
||||
name: values?.name || '',
|
||||
desc: values?.target || '',
|
||||
icon_uri: values?.icon_uri?.[0]?.uid || '',
|
||||
flow_mode: flowMode,
|
||||
bind_biz_id: bindBizId,
|
||||
bind_biz_type: bindBizType,
|
||||
project_id: projectId,
|
||||
create_conversation: projectId
|
||||
? values?.create_conversation
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resp = await workflowApi.CreateWorkflow(reqParams, {
|
||||
headers: {
|
||||
'x-locale': currentLocale,
|
||||
},
|
||||
});
|
||||
|
||||
const content =
|
||||
flowMode === WorkflowMode.Imageflow
|
||||
? I18n.t('imageflow_create_toast_success')
|
||||
: flowMode === WorkflowMode.ChatFlow
|
||||
? I18n.t('wf_chatflow_95')
|
||||
: I18n.t('workflow_list_create_success');
|
||||
Toast.success({
|
||||
content,
|
||||
showClose: false,
|
||||
});
|
||||
await onSuccess?.({
|
||||
workflowId: resp.data?.workflow_id,
|
||||
flowMode,
|
||||
});
|
||||
reporter.info({
|
||||
message: 'workflow_info_modal_create_success',
|
||||
namespace: 'workflow',
|
||||
});
|
||||
} catch (error) {
|
||||
reporter.error({
|
||||
message: 'workflow_info_modal_create_fail',
|
||||
namespace: 'workflow',
|
||||
error,
|
||||
});
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const title = useMemo(() => {
|
||||
const modelTitle = getModalTitle(mode, flowMode);
|
||||
if (customTitleRender && isFunction(customTitleRender)) {
|
||||
return customTitleRender(modelTitle);
|
||||
}
|
||||
return modelTitle;
|
||||
}, [mode, flowMode, customTitleRender]);
|
||||
const labels = useMemo<{
|
||||
nameLabel?: string;
|
||||
namePlaceholder?: string;
|
||||
descLabel?: string;
|
||||
descPlaceholder?: string;
|
||||
nameFormatRuleLabel?: string;
|
||||
nameRequiredLabel?: string;
|
||||
descRequiredLabel?: string;
|
||||
}>(() => {
|
||||
if (flowMode === WorkflowMode.Imageflow) {
|
||||
return {
|
||||
nameLabel: I18n.t('imageflow_create_name'),
|
||||
namePlaceholder: I18n.t('imageflow_create_name_placeholder'),
|
||||
descLabel: I18n.t('imageflow_create_description'),
|
||||
descPlaceholder: I18n.t('imageflow_create_description_placeholder'),
|
||||
nameRequiredLabel: I18n.t('imageflow_create_name_placeholder'),
|
||||
nameFormatRuleLabel: I18n.t('imageflow_create_name_wrong_format'),
|
||||
descRequiredLabel: I18n.t('imageflow_create_description_placeholder'),
|
||||
};
|
||||
}
|
||||
if (flowMode === WorkflowMode.ChatFlow) {
|
||||
return {
|
||||
nameLabel: I18n.t('wf_chatflow_85'),
|
||||
namePlaceholder: I18n.t('wf_chatflow_91'),
|
||||
descLabel: I18n.t('wf_chatflow_86'),
|
||||
descPlaceholder: I18n.t('wf_chatflow_92'),
|
||||
nameRequiredLabel: I18n.t('wf_chatflow_93'),
|
||||
nameFormatRuleLabel: I18n.t('wf_chatflow_94'),
|
||||
descRequiredLabel: I18n.t('wf_chatflow_122'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
nameLabel: I18n.t('workflow_list_create_modal_name_label'),
|
||||
namePlaceholder: I18n.t('workflow_list_create_modal_name_placeholder'),
|
||||
descLabel: I18n.t('workflow_list_create_modal_description_label'),
|
||||
descPlaceholder: I18n.t(
|
||||
'workflow_list_create_modal_description_placeholder',
|
||||
),
|
||||
nameRequiredLabel: I18n.t(
|
||||
'workflow_list_create_modal_name_rule_required',
|
||||
),
|
||||
nameFormatRuleLabel: I18n.t('workflow_list_create_modal_name_rule_reg'),
|
||||
descRequiredLabel: I18n.t(
|
||||
'workflow_list_create_modal_description_rule_required',
|
||||
),
|
||||
};
|
||||
}, [flowMode]);
|
||||
const iconType = useMemo(() => {
|
||||
switch (flowMode) {
|
||||
case WorkflowMode.Imageflow:
|
||||
return IconType.Imageflow;
|
||||
case WorkflowMode.Workflow:
|
||||
return IconType.Workflow;
|
||||
case WorkflowMode.ChatFlow:
|
||||
return IconType.ChatFlow;
|
||||
default:
|
||||
return IconType.Workflow;
|
||||
}
|
||||
}, [flowMode]);
|
||||
return (
|
||||
<UIModal
|
||||
type="action-small"
|
||||
keepDOM={false}
|
||||
icon={null}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
title={title}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
className="min-w-[96px]"
|
||||
color="primary"
|
||||
onClick={handleCancel}
|
||||
data-testid="workflow.list.create.cancel"
|
||||
>
|
||||
{I18n.t('workflow_list_create_modal_footer_cancel')}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
className="min-w-[96px]"
|
||||
color="hgltplus"
|
||||
disabled={confirmDisabled}
|
||||
onClick={
|
||||
mode === 'add' ? handleCreateWorkflow : handleUpdateWorkflow
|
||||
}
|
||||
data-testid="workflow.list.create.submit"
|
||||
>
|
||||
{I18n.t('workflow_list_create_modal_footer_confirm')}
|
||||
</LoadingButton>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form<Partial<FormValue>>
|
||||
ref={formRef}
|
||||
showValidateIcon={false}
|
||||
className={s['upload-form']}
|
||||
onValueChange={({ name, target }) => {
|
||||
setSensitiveTip(undefined);
|
||||
setConfirmDisabled(!name?.trim() || !target?.trim());
|
||||
}}
|
||||
>
|
||||
<PictureUpload
|
||||
noLabel
|
||||
fieldClassName={s['upload-field']}
|
||||
field="icon_uri"
|
||||
initValue={getPictureUploadInitValue(workFlow)}
|
||||
iconType={iconType}
|
||||
fileBizType={FileBizType.BIZ_BOT_WORKFLOW}
|
||||
/>
|
||||
<UIFormTextArea
|
||||
stopValidateWithError
|
||||
className={s['textarea-single-line']}
|
||||
field="name"
|
||||
placeholder={labels.namePlaceholder}
|
||||
label={labels.nameLabel}
|
||||
// noErrorMessage
|
||||
initValue={workFlow?.name}
|
||||
rows={1}
|
||||
maxCount={WORKFLOW_NAME_MAX_LEN}
|
||||
maxLength={WORKFLOW_NAME_MAX_LEN}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: labels.nameRequiredLabel,
|
||||
},
|
||||
{
|
||||
validator(_, value) {
|
||||
if (!WORKFLOW_NAME_REGEX.test(value)) {
|
||||
return new CustomError(
|
||||
REPORT_EVENTS.formValidation,
|
||||
labels.nameFormatRuleLabel ?? '',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
...nameValidators,
|
||||
]}
|
||||
data-testid="workflow.list.create.name.input"
|
||||
/>
|
||||
|
||||
{/* 只有项目内创建 Chatflow 时才可以绑定会话 */}
|
||||
{mode === 'add' && projectId && flowMode === WorkflowMode.ChatFlow ? (
|
||||
<Checkbox
|
||||
fieldClassName={s['conversation-field']}
|
||||
noLabel
|
||||
initValue={true}
|
||||
field="create_conversation"
|
||||
>
|
||||
<Typography.Text className="coz-fg-primary">
|
||||
{I18n.t('wf_chatflow_87')}
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
position="top"
|
||||
theme="dark"
|
||||
style={{ width: 278 }}
|
||||
content={I18n.t('wf_chatflow_82')}
|
||||
>
|
||||
<IconCozInfoCircle className="text-[16px] ml-1.5 coz-fg-dim" />
|
||||
</Tooltip>
|
||||
</Checkbox>
|
||||
) : null}
|
||||
<UIFormTextArea
|
||||
field="target"
|
||||
className={s['textarea-multi-line']}
|
||||
label={labels.descLabel}
|
||||
placeholder={labels.descPlaceholder}
|
||||
initValue={workFlow?.desc}
|
||||
maxCount={600}
|
||||
maxLength={600}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: labels.descRequiredLabel,
|
||||
},
|
||||
]}
|
||||
data-testid="workflow.list.create.desc.input"
|
||||
/>
|
||||
{typeof sensitiveTip === 'string' ? (
|
||||
<Form.ErrorMessage error={sensitiveTip} />
|
||||
) : null}
|
||||
</Form>
|
||||
</UIModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { OrderBy, WorkFlowListStatus } from '@coze-workflow/base/api';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
import { WORKFLOW_LIST_STATUS_ALL } from '@/workflow-modal/type';
|
||||
|
||||
/** 流程所有者选项, 全部/我的 */
|
||||
export const scopeOptions = [
|
||||
{
|
||||
label: I18n.t('workflow_list_scope_all'),
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: I18n.t('workflow_list_scope_mine'),
|
||||
value: 'me',
|
||||
},
|
||||
];
|
||||
|
||||
/** 流程状态选项, 全部/已发布/未发布 */
|
||||
export const statusOptions = [
|
||||
{
|
||||
label: I18n.t('workflow_list_status_all'),
|
||||
value: WORKFLOW_LIST_STATUS_ALL,
|
||||
},
|
||||
{
|
||||
label: I18n.t('workflow_list_status_published'),
|
||||
value: WorkFlowListStatus.HadPublished,
|
||||
},
|
||||
{
|
||||
label: I18n.t('workflow_list_status_unpublished'),
|
||||
value: WorkFlowListStatus.UnPublished,
|
||||
},
|
||||
];
|
||||
|
||||
/** 流程排序选项, 创建时间/更新时间 */
|
||||
export const sortOptions = [
|
||||
{
|
||||
label: I18n.t('workflow_list_sort_create_time'),
|
||||
value: OrderBy.CreateTime,
|
||||
},
|
||||
{
|
||||
label: I18n.t('workflow_list_sort_edit_time'),
|
||||
value: OrderBy.UpdateTime,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
button.button {
|
||||
min-width: 76px;
|
||||
|
||||
&.moreLevel {
|
||||
color: var(--light-usage-primary-color-primary-disabled, #b4baf6);
|
||||
background: var(--light-usage-bg-color-bg-0, #fff);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.mouseIn {
|
||||
color: #fff;
|
||||
background-color: rgba(var(--coze-red-5), 1) !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useBoolean } from 'ahooks';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Button, type ButtonProps } from '@coze-arch/coze-design';
|
||||
|
||||
import { useI18nText } from '@/workflow-modal/hooks/use-i18n-text';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
type WorkflowAddedButtonProps = ButtonProps;
|
||||
|
||||
export const WorkflowAddedButton: FC<
|
||||
WorkflowAddedButtonProps
|
||||
> = buttonProps => {
|
||||
const [isMouseIn, { setFalse, setTrue }] = useBoolean(false);
|
||||
|
||||
const onMouseEnter = () => {
|
||||
setTrue();
|
||||
};
|
||||
const onMouseLeave = () => {
|
||||
setFalse();
|
||||
};
|
||||
const { i18nText, ModalI18nKey } = useI18nText();
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
color={isMouseIn ? 'red' : 'primary'}
|
||||
className={classNames({
|
||||
[styles.button]: true,
|
||||
[styles.moreLevel]: true,
|
||||
})}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
data-testid="workflow.modal.button.added"
|
||||
>
|
||||
{isMouseIn
|
||||
? i18nText(ModalI18nKey.ListItemRemove)
|
||||
: I18n.t('workflow_add_list_added')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
.font-normal {
|
||||
cursor: text;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-left: 8px;
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
width: 76px;
|
||||
}
|
||||
}
|
||||
|
||||
.not_publish_tooltip {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
.content {
|
||||
.font-normal();
|
||||
|
||||
line-height: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow_count_span {
|
||||
display: inline-block;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 6px;
|
||||
|
||||
font-size: 10px;
|
||||
line-height: 17px;
|
||||
color: #fff;
|
||||
vertical-align: 1px;
|
||||
|
||||
background-color: rgba(77, 83, 232, 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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 FC, useContext, useMemo, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
|
||||
import { LoadingButton } from '@coze-arch/coze-design';
|
||||
import { Popconfirm, Tooltip } from '@coze-arch/bot-semi';
|
||||
import { CheckType, type CheckResult } from '@coze-arch/bot-api/workflow_api';
|
||||
import { type WorkflowNodeJSON } from '@flowgram-adapter/free-layout-editor';
|
||||
|
||||
import WorkflowModalContext from '../../../workflow-modal-context';
|
||||
import { isSelectProjectCategory } from '../../../utils';
|
||||
import { type WorkflowInfo, WorkflowModalFrom } from '../../../type';
|
||||
import { useI18nText } from '../../../hooks/use-i18n-text';
|
||||
import { WorkflowAddedButton } from './added-button';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface WorkflowBotButtonProps {
|
||||
data?: WorkflowInfo;
|
||||
isAdded?: boolean;
|
||||
from?: WorkflowModalFrom;
|
||||
loading?: boolean;
|
||||
workflowNodes?: WorkflowNodeJSON[];
|
||||
onAdd: () => Promise<boolean>;
|
||||
onRemove: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const WorkflowBotButton: FC<WorkflowBotButtonProps> = ({
|
||||
data,
|
||||
style,
|
||||
isAdded,
|
||||
onAdd,
|
||||
onRemove,
|
||||
className,
|
||||
from,
|
||||
workflowNodes,
|
||||
loading,
|
||||
}) => {
|
||||
const { plugin_id } = data || {};
|
||||
const isPublished = plugin_id !== '0';
|
||||
const isFromWorkflow =
|
||||
from === WorkflowModalFrom.WorkflowAddNode ||
|
||||
from === WorkflowModalFrom.ProjectWorkflowAddNode;
|
||||
const context = useContext(WorkflowModalContext);
|
||||
const isAddProjectWorkflow = isSelectProjectCategory(context?.modalState);
|
||||
const canAdd = isPublished || isAddProjectWorkflow;
|
||||
const isFromSocialScene = from === WorkflowModalFrom.SocialSceneHost;
|
||||
const [count, setCount] = useState((workflowNodes || []).length);
|
||||
|
||||
const isFromWorkflowAgent = from === WorkflowModalFrom.WorkflowAgent;
|
||||
const botAgentCheckResult = useMemo<CheckResult | undefined>(
|
||||
() => data?.check_result?.find(check => check.type === CheckType.BotAgent),
|
||||
[data],
|
||||
);
|
||||
|
||||
const { i18nText, ModalI18nKey } = useI18nText();
|
||||
const renderContent = () => {
|
||||
if (isFromWorkflowAgent) {
|
||||
if (botAgentCheckResult && !botAgentCheckResult.is_pass) {
|
||||
return (
|
||||
<Tooltip
|
||||
position="top"
|
||||
className={styles.not_publish_tooltip}
|
||||
content={
|
||||
<span className={styles.content}>
|
||||
{botAgentCheckResult.reason}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<LoadingButton
|
||||
disabled
|
||||
color="primary"
|
||||
className={styles.button}
|
||||
data-testid="workflow.modal.add"
|
||||
>
|
||||
{I18n.t('workflow_add_list_add')}
|
||||
</LoadingButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已添加,展示已添加按钮
|
||||
if (isAdded) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title={i18nText(ModalI18nKey.ListItemRemoveConfirmTitle)}
|
||||
content={i18nText(ModalI18nKey.ListItemRemoveConfirmDescription)}
|
||||
okType="danger"
|
||||
position="topRight"
|
||||
onConfirm={onRemove}
|
||||
zIndex={9999}
|
||||
okText={I18n.t('neutral_age_gate_confirm', {}, 'Confirm')}
|
||||
cancelText={I18n.t('workflow_240218_17', {}, 'Cancel')}
|
||||
>
|
||||
<div>
|
||||
<WorkflowAddedButton />
|
||||
</div>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
// 未添加,判断发布状态
|
||||
// 未发布,展示下面的按钮
|
||||
if (!canAdd) {
|
||||
let key: I18nKeysNoOptionsType = 'workflow_add_not_allow_before_publish';
|
||||
if (isFromWorkflow) {
|
||||
key = 'wf_node_add_wf_modal_tip_must_publish_to_add';
|
||||
} else if (isFromSocialScene) {
|
||||
key = 'scene_workflow_popup_add_forbidden';
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
position="top"
|
||||
className={styles.not_publish_tooltip}
|
||||
content={<span className={styles.content}>{I18n.t(key)}</span>}
|
||||
>
|
||||
<LoadingButton
|
||||
disabled
|
||||
color="primary"
|
||||
className={styles.button}
|
||||
data-testid="workflow.modal.add"
|
||||
>
|
||||
{I18n.t('workflow_add_list_add')}
|
||||
</LoadingButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// 已发布并且未添加,展示添加按钮
|
||||
if (!isAdded) {
|
||||
return (
|
||||
<LoadingButton
|
||||
onClick={async () => {
|
||||
const isSuccess = await onAdd?.();
|
||||
if (isSuccess) {
|
||||
setCount(prev => prev + 1);
|
||||
}
|
||||
}}
|
||||
color="primary"
|
||||
className={styles.button}
|
||||
data-testid="workflow.modal.add"
|
||||
>
|
||||
{I18n.t('workflow_add_list_add')}
|
||||
{isFromWorkflow && count !== 0 ? (
|
||||
<span className={styles.workflow_count_span}>{count}</span>
|
||||
) : null}
|
||||
</LoadingButton>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.container, className)}
|
||||
style={style}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 MouseEvent } from 'react';
|
||||
|
||||
import { logger } from '@coze-arch/logger';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
|
||||
import { IconButton, Popconfirm } from '@coze-arch/coze-design';
|
||||
|
||||
export const DeleteButton = ({
|
||||
className,
|
||||
onDelete,
|
||||
}: {
|
||||
className?: string;
|
||||
onDelete?: () => Promise<void>;
|
||||
}) => {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const handleClose = () => setModalVisible(false);
|
||||
const showDeleteConfirm = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = () =>
|
||||
// 使用 promise 让按钮出现 loading 的效果,参见
|
||||
// https://semi.design/zh-CN/feedback/popconfirm
|
||||
new Promise((resolve, reject) => {
|
||||
onDelete?.()
|
||||
.then(() => {
|
||||
handleClose();
|
||||
resolve(true);
|
||||
})
|
||||
.catch(error => {
|
||||
// 处理错误
|
||||
logger.error({
|
||||
error: error as Error,
|
||||
eventName: 'delete workflow error',
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return (
|
||||
<div className={className} onClick={e => e.stopPropagation()}>
|
||||
<Popconfirm
|
||||
visible={modalVisible}
|
||||
title={I18n.t('scene_workflow_popup_delete_confirm_title')}
|
||||
content={I18n.t('scene_workflow_popup_delete_confirm_subtitle')}
|
||||
okText={I18n.t('shortcut_modal_confirm')}
|
||||
cancelText={I18n.t('shortcut_modal_cancel')}
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={handleClose}
|
||||
okButtonColor="red"
|
||||
>
|
||||
<IconButton
|
||||
icon={<IconCozTrashCan />}
|
||||
type="primary"
|
||||
onClick={showDeleteConfirm}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
/* stylelint-disable max-nesting-depth */
|
||||
.font-normal {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
padding: 12px 16px 14px;
|
||||
|
||||
border-top: 1px solid
|
||||
var(--light-usage-border-color-border, rgba(28, 29, 35, 12%));
|
||||
|
||||
&:first-child {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--light-usage-fill-color-fill-0, rgba(46, 47, 56, 4%));
|
||||
border-top: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
|
||||
& + div {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
margin-right: 16px;
|
||||
|
||||
.icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 0;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
width: 100%;
|
||||
padding-bottom: 2px;
|
||||
|
||||
.title_wrapper {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
|
||||
width: 0;
|
||||
|
||||
.title {
|
||||
.font-normal();
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: var(--coz-fg-primary, #060709);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
|
||||
.text {
|
||||
margin-left: 4px;
|
||||
color: var(--coz-fg-primary, #060709);
|
||||
.font-normal();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
|
||||
.desc {
|
||||
.font-normal();
|
||||
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--coz-fg-secondary, #06070980);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.creator {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
|
||||
.avatar {
|
||||
display: flex;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
& img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
.font-normal();
|
||||
|
||||
max-width: 106px;
|
||||
margin-left: 4px;
|
||||
color: var(--coz-fg-secondary, #06070980);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.creator {
|
||||
background: unset;
|
||||
|
||||
&-avatar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&-name {
|
||||
max-width: 70px;
|
||||
margin-left: 4px;
|
||||
.font-normal();
|
||||
|
||||
color: var(--coz-fg-secondary, #06070980);
|
||||
}
|
||||
}
|
||||
|
||||
.symbol {
|
||||
.font-normal();
|
||||
|
||||
margin: 0 8px;
|
||||
line-height: 16px;
|
||||
color: var(--coz-stroke-primary);
|
||||
// color: var(--coz-fg-dim, #06070966);
|
||||
}
|
||||
|
||||
.date {
|
||||
.font-normal();
|
||||
|
||||
line-height: 16px;
|
||||
color: var(--coz-fg-dim, #06070966);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 16px;
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* 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 @coze-arch/max-line-per-function */
|
||||
import React, { type FC, useContext } from 'react';
|
||||
|
||||
import isNil from 'lodash-es/isNil';
|
||||
import { unix } from 'dayjs';
|
||||
import classNames from 'classnames';
|
||||
import { OrderBy, WorkFlowListStatus } from '@coze-workflow/base/api';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozClockFill,
|
||||
IconCozCheckMarkCircleFill,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Typography, LoadingButton } from '@coze-arch/coze-design';
|
||||
import { Avatar, Image, Tooltip } from '@coze-arch/bot-semi';
|
||||
import { CheckType } from '@coze-arch/bot-api/workflow_api';
|
||||
import { type Int64, SpaceType } from '@coze-arch/bot-api/developer_api';
|
||||
|
||||
import { LibButton } from '@/workflow-modal/content/card/lib-button';
|
||||
|
||||
import WorkflowModalContext from '../../workflow-modal-context';
|
||||
import { isSelectProjectCategory } from '../../utils';
|
||||
import {
|
||||
DataSourceType,
|
||||
MineActiveEnum,
|
||||
type ProductInfo,
|
||||
WorkflowCategory,
|
||||
type WorkflowInfo,
|
||||
WorkflowModalFrom,
|
||||
} from '../../type';
|
||||
import {
|
||||
useWorkflowAction,
|
||||
type WorkflowCardProps,
|
||||
} from '../../hooks/use-workflow-action';
|
||||
import { WorkflowParameters } from './parameters';
|
||||
import { DeleteButton } from './delete-button';
|
||||
import { WorkflowBotButton } from './bot-button';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const formatTime = (time?: Int64) => unix(Number(time)).format('YYYY-MM-DD');
|
||||
|
||||
const defaultWorkFlowList = [];
|
||||
|
||||
export const WorkflowCard: FC<WorkflowCardProps> = props => {
|
||||
const {
|
||||
data,
|
||||
workFlowList = defaultWorkFlowList,
|
||||
from,
|
||||
workflowNodes,
|
||||
dupText,
|
||||
itemShowDelete,
|
||||
} = props;
|
||||
const context = useContext(WorkflowModalContext);
|
||||
|
||||
const isProfessionalTemplate = (data as ProductInfo)?.meta_info
|
||||
?.is_professional;
|
||||
|
||||
const {
|
||||
dupWorkflowTpl,
|
||||
addWorkflow,
|
||||
removeWorkflow,
|
||||
deleteWorkflow,
|
||||
itemClick,
|
||||
} = useWorkflowAction({ ...props, isProfessionalTemplate });
|
||||
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const StatusMap = {
|
||||
unpublished: {
|
||||
label: I18n.t('workflow_add_status_unpublished'),
|
||||
icon: <IconCozClockFill className="coz-fg-dim text-xs" />,
|
||||
},
|
||||
published: {
|
||||
label: I18n.t('workflow_add_status_published'),
|
||||
icon: (
|
||||
<IconCozCheckMarkCircleFill className="text-xs coz-fg-hglt-green" />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const { orderBy, spaceType } = context;
|
||||
const {
|
||||
creator: creator,
|
||||
status,
|
||||
isSpaceWorkflow,
|
||||
workflowCategory,
|
||||
} = context.modalState;
|
||||
const isTeam = spaceType === SpaceType.Team;
|
||||
|
||||
function isTypeWorkflow(
|
||||
target: WorkflowInfo | ProductInfo,
|
||||
): target is WorkflowInfo {
|
||||
return context?.modalState.dataSourceType === DataSourceType.Workflow;
|
||||
}
|
||||
|
||||
const pluginId = isTypeWorkflow(data) ? data.plugin_id : '';
|
||||
const statusValue =
|
||||
!isNil(pluginId) && isSpaceWorkflow
|
||||
? StatusMap[pluginId === '0' ? 'unpublished' : 'published']
|
||||
: undefined;
|
||||
|
||||
const renderStatusValue = () => {
|
||||
// 添加项目里的工作流节点、官方示例不展示发布状态
|
||||
if (
|
||||
isSelectProjectCategory(context?.modalState) ||
|
||||
workflowCategory === WorkflowCategory.Example
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (statusValue) {
|
||||
return (
|
||||
<div className={classNames(styles.status)}>
|
||||
{statusValue.icon}
|
||||
<span className={styles.text}>{statusValue.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const renderBottomLeftDesc = () => {
|
||||
// 商品底部
|
||||
if (!isTypeWorkflow(data)) {
|
||||
const timeRender = `${I18n.t('workflow_add_list_updated')} ${formatTime(
|
||||
data.meta_info.listed_at,
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<div className={styles.creator}>
|
||||
<Avatar
|
||||
className={styles['creator-avatar']}
|
||||
src={data.meta_info.user_info?.avatar_url}
|
||||
/>
|
||||
<Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
className={styles['creator-name']}
|
||||
>
|
||||
{data.meta_info.user_info?.name ??
|
||||
I18n.t('workflow_add_list_unknown')}
|
||||
</Text>
|
||||
<span className={styles.symbol}>|</span>
|
||||
</div>
|
||||
<span className={styles.date}>{timeRender}</span>
|
||||
|
||||
{(Number(data?.workflow_extra?.duplicate_count) || 0) > 0 ? (
|
||||
<>
|
||||
<span className={styles.symbol}>|</span>
|
||||
<Text className={styles.date}>
|
||||
{Number(data?.workflow_extra?.duplicate_count) || 0}{' '}
|
||||
{I18n.t('workflowstore_card_duplicate')}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 用户创建的,展示修改时间
|
||||
if (isSpaceWorkflow || workflowCategory === WorkflowCategory.Example) {
|
||||
const showCreator =
|
||||
(creator !== MineActiveEnum.Mine && isTeam) ||
|
||||
from === WorkflowModalFrom.ProjectImportLibrary;
|
||||
const timeRender =
|
||||
orderBy === OrderBy.CreateTime
|
||||
? `${I18n.t('workflow_add_list_created')} ${formatTime(
|
||||
data.create_time,
|
||||
)}`
|
||||
: status === WorkFlowListStatus.HadPublished
|
||||
? `${I18n.t('workflow_add_list_publised')} ${formatTime(
|
||||
data.update_time,
|
||||
)}`
|
||||
: `${I18n.t('workflow_add_list_updated')} ${formatTime(
|
||||
data.update_time,
|
||||
)}`;
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
{showCreator ? (
|
||||
<div className={styles.creator}>
|
||||
<Avatar
|
||||
className={styles['creator-avatar']}
|
||||
src={data.creator?.avatar_url}
|
||||
/>
|
||||
<Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
className={styles['creator-name']}
|
||||
>
|
||||
{data.creator?.name ?? I18n.t('workflow_add_list_unknown')}
|
||||
</Text>
|
||||
<span className={styles.symbol}>|</span>
|
||||
</div>
|
||||
) : null}
|
||||
<span className={styles.date}>{timeRender}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 官方模板,展示创作者
|
||||
if (!isSpaceWorkflow) {
|
||||
return (
|
||||
<div className={styles.creator}>
|
||||
<Image
|
||||
preview={false}
|
||||
src={data.template_author_picture_url}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<Text ellipsis={{ showTooltip: true }} className={styles.name}>
|
||||
{data.template_author_name || '-'}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const renderBotButton = () => {
|
||||
if (workflowCategory === WorkflowCategory.Example && isTypeWorkflow(data)) {
|
||||
const botAgentCheckResult = data?.check_result?.find(
|
||||
check => check.type === CheckType.BotAgent,
|
||||
);
|
||||
const ButtonContent = (
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
data-testid="workflow.modal.add"
|
||||
disabled={botAgentCheckResult && !botAgentCheckResult?.is_pass}
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
await dupWorkflowTpl();
|
||||
}}
|
||||
>
|
||||
{dupText || I18n.t('workflowstore_duplicate_and_add')}
|
||||
</LoadingButton>
|
||||
);
|
||||
|
||||
if (
|
||||
botAgentCheckResult &&
|
||||
!botAgentCheckResult.is_pass &&
|
||||
botAgentCheckResult.reason
|
||||
) {
|
||||
return (
|
||||
<Tooltip content={botAgentCheckResult.reason}>
|
||||
{ButtonContent}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return ButtonContent;
|
||||
}
|
||||
|
||||
if (from === WorkflowModalFrom.ProjectImportLibrary) {
|
||||
return (
|
||||
<LibButton data={data as WorkflowInfo} onImport={props.onImport} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<WorkflowBotButton
|
||||
isAdded={workFlowList.some(
|
||||
workflow =>
|
||||
workflow.workflow_id === (data as WorkflowInfo)?.workflow_id,
|
||||
)}
|
||||
workflowNodes={workflowNodes}
|
||||
from={from}
|
||||
data={data as WorkflowInfo}
|
||||
onAdd={() => addWorkflow()}
|
||||
onRemove={() => {
|
||||
removeWorkflow();
|
||||
}}
|
||||
/>
|
||||
{itemShowDelete ? (
|
||||
<DeleteButton className="ml-[4px]" onDelete={deleteWorkflow} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => {
|
||||
itemClick();
|
||||
}}
|
||||
>
|
||||
<div className={styles.left}>
|
||||
<div className={styles.icon}>
|
||||
<Image
|
||||
preview={false}
|
||||
src={isTypeWorkflow(data) ? data.url : data.meta_info.icon_url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.center}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title_wrapper}>
|
||||
<Text ellipsis={{ showTooltip: true }} className={styles.title}>
|
||||
{isTypeWorkflow(data) ? data.name : data.meta_info.name}
|
||||
</Text>
|
||||
{renderStatusValue()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<Text
|
||||
ellipsis={{
|
||||
showTooltip: {
|
||||
opts: {
|
||||
style: {
|
||||
maxWidth: 600,
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
className={styles.desc}
|
||||
>
|
||||
{(isTypeWorkflow(data) ? data.desc : data.meta_info.description) ||
|
||||
''}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<WorkflowParameters data={data} />
|
||||
{renderBottomLeftDesc()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<div className={styles.buttons}>{renderBotButton()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 React from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Button, Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
import { type WorkFlowModalModeProps, type WorkflowInfo } from '../../type';
|
||||
export type LibButtonProps = Pick<WorkFlowModalModeProps, 'onImport'> & {
|
||||
data?: WorkflowInfo;
|
||||
};
|
||||
export const LibButton: React.FC<LibButtonProps> = ({ data, onImport }) => {
|
||||
const isPublished = data?.plugin_id && data?.plugin_id !== '0';
|
||||
const content = (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
disabled={!isPublished}
|
||||
color="primary"
|
||||
data-testid="workflow.modal.add"
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
data?.workflow_id &&
|
||||
onImport?.({
|
||||
workflow_id: data.workflow_id,
|
||||
name: data.name || '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{I18n.t('project_resource_modal_copy_to_project')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
if (isPublished) {
|
||||
return content;
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
position="top"
|
||||
content={I18n.t('project_toast_only_published_resources_can_be_imported')}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user