mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 11:44:28 +08:00
refactor(Navbar&Sidebar): rearrange navbar icons, add search functionality for assistants and topics (#7170)
* refactor(Navbar&Sidebar): rearrange navbar icons, add search functionality for assistants and topics - Updated ChatNavbar and MainNavbar to streamline the display of assistant icons based on their visibility state. - Introduced search input in MainSidebar for filtering assistants and topics. - Enhanced AssistantsTab and TopicsTab to support search functionality, allowing users to filter displayed items based on input. - Added a new utility function to improve search keyword matching logic. - Improved overall layout and styling for better user experience. * refactor: update icons * refactor(Search): allow clear * refactor: enhance search bar * refactor: improve search bar style * feat: new panel left icon
This commit is contained in:
parent
05727c637f
commit
202504fd17
@ -26,7 +26,7 @@ const Container = styled.div<{ $isNarrowMode: boolean }>`
|
||||
`
|
||||
|
||||
const Line = styled.div`
|
||||
width: 1.5px;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background-color: var(--color-text-2);
|
||||
border-radius: 5px;
|
||||
|
||||
42
src/renderer/src/components/Icons/PanelIcons.tsx
Normal file
42
src/renderer/src/components/Icons/PanelIcons.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
interface PanelIconProps extends Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> {
|
||||
size?: number | string
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
export const PanelLeftIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="lucide lucide-panel-left-icon lucide-panel-left"
|
||||
{...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
{expanded ? <path d="M10 7v10" strokeWidth={4} /> : <path d="M9 6v12" strokeWidth={2} />}
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PanelRightIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="lucide lucide-panel-right-icon lucide-panel-right"
|
||||
{...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
{expanded ? <path d="M14 7v10" strokeWidth={4} /> : <path d="M15 6v12" strokeWidth={2} />}
|
||||
</svg>
|
||||
)
|
||||
@ -1,5 +1,6 @@
|
||||
import { Navbar } from '@renderer/components/app/Navbar'
|
||||
import NarrowModeIcon from '@renderer/components/Icons/NarrowModeIcon'
|
||||
import { PanelLeftIcon } from '@renderer/components/Icons/PanelIcons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
@ -14,7 +15,7 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { setNarrowMode } from '@renderer/store/settings'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { LayoutGrid, PanelLeft, PanelRight, Search } from 'lucide-react'
|
||||
import { LayoutGrid, Search } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
@ -42,20 +43,20 @@ const ChatNavbar: FC = () => {
|
||||
<Navbar className="home-navbar">
|
||||
<NavbarContainer $isFullscreen={isFullscreen} $showSidebar={showAssistants} className="home-navbar-right">
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
{showAssistants ? <PanelLeft size={18} /> : <PanelRight size={18} />}
|
||||
</NavbarIcon>
|
||||
{!showAssistants && (
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
<PanelLeftIcon size={18} expanded={false} />
|
||||
</NavbarIcon>
|
||||
)}
|
||||
<SelectModelButton assistant={assistant} />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<UpdateAppButton />
|
||||
{isMac && (
|
||||
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={() => SearchPopup.show()}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('history.title')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={() => SearchPopup.show()}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={handleNarrowModeToggle}>
|
||||
<NarrowModeIcon isNarrowMode={narrowMode} />
|
||||
|
||||
@ -1,26 +1,24 @@
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { PanelLeftIcon } from '@renderer/components/Icons/PanelIcons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { MessageSquareDiff, Search } from 'lucide-react'
|
||||
import { MessageSquareDiff } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const HeaderNavbar: FC<Props> = () => {
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
return (
|
||||
<Container>
|
||||
<div>
|
||||
{!isMac && (
|
||||
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={() => SearchPopup.show()}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{showAssistants && (
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
<PanelLeftIcon size={18} expanded={true} />
|
||||
</NavbarIcon>
|
||||
)}
|
||||
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
|
||||
<MessageSquareDiff size={18} />
|
||||
@ -63,10 +61,4 @@ export const NavbarIcon = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const NarrowIcon = styled(NavbarIcon)`
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default HeaderNavbar
|
||||
|
||||
@ -32,7 +32,7 @@ import {
|
||||
Sun,
|
||||
SunMoon
|
||||
} from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useDeferredValue, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
@ -52,6 +52,7 @@ import {
|
||||
SubMenu
|
||||
} from './MainSidebarStyles'
|
||||
import OpenedMinappTabs from './OpenedMinapps'
|
||||
import SidebarSearch from './SidebarSearch'
|
||||
|
||||
type Tab = 'assistants' | 'topic'
|
||||
|
||||
@ -73,6 +74,9 @@ const MainSidebar: FC = () => {
|
||||
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
const [_searchValue, setSearchValue] = useState('')
|
||||
const searchValue = useDeferredValue(_searchValue)
|
||||
|
||||
useShortcut('toggle_show_assistants', toggleShowAssistants)
|
||||
useShortcut('toggle_show_topics', () => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR))
|
||||
|
||||
@ -175,6 +179,7 @@ const MainSidebar: FC = () => {
|
||||
}}>
|
||||
<MainNavbar />
|
||||
<MainMenu>
|
||||
<SidebarSearch onSearch={setSearchValue} />
|
||||
<MainMenuItem active={isAppMenuExpanded} onClick={() => setIsAppMenuExpanded(!isAppMenuExpanded)}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
@ -221,8 +226,8 @@ const MainSidebar: FC = () => {
|
||||
</AssistantContainer>
|
||||
)}
|
||||
<MainContainer>
|
||||
{tab === 'assistants' && <AssistantsTab />}
|
||||
{tab === 'topic' && <TopicsTab style={{ paddingTop: 4 }} />}
|
||||
{tab === 'assistants' && <AssistantsTab searchValue={searchValue} />}
|
||||
{tab === 'topic' && <TopicsTab searchValue={searchValue} style={{ paddingTop: 4 }} />}
|
||||
</MainContainer>
|
||||
<UserMenu>
|
||||
<UserMenuLeft onClick={() => UserPopup.show()}>
|
||||
|
||||
106
src/renderer/src/pages/home/MainSidebar/SidebarSearch.tsx
Normal file
106
src/renderer/src/pages/home/MainSidebar/SidebarSearch.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Input, InputRef } from 'antd'
|
||||
import { Search } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { MainMenuItem, MainMenuItemIcon, MainMenuItemLeft, MainMenuItemText } from './MainSidebarStyles'
|
||||
|
||||
interface SidebarSearchProps {
|
||||
onSearch: (text: string) => void
|
||||
}
|
||||
|
||||
const SidebarSearch: React.FC<SidebarSearchProps> = ({ onSearch }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(text: string) => {
|
||||
setSearchText(text)
|
||||
onSearch(text)
|
||||
},
|
||||
[onSearch]
|
||||
)
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
setIsExpanded(true)
|
||||
}, [])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setSearchText('')
|
||||
onSearch('')
|
||||
}, [onSearch])
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setSearchText('')
|
||||
setIsExpanded(false)
|
||||
onSearch('')
|
||||
}, [onSearch])
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCollapse()
|
||||
}
|
||||
},
|
||||
[handleCollapse]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isExpanded])
|
||||
|
||||
const renderInputBox = useMemo(() => {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={searchText}
|
||||
placeholder={t('chat.assistant.search.placeholder')}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={(e) => {
|
||||
// 如果输入框失焦且没有搜索内容,则收起
|
||||
if (!e.target.value.trim()) {
|
||||
handleCollapse()
|
||||
}
|
||||
}}
|
||||
onClear={handleClear}
|
||||
allowClear
|
||||
style={{
|
||||
paddingTop: 4
|
||||
}}
|
||||
prefix={
|
||||
<MainMenuItemIcon style={{ margin: '0 6px 0 -2px' }}>
|
||||
<Search size={18} className="icon" />
|
||||
</MainMenuItemIcon>
|
||||
}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)
|
||||
}, [handleClear, handleCollapse, handleInputKeyDown, handleTextChange, searchText, t])
|
||||
|
||||
const renderMenuItem = useMemo(() => {
|
||||
return (
|
||||
<MainMenuItem onClick={handleExpand} style={{ cursor: 'pointer' }}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
<Search size={18} className="icon" />
|
||||
</MainMenuItemIcon>
|
||||
<MainMenuItemText>{t('chat.assistant.search.placeholder')}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
</MainMenuItem>
|
||||
)
|
||||
}, [handleExpand, t])
|
||||
|
||||
return <SearchBarWrapper>{isExpanded ? renderInputBox : renderMenuItem}</SearchBarWrapper>
|
||||
}
|
||||
|
||||
const SearchBarWrapper = styled.div`
|
||||
height: 2.2rem;
|
||||
`
|
||||
|
||||
export default memo(SidebarSearch)
|
||||
@ -9,14 +9,19 @@ import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
|
||||
import { useTags } from '@renderer/hooks/useTags'
|
||||
import { Assistant, AssistantsSortType } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { includeKeywords } from '@renderer/utils/search'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC, useCallback, useRef, useState } from 'react'
|
||||
import { FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AssistantItem from './components/AssistantItem'
|
||||
|
||||
const Assistants: FC = () => {
|
||||
interface AssistantsTabProps {
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
const Assistants: FC<AssistantsTabProps> = ({ searchValue }) => {
|
||||
const { activeAssistant, setActiveAssistant } = useChat()
|
||||
const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants()
|
||||
const [dragging, setDragging] = useState(false)
|
||||
@ -27,6 +32,27 @@ const Assistants: FC = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
|
||||
// 过滤助手 - 根据名称搜索
|
||||
const filteredAssistants = useMemo(() => {
|
||||
if (!searchValue?.trim()) {
|
||||
return assistants
|
||||
}
|
||||
return assistants.filter((assistant) => includeKeywords(assistant.name || '', searchValue))
|
||||
}, [assistants, searchValue])
|
||||
|
||||
// 过滤分组助手 - 根据名称搜索
|
||||
const filteredGroupedAssistants = useMemo(() => {
|
||||
if (!searchValue?.trim()) {
|
||||
return getGroupedAssistants
|
||||
}
|
||||
return getGroupedAssistants
|
||||
.map((group) => ({
|
||||
...group,
|
||||
assistants: group.assistants.filter((assistant) => includeKeywords(assistant.name || '', searchValue))
|
||||
}))
|
||||
.filter((group) => group.assistants.length > 0)
|
||||
}, [getGroupedAssistants, searchValue])
|
||||
|
||||
const onCreateAssistant = async () => {
|
||||
const assistant = await AddAssistantPopup.show()
|
||||
assistant && setActiveAssistant(assistant)
|
||||
@ -166,7 +192,7 @@ const Assistants: FC = () => {
|
||||
<Container className="assistants-tab" ref={containerRef}>
|
||||
<DragableList
|
||||
droppableProps={{ type: 'TAG' }}
|
||||
list={getGroupedAssistants.map((_) => ({ ..._, disabled: _.tag === t('assistants.tags.untagged') }))}
|
||||
list={filteredGroupedAssistants.map((_) => ({ ..._, disabled: _.tag === t('assistants.tags.untagged') }))}
|
||||
onUpdate={() => {}}
|
||||
onDragEnd={handleGroupDragEnd}>
|
||||
{(group) => (
|
||||
@ -234,7 +260,7 @@ const Assistants: FC = () => {
|
||||
return (
|
||||
<Container className="assistants-tab" ref={containerRef}>
|
||||
<DragableList
|
||||
list={assistants}
|
||||
list={filteredAssistants}
|
||||
onUpdate={updateAssistants}
|
||||
style={{ paddingBottom: dragging ? '34px' : 0 }}
|
||||
onDragStart={() => setDragging(true)}
|
||||
|
||||
@ -36,6 +36,7 @@ import {
|
||||
topicToMarkdown
|
||||
} from '@renderer/utils/export'
|
||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import { includeKeywords } from '@renderer/utils/search'
|
||||
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
||||
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||
import dayjs from 'dayjs'
|
||||
@ -46,10 +47,11 @@ import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TopicsTabProps {
|
||||
searchValue?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const Topics: FC<TopicsTabProps> = ({ style }) => {
|
||||
const Topics: FC<TopicsTabProps> = ({ searchValue, style }) => {
|
||||
const { activeAssistant, activeTopic, setActiveTopic } = useChat()
|
||||
const { assistants } = useAssistants()
|
||||
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(activeAssistant.id)
|
||||
@ -429,10 +431,18 @@ const Topics: FC<TopicsTabProps> = ({ style }) => {
|
||||
return topics
|
||||
}, [topics, pinTopicsToTop])
|
||||
|
||||
// 过滤话题 - 根据名称搜索
|
||||
const filteredTopics = useMemo(() => {
|
||||
if (!searchValue?.trim()) {
|
||||
return sortedTopics
|
||||
}
|
||||
return sortedTopics.filter((topic) => includeKeywords(topic.name || '', searchValue))
|
||||
}, [sortedTopics, searchValue])
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
||||
<Container className={`topics-tab ${topicPosition === 'right' ? 'right' : ''}`} style={style}>
|
||||
<DragableList list={sortedTopics} onUpdate={updateTopics}>
|
||||
<DragableList list={filteredTopics} onUpdate={updateTopics}>
|
||||
{(topic) => {
|
||||
const isActive = topic.id === activeTopic?.id
|
||||
const topicName = topic.name.replace('`', '')
|
||||
|
||||
20
src/renderer/src/utils/search.ts
Normal file
20
src/renderer/src/utils/search.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 判断一个字符串是否包含由另一个字符串表示的 keywords
|
||||
* 将 keywords 按空白字符分割成多个关键词,检查目标字符串是否包含所有关键词
|
||||
* - 大小写不敏感
|
||||
* - 支持的分隔符:空格、制表符、换行符等各种空白字符
|
||||
*
|
||||
* @param target 被搜索的字符串
|
||||
* @param search 搜索词(用空白字符分隔)
|
||||
*/
|
||||
export function includeKeywords(target: string, search: string): boolean {
|
||||
if (!search?.trim()) return true
|
||||
if (!target) return false
|
||||
|
||||
const targetLower = target.toLowerCase()
|
||||
const searchLower = search.toLowerCase()
|
||||
|
||||
const keywords = searchLower.split(/\s+/).filter((keyword) => keyword.trim())
|
||||
|
||||
return keywords.every((keyword) => targetLower.includes(keyword))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user