mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
feat: enhance Selector component with SearchableSelector and update exports
- Introduced a new SearchableSelector component for improved item selection with search functionality. - Updated the Selector component to streamline item selection and added type exports for better type safety. - Refactored the preferenceSchemas to use the new MathEngine type for better clarity. - Added comprehensive README documentation for the Selector component detailing usage and features. - Updated various components and stories to utilize the new Selector and SearchableSelector components.
This commit is contained in:
parent
6c71b92d1d
commit
db4fcac768
@ -130,7 +130,7 @@ export interface PreferenceSchemas {
|
||||
// redux/settings/fontSize
|
||||
'chat.message.font_size': number
|
||||
// redux/settings/mathEngine
|
||||
'chat.message.math.engine': string
|
||||
'chat.message.math.engine': PreferenceTypes.MathEngine
|
||||
// redux/settings/mathEnableSingleDollar
|
||||
'chat.message.math.single_dollar': boolean
|
||||
// redux/settings/foldDisplayMode
|
||||
|
||||
@ -70,6 +70,8 @@ export type ProxyMode = 'system' | 'custom' | 'none'
|
||||
|
||||
export type MultiModelFoldDisplayMode = 'expanded' | 'compact'
|
||||
|
||||
export type MathEngine = 'KaTeX' | 'MathJax' | 'none'
|
||||
|
||||
export enum UpgradeChannel {
|
||||
LATEST = 'latest', // 最新稳定版本
|
||||
RC = 'rc', // 公测版本
|
||||
|
||||
333
packages/ui/src/components/base/Selector/README.md
Normal file
333
packages/ui/src/components/base/Selector/README.md
Normal file
@ -0,0 +1,333 @@
|
||||
# Selector 组件
|
||||
|
||||
基于 HeroUI Select 封装的下拉选择组件,简化了 Set 和 Selection 的转换逻辑。
|
||||
|
||||
## 核心特性
|
||||
|
||||
- ✅ **类型安全**: 单选和多选自动推断回调类型
|
||||
- ✅ **智能转换**: 自动处理 `Set<Key>` 和原始值的转换
|
||||
- ✅ **HeroUI 风格**: 保持与 HeroUI 生态一致的 API
|
||||
- ✅ **支持数字和字符串**: 泛型支持,自动识别值类型
|
||||
|
||||
## 基础用法
|
||||
|
||||
### 单选模式(默认)
|
||||
|
||||
```tsx
|
||||
import { Selector } from '@cherrystudio/ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
function Example() {
|
||||
const [language, setLanguage] = useState('zh-CN')
|
||||
|
||||
const languageOptions = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
{ label: '日本語', value: 'ja-JP' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Selector
|
||||
selectedKeys={language}
|
||||
onSelectionChange={(value) => {
|
||||
// value 类型自动推断为 string
|
||||
setLanguage(value)
|
||||
}}
|
||||
items={languageOptions}
|
||||
placeholder="选择语言"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 多选模式
|
||||
|
||||
```tsx
|
||||
import { Selector } from '@cherrystudio/ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
function Example() {
|
||||
const [languages, setLanguages] = useState(['zh-CN', 'en-US'])
|
||||
|
||||
const languageOptions = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
{ label: '日本語', value: 'ja-JP' },
|
||||
{ label: 'Français', value: 'fr-FR' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Selector
|
||||
selectionMode="multiple"
|
||||
selectedKeys={languages}
|
||||
onSelectionChange={(values) => {
|
||||
// values 类型自动推断为 string[]
|
||||
setLanguages(values)
|
||||
}}
|
||||
items={languageOptions}
|
||||
placeholder="选择语言"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 数字类型值
|
||||
|
||||
```tsx
|
||||
import { Selector } from '@cherrystudio/ui'
|
||||
|
||||
function Example() {
|
||||
const [priority, setPriority] = useState<number>(1)
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: '低', value: 1 },
|
||||
{ label: '中', value: 2 },
|
||||
{ label: '高', value: 3 }
|
||||
]
|
||||
|
||||
return (
|
||||
<Selector<number>
|
||||
selectedKeys={priority}
|
||||
onSelectionChange={(value) => {
|
||||
// value 类型为 number
|
||||
setPriority(value)
|
||||
}}
|
||||
items={priorityOptions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 禁用选项
|
||||
|
||||
```tsx
|
||||
const options = [
|
||||
{ label: '选项 1', value: '1' },
|
||||
{ label: '选项 2 (禁用)', value: '2', disabled: true },
|
||||
{ label: '选项 3', value: '3' }
|
||||
]
|
||||
|
||||
<Selector
|
||||
selectedKeys="1"
|
||||
onSelectionChange={handleChange}
|
||||
items={options}
|
||||
/>
|
||||
```
|
||||
|
||||
### 自定义 Label
|
||||
|
||||
```tsx
|
||||
import { Flex } from '@cherrystudio/ui'
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: (
|
||||
<Flex className="items-center gap-2">
|
||||
<span>🇨🇳</span>
|
||||
<span>中文</span>
|
||||
</Flex>
|
||||
),
|
||||
value: 'zh-CN'
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Flex className="items-center gap-2">
|
||||
<span>🇺🇸</span>
|
||||
<span>English</span>
|
||||
</Flex>
|
||||
),
|
||||
value: 'en-US'
|
||||
}
|
||||
]
|
||||
|
||||
<Selector
|
||||
selectedKeys="zh-CN"
|
||||
onSelectionChange={handleChange}
|
||||
items={options}
|
||||
/>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### SelectorProps
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `items` | `SelectorItem<V>[]` | - | 必填,选项列表 |
|
||||
| `selectedKeys` | `V` \| `V[]` | - | 受控的选中值(单选为单个值,多选为数组) |
|
||||
| `onSelectionChange` | `(key: V) => void` \| `(keys: V[]) => void` | - | 选择变化回调(类型根据 selectionMode 自动推断) |
|
||||
| `selectionMode` | `'single'` \| `'multiple'` | `'single'` | 选择模式 |
|
||||
| `placeholder` | `string` | - | 占位文本 |
|
||||
| `disabled` | `boolean` | `false` | 是否禁用 |
|
||||
| `isRequired` | `boolean` | `false` | 是否必填 |
|
||||
| `label` | `ReactNode` | - | 标签文本 |
|
||||
| `description` | `ReactNode` | - | 描述文本 |
|
||||
| `errorMessage` | `ReactNode` | - | 错误提示 |
|
||||
| ...rest | `SelectProps` | - | 其他 HeroUI Select 属性 |
|
||||
|
||||
### SelectorItem
|
||||
|
||||
```tsx
|
||||
interface SelectorItem<V = string | number> {
|
||||
label: string | ReactNode // 显示文本或自定义内容
|
||||
value: V // 选项值
|
||||
disabled?: boolean // 是否禁用
|
||||
[key: string]: any // 其他自定义属性
|
||||
}
|
||||
```
|
||||
|
||||
## 类型安全
|
||||
|
||||
组件使用 TypeScript 条件类型,根据 `selectionMode` 自动推断回调类型:
|
||||
|
||||
```tsx
|
||||
// 单选模式
|
||||
<Selector
|
||||
selectionMode="single" // 或省略(默认单选)
|
||||
selectedKeys={value} // 类型: V
|
||||
onSelectionChange={(v) => ...} // v 类型: V
|
||||
/>
|
||||
|
||||
// 多选模式
|
||||
<Selector
|
||||
selectionMode="multiple"
|
||||
selectedKeys={values} // 类型: V[]
|
||||
onSelectionChange={(vs) => ...} // vs 类型: V[]
|
||||
/>
|
||||
```
|
||||
|
||||
## 与 HeroUI Select 的区别
|
||||
|
||||
| 特性 | HeroUI Select | Selector (本组件) |
|
||||
|------|---------------|------------------|
|
||||
| `selectedKeys` | `Set<Key> \| 'all'` | `V` \| `V[]` (自动转换) |
|
||||
| `onSelectionChange` | `(keys: Selection) => void` | `(key: V) => void` \| `(keys: V[]) => void` |
|
||||
| 单选回调 | 返回 `Set` (需手动提取) | 直接返回单个值 |
|
||||
| 多选回调 | 返回 `Set` (需转数组) | 直接返回数组 |
|
||||
| 类型推断 | 无 | 根据 selectionMode 自动推断 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 显式声明 selectionMode
|
||||
|
||||
虽然单选是默认模式,但建议显式声明以提高代码可读性:
|
||||
|
||||
```tsx
|
||||
// ✅ 推荐
|
||||
<Selector selectionMode="single" ... />
|
||||
|
||||
// ⚠️ 可以但不够清晰
|
||||
<Selector ... />
|
||||
```
|
||||
|
||||
### 2. 使用泛型指定值类型
|
||||
|
||||
当值类型为数字或联合类型时,使用泛型获得更好的类型提示:
|
||||
|
||||
```tsx
|
||||
// ✅ 推荐
|
||||
<Selector<number> selectedKeys={priority} ... />
|
||||
|
||||
// ✅ 推荐(联合类型)
|
||||
type Status = 'pending' | 'approved' | 'rejected'
|
||||
<Selector<Status> selectedKeys={status} ... />
|
||||
```
|
||||
|
||||
### 3. 避免在渲染时创建 items
|
||||
|
||||
```tsx
|
||||
// ❌ 不推荐(每次渲染都创建新数组)
|
||||
<Selector items={[{ label: 'A', value: '1' }]} />
|
||||
|
||||
// ✅ 推荐(在组件外或使用 useMemo)
|
||||
const items = [{ label: 'A', value: '1' }]
|
||||
<Selector items={items} />
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从 antd Select 迁移
|
||||
|
||||
```tsx
|
||||
// antd Select
|
||||
import { Select } from 'antd'
|
||||
|
||||
<Select
|
||||
value={value}
|
||||
onChange={(value) => onChange(value)}
|
||||
options={[
|
||||
{ label: 'A', value: '1' },
|
||||
{ label: 'B', value: '2' }
|
||||
]}
|
||||
/>
|
||||
|
||||
// 迁移到 Selector
|
||||
import { Selector } from '@cherrystudio/ui'
|
||||
|
||||
<Selector
|
||||
selectedKeys={value} // value → selectedKeys
|
||||
onSelectionChange={(value) => onChange(value)} // onChange → onSelectionChange
|
||||
items={[ // options → items
|
||||
{ label: 'A', value: '1' },
|
||||
{ label: 'B', value: '2' }
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### 从旧版 Selector 迁移
|
||||
|
||||
```tsx
|
||||
// 旧版 Selector (返回数组)
|
||||
<Selector
|
||||
onSelectionChange={(values) => {
|
||||
const value = values[0] // 需要手动提取
|
||||
onChange(value)
|
||||
}}
|
||||
/>
|
||||
|
||||
// 新版 Selector (直接返回值)
|
||||
<Selector
|
||||
selectionMode="single"
|
||||
onSelectionChange={(value) => {
|
||||
onChange(value) // 直接使用
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么单选模式下还需要 selectedKeys 而不是 selectedKey?
|
||||
|
||||
A: 为了保持与 HeroUI API 命名的一致性,同时简化组件实现。组件内部会自动处理单个值和 Set 的转换。
|
||||
|
||||
### Q: 如何清空选择?
|
||||
|
||||
```tsx
|
||||
// 单选模式
|
||||
<Selector
|
||||
selectedKeys={value}
|
||||
onSelectionChange={setValue}
|
||||
isClearable // 添加清空按钮
|
||||
/>
|
||||
|
||||
// 或手动设置为 undefined
|
||||
setValue(undefined)
|
||||
```
|
||||
|
||||
### Q: 支持异步加载选项吗?
|
||||
|
||||
支持,配合 `isLoading` 属性使用:
|
||||
|
||||
```tsx
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems().then(data => {
|
||||
setItems(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
<Selector items={items} isLoading={loading} />
|
||||
```
|
||||
@ -0,0 +1,60 @@
|
||||
import { Autocomplete, AutocompleteItem } from '@heroui/react'
|
||||
import type { Key } from '@react-types/shared'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { SearchableSelectorItem, SearchableSelectorProps } from './types'
|
||||
|
||||
const SearchableSelector = <T extends SearchableSelectorItem>(props: SearchableSelectorProps<T>) => {
|
||||
const { items, onSelectionChange, selectedKeys, selectionMode = 'single', children, ...rest } = props
|
||||
|
||||
// 转换 selectedKeys: V | V[] → Key | undefined (Autocomplete 只支持单选)
|
||||
const autocompleteSelectedKey = useMemo(() => {
|
||||
if (selectedKeys === undefined) return undefined
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// Autocomplete 不支持多选,取第一个
|
||||
const keys = selectedKeys as T['value'][]
|
||||
return keys.length > 0 ? String(keys[0]) : undefined
|
||||
} else {
|
||||
return String(selectedKeys)
|
||||
}
|
||||
}, [selectedKeys, selectionMode])
|
||||
|
||||
// 处理选择变化
|
||||
const handleSelectionChange = (key: Key | null) => {
|
||||
if (!onSelectionChange || key === null) return
|
||||
|
||||
const strKey = String(key)
|
||||
// 尝试转换回数字类型
|
||||
const num = Number(strKey)
|
||||
const value = !isNaN(num) && items.some((item) => item.value === num) ? (num as T['value']) : (strKey as T['value'])
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// 多选模式: 返回数组 (Autocomplete 只支持单选,这里简化处理)
|
||||
;(onSelectionChange as (keys: T['value'][]) => void)([value])
|
||||
} else {
|
||||
// 单选模式: 返回单个值
|
||||
;(onSelectionChange as (key: T['value']) => void)(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认渲染函数
|
||||
const defaultRenderItem = (item: T) => (
|
||||
<AutocompleteItem key={String(item.value)} textValue={item.label ? String(item.label) : String(item.value)}>
|
||||
{item.label ?? item.value}
|
||||
</AutocompleteItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
{...rest}
|
||||
items={items}
|
||||
selectedKey={autocompleteSelectedKey}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
allowsCustomValue={false}>
|
||||
{children ?? defaultRenderItem}
|
||||
</Autocomplete>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchableSelector
|
||||
75
packages/ui/src/components/base/Selector/Selector.tsx
Normal file
75
packages/ui/src/components/base/Selector/Selector.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import type { Selection } from '@heroui/react'
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import type { Key } from '@react-types/shared'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { SelectorItem, SelectorProps } from './types'
|
||||
|
||||
const Selector = <T extends SelectorItem>(props: SelectorProps<T>) => {
|
||||
const { items, onSelectionChange, selectedKeys, selectionMode = 'single', children, ...rest } = props
|
||||
|
||||
// 转换 selectedKeys: V | V[] | undefined → Set<Key> | undefined
|
||||
const heroUISelectedKeys = useMemo(() => {
|
||||
if (selectedKeys === undefined) return undefined
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// 多选模式: V[] → Set<Key>
|
||||
return new Set((selectedKeys as T['value'][]).map((key) => String(key) as Key))
|
||||
} else {
|
||||
// 单选模式: V → Set<Key>
|
||||
return new Set([String(selectedKeys) as Key])
|
||||
}
|
||||
}, [selectedKeys, selectionMode])
|
||||
|
||||
// 处理选择变化,转换 Selection → V | V[]
|
||||
const handleSelectionChange = (keys: Selection) => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (keys === 'all') {
|
||||
// 如果是全选,返回所有非禁用项的值
|
||||
const allValues = items.filter((item) => !item.disabled).map((item) => item.value)
|
||||
if (selectionMode === 'multiple') {
|
||||
;(onSelectionChange as (keys: T['value'][]) => void)(allValues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 转换 Set<Key> 为原始类型
|
||||
const keysArray = Array.from(keys).map((key) => {
|
||||
const strKey = String(key)
|
||||
// 尝试转换回数字类型(如果原始值是数字)
|
||||
const num = Number(strKey)
|
||||
return !isNaN(num) && items.some((item) => item.value === num) ? (num as T['value']) : (strKey as T['value'])
|
||||
})
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// 多选模式: 返回数组
|
||||
;(onSelectionChange as (keys: T['value'][]) => void)(keysArray)
|
||||
} else {
|
||||
// 单选模式: 返回单个值
|
||||
if (keysArray.length > 0) {
|
||||
;(onSelectionChange as (key: T['value']) => void)(keysArray[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认渲染函数
|
||||
const defaultRenderItem = (item: T) => (
|
||||
<SelectItem key={String(item.value)} textValue={item.label ? String(item.label) : String(item.value)}>
|
||||
{item.label ?? item.value}
|
||||
</SelectItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...rest}
|
||||
items={items}
|
||||
selectionMode={selectionMode}
|
||||
selectedKeys={heroUISelectedKeys as 'all' | Iterable<Key> | undefined}
|
||||
onSelectionChange={handleSelectionChange}>
|
||||
{children ?? defaultRenderItem}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default Selector
|
||||
@ -1,51 +1,13 @@
|
||||
import type { Selection, SelectProps } from '@heroui/react'
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface SelectorItem<V = string | number> {
|
||||
label: string | ReactNode
|
||||
value: V
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface SelectorProps<V = string | number> extends Omit<SelectProps, 'children' | 'onSelectionChange'> {
|
||||
items: SelectorItem<V>[]
|
||||
onSelectionChange?: (keys: V[]) => void
|
||||
}
|
||||
|
||||
const Selector = <V extends string | number>({ items, onSelectionChange, ...rest }: SelectorProps<V>) => {
|
||||
// 处理选择变化,转换 Set 为数组
|
||||
const handleSelectionChange = (keys: Selection) => {
|
||||
if (!onSelectionChange) return
|
||||
if (keys === 'all') {
|
||||
// 如果是全选,返回所有非禁用项的值
|
||||
const allValues = items.filter((item) => !item.disabled).map((item) => item.value)
|
||||
onSelectionChange(allValues)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换 Set<Key> 为原始类型数组
|
||||
const keysArray = Array.from(keys).map((key) => {
|
||||
const strKey = String(key)
|
||||
// 尝试转换回数字类型(如果原始值是数字)
|
||||
const num = Number(strKey)
|
||||
return !isNaN(num) && items.some((item) => item.value === num) ? num : strKey
|
||||
}) as V[]
|
||||
|
||||
onSelectionChange(keysArray)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select {...rest} items={items} onSelectionChange={handleSelectionChange}>
|
||||
{({ value, label, ...restItem }: SelectorItem<V>) => (
|
||||
<SelectItem {...restItem} key={value} title={String(label)}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default Selector
|
||||
export type { SelectorItem, SelectorProps }
|
||||
// 统一导出 Selector 相关组件和类型
|
||||
export { default as SearchableSelector } from './SearchableSelector'
|
||||
export { default } from './Selector'
|
||||
export type {
|
||||
MultipleSearchableSelectorProps,
|
||||
MultipleSelectorProps,
|
||||
SearchableSelectorItem,
|
||||
SearchableSelectorProps,
|
||||
SelectorItem,
|
||||
SelectorProps,
|
||||
SingleSearchableSelectorProps,
|
||||
SingleSelectorProps
|
||||
} from './types'
|
||||
|
||||
79
packages/ui/src/components/base/Selector/types.ts
Normal file
79
packages/ui/src/components/base/Selector/types.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { AutocompleteProps, SelectProps } from '@heroui/react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface SelectorItem<V = string | number> {
|
||||
label?: string | ReactNode
|
||||
value: V
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 自定义渲染函数类型
|
||||
type SelectorRenderItem<T> = (item: T) => ReactElement
|
||||
|
||||
// 单选模式的 Props
|
||||
interface SingleSelectorProps<T extends SelectorItem = SelectorItem>
|
||||
extends Omit<SelectProps<T>, 'children' | 'onSelectionChange' | 'selectedKeys' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode?: 'single'
|
||||
selectedKeys?: T['value']
|
||||
onSelectionChange?: (key: T['value']) => void
|
||||
children?: SelectorRenderItem<T>
|
||||
}
|
||||
|
||||
// 多选模式的 Props
|
||||
interface MultipleSelectorProps<T extends SelectorItem = SelectorItem>
|
||||
extends Omit<SelectProps<T>, 'children' | 'onSelectionChange' | 'selectedKeys' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode: 'multiple'
|
||||
selectedKeys?: T['value'][]
|
||||
onSelectionChange?: (keys: T['value'][]) => void
|
||||
children?: SelectorRenderItem<T>
|
||||
}
|
||||
|
||||
type SelectorProps<T extends SelectorItem = SelectorItem> = SingleSelectorProps<T> | MultipleSelectorProps<T>
|
||||
|
||||
interface SearchableSelectorItem<V = string | number> {
|
||||
label?: string | ReactNode
|
||||
value: V
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 自定义渲染函数类型
|
||||
type SearchableRenderItem<T> = (item: T) => ReactElement
|
||||
|
||||
// 单选模式的 Props
|
||||
interface SingleSearchableSelectorProps<T extends SearchableSelectorItem = SearchableSelectorItem>
|
||||
extends Omit<AutocompleteProps<T>, 'children' | 'onSelectionChange' | 'selectedKey' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode?: 'single'
|
||||
selectedKeys?: T['value']
|
||||
onSelectionChange?: (key: T['value']) => void
|
||||
children?: SearchableRenderItem<T>
|
||||
}
|
||||
|
||||
// 多选模式的 Props
|
||||
interface MultipleSearchableSelectorProps<T extends SearchableSelectorItem = SearchableSelectorItem>
|
||||
extends Omit<AutocompleteProps<T>, 'children' | 'onSelectionChange' | 'selectedKey' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode: 'multiple'
|
||||
selectedKeys?: T['value'][]
|
||||
onSelectionChange?: (keys: T['value'][]) => void
|
||||
children?: SearchableRenderItem<T>
|
||||
}
|
||||
|
||||
type SearchableSelectorProps<T extends SearchableSelectorItem = SearchableSelectorItem> =
|
||||
| SingleSearchableSelectorProps<T>
|
||||
| MultipleSearchableSelectorProps<T>
|
||||
|
||||
export type {
|
||||
MultipleSearchableSelectorProps,
|
||||
MultipleSelectorProps,
|
||||
SearchableSelectorItem,
|
||||
SearchableSelectorProps,
|
||||
SelectorItem,
|
||||
SelectorProps,
|
||||
SingleSearchableSelectorProps,
|
||||
SingleSelectorProps
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// Base Components
|
||||
export { Avatar, AvatarGroup, type AvatarProps,EmojiAvatar } from './base/Avatar'
|
||||
export { Avatar, AvatarGroup, type AvatarProps, EmojiAvatar } from './base/Avatar'
|
||||
export { default as Button, type ButtonProps } from './base/Button'
|
||||
export { default as CopyButton } from './base/CopyButton'
|
||||
export { default as CustomCollapse } from './base/CustomCollapse'
|
||||
@ -48,8 +48,22 @@ export {
|
||||
export { default as SvgSpinners180Ring } from './icons/SvgSpinners180Ring'
|
||||
export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon'
|
||||
|
||||
// Interactive Components
|
||||
/* Interactive Components */
|
||||
|
||||
// Selector / SearchableSelector
|
||||
export { default as Selector } from './base/Selector'
|
||||
export { default as SearchableSelector } from './base/Selector/SearchableSelector'
|
||||
export type {
|
||||
MultipleSearchableSelectorProps,
|
||||
MultipleSelectorProps,
|
||||
SearchableSelectorItem,
|
||||
SearchableSelectorProps,
|
||||
SelectorItem,
|
||||
SelectorProps,
|
||||
SingleSearchableSelectorProps,
|
||||
SingleSelectorProps
|
||||
} from './base/Selector/types'
|
||||
// CodeEditor
|
||||
export {
|
||||
default as CodeEditor,
|
||||
type CodeEditorHandles,
|
||||
@ -58,14 +72,22 @@ export {
|
||||
getCmThemeByName,
|
||||
getCmThemeNames
|
||||
} from './interactive/CodeEditor'
|
||||
// CollapsibleSearchBar
|
||||
export { default as CollapsibleSearchBar } from './interactive/CollapsibleSearchBar'
|
||||
// DraggableList
|
||||
export { DraggableList, useDraggableReorder } from './interactive/DraggableList'
|
||||
// EditableNumber
|
||||
export type { EditableNumberProps } from './interactive/EditableNumber'
|
||||
// EditableNumber
|
||||
export { default as EditableNumber } from './interactive/EditableNumber'
|
||||
export { default as HelpTooltip } from './interactive/HelpTooltip'
|
||||
// ImageToolButton
|
||||
export { default as ImageToolButton } from './interactive/ImageToolButton'
|
||||
// InfoTooltip
|
||||
export { default as InfoTooltip } from './interactive/InfoTooltip'
|
||||
// Sortable
|
||||
export { Sortable } from './interactive/Sortable'
|
||||
// WarnTooltip
|
||||
export { default as WarnTooltip } from './interactive/WarnTooltip'
|
||||
|
||||
// Composite Components (复合组件)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import type { Meta } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Selector from '../../../src/components/base/Selector'
|
||||
@ -49,18 +49,18 @@ const meta: Meta<typeof Selector> = {
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// 基础用法
|
||||
export const Default: Story = {
|
||||
// 基础用法 - 单选
|
||||
export const Default = {
|
||||
render: function Render() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['react']))
|
||||
const [selectedValue, setSelectedValue] = useState<string>('react')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Selector
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(keys) => setSelectedKeys(new Set(keys.map(String)))}
|
||||
selectionMode="single"
|
||||
selectedKeys={selectedValue}
|
||||
onSelectionChange={(value) => setSelectedValue(value)}
|
||||
placeholder="选择框架"
|
||||
items={[
|
||||
{ value: 'react', label: 'React' },
|
||||
@ -70,7 +70,7 @@ export const Default: Story = {
|
||||
]}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选择: <code>{Array.from(selectedKeys).join(', ')}</code>
|
||||
当前选择: <code>{selectedValue}</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -78,16 +78,16 @@ export const Default: Story = {
|
||||
}
|
||||
|
||||
// 多选模式
|
||||
export const Multiple: Story = {
|
||||
export const Multiple = {
|
||||
render: function Render() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['react', 'vue']))
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>(['react', 'vue'])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Selector
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(keys) => setSelectedKeys(new Set(keys.map(String)))}
|
||||
selectedKeys={selectedValues}
|
||||
onSelectionChange={(values) => setSelectedValues(values)}
|
||||
placeholder="选择多个框架"
|
||||
items={[
|
||||
{ value: 'react', label: 'React' },
|
||||
@ -98,7 +98,7 @@ export const Multiple: Story = {
|
||||
]}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
已选择 ({selectedKeys.size}): {Array.from(selectedKeys).join(', ')}
|
||||
已选择 ({selectedValues.length}): {selectedValues.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -106,19 +106,16 @@ export const Multiple: Story = {
|
||||
}
|
||||
|
||||
// 数字值类型
|
||||
export const NumberValues: Story = {
|
||||
export const NumberValues = {
|
||||
render: function Render() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['2']))
|
||||
const [selectedValue, setSelectedValue] = useState<number>(2)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Selector
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(keys) => {
|
||||
setSelectedKeys(new Set(keys.map(String)))
|
||||
setSelectedValue(keys[0] as number)
|
||||
}}
|
||||
selectionMode="single"
|
||||
selectedKeys={selectedValue}
|
||||
onSelectionChange={(value) => setSelectedValue(value)}
|
||||
placeholder="选择优先级"
|
||||
items={[
|
||||
{ value: 1, label: '🔴 紧急' },
|
||||
@ -136,7 +133,7 @@ export const NumberValues: Story = {
|
||||
}
|
||||
|
||||
// 不同大小
|
||||
export const Sizes: Story = {
|
||||
export const Sizes = {
|
||||
render: function Render() {
|
||||
const items = [
|
||||
{ value: 'option1', label: '选项 1' },
|
||||
@ -164,22 +161,26 @@ export const Sizes: Story = {
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
selectedKeys: new Set(['react']),
|
||||
placeholder: '禁用的选择器',
|
||||
items: [
|
||||
{ value: 'react', label: 'React' },
|
||||
{ value: 'vue', label: 'Vue' }
|
||||
]
|
||||
export const Disabled = {
|
||||
render: function Render() {
|
||||
return (
|
||||
<Selector
|
||||
isDisabled
|
||||
selectedKeys="react"
|
||||
placeholder="禁用的选择器"
|
||||
items={[
|
||||
{ value: 'react', label: 'React' },
|
||||
{ value: 'vue', label: 'Vue' }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 实际应用场景:语言选择
|
||||
export const LanguageSelector: Story = {
|
||||
export const LanguageSelector = {
|
||||
render: function Render() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['zh']))
|
||||
const [selectedValue, setSelectedValue] = useState<string>('zh')
|
||||
|
||||
const languages = [
|
||||
{ value: 'zh', label: '🇨🇳 简体中文' },
|
||||
@ -193,13 +194,14 @@ export const LanguageSelector: Story = {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Selector
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(keys) => setSelectedKeys(new Set(keys.map(String)))}
|
||||
selectionMode="single"
|
||||
selectedKeys={selectedValue}
|
||||
onSelectionChange={(value) => setSelectedValue(value)}
|
||||
placeholder="选择语言"
|
||||
items={languages}
|
||||
/>
|
||||
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
当前语言: <strong>{languages.find((l) => l.value === Array.from(selectedKeys)[0])?.label}</strong>
|
||||
当前语言: <strong>{languages.find((l) => l.value === selectedValue)?.label}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, DescriptionSwitch, RowFlex, Selector } from '@cherrystudio/ui'
|
||||
import { Button, DescriptionSwitch, RowFlex, Selector, type SelectorItem } from '@cherrystudio/ui'
|
||||
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
|
||||
import EditableNumber from '@renderer/components/EditableNumber'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
@ -18,7 +18,7 @@ import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||
import type { SendMessageShortcut } from '@shared/data/preference/preferenceTypes'
|
||||
import type { MultiModelMessageStyle, SendMessageShortcut } from '@shared/data/preference/preferenceTypes'
|
||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Col, InputNumber, Row, Slider } from 'antd'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
@ -101,6 +101,63 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const messageStyleItems = useMemo<SelectorItem<'plain' | 'bubble'>[]>(
|
||||
() => [
|
||||
{ value: 'plain', label: t('message.message.style.plain') },
|
||||
{ value: 'bubble', label: t('message.message.style.bubble') }
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const multiModelMessageStyleItems = useMemo<SelectorItem<MultiModelMessageStyle>[]>(
|
||||
() => [
|
||||
{ value: 'fold', label: t('message.message.multi_model_style.fold.label') },
|
||||
{ value: 'vertical', label: t('message.message.multi_model_style.vertical') },
|
||||
{ value: 'horizontal', label: t('message.message.multi_model_style.horizontal') },
|
||||
{ value: 'grid', label: t('message.message.multi_model_style.grid') }
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const messageNavigationItems = useMemo<SelectorItem<'none' | 'buttons' | 'anchor'>[]>(
|
||||
() => [
|
||||
{ value: 'none', label: t('settings.messages.navigation.none') },
|
||||
{ value: 'buttons', label: t('settings.messages.navigation.buttons') },
|
||||
{ value: 'anchor', label: t('settings.messages.navigation.anchor') }
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const mathEngineItems = useMemo<SelectorItem<MathEngine>[]>(
|
||||
() => [
|
||||
{ value: 'KaTeX', label: 'KaTeX' },
|
||||
{ value: 'MathJax', label: 'MathJax' },
|
||||
{ value: 'none', label: t('settings.math.engine.none') }
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const codeStyleItems = useMemo<SelectorItem<CodeStyleVarious>[]>(
|
||||
() => themeNames.map((theme) => ({ value: theme, label: theme })),
|
||||
[themeNames]
|
||||
)
|
||||
|
||||
const targetLanguageItems = useMemo<SelectorItem<string>[]>(
|
||||
() => translateLanguages.map((item) => ({ value: item.langCode, label: item.emoji + ' ' + item.label() })),
|
||||
[translateLanguages]
|
||||
)
|
||||
|
||||
const sendMessageShortcutItems = useMemo<SelectorItem<SendMessageShortcut>[]>(
|
||||
() => [
|
||||
{ value: 'Enter', label: getSendMessageShortcutLabel('Enter') },
|
||||
{ value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') },
|
||||
{ value: 'Alt+Enter', label: getSendMessageShortcutLabel('Alt+Enter') },
|
||||
{ value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') },
|
||||
{ value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') }
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
|
||||
updateAssistantSettings(settings)
|
||||
}
|
||||
@ -342,12 +399,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Selector
|
||||
size="sm"
|
||||
label={t('message.message.style.label')}
|
||||
selectedKeys={[messageStyle]}
|
||||
onSelectionChange={(value) => setMessageStyle(value[0] as 'plain' | 'bubble')}
|
||||
items={[
|
||||
{ value: 'plain', label: t('message.message.style.plain') },
|
||||
{ value: 'bubble', label: t('message.message.style.bubble') }
|
||||
]}
|
||||
selectionMode="single"
|
||||
selectedKeys={messageStyle}
|
||||
onSelectionChange={(value) => setMessageStyle(value)}
|
||||
items={messageStyleItems}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -356,14 +411,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Selector
|
||||
size="sm"
|
||||
label={t('message.message.multi_model_style.label')}
|
||||
selectedKeys={[multiModelMessageStyle]}
|
||||
onSelectionChange={(value) => setMultiModelMessageStyle(value[0])}
|
||||
items={[
|
||||
{ value: 'fold', label: t('message.message.multi_model_style.fold.label') },
|
||||
{ value: 'vertical', label: t('message.message.multi_model_style.vertical') },
|
||||
{ value: 'horizontal', label: t('message.message.multi_model_style.horizontal') },
|
||||
{ value: 'grid', label: t('message.message.multi_model_style.grid') }
|
||||
]}
|
||||
selectionMode="single"
|
||||
selectedKeys={multiModelMessageStyle}
|
||||
onSelectionChange={(value) => setMultiModelMessageStyle(value)}
|
||||
items={multiModelMessageStyleItems}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -372,13 +423,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Selector
|
||||
size="sm"
|
||||
label={t('settings.messages.navigation.label')}
|
||||
selectedKeys={[messageNavigation]}
|
||||
onSelectionChange={(value) => setMessageNavigation(value[0] as 'none' | 'buttons' | 'anchor')}
|
||||
items={[
|
||||
{ value: 'none', label: t('settings.messages.navigation.none') },
|
||||
{ value: 'buttons', label: t('settings.messages.navigation.buttons') },
|
||||
{ value: 'anchor', label: t('settings.messages.navigation.anchor') }
|
||||
]}
|
||||
selectionMode="single"
|
||||
selectedKeys={messageNavigation}
|
||||
onSelectionChange={(value) => setMessageNavigation(value)}
|
||||
items={messageNavigationItems}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -412,13 +460,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Selector
|
||||
size="sm"
|
||||
label={t('settings.math.engine.label')}
|
||||
selectedKeys={[mathEngine]}
|
||||
onSelectionChange={(value) => setMathEngine(value[0] as MathEngine)}
|
||||
items={[
|
||||
{ value: 'KaTeX', label: 'KaTeX' },
|
||||
{ value: 'MathJax', label: 'MathJax' },
|
||||
{ value: 'none', label: t('settings.math.engine.none') }
|
||||
]}
|
||||
selectionMode="single"
|
||||
selectedKeys={mathEngine}
|
||||
onSelectionChange={(value) => setMathEngine(value)}
|
||||
items={mathEngineItems}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -444,12 +489,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Selector
|
||||
size="sm"
|
||||
label={t('message.message.code_style')}
|
||||
selectedKeys={[codeStyle]}
|
||||
onSelectionChange={(value) => onCodeStyleChange(value[0] as CodeStyleVarious)}
|
||||
items={themeNames.map((theme) => ({
|
||||
value: theme,
|
||||
label: theme
|
||||
}))}
|
||||
selectionMode="single"
|
||||
selectedKeys={codeStyle}
|
||||
onSelectionChange={(value) => onCodeStyleChange(value)}
|
||||
items={codeStyleItems}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -682,12 +725,11 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Selector
|
||||
size="sm"
|
||||
label={t('settings.input.target_language.label')}
|
||||
selectedKeys={[targetLanguage]}
|
||||
onSelectionChange={(value) => setTargetLanguage(value[0])}
|
||||
selectionMode="single"
|
||||
selectedKeys={targetLanguage}
|
||||
onSelectionChange={(value) => setTargetLanguage(value)}
|
||||
placeholder={UNKNOWN.emoji + ' ' + UNKNOWN.label()}
|
||||
items={translateLanguages.map((item) => {
|
||||
return { value: item.langCode, label: item.emoji + ' ' + item.label() }
|
||||
})}
|
||||
items={targetLanguageItems}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -696,15 +738,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Selector
|
||||
size="sm"
|
||||
label={t('settings.messages.input.send_shortcuts')}
|
||||
selectedKeys={[sendMessageShortcut]}
|
||||
onSelectionChange={(value) => setSendMessageShortcut(value[0] as SendMessageShortcut)}
|
||||
items={[
|
||||
{ value: 'Enter', label: getSendMessageShortcutLabel('Enter') },
|
||||
{ value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') },
|
||||
{ value: 'Alt+Enter', label: getSendMessageShortcutLabel('Alt+Enter') },
|
||||
{ value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') },
|
||||
{ value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') }
|
||||
]}
|
||||
selectionMode="single"
|
||||
selectedKeys={sendMessageShortcut}
|
||||
onSelectionChange={(value) => setSendMessageShortcut(value)}
|
||||
items={sendMessageShortcutItems}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user