返回 AiToEarn
MainPage.tsx
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
343 lines Plain Text