feat: enhance Button component with loading state and custom loading icon

- Added loading state support to the Button component, allowing for a spinner to be displayed when the button is in a loading state.
- Introduced props for custom loading icons and adjusted button behavior to disable when loading.
- Updated various components to utilize the new loading feature for better user experience during asynchronous actions.
This commit is contained in:
MyPrototypeWhat 2025-11-03 16:30:45 +08:00
parent e06142b89a
commit e56edbaa4f
10 changed files with 96 additions and 57 deletions

View File

@ -1,6 +1,7 @@
import { cn } from '@cherrystudio/ui/utils/index' import { cn } from '@cherrystudio/ui/utils/index'
import { Slot } from '@radix-ui/react-slot' import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from 'class-variance-authority'
import { Loader } from 'lucide-react'
import * as React from 'react' import * as React from 'react'
const buttonVariants = cva( const buttonVariants = cva(
@ -38,14 +39,46 @@ function Button({
variant, variant,
size, size,
asChild = false, asChild = false,
loading = false,
loadingIcon,
loadingIconClassName,
disabled,
children,
...props ...props
}: React.ComponentProps<'button'> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
loading?: boolean
loadingIcon?: React.ReactNode
loadingIconClassName?: string
}) { }) {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : 'button'
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> // 根据按钮尺寸确定 spinner 大小
const getSpinnerSize = () => {
if (size === 'sm' || size === 'icon-sm') return 14
if (size === 'lg' || size === 'icon-lg') return 18
return 16
}
// 默认 loading icon
const defaultLoadingIcon = (
<Loader className={cn('animate-spin', loadingIconClassName)} size={getSpinnerSize()} />
)
// 使用自定义 icon 或默认 icon
const spinnerElement = loadingIcon ?? defaultLoadingIcon
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || loading}
{...props}>
{loading && spinnerElement}
{children}
</Comp>
)
} }
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@ -1,4 +1,5 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import React from 'react' import React from 'react'
import EmojiPicker from '../EmojiPicker' import EmojiPicker from '../EmojiPicker'
@ -12,7 +13,9 @@ export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => {
return ( return (
<Popover> <Popover>
<PopoverTrigger> <PopoverTrigger>
<Button size="sm" startContent={<span className="text-lg">{emoji}</span>} isIconOnly /> <Button size="icon-sm" asChild>
<span className="text-lg">{emoji}</span>
</Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<EmojiPicker onEmojiClick={onPick}></EmojiPicker> <EmojiPicker onEmojiClick={onPick}></EmojiPicker>

View File

@ -1,4 +1,4 @@
import { Button } from '@heroui/button' import { Button } from '@cherrystudio/ui'
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal' import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal'
import { Progress } from '@heroui/progress' import { Progress } from '@heroui/progress'
import { Spinner } from '@heroui/spinner' import { Spinner } from '@heroui/spinner'
@ -517,10 +517,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<SettingRow style={{ display: 'flex', alignItems: 'center' }}> <SettingRow style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}> <div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}> <Button variant="secondary" onClick={handleSelectZip} disabled={isSending}>
{t('settings.data.export_to_phone.lan.selectZip')} {t('settings.data.export_to_phone.lan.selectZip')}
</Button> </Button>
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}> <Button onClick={handleSendZip} disabled={!canSend} loading={isSending}>
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')} {transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
</Button> </Button>
</div> </div>
@ -564,10 +564,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
{t('settings.data.export_to_phone.lan.confirm_close_message')} {t('settings.data.export_to_phone.lan.confirm_close_message')}
</span> </span>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}> <Button size="sm" onClick={handleCancelClose}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
<Button size="sm" color="danger" onPress={handleForceClose}> <Button size="sm" variant="destructive" onClick={handleForceClose}>
{t('settings.data.export_to_phone.lan.force_close')} {t('settings.data.export_to_phone.lan.force_close')}
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,5 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store' import { handleSaveData } from '@renderer/store'
import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime' import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
@ -82,12 +83,11 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInf
</Button> </Button>
<Button <Button
color="primary"
onClick={async () => { onClick={async () => {
await handleInstall() await handleInstall()
onModalClose() onModalClose()
}} }}
isLoading={isInstalling}> loading={isInstalling}>
{t('update.install')} {t('update.install')}
</Button> </Button>
</ModalFooter> </ModalFooter>

View File

