user actions

This commit is contained in:
JzoNg 2025-07-27 16:46:40 +08:00
parent e257455c9c
commit b02199145e
9 changed files with 262 additions and 4 deletions

View File

@ -0,0 +1,108 @@
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiFontSize,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import { UserActionButtonType } from '../types'
import cn from '@/utils/classnames'
const i18nPrefix = 'workflow.nodes.humanInput'
type Props = {
text: string
data: UserActionButtonType
onChange: (state: UserActionButtonType) => void
}
const ButtonStyleDropdown: FC<Props> = ({
text = 'Button Text',
data,
onChange,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const currentStyle = useMemo(() => {
switch (data) {
case UserActionButtonType.Primary:
return 'primary'
case UserActionButtonType.Default:
return 'secondary'
case UserActionButtonType.Accent:
return 'secondary-accent'
default:
return 'ghost'
}
}, [data])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 44,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className={cn('flex cursor-pointer items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1 hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
<Button size='small' className='pointer-events-none px-1' variant={currentStyle}>
<RiFontSize className='h-4 w-4' />
</Button>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className='rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-sm'>
<div className='system-md-medium text-text-primary'>{t(`${i18nPrefix}.userActions.chooseStyle`)}</div>
<div className='mt-2 flex w-[324px] flex-wrap gap-1'>
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Primary && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Primary)}
>
<Button variant='primary' className='pointer-events-none'>{text}</Button>
</div>
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Default && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Default)}
>
<Button variant='secondary' className='pointer-events-none'>{text}</Button>
</div>
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Accent && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Accent)}
>
<Button variant='secondary-accent' className='pointer-events-none'>{text}</Button>
</div>
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Ghost && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Ghost)}
>
<Button variant='ghost' className='pointer-events-none'>{text}</Button>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ButtonStyleDropdown

View File

@ -0,0 +1,59 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiDeleteBinLine,
} from '@remixicon/react'
import type { UserAction } from '../types'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import ButtonStyleDropdown from './button-style-dropdown'
const i18nPrefix = 'workflow.nodes.humanInput'
type Props = {
data: UserAction
onChange: (state: UserAction) => void
onDelete: (id: string) => void
}
const UserActionItem: FC<Props> = ({
data,
onChange,
onDelete,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-1'>
<div className='shrink-0'>
<Input
wrapperClassName='w-[120px]'
value={data.name}
placeholder={t(`${i18nPrefix}.userActions.actionNamePlaceholder`)}
onChange={e => onChange({ ...data, name: e.target.value })}
/>
</div>
<div className='grow'>
<Input
value={data.text}
placeholder={t(`${i18nPrefix}.userActions.buttonTextPlaceholder`)}
onChange={e => onChange({ ...data, text: e.target.value })}
/>
</div>
<ButtonStyleDropdown
text={data.text}
data={data.type}
onChange={type => onChange({ ...data, type })}
/>
<Button
className='px-2'
variant='tertiary'
onClick={() => onDelete(data.id)}
>
<RiDeleteBinLine className='h-4 w-4' />
</Button>
</div>
)
}
export default UserActionItem

View File

