返回 AiToEarn
steps.tsx
根目录 / project / aitoearn-web / src / components / ui / steps.tsx
1 /**
2 * Steps - 步骤条组件
3 * 用于显示步骤进度
4 */
5
6 'use client'
7
8 import { Check, Loader2, X } from 'lucide-react'
9 import { cn } from '@/utils/className'
10
11 export type StepStatus = 'wait' | 'process' | 'finish' | 'error'
12
13 export interface StepItem {
14 title: React.ReactNode
15 description?: React.ReactNode
16 status?: StepStatus
17 }
18
19 interface StepsProps {
20 /** 当前步骤索引 */
21 current?: number
22 /** 步骤项数组 */
23 items: StepItem[]
24 /** 方向,vertical 或 horizontal */
25 direction?: 'vertical' | 'horizontal'
26 /** 当前步骤图标样式 */
27 processIcon?: 'spinner' | 'number'
28 /** 自定义类名 */
29 className?: string
30 }
31
32 export function Steps({
33 current = 0,
34 items,
35 direction = 'horizontal',
36 processIcon = 'spinner',
37 className,
38 }: StepsProps) {
39 const isVertical = direction === 'vertical'
40
41 const getStepStatus = (index: number, itemStatus?: StepStatus): StepStatus => {
42 if (itemStatus)
43 return itemStatus
44 if (index < current)
45 return 'finish'
46 if (index === current)
47 return 'process'
48 return 'wait'
49 }
50
51 const getStepIcon = (index: number, status: StepStatus) => {
52 if (status === 'finish') {
53 return <Check className="w-5 h-5 text-white" />
54 }
55 if (status === 'process') {
56 if (processIcon === 'number')
57 return <span className="text-sm font-medium">{index + 1}</span>
58 return <Loader2 className="w-5 h-5 text-white animate-spin" />
59 }
60 if (status === 'error') {
61 return <X className="w-5 h-5 text-white" />
62 }
63 return <span className="text-sm font-medium">{index + 1}</span>
64 }
65
66 const getStepClassName = (status: StepStatus) => {
67 const base = 'flex items-center justify-center w-8 h-8 rounded-full border-2 transition-colors'
68 switch (status) {
69 case 'finish':
70 return cn(base, 'bg-primary border-primary text-white')
71 case 'process':
72 return cn(base, 'bg-primary border-primary text-white')
73 case 'error':
74 return cn(base, 'bg-destructive border-destructive text-white')
75 default:
76 return cn(base, 'bg-background border-muted-foreground/30 text-muted-foreground')
77 }
78 }
79
80 if (isVertical) {
81 return (
82 <div className={cn('flex flex-col gap-4', className)}>
83 {items.map((item, index) => {
84 const status = getStepStatus(index, item.status)
85 const isLast = index === items.length - 1
86
87 return (
88 <div key={index} className="flex gap-4">
89 {/* 步骤图标和连接线 */}
90 <div className="flex flex-col items-center">
91 <div className={getStepClassName(status)}>{getStepIcon(index, status)}</div>
92 {!isLast && (
93 <div
94 className={cn(
95 'w-0.5 flex-1 mt-2',
96 status === 'finish' || status === 'process'
97 ? 'bg-primary'
98 : 'bg-muted-foreground/30',
99 )}
100 />
101 )}
102 </div>
103
104 {/* 步骤内容 */}
105 <div className="flex-1 pb-4">
106 <div
107 className={cn(
108 'font-medium mb-1',
109 status === 'process'
110 ? 'text-primary'
111 : status === 'error'
112 ? 'text-destructive'
113 : 'text-foreground',
114 )}
115 >
116 {item.title}
117 </div>
118 {item.description && (
119 <div className="text-sm text-muted-foreground">{item.description}</div>
120 )}
121 </div>
122 </div>
123 )
124 })}
125 </div>
126 )
127 }
128
129 // Horizontal layout
130 return (
131 <div className={cn('flex items-center', className)}>
132 {items.map((item, index) => {
133 const status = getStepStatus(index, item.status)
134 const isLast = index === items.length - 1
135
136 return (
137 <div key={index} className="flex items-center flex-1">
138 <div className="flex flex-col items-center flex-1">
139 {/* 步骤图标 */}
140 <div className={getStepClassName(status)}>{getStepIcon(index, status)}</div>
141
142 {/* 步骤内容 */}
143 <div className="mt-2 text-center">
144 <div
145 className={cn(
146 'font-medium text-sm',
147 status === 'process'
148 ? 'text-primary'
149 : status === 'error'
150 ? 'text-destructive'
151 : 'text-foreground',
152 )}
153 >
154 {item.title}
155 </div>
156 {item.description && (
157 <div className="text-xs text-muted-foreground mt-1">{item.description}</div>
158 )}
159 </div>
160 </div>
161
162 {/* 连接线 */}
163 {!isLast && (
164 <div
165 className={cn(
166 'h-0.5 flex-1 mx-2',
167 status === 'finish' || status === 'process'
168 ? 'bg-primary'
169 : 'bg-muted-foreground/30',
170 )}
171 />
172 )}
173 </div>
174 )
175 })}
176 </div>
177 )
178 }
179
179 lines Plain Text