feat: add migration status documentation and new UI components

- Introduced migration status documentation in both English and Chinese to track the progress of the UI component library migration.
- Added new UI components including CopyButton, DividerWithText, EmojiIcon, IndicatorLight, Spinner, TextBadge, and various display and icon components.
- Updated the index.ts file to export the newly added components for easier access.
- Enhanced the directory structure and component classification guidelines for better organization and clarity.
This commit is contained in:
MyPrototypeWhat 2025-09-15 14:10:49 +08:00
parent bf2f6ddd7f
commit 2e07b4ea58
22 changed files with 691 additions and 2 deletions

View File

@ -0,0 +1,104 @@
# UI 组件库迁移状态
## 使用示例
```typescript
// 从 @cherrystudio/ui 导入组件
import { Spinner, DividerWithText, InfoTooltip } from '@cherrystudio/ui'
// 在组件中使用
function MyComponent() {
return (
<div>
<Spinner size={24} />
<DividerWithText text="分隔文本" />
<InfoTooltip content="提示信息" />
</div>
)
}
```
## 目录结构说明
```
@packages/ui/
├── src/
│ ├── components/ # 组件主目录
│ │ ├── base/ # 基础组件(按钮、输入框、标签等)
│ │ ├── display/ # 显示组件(卡片、列表、表格等)
│ │ ├── layout/ # 布局组件(容器、网格、间距等)
│ │ ├── icons/ # 图标组件
│ │ ├── interactive/ # 交互组件(弹窗、提示、下拉等)
│ │ └── composite/ # 复合组件(多个基础组件组合而成)
│ ├── hooks/ # 自定义 React Hooks
│ └── types/ # TypeScript 类型定义
```
### 组件分类指南
提交 PR 时,请根据组件功能将其放入正确的目录:
- **base**: 最基础的 UI 元素,如按钮、输入框、开关、标签等
- **display**: 用于展示内容的组件,如卡片、列表、表格、标签页等
- **layout**: 用于页面布局的组件,如容器、网格系统、分隔符等
- **icons**: 所有图标相关的组件
- **interactive**: 需要用户交互的组件,如模态框、抽屉、提示框、下拉菜单等
- **composite**: 复合组件,由多个基础组件组合而成
## 迁移概览
- **总组件数**: 236
- **已迁移**: 18
- **已重构**: 0
- **待迁移**: 218
## 组件状态表
| 组件名称 | 原路径 | 分类 | 迁移状态 | 重构状态 |
|---------|--------|------|---------|---------|
| CopyButton | src/renderer/src/components/CopyButton.tsx | base | ✅ | ❌ |
| DividerWithText | src/renderer/src/components/DividerWithText.tsx | base | ✅ | ❌ |
| EmojiIcon | src/renderer/src/components/EmojiIcon.tsx | base | ✅ | ❌ |
| IndicatorLight | src/renderer/src/components/IndicatorLight.tsx | base | ✅ | ❌ |
| Spinner | src/renderer/src/components/Spinner.tsx | base | ✅ | ❌ |
| TextBadge | src/renderer/src/components/TextBadge.tsx | base | ✅ | ❌ |
| Ellipsis | src/renderer/src/components/Ellipsis/index.tsx | display | ✅ | ❌ |
| ExpandableText | src/renderer/src/components/ExpandableText.tsx | display | ✅ | ❌ |
| ThinkingEffect | src/renderer/src/components/ThinkingEffect.tsx | display | ✅ | ❌ |
| HorizontalScrollContainer | src/renderer/src/components/HorizontalScrollContainer/index.tsx | layout | ✅ | ❌ |
| Scrollbar | src/renderer/src/components/Scrollbar/index.tsx | layout | ✅ | ❌ |
| VisionIcon | src/renderer/src/components/Icons/VisionIcon.tsx | icons | ✅ | ❌ |
| WebSearchIcon | src/renderer/src/components/Icons/WebSearchIcon.tsx | icons | ✅ | ❌ |
| ToolsCallingIcon | src/renderer/src/components/Icons/ToolsCallingIcon.tsx | icons | ✅ | ❌ |
| FileIcons | src/renderer/src/components/Icons/FileIcons.tsx | icons | ✅ | ❌ |
| SvgSpinners180Ring | src/renderer/src/components/Icons/SvgSpinners180Ring.tsx | icons | ✅ | ❌ |
| ReasoningIcon | src/renderer/src/components/Icons/ReasoningIcon.tsx | icons | ✅ | ❌ |
| InfoTooltip | src/renderer/src/components/TooltipIcons/InfoTooltip.tsx | interactive | ✅ | ❌ |
## 迁移步骤
### 第一阶段:复制迁移(当前阶段)
- 将组件原样复制到 @packages/ui
- 保留原有依赖antd、styled-components 等)
- 在文件顶部添加原路径注释
### 第二阶段:重构优化
- 移除 antd 依赖,替换为 HeroUI
- 移除 styled-components替换为 Tailwind CSS
- 优化组件 API 和类型定义
## 注意事项
1. **不迁移**包含以下依赖的组件**(解耦后可迁移)**
- window.api 调用
- ReduxuseSelector、useDispatch 等)
- 其他外部数据源
2. **可迁移**但需要后续解耦的组件:
- 使用 i18n 的组件(将 i18n 改为 props 传入)
- 使用 antd 的组件(后续替换为 HeroUI
3. **提交规范**
- 每次 PR 专注于一个类别的组件
- 确保所有迁移的组件都有导出
- 更新此文档的迁移状态

View File

@ -0,0 +1,106 @@
# UI Component Library Migration Status
## Usage Example
```typescript
// Import components from @cherrystudio/ui
import { Spinner, DividerWithText, InfoTooltip } from '@cherrystudio/ui'
// Use in components
function MyComponent() {
return (
<div>
<Spinner size={24} />
<DividerWithText text="Divider Text" />
<InfoTooltip content="Tooltip message" />
</div>
)
}
```
## Directory Structure
```
@packages/ui/
├── src/
│ ├── components/ # Main components directory
│ │ ├── base/ # Basic components (buttons, inputs, labels, etc.)
│ │ ├── display/ # Display components (cards, lists, tables, etc.)
│ │ ├── layout/ # Layout components (containers, grids, spacing, etc.)
│ │ ├── icons/ # Icon components
│ │ ├── interactive/ # Interactive components (modals, tooltips, dropdowns, etc.)
│ │ └── composite/ # Composite components (made from multiple base components)
│ ├── hooks/ # Custom React Hooks
│ └── types/ # TypeScript type definitions
```
### Component Classification Guide
When submitting PRs, please place components in the correct directory based on their function:
- **base**: Most basic UI elements like buttons, inputs, switches, labels, etc.
- **display**: Components for displaying content like cards, lists, tables, tabs, etc.
- **layout**: Components for page layout like containers, grid systems, dividers, etc.
- **icons**: All icon-related components
- **interactive**: Components requiring user interaction like modals, drawers, tooltips, dropdowns, etc.
- **composite**: Composite components made from multiple base components
## Migration Overview
- **Total Components**: 236
- **Migrated**: 18
- **Refactored**: 0
- **Pending Migration**: 218
## Component Status Table
| Component Name | Original Path | Category | Migration Status | Refactoring Status |
|---------------|---------------|----------|------------------|-------------------|
| CopyButton | src/renderer/src/components/CopyButton.tsx | base | ✅ | ❌ |
| DividerWithText | src/renderer/src/components/DividerWithText.tsx | base | ✅ | ❌ |
| EmojiIcon | src/renderer/src/components/EmojiIcon.tsx | base | ✅ | ❌ |
| IndicatorLight | src/renderer/src/components/IndicatorLight.tsx | base | ✅ | ❌ |
| Spinner | src/renderer/src/components/Spinner.tsx | base | ✅ | ❌ |
| TextBadge | src/renderer/src/components/TextBadge.tsx | base | ✅ | ❌ |
| Ellipsis | src/renderer/src/components/Ellipsis/index.tsx | display | ✅ | ❌ |
| ExpandableText | src/renderer/src/components/ExpandableText.tsx | display | ✅ | ❌ |
| ThinkingEffect | src/renderer/src/components/ThinkingEffect.tsx | display | ✅ | ❌ |
| HorizontalScrollContainer | src/renderer/src/components/HorizontalScrollContainer/index.tsx | layout | ✅ | ❌ |
| Scrollbar | src/renderer/src/components/Scrollbar/index.tsx | layout | ✅ | ❌ |
| VisionIcon | src/renderer/src/components/Icons/VisionIcon.tsx | icons | ✅ | ❌ |
| WebSearchIcon | src/renderer/src/components/Icons/WebSearchIcon.tsx | icons | ✅ | ❌ |
| ToolsCallingIcon | src/renderer/src/components/Icons/ToolsCallingIcon.tsx | icons | ✅ | ❌ |
| FileIcons | src/renderer/src/components/Icons/FileIcons.tsx | icons | ✅ | ❌ |
| SvgSpinners180Ring | src/renderer/src/components/Icons/SvgSpinners180Ring.tsx | icons | ✅ | ❌ |
| ReasoningIcon | src/renderer/src/components/Icons/ReasoningIcon.tsx | icons | ✅ | ❌ |
| InfoTooltip | src/renderer/src/components/TooltipIcons/InfoTooltip.tsx | interactive | ✅ | ❌ |
## Migration Steps
### Phase 1: Copy Migration (Current Phase)
- Copy components as-is to @packages/ui
- Retain original dependencies (antd, styled-components, etc.)
- Add original path comment at file top
### Phase 2: Refactor and Optimize
- Remove antd dependencies, replace with HeroUI
- Remove styled-components, replace with Tailwind CSS
- Optimize component APIs and type definitions
## Notes
1. **Do NOT migrate** components with these dependencies:
- window.api calls
- Redux (useSelector, useDispatch, etc.)
- Other external data sources
2. **Can migrate** but need decoupling later:
- Components using i18n (change i18n to props)
- Components using antd (replace with HeroUI later)
3. **Submission Guidelines**:
- Each PR should focus on one category of components
- Ensure all migrated components are exported
- Update migration status in this document

View File

@ -0,0 +1,69 @@
// Original path: src/renderer/src/components/CopyButton.tsx
import { Tooltip } from 'antd'
import { Copy } from 'lucide-react'
import { FC } from 'react'
import styled from 'styled-components'
interface CopyButtonProps {
tooltip?: string
label?: string
color?: string
hoverColor?: string
size?: number
}
interface ButtonContainerProps {
$color: string
$hoverColor: string
}
const CopyButton: FC<CopyButtonProps> = ({
tooltip,
label,
color = 'var(--color-text-2)',
hoverColor = 'var(--color-primary)',
size = 14,
...props
}) => {
const button = (
<ButtonContainer $color={color} $hoverColor={hoverColor} {...props}>
<Copy size={size} className="copy-icon" />
{label && <RightText size={size}>{label}</RightText>}
</ButtonContainer>
)
if (tooltip) {
return <Tooltip title={tooltip}>{button}</Tooltip>
}
return button
}
const ButtonContainer = styled.div<ButtonContainerProps>`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
cursor: pointer;
color: ${(props) => props.$color};
transition: color 0.2s;
.copy-icon {
color: ${(props) => props.$color};
transition: color 0.2s;
}
&:hover {
color: ${(props) => props.$hoverColor};
.copy-icon {
color: ${(props) => props.$hoverColor};
}
}
`
const RightText = styled.span<{ size: number }>`
font-size: ${(props) => props.size}px;
`
export default CopyButton

View File

@ -0,0 +1,49 @@
// Original path: src/renderer/src/components/EmojiIcon.tsx
import { FC } from 'react'
import styled from 'styled-components'
interface EmojiIconProps {
emoji: string
className?: string
size?: number
fontSize?: number
}
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className, size = 26, fontSize = 15 }) => {
return (
<Container className={className} $size={size} $fontSize={fontSize}>
<EmojiBackground>{emoji || '⭐️'}</EmojiBackground>
{emoji}
</Container>
)
}
const Container = styled.div<{ $size: number; $fontSize: number }>`
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: ${({ $size }) => $size / 2}px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: ${({ $fontSize }) => $fontSize}px;
position: relative;
overflow: hidden;
margin-right: 3px;
`
const EmojiBackground = styled.div`
width: 100%;
height: 100%;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 200%;
transform: scale(1.5);
filter: blur(5px);
opacity: 0.4;
`
export default EmojiIcon