@ -1,5 +1,6 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { Button, Chip, ScrollShadow } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Chip, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions' import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
@ -137,23 +138,23 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
<Button <Button
aria-label={t('agent.toolPermission.aria.denyRequest')} aria-label={t('agent.toolPermission.aria.denyRequest')}
className="h-8" className="h-8"
color="danger" variant="outline"
isDisabled={isSubmitting || isExpired} disabled={isSubmitting || isExpired}
isLoading={isSubmittingDeny} loading={isSubmittingDeny}
onPress={() => handleDecision('deny')} onClick={() => handleDecision('deny')}
startContent={<CircleX size={16} />} loadingIcon={<CircleX size={16} />}>
variant="bordered"> <CircleX size={16} />
{t('agent.toolPermission.button.cancel')} {t('agent.toolPermission.button.cancel')}
</Button> </Button>
<Button <Button
aria-label={t('agent.toolPermission.aria.allowRequest')} aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3" className="h-8 px-3 bg-green-600 hover:bg-green-700 text-white"
color="success" disabled={isSubmitting || isExpired}
isDisabled={isSubmitting || isExpired} loading={isSubmittingAllow}
isLoading={isSubmittingAllow} onClick={() => handleDecision('allow')}
onPress={() => handleDecision('allow')} loadingIcon={<CirclePlay size={16} />}>
startContent={<CirclePlay size={16} />}> <CirclePlay size={16} />
{t('agent.toolPermission.button.run')} {t('agent.toolPermission.button.run')}
</Button> </Button>
@ -161,10 +162,10 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
aria-label={ aria-label={
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails') showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
} }
className="h-8" size="icon"
isIconOnly className="h-8 w-8"
onPress={() => setShowDetails((value) => !value)} onClick={() => setShowDetails((value) => !value)}
variant="light"> variant="ghost">
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} /> <ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,5 @@
import { Button, Divider } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Divider } from '@heroui/react'
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings' import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings' import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings'
@ -33,7 +34,7 @@ const SessionSettingsTab: FC<Props> = ({ session, update }) => {
<EssentialSettings agentBase={session} update={update} showModelSetting={false} /> <EssentialSettings agentBase={session} update={update} showModelSetting={false} />
<AdvancedSettings agentBase={session} update={update} /> <AdvancedSettings agentBase={session} update={update} />
<Divider className="my-2" /> <Divider className="my-2" />
<Button size="sm" fullWidth onClick={onMoreSetting}> <Button size="sm" className="w-full" onClick={onMoreSetting}>
{t('settings.moresetting.label')} {t('settings.moresetting.label')}
</Button> </Button>
</div> </div>

View File

@ -1,4 +1,5 @@
import { Button, Tooltip } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Tooltip } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types' import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
@ -66,7 +67,9 @@ export const AccessibleDirsSetting = ({ base, update }: AccessibleDirsSettingPro
<SettingsTitle <SettingsTitle
actions={ actions={
<Tooltip content={t('agent.session.accessible_paths.add')}> <Tooltip content={t('agent.session.accessible_paths.add')}>
<Button variant="ghost" size="sm" startContent={<Plus />} isIconOnly onClick={addAccessiblePath} /> <Button variant="ghost" size="icon-sm" onClick={addAccessiblePath}>
<Plus />
</Button>
</Tooltip> </Tooltip>
}> }>
{t('agent.session.accessible_paths.label')} {t('agent.session.accessible_paths.label')}
@ -79,7 +82,7 @@ export const AccessibleDirsSetting = ({ base, update }: AccessibleDirsSettingPro
<span className="w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm" title={path}> <span className="w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm" title={path}>
{path} {path}
</span> </span>
<Button size="sm" variant="ghost" color="danger" onClick={() => removeAccessiblePath(path)}> <Button size="sm" variant="destructive" onClick={() => removeAccessiblePath(path)}>
{t('common.delete')} {t('common.delete')}
</Button> </Button>
</li> </li>

View File

@ -1,4 +1,5 @@
import { Button, Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react'
import type { InstalledPlugin } from '@renderer/types/plugin' import type { InstalledPlugin } from '@renderer/types/plugin'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
@ -81,13 +82,12 @@ export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, o
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button
size="sm" size="icon-sm"
color="danger" variant="ghost"
variant="light" className="text-destructive hover:text-destructive"
isIconOnly onClick={() => handleUninstall(plugin)}
onPress={() => handleUninstall(plugin)} loading={uninstallingPlugin === plugin.filename}
isLoading={uninstallingPlugin === plugin.filename} disabled={loading}>
isDisabled={loading}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TableCell> </TableCell>

View File

@ -1,4 +1,5 @@
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Tab, Tabs } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Tab, Tabs } from '@heroui/react'
import type { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin' import type { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin'
import { Filter, Search } from 'lucide-react' import { Filter, Search } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
@ -185,10 +186,8 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
<Dropdown placement="bottom-end" classNames={{ content: 'max-h-60 overflow-y-auto p-0' }}> <Dropdown placement="bottom-end" classNames={{ content: 'max-h-60 overflow-y-auto p-0' }}>
<DropdownTrigger> <DropdownTrigger>
<Button <Button
isIconOnly size="icon-sm"
variant={selectedCategories.length > 0 ? 'flat' : 'light'} variant={selectedCategories.length > 0 ? 'default' : 'ghost'}
color={selectedCategories.length > 0 ? 'primary' : 'default'}
size="sm"
className="-translate-y-1/2 absolute top-1/2 right-2 z-10"> className="-translate-y-1/2 absolute top-1/2 right-2 z-10">
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
</Button> </Button>

View File

@ -1,4 +1,5 @@
import { Button, Card, CardBody, CardFooter, CardHeader, Chip, Spinner } from '@heroui/react' import { Button } from '@cherrystudio/ui'
import { Card, CardBody, CardFooter, CardHeader, Chip, Spinner } from '@heroui/react'
import type { PluginMetadata } from '@renderer/types/plugin' import type { PluginMetadata } from '@renderer/types/plugin'
import { upperFirst } from 'lodash' import { upperFirst } from 'lodash'
import { Download, Trash2 } from 'lucide-react' import { Download, Trash2 } from 'lucide-react'
@ -56,30 +57,28 @@ export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall,
<CardFooter className="pt-2"> <CardFooter className="pt-2">
{installed ? ( {installed ? (
<Button <Button
color="danger" variant="destructive"
variant="flat"
size="sm" size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onUninstall() onUninstall()
}} }}
isDisabled={loading} disabled={loading}
fullWidth> className="w-full">
{loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')} {loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button> </Button>
) : ( ) : (
<Button <Button
color="primary" variant="secondary"
variant="flat"
size="sm" size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onInstall() onInstall()
}} }}
isDisabled={loading} disabled={loading}
fullWidth> className="w-full">
{loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
{loading ? t('plugins.installing') : t('plugins.install')} {loading ? t('plugins.installing') : t('plugins.install')}
</Button> </Button>
)} )}