返回 AiToEarn
SpaceItem.tsx
1 /**
2 * SpaceItem - 空间项组件
3 *
4 * 功能:
5 * - 显示空间信息和操作按钮
6 * - 支持编辑空间名称
7 * - 显示频道列表
8 * - 处理空间折叠展开
9 */
10
11 'use client'
12
13 import type { SocialAccount } from '@/api/accounts/account.types'
14 import type { PlatType } from '@/app/config/platConfig'
15 import {
16 Box,
17 ChevronDown,
18 ChevronRight,
19 ChevronUp,
20 Edit,
21 Loader2,
22 MoreVertical,
23 Plus,
24 Trash2,
25 } from 'lucide-react'
26 import { useTransClient } from '@/app/i18n/client'
27 import { Button } from '@/components/ui/button'
28 import {
29 DropdownMenu,
30 DropdownMenuContent,
31 DropdownMenuItem,
32 DropdownMenuSeparator,
33 DropdownMenuTrigger,
34 } from '@/components/ui/dropdown-menu'
35 import { Input } from '@/components/ui/input'
36 import { cn } from '@/utils/className'
37 import { ChannelItem } from './ChannelItem'
38
39 interface AccountGroup {
40 id: string
41 name: string
42 isDefault: boolean
43 }
44
45 interface SpaceItemProps {
46 space: AccountGroup
47 channels: SocialAccount[]
48 index: number
49 totalSpaces: number
50 isCollapsed: boolean
51 isEditing: boolean
52 editSpaceName: string
53 editingSpaceLoading: boolean
54 deleteLoading?: string | null
55 sortingLoading?: string | null
56 fansRefreshTarget?: 'all' | PlatType | null
57 onToggleCollapse: () => void
58 onStartEdit: () => void
59 onEditNameChange: (name: string) => void
60 onSaveEdit: () => void
61 onCancelEdit: () => void
62 onMoveUp?: () => void
63 onMoveDown?: () => void
64 onDelete: () => void
65 onChannelDelete: (channel: SocialAccount) => void
66 onChannelFansRefresh?: (platform: PlatType) => void
67 onRefresh: () => void
68 onAddChannel?: () => void
69 }
70
71 export function SpaceItem({
72 space,
73 channels,
74 index,
75 totalSpaces,
76 isCollapsed,
77 isEditing,
78 editSpaceName,
79 editingSpaceLoading,
80 deleteLoading,
81 sortingLoading,
82 fansRefreshTarget,
83 onToggleCollapse,
84 onStartEdit,
85 onEditNameChange,
86 onSaveEdit,
87 onCancelEdit,
88 onMoveUp,
89 onMoveDown,
90 onDelete,
91 onChannelDelete,
92 onChannelFansRefresh,
93 onRefresh,
94 onAddChannel,
95 }: SpaceItemProps) {
96 const { t } = useTransClient('account')
97
98 const canMoveUp = index > 0
99 const canMoveDown = index < totalSpaces - 1
100 const isSorting = sortingLoading === space.id
101
102 return (
103 <div
104 data-testid="cm-space-item"
105 className={cn(
106 'relative min-w-0 overflow-hidden rounded-lg border border-border/70 bg-background shadow-sm transition-shadow before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-gradient-back before:content-[""] hover:shadow-md',
107 isSorting && 'pointer-events-none opacity-60',
108 )}
109 >
110 {/* 空间标题栏 */}
111 <div className="flex items-center gap-1.5 overflow-hidden border-b border-border/70 bg-background px-2 py-3 transition-colors hover:bg-muted/20 sm:gap-3 sm:px-5">
112 {/* 可点击的折叠区域 */}
113 <div
114 data-testid="cm-space-toggle"
115 className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 sm:gap-3"
116 onClick={() => !isEditing && onToggleCollapse()}
117 >
118 {isCollapsed ? (
119 <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
120 ) : (
121 <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
122 )}
123
124 <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
125 <Box className="h-4 w-4" />
126 </span>
127
128 {isEditing ? (
129 <div
130 className="flex min-w-0 flex-1 flex-wrap gap-1.5 sm:flex-nowrap sm:gap-2"
131 onClick={e => e.stopPropagation()}
132 >
133 <Input
134 data-testid="cm-space-edit-input"
135 value={editSpaceName}
136 onChange={e => onEditNameChange(e.target.value)}
137 onKeyDown={(e) => {
138 if (e.key === 'Enter')
139 onSaveEdit()
140 if (e.key === 'Escape')
141 onCancelEdit()
142 }}
143 autoFocus
144 className="h-8 min-w-0 flex-1 rounded-lg text-sm"
145 />
146 <div className="flex gap-1.5 sm:gap-2">
147 <Button
148 onClick={onSaveEdit}
149 size="sm"
150 disabled={editingSpaceLoading}
151 className="cursor-pointer rounded-lg"
152 >
153 {editingSpaceLoading ? (
154 <Loader2 className="h-4 w-4 animate-spin sm:mr-1" />
155 ) : null}
156 <span className="hidden sm:inline">{t('common.save', '保存')}</span>
157 <span className="sm:hidden">{editingSpaceLoading ? '' : '✓'}</span>
158 </Button>
159 <Button
160 variant="outline"
161 onClick={onCancelEdit}
162 size="sm"
163 disabled={editingSpaceLoading}
164 className="cursor-pointer rounded-lg"
165 >
166 <span className="hidden sm:inline">{t('common.cancel', '取消')}</span>
167 <span className="sm:hidden">✕</span>
168 </Button>
169 </div>
170 </div>
171 ) : (
172 <>
173 <div className="min-w-0 flex-1">
174 <div data-testid="cm-space-name" className="truncate text-base font-semibold text-foreground">{space.name}</div>
175 </div>
176
177 <div className="shrink-0 text-sm font-medium text-muted-foreground">
178 <span className="hidden sm:inline">
179 {t('channelManager.channelCount', { count: channels.length })}
180 </span>
181 <span className="sm:hidden">{channels.length}</span>
182 </div>
183 </>
184 )}
185 </div>
186
187 {/* 操作按钮区域 - 不参与折叠点击 */}
188 {!isEditing && (
189 <>
190 {/* 添加频道按钮 */}
191 {onAddChannel && (
192 <Button
193 data-testid="cm-space-add-channel-btn"
194 variant="outline"
195 size="sm"
196 className="h-9 shrink-0 cursor-pointer rounded-lg border-primary/45 bg-background px-4 font-semibold text-primary shadow-sm hover:border-primary/70 hover:bg-primary/5 hover:text-primary"
197 onClick={(e) => {
198 e.stopPropagation()
199 onAddChannel()
200 }}
201 >
202 <Plus className="mr-1 h-4 w-4" />
203 <span>{t('channelManager.addChannel')}</span>
204 </Button>
205 )}
206
207 {isSorting && (
208 <div className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
209 <Loader2 className="h-3 w-3 animate-spin" />
210 {t('channelManager.sorting', '排序中...')}
211 </div>
212 )}
213
214 {!space.isDefault && (
215 <DropdownMenu>
216 <DropdownMenuTrigger asChild>
217 <Button
218 data-testid="cm-space-more-menu"
219 variant="ghost"
220 size="sm"
221 className="h-8 w-8 shrink-0 rounded-lg p-0 text-muted-foreground hover:text-foreground"
222 onClick={e => e.stopPropagation()}
223 >
224 <MoreVertical className="h-4 w-4" />
225 </Button>
226 </DropdownMenuTrigger>
227 <DropdownMenuContent align="end">
228 <DropdownMenuItem onClick={onStartEdit}>
229 <Edit className="mr-2 h-4 w-4" />
230 {t('actions.edit')}
231 </DropdownMenuItem>
232 {canMoveUp && (
233 <DropdownMenuItem
234 onClick={(e) => {
235 e.stopPropagation()
236 if (!isSorting)
237 onMoveUp?.()
238 }}
239 disabled={isSorting}
240 >
241 <ChevronUp className="mr-2 h-4 w-4" />
242 {t('channelManager.moveUp', '上移')}
243 </DropdownMenuItem>
244 )}
245 {canMoveDown && (
246 <DropdownMenuItem
247 onClick={(e) => {
248 e.stopPropagation()
249 if (!isSorting)
250 onMoveDown?.()
251 }}
252 disabled={isSorting}
253 >
254 <ChevronDown className="mr-2 h-4 w-4" />
255 {t('channelManager.moveDown', '下移')}
256 </DropdownMenuItem>
257 )}
258 <DropdownMenuSeparator />
259 <DropdownMenuItem onClick={onDelete} className="text-destructive">
260 <Trash2 className="mr-2 h-4 w-4" />
261 {t('actions.delete')}
262 </DropdownMenuItem>
263 </DropdownMenuContent>
264 </DropdownMenu>
265 )}
266 </>
267 )}
268 </div>
269
270 {/* 频道列表 */}
271 {!isCollapsed && (
272 <div className="divide-y divide-border/70">
273 {channels.length === 0 ? (
274 <div className="py-8 text-center text-sm text-muted-foreground">
275 {t('channelManager.noChannels', '暂无频道')}
276 </div>
277 ) : (
278 channels.map(channel => (
279 <ChannelItem
280 key={channel.id}
281 channel={channel}
282 onDelete={onChannelDelete}
283 deleteLoading={deleteLoading}
284 fansRefreshTarget={fansRefreshTarget}
285 onRefreshFans={onChannelFansRefresh}
286 />
287 ))
288 )}
289 </div>
290 )}
291 </div>
292 )
293 }
294
294 lines Plain Text