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 { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader } from 'lucide-react'
import * as React from 'react'
const buttonVariants = cva(
@ -38,14 +39,46 @@ function Button({
variant,
size,
asChild = false,
loading = false,
loadingIcon,
loadingIconClassName,
disabled,
children,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
loading?: boolean
loadingIcon?: React.ReactNode
loadingIconClassName?: string
}) {
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 }

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 EmojiPicker from '../EmojiPicker'
@ -12,7 +13,9 @@ export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => {
return (
<Popover>
<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>
<PopoverContent>
<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 { Progress } from '@heroui/progress'
import { Spinner } from '@heroui/spinner'
@ -517,10 +517,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
<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')}
</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')}
</Button>
</div>
@ -564,10 +564,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
{t('settings.data.export_to_phone.lan.confirm_close_message')}
</span>
<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')}
</Button>
<Button size="sm" color="danger" onPress={handleForceClose}>
<Button size="sm" variant="destructive" onClick={handleForceClose}>
{t('settings.data.export_to_phone.lan.force_close')}
</Button>
</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 { handleSaveData } from '@renderer/store'
import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
@ -82,12 +83,11 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInf
</Button>
<Button
color="primary"
onClick={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
loading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>

View File

@ -1,5 +1,6 @@
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 { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
@ -137,23 +138,23 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
<Button
aria-label={t('agent.toolPermission.aria.denyRequest')}
className="h-8"
color="danger"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingDeny}
onPress={() => handleDecision('deny')}
startContent={<CircleX size={16} />}
variant="bordered">
variant="outline"
disabled={isSubmitting || isExpired}
loading={isSubmittingDeny}
onClick={() => handleDecision('deny')}
loadingIcon={<CircleX size={16} />}>
<CircleX size={16} />
{t('agent.toolPermission.button.cancel')}
</Button>
<Button
aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
className="h-8 px-3 bg-green-600 hover:bg-green-700 text-white"
disabled={isSubmitting || isExpired}
loading={isSubmittingAllow}
onClick={() => handleDecision('allow')}
loadingIcon={<CirclePlay size={16} />}>
<CirclePlay size={16} />
{t('agent.toolPermission.button.run')}
</Button>
@ -161,10 +162,10 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
aria-label={
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
}
className="h-8"
isIconOnly
onPress={() => setShowDetails((value) => !value)}
variant="light">
size="icon"
className="h-8 w-8"
onClick={() => setShowDetails((value) => !value)}
variant="ghost">
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />
</Button>
</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 { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
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} />
<AdvancedSettings agentBase={session} update={update} />
<Divider className="my-2" />
<Button size="sm" fullWidth onClick={onMoreSetting}>
<Button size="sm" className="w-full" onClick={onMoreSetting}>
{t('settings.moresetting.label')}
</Button>
</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 type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { Plus } from 'lucide-react'
@ -66,7 +67,9 @@ export const AccessibleDirsSetting = ({ base, update }: AccessibleDirsSettingPro
<SettingsTitle
actions={
<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>
}>
{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}>
{path}
</span>
<Button size="sm" variant="ghost" color="danger" onClick={() => removeAccessiblePath(path)}>
<Button size="sm" variant="destructive" onClick={() => removeAccessiblePath(path)}>
{t('common.delete')}
</Button>
</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 { Trash2 } from 'lucide-react'
import type { FC } from 'react'
@ -81,13 +82,12 @@ export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, o
</TableCell>
<TableCell>
<Button
size="sm"
color="danger"
variant="light"
isIconOnly
onPress={() => handleUninstall(plugin)}
isLoading={uninstallingPlugin === plugin.filename}
isDisabled={loading}>
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => handleUninstall(plugin)}
loading={uninstallingPlugin === plugin.filename}
disabled={loading}>
<Trash2 className="h-4 w-4" />
</Button>
</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 { Filter, Search } from 'lucide-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' }}>
<DropdownTrigger>
<Button
isIconOnly
variant={selectedCategories.length > 0 ? 'flat' : 'light'}
color={selectedCategories.length > 0 ? 'primary' : 'default'}
size="sm"
size="icon-sm"
variant={selectedCategories.length > 0 ? 'default' : 'ghost'}
className="-translate-y-1/2 absolute top-1/2 right-2 z-10">
<Filter className="h-4 w-4" />
</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 { upperFirst } from 'lodash'
import { Download, Trash2 } from 'lucide-react'
@ -56,30 +57,28 @@ export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall,
<CardFooter className="pt-2">
{installed ? (
<Button
color="danger"
variant="flat"
variant="destructive"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onUninstall()
}}
isDisabled={loading}
fullWidth>
disabled={loading}
className="w-full">
{loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button>
) : (
<Button
color="primary"
variant="flat"
variant="secondary"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onInstall()
}}
isDisabled={loading}
fullWidth>
disabled={loading}
className="w-full">
{loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}