fix: add pending state to export button to prevent duplicate clicks

Use useTransition to disable the export button and show loading state
in the DSL export confirm modal during async export operations.
This commit is contained in:
yyh 2026-02-02 15:52:03 +08:00
parent 6ea16837ff
commit 808a32c457
No known key found for this signature in database
2 changed files with 19 additions and 13 deletions

View File

@ -11,7 +11,7 @@ import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLi
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { AppTypeIcon } from '@/app/components/app/type-selector'
@ -79,6 +79,7 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [exporting, startExport] = useTransition()
const onConfirmDelete = useCallback(async () => {
try {
@ -186,14 +187,14 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
const exportCheck = async () => {
if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) {
onExport()
await onExport()
return
}
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
if (list.length === 0) {
onExport()
await onExport()
return
}
setSecretEnvList(list)
@ -235,11 +236,13 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
e.preventDefault()
setShowDuplicateModal(true)
}
const onClickExport = async (e: React.MouseEvent<HTMLButtonElement>) => {
const onClickExport = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
exportCheck()
startExport(async () => {
await exportCheck()
})
}
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
@ -288,7 +291,7 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickDuplicate}>
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
</button>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExport}>
<button type="button" disabled={exporting || secretEnvList.length > 0} className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50" onClick={onClickExport}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</button>
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (

View File

@ -3,7 +3,7 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { RiCloseLine, RiLock2Line } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useState } from 'react'
import { useState, useTransition } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@ -13,7 +13,7 @@ import { cn } from '@/utils/classnames'
export type DSLExportConfirmModalProps = {
envList: EnvironmentVariable[]
onConfirm: (state: boolean) => void
onConfirm: (state: boolean) => void | Promise<void>
onClose: () => void
}
@ -25,10 +25,13 @@ const DSLExportConfirmModal = ({
const { t } = useTranslation()
const [exportSecrets, setExportSecrets] = useState<boolean>(false)
const [exporting, startExport] = useTransition()
const submit = () => {
onConfirm(exportSecrets)
onClose()
startExport(async () => {
await onConfirm(exportSecrets)
onClose()
})
}
return (
@ -38,7 +41,7 @@ const DSLExportConfirmModal = ({
className={cn('w-[480px] max-w-[480px]')}
>
<div className="title-2xl-semi-bold relative pb-6 text-text-primary">{t('env.export.title', { ns: 'workflow' })}</div>
<div className="absolute right-4 top-4 cursor-pointer p-2" onClick={onClose}>
<div className={cn('absolute right-4 top-4 p-2', exporting ? 'pointer-events-none opacity-50' : 'cursor-pointer')} onClick={onClose}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<div className="relative">
@ -77,8 +80,8 @@ const DSLExportConfirmModal = ({
<div className="system-sm-medium cursor-pointer text-text-primary" onClick={() => setExportSecrets(!exportSecrets)}>{t('env.export.checkbox', { ns: 'workflow' })}</div>
</div>
<div className="flex flex-row-reverse pt-6">
<Button className="ml-2" variant="primary" onClick={submit}>{exportSecrets ? t('env.export.export', { ns: 'workflow' }) : t('env.export.ignore', { ns: 'workflow' })}</Button>
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button className="ml-2" variant="primary" loading={exporting} disabled={exporting} onClick={submit}>{exportSecrets ? t('env.export.export', { ns: 'workflow' }) : t('env.export.ignore', { ns: 'workflow' })}</Button>
<Button disabled={exporting} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
</Modal>
)