mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-04 03:40:33 +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 { 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 }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user