refactor(ui): enhance CustomCollapse and ToolsCallingIcon components

- Refactored CustomCollapse to utilize Accordion and AccordionItem from HeroUI, simplifying props and improving functionality.
- Updated ToolsCallingIcon to accept TooltipProps for better customization.
- Revised stories for CustomCollapse to reflect new prop structure and added examples for various use cases.
- Cleaned up unnecessary props and improved documentation in story files.
This commit is contained in:
MyPrototypeWhat 2025-09-17 15:45:22 +08:00
parent 76b3ba5d7e
commit 1b04fd065d
6 changed files with 204 additions and 265 deletions

View File

@ -1,88 +1,42 @@
import { Accordion, AccordionItem } from '@heroui/react'
import { Accordion, AccordionItem, type AccordionItemProps, type AccordionProps } from '@heroui/react'
import type { FC } from 'react'
import { memo, useEffect, useState } from 'react'
import { memo } from 'react'
// 重新导出 HeroUI 的组件,方便直接使用
export { Accordion, AccordionItem } from '@heroui/react'
interface CustomCollapseProps {
label: React.ReactNode
extra?: React.ReactNode
children: React.ReactNode
destroyInactivePanel?: boolean
defaultActiveKey?: string[]
activeKey?: string[]
collapsible?: 'header' | 'icon' | 'disabled'
onChange?: (activeKeys: string | string[]) => void
style?: React.CSSProperties
classNames?: {
trigger?: string
content?: string
}
className?: string
variant?: 'light' | 'shadow' | 'bordered' | 'splitted'
accordionProps?: Omit<AccordionProps, 'children'>
accordionItemProps?: Omit<AccordionItemProps, 'children'>
}
const CustomCollapse: FC<CustomCollapseProps> = ({
label,
extra,
children,
defaultActiveKey = ['1'],
activeKey,
collapsible,
onChange,
style,
classNames,
className = '',
variant = 'bordered'
}) => {
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => {
if (activeKey !== undefined) {
return new Set(activeKey)
}
return new Set(defaultActiveKey)
})
const CustomCollapse: FC<CustomCollapseProps> = ({ children, accordionProps = {}, accordionItemProps = {} }) => {
// 解构 Accordion 的 props
const {
defaultExpandedKeys = ['1'],
variant = 'bordered',
className = '',
isDisabled = false,
...restAccordionProps
} = accordionProps
useEffect(() => {
if (activeKey !== undefined) {
setExpandedKeys(new Set(activeKey))
}
}, [activeKey])
const handleSelectionChange = (keys: 'all' | Set<React.Key>) => {
if (keys === 'all') return
const stringKeys = Array.from(keys).map((key) => String(key))
const newExpandedKeys = new Set(stringKeys)
if (activeKey === undefined) {
setExpandedKeys(newExpandedKeys)
}
onChange?.(stringKeys.length === 1 ? stringKeys[0] : stringKeys)
}
const isDisabled = collapsible === 'disabled'
// 解构 AccordionItem 的 props
const { title = 'Collapse Panel', ...restAccordionItemProps } = accordionItemProps
return (
<Accordion
className={className}
style={style}
defaultExpandedKeys={defaultExpandedKeys}
variant={variant}
defaultExpandedKeys={activeKey === undefined ? defaultActiveKey : undefined}
selectedKeys={activeKey !== undefined ? expandedKeys : undefined}
onSelectionChange={handleSelectionChange}
className={className}
isDisabled={isDisabled}
selectionMode="multiple">
selectionMode="multiple"
{...restAccordionProps}>
<AccordionItem
key="1"
aria-label={typeof label === 'string' ? label : 'collapse-item'}
title={label}
startContent={extra}
classNames={{
trigger: classNames?.trigger ?? '',
content: classNames?.content ?? ''
}}>
aria-label={typeof title === 'string' ? title : 'collapse-item'}
title={title}
{...restAccordionItemProps}>
{children}
</AccordionItem>
</Accordion>

View File

@ -1,22 +1,20 @@
// Original: src/renderer/src/components/Icons/ToolsCallingIcon.tsx
import { Tooltip } from '@heroui/react'
import { Tooltip, type TooltipProps } from '@heroui/react'
import { Wrench } from 'lucide-react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '../../../utils'
interface ToolsCallingIconProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string
iconClassName?: string
TooltipProps?: TooltipProps
}
const ToolsCallingIcon = ({ className, iconClassName, ...props }: ToolsCallingIconProps) => {
const { t } = useTranslation()
const ToolsCallingIcon = ({ className, iconClassName, TooltipProps, ...props }: ToolsCallingIconProps) => {
return (
<div className={cn('flex justify-center items-center', className)} {...props}>
<Tooltip content={t('models.function_calling')} placement="top">
<Tooltip placement="top" {...TooltipProps}>
<Wrench className={cn('w-4 h-4 mr-1.5 text-[#00b96b]', iconClassName)} />
</Tooltip>
</div>

View File

@ -13,55 +13,17 @@ const meta: Meta<typeof CustomCollapse> = {
},
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
description: '面板标题'
},
extra: {
control: false,
description: '额外内容(副标题)'
},
children: {
control: false,
description: '面板内容'
},
defaultActiveKey: {
accordionProps: {
control: false,
description: '默认激活的面板键值'
description: 'Accordion 组件的属性'
},
activeKey: {
accordionItemProps: {
control: false,
description: '当前激活的面板键值(受控模式)'
},
onChange: {
control: false,
description: '面板状态变化回调'
},
collapsible: {
control: 'select',
options: ['header', 'icon', 'disabled'],
description: '折叠触发方式'
},
className: {
control: 'text',
description: '额外的 CSS 类名'
},
variant: {
control: 'select',
options: ['light', 'shadow', 'bordered', 'splitted'],
description: 'HeroUI 样式变体'
},
destroyInactivePanel: {
control: 'boolean',
description: '是否销毁非激活面板'
},
style: {
control: false,
description: '自定义样式'
},
styles: {
control: false,
description: '自定义头部和内容样式'
description: 'AccordionItem 组件的属性'
}
}
}
@ -72,7 +34,9 @@ type Story = StoryObj<typeof meta>
// 基础用法
export const Default: Story = {
args: {
label: '默认折叠面板',
accordionItemProps: {
title: '默认折叠面板'
},
children: (
<div className="p-4">
<p></p>
@ -85,13 +49,17 @@ export const Default: Story = {
// 带副标题
export const WithSubtitle: Story = {
args: {
label: '带副标题的折叠面板',
extra: <span className="text-sm text-gray-500"></span>,
defaultActiveKey: ['1'],
accordionProps: {
defaultExpandedKeys: ['1']
},
accordionItemProps: {
title: '带副标题的折叠面板',
subtitle: <span className="text-sm text-gray-500"></span>
},
children: (
<div className="p-4">
<p></p>
<p> extra </p>
<p> subtitle </p>
</div>
)
}
@ -100,8 +68,12 @@ export const WithSubtitle: Story = {
// HeroUI 样式变体
export const VariantLight: Story = {
args: {
label: 'Light 变体',
variant: 'light',
accordionProps: {
variant: 'light'
},
accordionItemProps: {
title: 'Light 变体'
},
children: (
<div className="p-4">
<p> HeroUI Light </p>
@ -112,10 +84,14 @@ export const VariantLight: Story = {
export const VariantShadow: Story = {
args: {
label: 'Shadow 变体',
extra: '带阴影的面板样式',
variant: 'shadow',
className: 'p-2',
accordionProps: {
variant: 'shadow',
className: 'p-2'
},
accordionItemProps: {
title: 'Shadow 变体',
subtitle: '带阴影的面板样式'
},
children: (
<div className="p-4">
<p> HeroUI Shadow </p>
@ -126,8 +102,12 @@ export const VariantShadow: Story = {
export const VariantBordered: Story = {
args: {
label: 'Bordered 变体(默认)',
variant: 'bordered',
accordionProps: {
variant: 'bordered'
},
accordionItemProps: {
title: 'Bordered 变体(默认)'
},
children: (
<div className="p-4">
<p> HeroUI Bordered </p>
@ -138,8 +118,12 @@ export const VariantBordered: Story = {
export const VariantSplitted: Story = {
args: {
label: 'Splitted 变体',
variant: 'splitted',
accordionProps: {
variant: 'splitted'
},
accordionItemProps: {
title: 'Splitted 变体'
},
children: (
<div className="p-4">
<p> HeroUI Splitted </p>
@ -151,12 +135,14 @@ export const VariantSplitted: Story = {
// 富内容标题
export const RichLabel: Story = {
args: {
label: (
<div className="flex items-center gap-2">
<Settings className="text-default-500" size={20} />
<span></span>
</div>
),
accordionItemProps: {
title: (
<div className="flex items-center gap-2">
<Settings className="text-default-500" size={20} />
<span></span>
</div>
)
},
children: (
<div className="p-4">
<div className="space-y-3">
@ -181,17 +167,19 @@ export const RichLabel: Story = {
// 带警告提示
export const WithWarning: Story = {
args: {
label: (
<div className="flex items-center gap-2">
<Monitor className="text-primary" size={20} />
<span></span>
</div>
),
extra: (
<p className="flex">
2<span className="text-primary ml-1"></span>
</p>
),
accordionItemProps: {
title: (
<div className="flex items-center gap-2">
<Monitor className="text-primary" size={20} />
<span></span>
</div>
),
subtitle: (
<p className="flex">
2<span className="text-primary ml-1"></span>
</p>
)
},
children: (
<div className="p-4">
<p className="text-small"></p>
@ -207,9 +195,13 @@ export const WithWarning: Story = {
// 禁用状态
export const Disabled: Story = {
args: {
label: '禁用的折叠面板',
collapsible: 'disabled',
defaultActiveKey: ['1'],
accordionProps: {
isDisabled: true,
defaultExpandedKeys: ['1']
},
accordionItemProps: {
title: '禁用的折叠面板'
},
children: (
<div className="p-4">
<p></p>
@ -221,28 +213,36 @@ export const Disabled: Story = {
// 受控模式
export const ControlledMode: Story = {
render: function ControlledMode() {
const [activeKey, setActiveKey] = useState<string[]>(['1'])
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['1']))
return (
<div className="space-y-4">
<div className="flex gap-2">
<Button size="sm" onPress={() => setActiveKey(['1'])} color="primary">
<Button size="sm" onPress={() => setSelectedKeys(new Set(['1']))} color="primary">
</Button>
<Button size="sm" onPress={() => setActiveKey([])} color="default">
<Button size="sm" onPress={() => setSelectedKeys(new Set())} color="default">
</Button>
</div>
<CustomCollapse
label="受控的折叠面板"
activeKey={activeKey}
onChange={(keys) => setActiveKey(Array.isArray(keys) ? keys : [keys])}>
accordionProps={{
selectedKeys,
onSelectionChange: (keys) => {
if (keys !== 'all') {
setSelectedKeys(keys as Set<string>)
}
}
}}
accordionItemProps={{
title: '受控的折叠面板'
}}>
<div className="p-4">
<p></p>
<p></p>
</div>
</CustomCollapse>
<div className="text-sm text-gray-600">{activeKey.length > 0 ? '展开' : '收起'}</div>
<div className="text-sm text-gray-600">{selectedKeys.size > 0 ? '展开' : '收起'}</div>
</div>
)
}
@ -252,17 +252,21 @@ export const ControlledMode: Story = {
export const MultipleSinglePanels: Story = {
render: () => (
<div className="space-y-4">
<CustomCollapse label="第一个面板" defaultActiveKey={['1']}>
<CustomCollapse accordionProps={{ defaultExpandedKeys: ['1'] }} accordionItemProps={{ title: '第一个面板' }}>
<div className="p-4">
<p></p>
</div>
</CustomCollapse>
<CustomCollapse label="第二个面板" extra="带副标题">
<CustomCollapse
accordionItemProps={{
title: '第二个面板',
subtitle: '带副标题'
}}>
<div className="p-4">
<p></p>
</div>
</CustomCollapse>
<CustomCollapse label="第三个面板(禁用)" collapsible="disabled">
<CustomCollapse accordionProps={{ isDisabled: true }} accordionItemProps={{ title: '第三个面板(禁用)' }}>
<div className="p-4">
<p></p>
</div>
@ -330,22 +334,24 @@ export const NativeAccordionMultiple: Story = {
// 富内容面板
export const RichContent: Story = {
args: {
label: (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="text-default-500" size={20} />
<span></span>
accordionItemProps: {
title: (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="text-default-500" size={20} />
<span></span>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="flat" color="primary">
</Button>
<Button size="sm" variant="flat">
</Button>
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="flat" color="primary">
</Button>
<Button size="sm" variant="flat">
</Button>
</div>
</div>
),
)
},
children: (
<div className="p-4 space-y-4">
<div>
@ -371,11 +377,7 @@ export const RichContent: Story = {
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<textarea
className="w-full px-3 py-2 border border-gray-300 rounded-md"
rows={3}
placeholder="请输入描述"
/>
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} placeholder="请输入描述" />
</div>
</div>
)
@ -385,15 +387,19 @@ export const RichContent: Story = {
// 自定义样式
export const CustomStyles: Story = {
args: {
label: (
<div className="flex items-center gap-2">
<AlertTriangle className="text-warning" size={16} />
<span></span>
</div>
),
style: {
backgroundColor: 'rgba(255, 193, 7, 0.1)',
borderColor: 'var(--color-warning)'
accordionProps: {
style: {
backgroundColor: 'rgba(255, 193, 7, 0.1)',
borderColor: 'var(--color-warning)'
}
},
accordionItemProps: {
title: (
<div className="flex items-center gap-2">
<AlertTriangle className="text-warning" size={16} />
<span></span>
</div>
)
},
children: (
<div className="p-4 bg-warning-50 dark:bg-warning-900/20">
@ -425,7 +431,7 @@ export const NativeAccordionControlled: Story = {
selectedKeys={activeKeys}
onSelectionChange={(keys) => {
if (keys !== 'all') {
setActiveKeys(keys)
setActiveKeys(keys as Set<string>)
}
}}>
<AccordionItem key="1" title="受控面板 1">

View File

@ -124,8 +124,7 @@ export const LoadingStatesInButtons: Story = {
<button
type="button"
className="flex items-center gap-2 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
disabled
>
disabled>
<SvgSpinners180Ring size="16" />
<span>...</span>
</button>
@ -133,8 +132,7 @@ export const LoadingStatesInButtons: Story = {
<button
type="button"
className="flex items-center gap-2 rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600"
disabled
>
disabled>
<SvgSpinners180Ring size="16" />
<span></span>
</button>
@ -142,8 +140,7 @@ export const LoadingStatesInButtons: Story = {
<button
type="button"
className="flex items-center gap-2 rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600"
disabled
>
disabled>
<SvgSpinners180Ring size="16" />
<span></span>
</button>
@ -151,8 +148,7 @@ export const LoadingStatesInButtons: Story = {
<button
type="button"
className="flex items-center gap-2 rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
disabled
>
disabled>
<SvgSpinners180Ring size="16" className="text-gray-500" />
<span></span>
</button>
@ -323,18 +319,15 @@ export const InteractiveLoadingDemo: Story = {
onClick={() => {
// 演示用途 - 在实际应用中这里会触发真实的加载状态
alert(`触发 ${state.text} 加载状态`)
}}
>
}}>
<SvgSpinners180Ring size="16" />
<span>{state.text}...</span>
</button>
))}
</div>
<p className="text-xs text-gray-500">
</p>
<p className="text-xs text-gray-500"></p>
</div>
)
}
}
}

View File

@ -33,9 +33,7 @@ export const BasicToolsCallingIcon: Story = {
<div className="flex items-center gap-4">
<ToolsCallingIcon />
</div>
<p className="mt-2 text-sm text-gray-600">
"函数调用"
</p>
<p className="mt-2 text-sm text-gray-600">"函数调用"</p>
</div>
</div>
)
@ -123,9 +121,7 @@ export const ModelFeaturesContext: Story = {
<h4 className="font-medium">GPT-4 Turbo</h4>
<ToolsCallingIcon />
</div>
<p className="text-sm text-gray-600">
API
</p>
<p className="text-sm text-gray-600">API</p>
<div className="mt-2 flex gap-2">
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800"></span>
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800"></span>
@ -137,9 +133,7 @@ export const ModelFeaturesContext: Story = {
<h4 className="font-medium">Claude 3.5 Sonnet</h4>
<ToolsCallingIcon />
</div>
<p className="text-sm text-gray-600">
Anthropic的高性能模型使
</p>
<p className="text-sm text-gray-600">Anthropic的高性能模型使</p>
<div className="mt-2 flex gap-2">
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800"></span>
<span className="rounded bg-orange-100 px-2 py-1 text-xs text-orange-800"></span>
@ -151,9 +145,7 @@ export const ModelFeaturesContext: Story = {
<h4 className="font-medium">Llama 3.1 8B</h4>
{/* 不支持函数调用 */}
</div>
<p className="text-sm text-gray-600">
Meta的开源模型
</p>
<p className="text-sm text-gray-600">Meta的开源模型</p>
<div className="mt-2 flex gap-2">
<span className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-800"></span>
</div>
@ -175,9 +167,7 @@ export const ChatMessageContext: Story = {
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
<span className="font-medium">调用工具: weather_api</span>
</div>
<p className="text-sm text-blue-700">
...
</p>
<p className="text-sm text-blue-700">...</p>
</div>
<div className="rounded-lg bg-green-50 p-3">
@ -185,9 +175,7 @@ export const ChatMessageContext: Story = {
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
<span className="font-medium">调用工具: search_web</span>
</div>
<p className="text-sm text-green-700">
AI新闻...
</p>
<p className="text-sm text-green-700">AI新闻...</p>
</div>
<div className="rounded-lg bg-orange-50 p-3">
@ -195,9 +183,7 @@ export const ChatMessageContext: Story = {
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
<span className="font-medium">调用工具: code_interpreter</span>
</div>
<p className="text-sm text-orange-700">
Python代码计算结果...
</p>
<p className="text-sm text-orange-700">Python代码计算结果...</p>
</div>
</div>
</div>
@ -278,12 +264,9 @@ export const InteractiveToolSelection: Story = {
? 'border-gray-200 hover:border-blue-500'
: 'border-gray-200 opacity-60 cursor-not-allowed'
}`}
disabled={!tool.available}
>
disabled={!tool.available}>
<ToolsCallingIcon
iconClassName={`w-4 h-4 mr-1.5 ${
tool.available ? 'text-[#00b96b]' : 'text-gray-400'
}`}
iconClassName={`w-4 h-4 mr-1.5 ${tool.available ? 'text-[#00b96b]' : 'text-gray-400'}`}
/>
<div className="flex-1">
<div className="font-medium">{tool.name}</div>
@ -326,9 +309,7 @@ export const LoadingToolCalls: Story = {
<span className="font-medium text-green-800"></span>
<span className="text-green-600"></span>
</div>
<p className="mt-1 text-sm text-green-700">
22°C
</p>
<p className="mt-1 text-sm text-green-700"> 22°C</p>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
@ -337,9 +318,7 @@ export const LoadingToolCalls: Story = {
<span className="font-medium text-red-800"></span>
<span className="text-red-600"></span>
</div>
<p className="mt-1 text-sm text-red-700">
API密钥无效
</p>
<p className="mt-1 text-sm text-red-700">API密钥无效</p>
</div>
</div>
</div>
@ -386,4 +365,4 @@ export const SettingsPanel: Story = {
</div>
</div>
)
}
}

View File

@ -36,8 +36,13 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
const { t } = useTranslation()
const listRef = useRef<DynamicVirtualListRef>(null)
const handleCollapseChange = useCallback((activeKeys: string[] | string) => {
const isNowExpanded = Array.isArray(activeKeys) ? activeKeys.length > 0 : !!activeKeys
const handleCollapseChange = useCallback((keys: 'all' | Set<React.Key>) => {
if (keys === 'all') {
return
}
const stringKeys = Array.from(keys)
const isNowExpanded = Array.isArray(stringKeys) ? stringKeys.length > 0 : !!stringKeys
if (isNowExpanded) {
// 延迟到 DOM 可见后测量
requestAnimationFrame(() => listRef.current?.measure())
@ -47,30 +52,34 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
return (
<CustomCollapseWrapper>
<CustomCollapse
variant="shadow"
defaultActiveKey={defaultOpen ? ['1'] : []}
onChange={handleCollapseChange}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 'bold' }}>{groupName}</span>
</Flex>
}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')} mouseLeaveDelay={0}>
<Button
type="text"
className="toolbar-item"
icon={<Minus size={14} />}
onClick={(e) => {
e.stopPropagation()
onRemoveGroup()
}}
disabled={disabled}
/>
</Tooltip>
}
styles={{
trigger: 'p-[3px_calc(6px_+_var(--scrollbar-width))_3px_16px]'
accordionProps={{
variant: 'shadow',
defaultExpandedKeys: defaultOpen ? ['1'] : [],
onSelectionChange: handleCollapseChange
}}
accordionItemProps={{
startContent: (
<Tooltip title={t('settings.models.manage.remove_whole_group')} mouseLeaveDelay={0}>
<Button
type="text"
className="toolbar-item"
icon={<Minus size={14} />}
onClick={(e) => {
e.stopPropagation()
onRemoveGroup()
}}
disabled={disabled}
/>
</Tooltip>
),
classNames: {
trigger: 'p-[3px_calc(6px_+_var(--scrollbar-width))_3px_16px]'
},
title: (
<Flex align="center" gap={10}>
<span style={{ fontWeight: 'bold' }}>{groupName}</span>
</Flex>
)
}}>
<DynamicVirtualList
ref={listRef}
@ -114,7 +123,7 @@ const CustomCollapseWrapper = styled.div`
/* 移除 collapse 的 padding转而在 scroller 内部调整 */
.ant-collapse-content-box {
padding: 0 !important;
}import { classNames } from '../../../../utils/style';
}
`