| 1 | /** |
| 2 | * MainPage - 频道管理主页 |
| 3 | * 左侧侧边栏 + 右侧空间和账号管理 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import type { SocialAccount } from '@/api/accounts/account.types' |
| 9 | import type { PlatType } from '@/app/config/platConfig' |
| 10 | import { Plus } from 'lucide-react' |
| 11 | import { useCallback, useMemo, useState } from 'react' |
| 12 | import { useShallow } from 'zustand/react/shallow' |
| 13 | import { apiUpdateAccountGroupSortRank, deleteAccountApi, deleteAccountGroupApi, updateAccountGroupApi } from '@/api/accounts/account.api' |
| 14 | |
| 15 | import { useTransClient } from '@/app/i18n/client' |
| 16 | import { Button } from '@/components/ui/button' |
| 17 | import { ScrollArea } from '@/components/ui/scroll-area' |
| 18 | import { useAccountStore } from '@/store/account' |
| 19 | import { toast } from '@/utils/ui/toast' |
| 20 | import { useChannelManagerStore } from '../channelManagerStore' |
| 21 | import { useFansRefresh } from '../hooks/useFansRefresh' |
| 22 | import { ChannelSidebar } from './ChannelSidebar' |
| 23 | import { CreateSpaceSection } from './CreateSpaceSection' |
| 24 | import { DeleteConfirmDialog } from './DeleteConfirmDialog' |
| 25 | import { FansRefreshActions } from './FansRefreshActions' |
| 26 | import { SpaceItem } from './SpaceItem' |
| 27 | import { SpaceListSkeleton } from './SpaceListSkeleton' |
| 28 | |
| 29 | export function MainPage() { |
| 30 | const { t } = useTransClient('account') |
| 31 | |
| 32 | const { selectedPlatform, setCurrentView, startAuth, setTargetSpaceId } = useChannelManagerStore( |
| 33 | useShallow(state => ({ |
| 34 | selectedPlatform: state.selectedPlatform, |
| 35 | setCurrentView: state.setCurrentView, |
| 36 | startAuth: state.startAuth, |
| 37 | setTargetSpaceId: state.setTargetSpaceId, |
| 38 | })), |
| 39 | ) |
| 40 | |
| 41 | const { accountGroupList, accountList, accountLoading, getAccountGroup, getAccountList } |
| 42 | = useAccountStore( |
| 43 | useShallow(state => ({ |
| 44 | accountGroupList: state.accountGroupList, |
| 45 | accountList: state.accountList, |
| 46 | accountLoading: state.accountLoading, |
| 47 | getAccountGroup: state.getAccountGroup, |
| 48 | getAccountList: state.getAccountList, |
| 49 | })), |
| 50 | ) |
| 51 | |
| 52 | const { refreshingTarget, refreshProgress, refreshAllFans, refreshPlatformFans } = useFansRefresh() |
| 53 | |
| 54 | // UI状态 |
| 55 | const [editingSpace, setEditingSpace] = useState<string | null>(null) |
| 56 | const [editSpaceName, setEditSpaceName] = useState('') |
| 57 | const [editingSpaceLoading, setEditingSpaceLoading] = useState(false) |
| 58 | const [collapsedSpaces, setCollapsedSpaces] = useState<Set<string>>(new Set()) |
| 59 | const [deleteLoading, setDeleteLoading] = useState<string | null>(null) |
| 60 | const [sortingSpaceLoading, setSortingSpaceLoading] = useState<string | null>(null) |
| 61 | |
| 62 | // 删除确认对话框 |
| 63 | const [deleteDialog, setDeleteDialog] = useState<{ |
| 64 | open: boolean |
| 65 | type: 'space' | 'channel' |
| 66 | id: string |
| 67 | name: string |
| 68 | }>({ |
| 69 | open: false, |
| 70 | type: 'space', |
| 71 | id: '', |
| 72 | name: '', |
| 73 | }) |
| 74 | |
| 75 | // 开始编辑空间 |
| 76 | const startEditingSpace = useCallback((spaceId: string, currentName: string) => { |
| 77 | setEditingSpace(spaceId) |
| 78 | setEditSpaceName(currentName) |
| 79 | }, []) |
| 80 | |
| 81 | // 保存空间编辑 |
| 82 | const saveSpaceEdit = useCallback(async () => { |
| 83 | if (!editingSpace || !editSpaceName.trim()) |
| 84 | return |
| 85 | |
| 86 | setEditingSpaceLoading(true) |
| 87 | try { |
| 88 | await updateAccountGroupApi({ |
| 89 | id: editingSpace, |
| 90 | name: editSpaceName.trim(), |
| 91 | }) |
| 92 | await getAccountGroup() |
| 93 | toast.success(t('channelManager.editSpaceSuccess')) |
| 94 | setEditingSpace(null) |
| 95 | setEditSpaceName('') |
| 96 | } |
| 97 | catch (error) { |
| 98 | toast.error(t('channelManager.editSpaceFailed')) |
| 99 | } |
| 100 | finally { |
| 101 | setEditingSpaceLoading(false) |
| 102 | } |
| 103 | }, [editingSpace, editSpaceName, getAccountGroup, t]) |
| 104 | |
| 105 | // 取消编辑 |
| 106 | const cancelSpaceEdit = useCallback(() => { |
| 107 | setEditingSpace(null) |
| 108 | setEditSpaceName('') |
| 109 | }, []) |
| 110 | |
| 111 | // 删除空间或频道 |
| 112 | const confirmDelete = useCallback(async () => { |
| 113 | setDeleteLoading(deleteDialog.id) |
| 114 | try { |
| 115 | if (deleteDialog.type === 'space') { |
| 116 | await deleteAccountGroupApi([deleteDialog.id]) |
| 117 | toast.success(t('channelManager.deleteSpaceSuccess')) |
| 118 | await getAccountGroup() |
| 119 | } |
| 120 | else { |
| 121 | await deleteAccountApi(deleteDialog.id) |
| 122 | toast.success(t('channelManager.deleteChannelSuccess')) |
| 123 | await Promise.all([getAccountList(), getAccountGroup()]) |
| 124 | } |
| 125 | setDeleteDialog({ open: false, type: 'space', id: '', name: '' }) |
| 126 | } |
| 127 | catch (error) { |
| 128 | toast.error(deleteDialog.type === 'space' |
| 129 | ? t('channelManager.deleteSpaceFailed') |
| 130 | : t('channelManager.deleteChannelFailed')) |
| 131 | } |
| 132 | finally { |
| 133 | setDeleteLoading(null) |
| 134 | } |
| 135 | }, [deleteDialog, getAccountGroup, getAccountList, t]) |
| 136 | |
| 137 | // 切换空间折叠状态 |
| 138 | const toggleSpaceCollapse = useCallback((spaceId: string) => { |
| 139 | setCollapsedSpaces((prev) => { |
| 140 | const newSet = new Set(prev) |
| 141 | if (newSet.has(spaceId)) { |
| 142 | newSet.delete(spaceId) |
| 143 | } |
| 144 | else { |
| 145 | newSet.add(spaceId) |
| 146 | } |
| 147 | return newSet |
| 148 | }) |
| 149 | }, []) |
| 150 | |
| 151 | // 空间排序函数 |
| 152 | const handleSpaceSort = useCallback( |
| 153 | async (spaceId: string, direction: 'up' | 'down') => { |
| 154 | if (sortingSpaceLoading) |
| 155 | return |
| 156 | |
| 157 | setSortingSpaceLoading(spaceId) |
| 158 | |
| 159 | try { |
| 160 | const sortedGroups = [...accountGroupList].sort((a, b) => (a.rank || 0) - (b.rank || 0)) |
| 161 | const currentIndex = sortedGroups.findIndex(g => g.id === spaceId) |
| 162 | |
| 163 | if (currentIndex === -1) |
| 164 | return |
| 165 | |
| 166 | const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1 |
| 167 | if (newIndex < 0 || newIndex >= sortedGroups.length) { |
| 168 | return |
| 169 | }[sortedGroups[currentIndex], sortedGroups[newIndex]] = [ |
| 170 | sortedGroups[newIndex], |
| 171 | sortedGroups[currentIndex], |
| 172 | ] |
| 173 | |
| 174 | const updateList = sortedGroups.map((group, index) => ({ |
| 175 | id: group.id, |
| 176 | rank: index, |
| 177 | })) |
| 178 | |
| 179 | await apiUpdateAccountGroupSortRank({ list: updateList }) |
| 180 | await getAccountGroup() |
| 181 | toast.success(t('channelManager.sortSuccess')) |
| 182 | } |
| 183 | catch (error) { |
| 184 | toast.error(t('channelManager.sortFailed')) |
| 185 | } |
| 186 | finally { |
| 187 | setSortingSpaceLoading(null) |
| 188 | } |
| 189 | }, |
| 190 | [accountGroupList, getAccountGroup, t, sortingSpaceLoading], |
| 191 | ) |
| 192 | |
| 193 | // 获取空间下的频道(支持平台过滤) |
| 194 | const getChannelsInSpace = useCallback( |
| 195 | (spaceId: string): SocialAccount[] => { |
| 196 | let channels = accountList.filter(account => account.groupId === spaceId) |
| 197 | |
| 198 | // 根据侧边栏选择的平台过滤 |
| 199 | if (selectedPlatform !== 'all') { |
| 200 | channels = channels.filter(account => account.type === selectedPlatform) |
| 201 | } |
| 202 | |
| 203 | return channels |
| 204 | }, |
| 205 | [accountList, selectedPlatform], |
| 206 | ) |
| 207 | |
| 208 | // 处理添加频道点击 |
| 209 | const handleAddChannel = useCallback( |
| 210 | (spaceId: string) => { |
| 211 | setTargetSpaceId(spaceId) |
| 212 | |
| 213 | if (selectedPlatform === 'all') { |
| 214 | // 选中"全部频道"时,进入连接频道列表页 |
| 215 | setCurrentView('connect-list') |
| 216 | } |
| 217 | else { |
| 218 | // 选中具体平台时,直接进入授权流程 |
| 219 | startAuth(selectedPlatform as PlatType, spaceId) |
| 220 | } |
| 221 | }, |
| 222 | [selectedPlatform, setCurrentView, startAuth, setTargetSpaceId], |
| 223 | ) |
| 224 | |
| 225 | // 过滤后的空间列表(如果选中某平台,只显示有该平台账号的空间,或空空间) |
| 226 | const filteredAccountGroupList = useMemo(() => { |
| 227 | if (selectedPlatform === 'all') { |
| 228 | return accountGroupList |
| 229 | } |
| 230 | |
| 231 | // 选中具体平台时,显示所有空间(但频道会被过滤) |
| 232 | return accountGroupList |
| 233 | }, [accountGroupList, selectedPlatform]) |
| 234 | |
| 235 | return ( |
| 236 | <div data-testid="cm-main-page" className="flex h-full flex-col bg-background md:flex-row md:gap-5 md:p-5"> |
| 237 | {/* 左侧侧边栏 - 移动端隐藏 */} |
| 238 | <div className="hidden w-[226px] shrink-0 md:block"> |
| 239 | <ChannelSidebar /> |
| 240 | </div> |
| 241 | |
| 242 | {/* 移动端顶部操作栏 */} |
| 243 | <div className="flex items-center justify-between border-b border-border/70 bg-card/80 px-4 py-3 md:hidden"> |
| 244 | <span data-testid="cm-channel-count" className="text-sm text-muted-foreground"> |
| 245 | {t('channelManager.channelCount', { count: accountList.length })} |
| 246 | </span> |
| 247 | <Button |
| 248 | data-testid="cm-add-channel-btn" |
| 249 | variant="default" |
| 250 | size="sm" |
| 251 | className="cursor-pointer rounded-full" |
| 252 | onClick={() => setCurrentView('connect-list')} |
| 253 | > |
| 254 | <Plus className="mr-1.5 h-4 w-4" /> |
| 255 | {t('channelManager.addChannel')} |
| 256 | </Button> |
| 257 | </div> |
| 258 | |
| 259 | {/* 右侧主内容区 */} |
| 260 | <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> |
| 261 | {accountLoading ? ( |
| 262 | <div className="flex-1 p-4 md:p-0"> |
| 263 | <SpaceListSkeleton /> |
| 264 | </div> |
| 265 | ) : ( |
| 266 | <div className="flex flex-1 flex-col gap-4 overflow-hidden p-4 md:p-0"> |
| 267 | <FansRefreshActions |
| 268 | refreshingTarget={refreshingTarget} |
| 269 | refreshProgress={refreshProgress} |
| 270 | onRefreshAll={refreshAllFans} |
| 271 | /> |
| 272 | |
| 273 | {/* 添加新空间 */} |
| 274 | <CreateSpaceSection onSpaceCreated={getAccountGroup} /> |
| 275 | |
| 276 | {/* 空间和频道列表 */} |
| 277 | <ScrollArea className="h-full flex-1"> |
| 278 | <div className="space-y-3 pr-1"> |
| 279 | {filteredAccountGroupList.map((space, index) => { |
| 280 | const channels = getChannelsInSpace(space.id) |
| 281 | const isEditing = editingSpace === space.id |
| 282 | const isCollapsed = collapsedSpaces.has(space.id) |
| 283 | |
| 284 | return ( |
| 285 | <SpaceItem |
| 286 | key={space.id} |
| 287 | space={space} |
| 288 | channels={channels} |
| 289 | index={index} |
| 290 | totalSpaces={filteredAccountGroupList.length} |
| 291 | isCollapsed={isCollapsed} |
| 292 | isEditing={isEditing} |
| 293 | editSpaceName={editSpaceName} |
| 294 | editingSpaceLoading={editingSpaceLoading} |
| 295 | deleteLoading={deleteLoading} |
| 296 | sortingLoading={sortingSpaceLoading} |
| 297 | fansRefreshTarget={refreshingTarget} |
| 298 | onToggleCollapse={() => toggleSpaceCollapse(space.id)} |
| 299 | onStartEdit={() => startEditingSpace(space.id, space.name)} |
| 300 | onEditNameChange={setEditSpaceName} |
| 301 | onSaveEdit={saveSpaceEdit} |
| 302 | onCancelEdit={cancelSpaceEdit} |
| 303 | onMoveUp={() => handleSpaceSort(space.id, 'up')} |
| 304 | onMoveDown={() => handleSpaceSort(space.id, 'down')} |
| 305 | onDelete={() => |
| 306 | setDeleteDialog({ |
| 307 | open: true, |
| 308 | type: 'space', |
| 309 | id: space.id, |
| 310 | name: space.name, |
| 311 | })} |
| 312 | onChannelDelete={(channel: SocialAccount) => |
| 313 | setDeleteDialog({ |
| 314 | open: true, |
| 315 | type: 'channel', |
| 316 | id: channel.id, |
| 317 | name: channel.nickname, |
| 318 | })} |
| 319 | onChannelFansRefresh={refreshPlatformFans} |
| 320 | onRefresh={getAccountGroup} |
| 321 | onAddChannel={() => handleAddChannel(space.id)} |
| 322 | /> |
| 323 | ) |
| 324 | })} |
| 325 | </div> |
| 326 | </ScrollArea> |
| 327 | |
| 328 | {/* 删除确认对话框 */} |
| 329 | <DeleteConfirmDialog |
| 330 | open={deleteDialog.open} |
| 331 | type={deleteDialog.type} |
| 332 | name={deleteDialog.name} |
| 333 | loading={!!deleteLoading} |
| 334 | onOpenChange={open => setDeleteDialog(prev => ({ ...prev, open }))} |
| 335 | onConfirm={confirmDelete} |
| 336 | /> |
| 337 | </div> |
| 338 | )} |
| 339 | </div> |
| 340 | </div> |
| 341 | ) |
| 342 | } |
| 343 |