mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🔧 refactor: improve Tools popover component structure and fix UI consistency (#11356)
* ✨ feat: add nativeButton prop to various components for improved UI consistency - Updated SwitchPanel, HeaderActions, ActionPopover, and ModelSwitchPanel to include nativeButton={false} for better button behavior. - Introduced ToolsList component to enhance the tools dropdown functionality in the ActionBar. - Refactored Tools component to utilize the new ToolsList and streamline the rendering of tool items. Signed-off-by: Innei <tukon479@gmail.com> * 🔧 refactor: correct CheckboxItem import and enhance Tools component structure - Fixed import path for CheckboxItem in multiple files to ensure consistent naming. - Introduced PopoverContent component to streamline the rendering of tool items in the Tools component. - Refactored Tools component to utilize PopoverContent for improved organization and maintainability. Signed-off-by: Innei <tukon479@gmail.com> * ✨ feat: enhance PopoverContent and ToolsList components for improved UI - Introduced static styles for header and footer in PopoverContent to enhance layout consistency. - Updated ToolsList to include itemIcon styling for better alignment and presentation of icons. - Modified ToolItem to remove padding for a cleaner appearance. - Added hasPadding prop to CheckboxItem for flexible padding control. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -42,6 +42,7 @@ const SwitchPanel = memo<PropsWithChildren>(({ children }) => {
|
||||
<Popover
|
||||
classNames={{ trigger: styles.trigger }}
|
||||
content={content}
|
||||
nativeButton={false}
|
||||
placement="bottomLeft"
|
||||
styles={{
|
||||
content: {
|
||||
|
||||
@@ -12,7 +12,7 @@ const HeaderActions = memo(() => {
|
||||
const { menuItems } = useMenu();
|
||||
|
||||
return (
|
||||
<DropdownMenu items={menuItems}>
|
||||
<DropdownMenu items={menuItems} nativeButton={false}>
|
||||
<ActionIcon icon={MoreHorizontal} size={DESKTOP_HEADER_ICON_SIZE} />
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
|
||||
import { useAgentId } from '../../hooks/useAgentId';
|
||||
import CheckboxItem from '../components/CheckbokWithLoading';
|
||||
import CheckboxItem from '../components/CheckboxWithLoading';
|
||||
|
||||
export const useControls = ({
|
||||
setModalOpen,
|
||||
|
||||
92
src/features/ChatInput/ActionBar/Tools/PopoverContent.tsx
Normal file
92
src/features/ChatInput/ActionBar/Tools/PopoverContent.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Flexbox, Icon, type ItemType, Segmented, usePopoverContext } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { ChevronRight, Store } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolsList, { toolsListStyles } from './ToolsList';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
footer: css`
|
||||
padding: 4px;
|
||||
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
header: css`
|
||||
padding: 8px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
trailingIcon: css`
|
||||
opacity: 0.5;
|
||||
`,
|
||||
}));
|
||||
|
||||
type TabType = 'all' | 'installed';
|
||||
|
||||
interface PopoverContentProps {
|
||||
activeTab: TabType;
|
||||
currentItems: ItemType[];
|
||||
enableKlavis: boolean;
|
||||
onOpenStore: () => void;
|
||||
onTabChange: (tab: TabType) => void;
|
||||
}
|
||||
|
||||
const PopoverContent = memo<PopoverContentProps>(
|
||||
({ activeTab, currentItems, enableKlavis, onTabChange, onOpenStore }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
const { close: closePopover } = usePopoverContext();
|
||||
|
||||
return (
|
||||
<Flexbox gap={0}>
|
||||
<div className={styles.header}>
|
||||
<Segmented
|
||||
block
|
||||
onChange={(v) => onTabChange(v as TabType)}
|
||||
options={[
|
||||
{
|
||||
label: t('tools.tabs.all', { defaultValue: 'all' }),
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
|
||||
value: 'installed',
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
value={activeTab}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 500,
|
||||
minHeight: enableKlavis ? 500 : undefined,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<ToolsList items={currentItems} />
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<div
|
||||
className={toolsListStyles.item}
|
||||
onClick={() => {
|
||||
closePopover();
|
||||
onOpenStore();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={toolsListStyles.itemIcon}>
|
||||
<Icon icon={Store} size={20} />
|
||||
</div>
|
||||
<div className={toolsListStyles.itemContent}>{t('tools.plugins.store')}</div>
|
||||
<Icon className={styles.trailingIcon} icon={ChevronRight} size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PopoverContent.displayName = 'PopoverContent';
|
||||
|
||||
export default PopoverContent;
|
||||
@@ -5,7 +5,7 @@ import PluginTag from '@/components/Plugins/PluginTag';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { customPluginSelectors } from '@/store/tool/selectors';
|
||||
|
||||
import CheckboxItem, { type CheckboxItemProps } from '../components/CheckbokWithLoading';
|
||||
import CheckboxItem, { type CheckboxItemProps } from '../components/CheckboxWithLoading';
|
||||
|
||||
const ToolItem = memo<CheckboxItemProps>(({ id, onUpdate, label, checked }) => {
|
||||
const isCustom = useToolStore((s) => customPluginSelectors.isCustomPlugin(id)(s));
|
||||
@@ -13,6 +13,7 @@ const ToolItem = memo<CheckboxItemProps>(({ id, onUpdate, label, checked }) => {
|
||||
return (
|
||||
<CheckboxItem
|
||||
checked={checked}
|
||||
hasPadding={false}
|
||||
id={id}
|
||||
label={
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
|
||||
107
src/features/ChatInput/ActionBar/Tools/ToolsList.tsx
Normal file
107
src/features/ChatInput/ActionBar/Tools/ToolsList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Flexbox, Icon, type ItemType, Text } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Fragment, isValidElement, memo } from 'react';
|
||||
|
||||
export const toolsListStyles = createStaticStyles(({ css }) => ({
|
||||
groupLabel: css`
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
`,
|
||||
item: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
itemContent: css`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`,
|
||||
itemIcon: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ToolItemData {
|
||||
children?: ToolItemData[];
|
||||
extra?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
key?: string;
|
||||
label?: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: 'group' | 'divider';
|
||||
}
|
||||
|
||||
interface ToolsListProps {
|
||||
items: ItemType[];
|
||||
}
|
||||
|
||||
const ToolsList = memo<ToolsListProps>(({ items }) => {
|
||||
const renderItem = (item: ToolItemData, index: number) => {
|
||||
if (item.type === 'divider') {
|
||||
return <Divider key={`divider-${index}`} style={{ margin: '4px 0' }} />;
|
||||
}
|
||||
|
||||
if (item.type === 'group') {
|
||||
return (
|
||||
<Fragment key={item.key || `group-${index}`}>
|
||||
<Text className={toolsListStyles.groupLabel} fontSize={12} type="secondary">
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.children?.map((child, childIndex) => renderItem(child, childIndex))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular item
|
||||
// icon can be: ReactNode (already rendered), LucideIcon/ForwardRef (needs Icon wrapper), or undefined
|
||||
const iconNode = item.icon ? (
|
||||
isValidElement(item.icon) ? (
|
||||
item.icon
|
||||
) : (
|
||||
<Icon icon={item.icon as any} size={20} />
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={toolsListStyles.item}
|
||||
key={item.key || `item-${index}`}
|
||||
onClick={item.onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{iconNode && <div className={toolsListStyles.itemIcon}>{iconNode}</div>}
|
||||
<div className={toolsListStyles.itemContent}>{item.label}</div>
|
||||
{item.extra}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={0} padding={4}>
|
||||
{items.map((item, index) => renderItem(item as ToolItemData, index))}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default ToolsList;
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Segmented } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { Suspense, memo, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -12,46 +10,17 @@ import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfi
|
||||
|
||||
import { useAgentId } from '../../hooks/useAgentId';
|
||||
import Action from '../components/Action';
|
||||
import PopoverContent from './PopoverContent';
|
||||
import { useControls } from './useControls';
|
||||
|
||||
type TabType = 'all' | 'installed';
|
||||
|
||||
const prefixCls = 'ant';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
dropdown: css`
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
|
||||
background: ${cssVar.colorBgElevated};
|
||||
box-shadow: ${cssVar.boxShadowSecondary};
|
||||
|
||||
.${prefixCls}-dropdown-menu {
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`,
|
||||
header: css`
|
||||
padding: ${cssVar.paddingXS};
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
background: transparent;
|
||||
`,
|
||||
scroller: css`
|
||||
overflow: hidden auto;
|
||||
`,
|
||||
}));
|
||||
|
||||
const Tools = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
||||
const { marketItems, installedPluginItems } = useControls({
|
||||
setModalOpen,
|
||||
setUpdating,
|
||||
});
|
||||
|
||||
@@ -82,52 +51,26 @@ const Tools = memo(() => {
|
||||
return (
|
||||
<Suspense fallback={<Action disabled icon={Blocks} title={t('tools.title')} />}>
|
||||
<Action
|
||||
dropdown={{
|
||||
maxWidth: 320,
|
||||
menu: {
|
||||
items: [...currentItems],
|
||||
style: {
|
||||
// let only the custom scroller scroll
|
||||
maxHeight: 'unset',
|
||||
overflowY: 'visible',
|
||||
},
|
||||
},
|
||||
minHeight: enableKlavis ? 500 : undefined,
|
||||
minWidth: 320,
|
||||
popupRender: (menu) => (
|
||||
<div className={styles.dropdown}>
|
||||
<div className={styles.header}>
|
||||
<Segmented
|
||||
block
|
||||
onChange={(v) => setActiveTab(v as TabType)}
|
||||
options={[
|
||||
{
|
||||
label: t('tools.tabs.all', { defaultValue: 'all' }),
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
|
||||
value: 'installed',
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
value={effectiveTab}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={styles.scroller}
|
||||
style={{
|
||||
maxHeight: 500,
|
||||
minHeight: enableKlavis ? 500 : undefined,
|
||||
}}
|
||||
>
|
||||
{menu}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
icon={Blocks}
|
||||
loading={updating}
|
||||
popover={{
|
||||
content: (
|
||||
<PopoverContent
|
||||
activeTab={effectiveTab}
|
||||
currentItems={currentItems}
|
||||
enableKlavis={enableKlavis}
|
||||
onOpenStore={() => setModalOpen(true)}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
),
|
||||
maxWidth: 320,
|
||||
minWidth: 320,
|
||||
styles: {
|
||||
content: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
showTooltip={false}
|
||||
title={t('tools.title')}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { Avatar, Flexbox, Icon, Image, type ItemType } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ArrowRight, Store, ToyBrick } from 'lucide-react';
|
||||
import { ToyBrick } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -61,13 +61,7 @@ const LobehubSkillIcon = memo<Pick<LobehubSkillProviderType, 'icon' | 'label'>>(
|
||||
|
||||
LobehubSkillIcon.displayName = 'LobehubSkillIcon';
|
||||
|
||||
export const useControls = ({
|
||||
setModalOpen,
|
||||
setUpdating,
|
||||
}: {
|
||||
setModalOpen: (open: boolean) => void;
|
||||
setUpdating: (updating: boolean) => void;
|
||||
}) => {
|
||||
export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean) => void }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const agentId = useAgentId();
|
||||
const list = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
|
||||
@@ -234,18 +228,6 @@ export const useControls = ({
|
||||
),
|
||||
type: 'group',
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
extra: <Icon icon={ArrowRight} />,
|
||||
icon: Store,
|
||||
key: 'plugin-store',
|
||||
label: t('tools.plugins.store'),
|
||||
onClick: () => {
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 已安装 tab 的 items - 只显示已安装的插件
|
||||
|
||||
@@ -21,7 +21,7 @@ import { preferenceSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { useAgentId } from '../../hooks/useAgentId';
|
||||
import Action from '../components/Action';
|
||||
import CheckboxItem from '../components/CheckbokWithLoading';
|
||||
import CheckboxItem from '../components/CheckboxWithLoading';
|
||||
|
||||
const hotArea = css`
|
||||
&::before {
|
||||
|
||||
@@ -84,6 +84,7 @@ const ActionPopover = memo<ActionPopoverProps>(
|
||||
content: contentClassName,
|
||||
}}
|
||||
content={popoverContent}
|
||||
nativeButton={false}
|
||||
placement={isMobile ? 'top' : placement}
|
||||
styles={{
|
||||
...(typeof resolvedStyles === 'object' ? resolvedStyles : {}),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Center, Flexbox, Icon , Checkbox } from '@lobehub/ui';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { type ReactNode, memo, useState } from 'react';
|
||||
|
||||
export interface CheckboxItemProps {
|
||||
checked?: boolean;
|
||||
id: string;
|
||||
label?: ReactNode;
|
||||
onUpdate: (id: string, enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const CheckboxItem = memo<CheckboxItemProps>(({ id, onUpdate, label, checked }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateState = async () => {
|
||||
setLoading(true);
|
||||
await onUpdate(id, !checked);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
gap={24}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
updateState();
|
||||
}}
|
||||
style={{
|
||||
paddingLeft: 8,
|
||||
}}
|
||||
>
|
||||
{label || id}
|
||||
{loading ? (
|
||||
<Center width={18}>
|
||||
<Icon icon={Loader2} spin />
|
||||
</Center>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await updateState();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default CheckboxItem;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Center, Checkbox, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { type ReactNode, memo, useState } from 'react';
|
||||
|
||||
export interface CheckboxItemProps {
|
||||
checked?: boolean;
|
||||
hasPadding?: boolean;
|
||||
id: string;
|
||||
label?: ReactNode;
|
||||
onUpdate: (id: string, enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const CheckboxItem = memo<CheckboxItemProps>(
|
||||
({ id, onUpdate, label, checked, hasPadding = true }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateState = async () => {
|
||||
setLoading(true);
|
||||
await onUpdate(id, !checked);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
gap={24}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
updateState();
|
||||
}}
|
||||
style={
|
||||
hasPadding
|
||||
? {
|
||||
paddingLeft: 8,
|
||||
}
|
||||
: void 0
|
||||
}
|
||||
>
|
||||
{label || id}
|
||||
{loading ? (
|
||||
<Center width={18}>
|
||||
<Icon icon={Loader2} spin />
|
||||
</Center>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await updateState();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default CheckboxItem;
|
||||
@@ -43,6 +43,7 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
|
||||
provider={providerProp}
|
||||
/>
|
||||
}
|
||||
nativeButton={false}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
placement={placement}
|
||||
|
||||
Reference in New Issue
Block a user