| 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 |