diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index 5f87bbb2d9..36dc3416a8 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -2,7 +2,7 @@ import type { StorybookConfig } from '@storybook/react-vite' const config: StorybookConfig = { stories: ['../stories/components/**/*.stories.@(js|jsx|ts|tsx)'], - addons: ['@storybook/addon-docs'], + addons: ['@storybook/addon-docs', '@storybook/addon-themes'], framework: '@storybook/react-vite', viteFinal: async (config) => { const { mergeConfig } = await import('vite') diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 3830b9b44c..d0feaf4eea 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -1,16 +1,18 @@ import '../stories/tailwind.css' +import { withThemeByClassName } from '@storybook/addon-themes' import type { Preview } from '@storybook/react' const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i - } - } - } + decorators: [ + withThemeByClassName({ + themes: { + light: '', + dark: 'dark' + }, + defaultTheme: 'light' + }) + ] } export default preview diff --git a/packages/ui/package.json b/packages/ui/package.json index 8daeb736c7..cc0c083999 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -49,6 +49,7 @@ "devDependencies": { "@heroui/react": "^2.8.4", "@storybook/addon-docs": "^9.1.6", + "@storybook/addon-themes": "^9.1.6", "@storybook/react-vite": "^9.1.6", "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", diff --git a/packages/ui/src/components/base/CustomCollapse/index.tsx b/packages/ui/src/components/base/CustomCollapse/index.tsx index cd4cf2fc46..89699a2cdf 100644 --- a/packages/ui/src/components/base/CustomCollapse/index.tsx +++ b/packages/ui/src/components/base/CustomCollapse/index.tsx @@ -1,7 +1,9 @@ -// Original path: src/renderer/src/components/CustomCollapse.tsx -import { ChevronRight } from 'lucide-react' +import { Accordion, AccordionItem } from '@heroui/react' import type { FC } from 'react' -import { memo, useState } from 'react' +import { memo, useEffect, useState } from 'react' + +// 重新导出 HeroUI 的组件,方便直接使用 +export { Accordion, AccordionItem } from '@heroui/react' interface CustomCollapseProps { label: React.ReactNode @@ -12,59 +14,78 @@ interface CustomCollapseProps { 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' } const CustomCollapse: FC = ({ label, extra, children, - destroyInactivePanel = false, defaultActiveKey = ['1'], activeKey, - collapsible = undefined, + collapsible, onChange, - className = '' + style, + classNames, + className = '', + variant = 'bordered' }) => { - const [isOpen, setIsOpen] = useState(activeKey ? activeKey.includes('1') : defaultActiveKey.includes('1')) + const [expandedKeys, setExpandedKeys] = useState>(() => { + if (activeKey !== undefined) { + return new Set(activeKey) + } + return new Set(defaultActiveKey) + }) - const handleToggle = () => { - if (collapsible === 'disabled') return + useEffect(() => { + if (activeKey !== undefined) { + setExpandedKeys(new Set(activeKey)) + } + }, [activeKey]) - const newState = !isOpen - setIsOpen(newState) - onChange?.(newState ? ['1'] : []) + const handleSelectionChange = (keys: 'all' | Set) => { + 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 shouldRenderContent = !destroyInactivePanel || isOpen + const isDisabled = collapsible === 'disabled' return ( -
-
-
-
- {(collapsible === 'icon' || collapsible === undefined) && ( -
- -
- )} - {label} -
- {extra &&
{extra}
} -
-
- {isOpen &&
{shouldRenderContent && children}
} -
+ + + {children} + + ) } diff --git a/packages/ui/src/components/display/ProviderAvatar/index.tsx b/packages/ui/src/components/display/ProviderAvatar/index.tsx index 084c9180c3..7baa984ad2 100644 --- a/packages/ui/src/components/display/ProviderAvatar/index.tsx +++ b/packages/ui/src/components/display/ProviderAvatar/index.tsx @@ -19,7 +19,7 @@ export const ProviderAvatar: React.FC = ({ providerName, logoSrc, size = 'md', - className, + className = '', style, renderCustomLogo }) => { @@ -51,7 +51,7 @@ export const ProviderAvatar: React.FC = ({ if (customLogo) { return (
{customLogo}
@@ -65,7 +65,7 @@ export const ProviderAvatar: React.FC = ({ @@ -80,7 +80,7 @@ export const ProviderAvatar: React.FC = ({ , HTMLElement>> = (props) => { - const { t } = useTranslation() - - return ( - - - - - - ) -} - -const Container = styled.div` - display: flex; - justify-content: center; - align-items: center; -` - -const Icon = styled.i` - color: var(--color-link); - font-size: 16px; - margin-right: 6px; -` - -export default ReasoningIcon diff --git a/packages/ui/src/components/icons/SvgSpinners180Ring/index.tsx b/packages/ui/src/components/icons/SvgSpinners180Ring/index.tsx index b98a08f372..cd65a6b911 100644 --- a/packages/ui/src/components/icons/SvgSpinners180Ring/index.tsx +++ b/packages/ui/src/components/icons/SvgSpinners180Ring/index.tsx @@ -1,8 +1,14 @@ // Original path: src/renderer/src/components/Icons/SvgSpinners180Ring.tsx import type { SVGProps } from 'react' -export function SvgSpinners180Ring(props: SVGProps & { size?: number | string }) { - const { size = '1em', ...svgProps } = props +import { cn } from '../../../utils' + +interface SvgSpinners180RingProps extends SVGProps { + size?: number | string +} + +export function SvgSpinners180Ring(props: SvgSpinners180RingProps) { + const { size = '1em', className, ...svgProps } = props return ( & { size?: num height={size} viewBox="0 0 24 24" {...svgProps} - className={`animation-rotate ${svgProps.className || ''}`.trim()}> + className={cn('animate-spin', className)}> {/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */} & { size?: num ) } + export default SvgSpinners180Ring diff --git a/packages/ui/src/components/icons/ToolsCallingIcon/index.tsx b/packages/ui/src/components/icons/ToolsCallingIcon/index.tsx index c193d202c8..9282548ad5 100644 --- a/packages/ui/src/components/icons/ToolsCallingIcon/index.tsx +++ b/packages/ui/src/components/icons/ToolsCallingIcon/index.tsx @@ -1,33 +1,26 @@ // Original: src/renderer/src/components/Icons/ToolsCallingIcon.tsx -import { ToolOutlined } from '@ant-design/icons' -import { Tooltip } from 'antd' -import type { FC } from 'react' +import { Tooltip } from '@heroui/react' +import { Wrench } from 'lucide-react' import React from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' -const ToolsCallingIcon: FC, HTMLElement>> = (props) => { +import { cn } from '../../../utils' + +interface ToolsCallingIconProps extends React.HTMLAttributes { + className?: string + iconClassName?: string +} + +const ToolsCallingIcon = ({ className, iconClassName, ...props }: ToolsCallingIconProps) => { const { t } = useTranslation() return ( - - - +
+ + - +
) } -const Container = styled.div` - display: flex; - justify-content: center; - align-items: center; -` - -const Icon = styled(ToolOutlined)` - color: var(--color-primary); - font-size: 15px; - margin-right: 6px; -` - export default ToolsCallingIcon diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 4f765956d5..796d28e2b4 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -42,7 +42,6 @@ export { WebSearchIcon, WrapIcon } from './icons/Icon' -export { default as ReasoningIcon } from './icons/ReasoningIcon' export { default as SvgSpinners180Ring } from './icons/SvgSpinners180Ring' export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon' diff --git a/packages/ui/stories/components/base/CustomCollapse.stories.tsx b/packages/ui/stories/components/base/CustomCollapse.stories.tsx index e6a710f00d..ec96211b30 100644 --- a/packages/ui/stories/components/base/CustomCollapse.stories.tsx +++ b/packages/ui/stories/components/base/CustomCollapse.stories.tsx @@ -1,9 +1,9 @@ import { Button } from '@heroui/react' import type { Meta, StoryObj } from '@storybook/react' -import { AlertTriangle, Info, Settings } from 'lucide-react' +import { AlertTriangle, CreditCard, Info, Monitor, Settings, Shield } from 'lucide-react' import { useState } from 'react' -import CustomCollapse from '../../../src/components/base/CustomCollapse' +import CustomCollapse, { Accordion, AccordionItem } from '../../../src/components/base/CustomCollapse' const meta: Meta = { title: 'Base/CustomCollapse', @@ -14,20 +14,16 @@ const meta: Meta = { tags: ['autodocs'], argTypes: { label: { - control: false, - description: '折叠面板的标题内容' + control: 'text', + description: '面板标题' }, extra: { control: false, - description: '标题栏右侧的额外内容' + description: '额外内容(副标题)' }, children: { control: false, - description: '折叠面板的内容' - }, - destroyInactivePanel: { - control: 'boolean', - description: '是否销毁非活动面板的内容' + description: '面板内容' }, defaultActiveKey: { control: false, @@ -37,14 +33,35 @@ const meta: Meta = { control: false, description: '当前激活的面板键值(受控模式)' }, - collapsible: { - control: 'select', - options: ['header', 'icon', 'disabled', undefined], - 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: '自定义头部和内容样式' } } } @@ -52,6 +69,7 @@ const meta: Meta = { export default meta type Story = StoryObj +// 基础用法 export const Default: Story = { args: { label: '默认折叠面板', @@ -64,28 +82,78 @@ export const Default: Story = { } } -export const WithExtra: Story = { +// 带副标题 +export const WithSubtitle: Story = { args: { - label: '带额外内容的面板', - extra: ( - - ), + label: '带副标题的折叠面板', + extra: 这是副标题内容, + defaultActiveKey: ['1'], children: (
-

这个面板在标题栏右侧有一个额外的按钮。

-

额外内容不会触发折叠/展开操作。

+

面板内容

+

可以在 extra 属性中设置副标题

) } } -export const WithIcon: Story = { +// HeroUI 样式变体 +export const VariantLight: Story = { + args: { + label: 'Light 变体', + variant: 'light', + children: ( +
+

这是 HeroUI 的 Light 变体样式。

+
+ ) + } +} + +export const VariantShadow: Story = { + args: { + label: 'Shadow 变体', + extra: '带阴影的面板样式', + variant: 'shadow', + className: 'p-2', + children: ( +
+

这是 HeroUI 的 Shadow 变体样式。

+
+ ) + } +} + +export const VariantBordered: Story = { + args: { + label: 'Bordered 变体(默认)', + variant: 'bordered', + children: ( +
+

这是 HeroUI 的 Bordered 变体样式。

+
+ ) + } +} + +export const VariantSplitted: Story = { + args: { + label: 'Splitted 变体', + variant: 'splitted', + children: ( +
+

这是 HeroUI 的 Splitted 变体样式。

+
+ ) + } +} + +// 富内容标题 +export const RichLabel: Story = { args: { label: (
- + 设置面板
), @@ -110,71 +178,172 @@ export const WithIcon: Story = { } } -export const CollapsibleHeader: Story = { +// 带警告提示 +export const WithWarning: Story = { args: { - label: '点击整个标题栏展开/收起', - collapsible: 'header', + label: ( +
+ + 连接的设备 +
+ ), + extra: ( +

+ 2个问题需要立即修复 +

+ ), children: (
-

通过设置 collapsible="header",点击整个标题栏都可以触发折叠/展开。

-
- ) - } -} - -export const CollapsibleIcon: Story = { - args: { - label: '仅点击图标展开/收起', - collapsible: 'icon', - children: ( -
-

通过设置 collapsible="icon",只有点击左侧的箭头图标才能触发折叠/展开。

+

检测到以下设备连接异常:

+
    +
  • 外部显示器连接不稳定
  • +
  • 蓝牙键盘配对失败
  • +
) } } +// 禁用状态 export const Disabled: Story = { args: { label: '禁用的折叠面板', collapsible: 'disabled', + defaultActiveKey: ['1'], children: (
-

