mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 04:31:27 +08:00
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:
parent
e06142b89a
commit
e56edbaa4f
@ -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 }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user