@ -17,21 +17,25 @@ const nodeDefault: NodeDefault<HumanInputNodeType> = {
],
userActions: [
{
id: 'approve-action',
name: 'approve',
text: 'Post to X',
type: UserActionButtonType.Primary,
},
{
id: 'regenerate-action',
name: 'regenerate',
text: 'regenerate',
type: UserActionButtonType.Default,
},
{
id: 'thinking-action',
name: 'thinking',
text: 'think more',
type: UserActionButtonType.Accent,
},
{
id: 'cancel-action',
name: 'cancel',
text: 'cancel',
type: UserActionButtonType.Ghost,

View File

@ -46,7 +46,7 @@ const Node: FC<NodeProps<HumanInputNodeType>> = (props) => {
{userActions.length > 0 && (
<div className='space-y-0.5 py-1'>
{userActions.map(userAction => (
<div key={userAction.name} className='relative flex flex-row-reverse items-center px-4 py-1'>
<div key={userAction.id} className='relative flex flex-row-reverse items-center px-4 py-1'>
<span className='system-xs-semibold-uppercase truncate text-text-secondary'>{userAction.name}</span>
<NodeSourceHandle
{...props}

View File

@ -1,11 +1,19 @@
import type { FC } from 'react'
import React from 'react'
import {
RiAddLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import useConfig from './use-config'
import type { HumanInputNodeType } from './types'
import { UserActionButtonType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import Divider from '@/app/components/base/divider'
import UserActionItem from './components/user-action'
import TimeoutInput from './components/timeout'
import { v4 as uuid4 } from 'uuid'
const i18nPrefix = 'workflow.nodes.humanInput'
@ -16,10 +24,51 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
const { t } = useTranslation()
const {
inputs,
handleUserActionChange,
handleUserActionDelete,
handleTimeoutChange,
} = useConfig(id, data)
return (
<div className='py-2'>
<div className='px-4 py-2'>
<div className='mb-1 flex items-center justify-between'>
<div className='flex items-center gap-0.5'>
<div className='system-sm-semibold-uppercase text-text-secondary'>{t(`${i18nPrefix}.userActions.title`)}</div>
<Tooltip
popupContent={t(`${i18nPrefix}.userActions.tooltip`)}
/>
</div>
<div className='flex items-center px-1'>
<ActionButton
onClick={() => {
inputs.userActions.push({
id: uuid4(),
name: 'Action',
text: 'Button Text',
type: UserActionButtonType.Default,
})
}}
>
<RiAddLine className='h-4 w-4' />
</ActionButton>
</div>
</div>
{!inputs.userActions.length && (
<div className='system-xs-regular flex items-center justify-center rounded-[10px] bg-background-section p-3 text-text-tertiary'>{t(`${i18nPrefix}.userActions.emptyTip`)}</div>
)}
{inputs.userActions.length > 0 && (
<div className='space-y-2'>
{inputs.userActions.map(action => (
<UserActionItem
key={action.id}
data={action}
onChange={handleUserActionChange}
onDelete={handleUserActionDelete}
/>
))}
</div>
)}
</div>
<div className='px-4 py-2'>
<Divider className='!my-0 !h-px !bg-divider-subtle' />
</div>

View File

@ -33,6 +33,7 @@ export enum UserActionButtonType {
}
export type UserAction = {
id: string
name: string
text: string
type: UserActionButtonType

View File

@ -1,4 +1,5 @@
import type { HumanInputNodeType, Timeout } from './types'
import produce from 'immer'
import type { HumanInputNodeType, Timeout, UserAction } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import {
useNodesReadOnly,
@ -7,6 +8,26 @@ const useConfig = (id: string, payload: HumanInputNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { inputs, setInputs } = useNodeCrud<HumanInputNodeType>(id, payload)
const handleUserActionChange = (updatedAction: UserAction) => {
const newActions = produce(inputs.userActions, (draft) => {
const index = draft.findIndex(a => a.id === updatedAction.id)
if (index !== -1)
draft[index] = updatedAction
})
setInputs({
...inputs,
userActions: newActions,
})
}
const handleUserActionDelete = (actionId: string) => {
const newActions = inputs.userActions.filter(action => action.id !== actionId)
setInputs({
...inputs,
userActions: newActions,
})
}
const handleTimeoutChange = (timeout: Timeout) => {
setInputs({
...inputs,
@ -17,6 +38,8 @@ const useConfig = (id: string, payload: HumanInputNodeType) => {
return {
readOnly,
inputs,
handleUserActionChange,
handleUserActionDelete,
handleTimeoutChange,
}
}

View File

@ -909,7 +909,14 @@ const translation = {
title: 'Delivery Method',
},
formContent: 'form content',
userActions: 'user actions',
userActions: {
title: 'User Actions',
tooltip: 'Define buttons that users can click to respond to this form. Each button can trigger different workflow paths.',
emptyTip: 'Click the \'+\' button to add user actions',
actionNamePlaceholder: 'Action Name',
buttonTextPlaceholder: 'Button display Text',
chooseStyle: 'Choose a button style',
},
timeout: {
title: 'Timeout',
hours: 'Hours',

View File

@ -910,7 +910,14 @@ const translation = {
title: '提交方式',
},
formContent: '表单内容',
userActions: '用户操作',
userActions: {
title: '用户操作',
tooltip: '定义用户可以点击以响应此表单的按钮。每个按钮都可以触发不同的工作流路径。',
emptyTip: '点击 \'+\' 按钮添加用户操作',
actionNamePlaceholder: '操作名称',
buttonTextPlaceholder: '按钮显示文本',
chooseStyle: '选择按钮样式',
},
timeout: {
title: '超时设置',
hours: '小时',