View File

@ -0,0 +1,38 @@
import type { Variants } from 'motion/react'
export const lightbulbVariants: Variants = {
active: {
opacity: [1, 0.2, 1],
transition: {
duration: 1.2,
ease: 'easeInOut',
times: [0, 0.5, 1],
repeat: Infinity
}
},
idle: {
opacity: 1,
transition: {
duration: 0.3,
ease: 'easeInOut'
}
}
}
export const lightbulbSoftVariants: Variants = {
active: {
opacity: [1, 0.5, 1],
transition: {
duration: 2,
ease: 'easeInOut',
times: [0, 0.5, 1],
repeat: Infinity
}
},
idle: {
opacity: 1,
transition: {
duration: 0.3,
ease: 'easeInOut'
}
}
}

View File

@ -0,0 +1,190 @@
// Original path: src/renderer/src/components/ThinkingEffect.tsx
import { isEqual } from 'lodash'
import { ChevronRight, Lightbulb } from 'lucide-react'
import { motion } from 'motion/react'
import React, { useEffect, useMemo, useState } from 'react'
import styled from 'styled-components'
import { lightbulbVariants } from './defaultVariants'
interface Props {
isThinking: boolean
thinkingTimeText: React.ReactNode
content: string
expanded: boolean
}
const ThinkingEffect: React.FC<Props> = ({ isThinking, thinkingTimeText, content, expanded }) => {
const [messages, setMessages] = useState<string[]>([])
useEffect(() => {
const allLines = (content || '').split('\n')
const newMessages = isThinking ? allLines.slice(0, -1) : allLines
const validMessages = newMessages.filter((line) => line.trim() !== '')
if (!isEqual(messages, validMessages)) {
setMessages(validMessages)
}
}, [content, isThinking, messages])
const showThinking = useMemo(() => {
return isThinking && !expanded
}, [expanded, isThinking])
const LINE_HEIGHT = 14
const containerHeight = useMemo(() => {
if (!showThinking || messages.length < 1) return 38
return Math.min(75, Math.max(messages.length + 1, 2) * LINE_HEIGHT + 25)
}, [showThinking, messages.length])
return (
<ThinkingContainer style={{ height: containerHeight }} className={expanded ? 'expanded' : ''}>
<LoadingContainer>
<motion.div variants={lightbulbVariants} animate={isThinking ? 'active' : 'idle'} initial="idle">
<Lightbulb
size={!showThinking || messages.length < 2 ? 20 : 30}
style={{ transition: 'width,height, 150ms' }}
/>
</motion.div>
</LoadingContainer>
<TextContainer>
<Title className={!showThinking || !messages.length ? 'showThinking' : ''}>{thinkingTimeText}</Title>
{showThinking && (
<Content>
<Messages
style={{
height: messages.length * LINE_HEIGHT
}}
initial={{
y: -2
}}
animate={{
y: -messages.length * LINE_HEIGHT - 2
}}
transition={{
duration: 0.15,
ease: 'linear'
}}>
{messages.map((message, index) => {
if (index < messages.length - 5) return null
return <Message key={index}>{message}</Message>
})}
</Messages>
</Content>
)}
</TextContainer>
<ArrowContainer className={expanded ? 'expanded' : ''}>
<ChevronRight size={20} color="var(--color-text-3)" strokeWidth={1} />
</ArrowContainer>
</ThinkingContainer>
)
}
const ThinkingContainer = styled.div`
width: 100%;
border-radius: 10px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
border: 0.5px solid var(--color-border);
transition: height, border-radius, 150ms;
pointer-events: none;
user-select: none;
&.expanded {
border-radius: 10px 10px 0 0;
}
`
const Title = styled.div`
position: absolute;
inset: 0 0 auto 0;
font-size: 14px;
line-height: 14px;
font-weight: 500;
padding: 10px 0;
z-index: 99;
transition: padding-top 150ms;
&.showThinking {
padding-top: 12px;
}
`
const LoadingContainer = styled.div`
width: 50px;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-shrink: 0;
position: relative;
padding-left: 5px;
transition: width 150ms;
> div {
display: flex;
justify-content: center;
align-items: center;
}
`
const TextContainer = styled.div`
flex: 1;
height: 100%;
padding: 5px 0;
overflow: hidden;
position: relative;
`
const Content = styled.div`
width: 100%;
height: 100%;
mask: linear-gradient(
to bottom,
rgb(0 0 0 / 0%) 0%,
rgb(0 0 0 / 0%) 35%,
rgb(0 0 0 / 25%) 40%,
rgb(0 0 0 / 100%) 90%,
rgb(0 0 0 / 100%) 100%
);
position: relative;
`
const Messages = styled(motion.div)`
width: 100%;
position: absolute;
top: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
`
const Message = styled.div`
width: 100%;
line-height: 14px;
font-size: 11px;
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const ArrowContainer = styled.div`
width: 40px;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-shrink: 0;
position: relative;
color: var(--color-border);
transition: transform 150ms;
&.expanded {
transform: rotate(90deg);
}
`
export default ThinkingEffect

View File

@ -0,0 +1,71 @@
// Original path: src/renderer/src/components/Icons/FileIcons.tsx
import { CSSProperties, SVGProps } from 'react'
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
size?: string | number
text?: string
}
const textStyle: CSSProperties = {
fontStyle: 'italic',
fontSize: '7.70985px',
lineHeight: 0.8,
fontFamily: "'Times New Roman'",
textAlign: 'center',
writingMode: 'horizontal-tb',
direction: 'ltr',
textAnchor: 'middle',
fill: 'none',
stroke: '#000000',
strokeWidth: '0.289119',
strokeLinejoin: 'round',
strokeDasharray: 'none'
}
const tspanStyle: CSSProperties = {
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: 'normal',
fontStretch: 'condensed',
fontSize: '7.70985px',
lineHeight: 0.8,
fontFamily: 'Arial',
fill: '#000000',
fillOpacity: 1,
strokeWidth: '0.289119',
strokeDasharray: 'none'
}
const BaseFileIcon = ({ size = '1.1em', text = 'SVG', ...props }: BaseFileIconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
version="1.1"
id="svg4"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<defs id="defs4" />
<path d="m 14,2 v 4 a 2,2 0 0 0 2,2 h 4" id="path3" />
<path d="M 15,2 H 6 A 2,2 0 0 0 4,4 v 16 a 2,2 0 0 0 2,2 h 12 a 2,2 0 0 0 2,-2 V 7 Z" id="path4" />
<text
xmlSpace="preserve"
style={textStyle}
x="12.478625"
y="17.170216"
id="text4"
transform="scale(0.96196394,1.03954)">
<tspan id="tspan4" x="12.478625" y="17.170216" style={tspanStyle}>
{text}
</tspan>
</text>
</svg>
)
export const FileSvgIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="SVG" {...props} />
export const FilePngIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="PNG" {...props} />

View File

@ -0,0 +1,31 @@
// Original path: src/renderer/src/components/Icons/ReasoningIcon.tsx
import { Tooltip } from 'antd'
import React, { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const ReasoningIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
const { t } = useTranslation()
return (
<Container>
<Tooltip title={t('models.type.reasoning')} placement="top">
<Icon className="iconfont icon-thinking" {...(props as any)} />
</Tooltip>
</Container>
)
}
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

View File

@ -0,0 +1,22 @@
// Original path: src/renderer/src/components/Icons/SvgSpinners180Ring.tsx
import { SVGProps } from 'react'
export function SvgSpinners180Ring(props: SVGProps<SVGSVGElement> & { size?: number | string }) {
const { size = '1em', ...svgProps } = props
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
{...svgProps}
className={`animation-rotate ${svgProps.className || ''}`.trim()}>
{/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */}
<path
fill="currentColor"
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"></path>
</svg>
)
}
export default SvgSpinners180Ring

View File

@ -1,5 +1,7 @@
// Base Components
export { default as CopyButton } from './base/CopyButton'
export { default as DividerWithText } from './base/DividerWithText'
export { default as EmojiIcon } from './base/EmojiIcon'
export { default as IndicatorLight } from './base/IndicatorLight'
export { default as Spinner } from './base/Spinner'
export { default as TextBadge } from './base/TextBadge'
@ -7,15 +9,22 @@ export { default as TextBadge } from './base/TextBadge'
// Display Components
export { default as Ellipsis } from './display/Ellipsis'
export { default as ExpandableText } from './display/ExpandableText'
export { default as ThinkingEffect } from './display/ThinkingEffect'
// Layout Components
export { default as HorizontalScrollContainer } from './layout/HorizontalScrollContainer'
export { default as Scrollbar } from './layout/Scrollbar'
// Icon Components
export { FilePngIcon, FileSvgIcon } from './icons/FileIcons'
export { default as ReasoningIcon } from './icons/ReasoningIcon'
export { default as SvgSpinners180Ring } from './icons/SvgSpinners180Ring'
export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon'
export { default as VisionIcon } from './icons/VisionIcon'
export { default as WebSearchIcon } from './icons/WebSearchIcon'
// Interactive Components
export { default as InfoTooltip } from './interactive/InfoTooltip'
// Composite Components (复合组件)
// 暂无复合组件

View File

@ -18,4 +18,4 @@ const InfoTooltip = ({ iconColor = 'var(--color-text-2)', iconSize = 14, iconSty
)
}
export default InfoTooltip
export default InfoTooltip

View File

@ -3,7 +3,7 @@ import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import Scrollbar from './Scrollbar'
import Scrollbar from '../Scrollbar'
/**
*