这个面板被禁用了,无法折叠或展开。

+

这个面板被禁用了,无法操作。

) } } -export const DestroyInactivePanel: Story = { - args: { - label: '销毁非活动内容', - destroyInactivePanel: true, - children: ( -
-

当 destroyInactivePanel=true 时,面板收起时会销毁内容,展开时重新渲染。

-

当前时间:{new Date().toLocaleTimeString()}

+// 受控模式 +export const ControlledMode: Story = { + render: function ControlledMode() { + const [activeKey, setActiveKey] = useState(['1']) + + return ( +
+
+ + +
+ setActiveKey(Array.isArray(keys) ? keys : [keys])}> +
+

这是一个受控的折叠面板

+

通过按钮控制展开和收起状态

+
+
+
当前状态:{activeKey.length > 0 ? '展开' : '收起'}
) } } +// 多个单面板组合 +export const MultipleSinglePanels: Story = { + render: () => ( +
+ +
+

第一个面板的内容

+
+
+ +
+

第二个面板的内容

+
+
+ +
+

这个面板被禁用了

+
+
+
+ ) +} + +// 使用原生 HeroUI Accordion 的多面板示例 +export const NativeAccordionMultiple: Story = { + render: () => ( +
+

原生 HeroUI Accordion 多面板

+ + } + subtitle={ +

+ 2个问题需要立即修复 +

+ }> +
+

设备连接状态监控

+
+
+ } + subtitle="3个应用有读取权限"> +
+

管理应用的系统权限设置

+
+
+ } + subtitle={请完善您的个人资料}> +
+

您还有一些信息需要完善

+
+
+ + 卡片已过期 + *4812 +

+ } + startContent={} + subtitle={请立即更新}> +
+

您的信用卡已过期,请更新支付信息

+
+
+
+
+ ) +} + +// 富内容面板 export const RichContent: Story = { args: { label: ( -
- - 详细信息 -
- ), - extra: ( -
- - +
+
+ + 详细信息 +
+
e.stopPropagation()}> + + +
), children: ( @@ -202,82 +371,82 @@ export const RichContent: Story = {
-