返回 AiToEarn
ChannelSidebar.tsx
1 /**
2 * ChannelSidebar - 频道类型侧边栏
3 * 显示"全部频道"和各平台列表,支持过滤和快速授权
4 */
5
6 'use client'
7
8 import type { PlatType } from '@/app/config/platConfig'
9 import { Layers, Plus, Puzzle } from 'lucide-react'
10 import { useMemo } from 'react'
11 import { useShallow } from 'zustand/react/shallow'
12 import { useTransClient } from '@/app/i18n/client'
13 import { PlatformIcon } from '@/components/common/PlatformIcon'
14 import { Button } from '@/components/ui/button'
15 import { ScrollArea } from '@/components/ui/scroll-area'
16 import { isChina } from '@/constant'
17 import { usePlatformInfoMap, useRegionSortedPlatforms } from '@/hooks/usePlatformMetadata'
18 import { useAccountStore } from '@/store/account'
19 import { isPlatformComingSoon, isPlatformRegionLimited } from '@/store/platformMetadata/utils'
20 import { cn } from '@/utils/className'
21 import { confirmPlatformRegionRedirect } from '@/utils/region/redirect'
22 import { toast } from '@/utils/ui/toast'
23 import { useChannelManagerStore } from '../channelManagerStore'
24
25 export function ChannelSidebar() {
26 const { t } = useTransClient('account')
27 const regionSortedPlatforms = useRegionSortedPlatforms()
28 const platformInfoMap = usePlatformInfoMap()
29
30 const { selectedPlatform, setSelectedPlatform, setCurrentView, startAuth, setTargetSpaceId }
31 = useChannelManagerStore(
32 useShallow(state => ({
33 selectedPlatform: state.selectedPlatform,
34 setSelectedPlatform: state.setSelectedPlatform,
35 setCurrentView: state.setCurrentView,
36 startAuth: state.startAuth,
37 setTargetSpaceId: state.setTargetSpaceId,
38 })),
39 )
40
41 const { accountList, accountGroupList } = useAccountStore(
42 useShallow(state => ({
43 accountList: state.accountList,
44 accountGroupList: state.accountGroupList,
45 })),
46 )
47
48 // 获取所有平台列表(始终显示所有平台,不可用的会禁用)
49 const allPlatforms = useMemo<PlatType[]>(() => {
50 return regionSortedPlatforms.map(([platform]) => platform)
51 }, [regionSortedPlatforms])
52
53 // 统计各平台账号数量
54 const platformAccountCounts = useMemo(() => {
55 const counts = new Map<PlatType, number>()
56 for (const account of accountList) {
57 const current = counts.get(account.type) || 0
58 counts.set(account.type, current + 1)
59 }
60 return counts
61 }, [accountList])
62
63 // 获取有账号的平台列表(暂未开放的平台统一排到最后)
64 const platformsWithAccounts = useMemo(() => {
65 return allPlatforms.filter((platform) => {
66 const platformInfo = platformInfoMap.get(platform)
67 return !isPlatformComingSoon(platformInfo) && (platformAccountCounts.get(platform) || 0) > 0
68 })
69 }, [allPlatforms, platformAccountCounts, platformInfoMap])
70
71 // 获取无账号的平台列表(暂未开放的平台统一排到最后)
72 const platformsWithoutAccounts = useMemo(() => {
73 return allPlatforms.filter((platform) => {
74 const platformInfo = platformInfoMap.get(platform)
75 return !isPlatformComingSoon(platformInfo) && (platformAccountCounts.get(platform) || 0) === 0
76 })
77 }, [allPlatforms, platformAccountCounts, platformInfoMap])
78
79 // 获取暂未开放的平台列表,始终显示在最后
80 const comingSoonPlatforms = useMemo(() => {
81 return allPlatforms.filter(platform => isPlatformComingSoon(platformInfoMap.get(platform)))
82 }, [allPlatforms, platformInfoMap])
83
84 // 处理平台点击
85 const handlePlatformClick = async (platform: PlatType) => {
86 const platformInfo = platformInfoMap.get(platform)
87 const platformName = platformInfo?.name || platform
88
89 if (isPlatformComingSoon(platformInfo)) {
90 toast.warning(t('channelManager.platformComingSoon', { platform: platformName }))
91 return
92 }
93
94 if (isPlatformRegionLimited(platformInfo)) {
95 await confirmPlatformRegionRedirect(platformName)
96 return
97 }
98
99 const hasAccount = (platformAccountCounts.get(platform) || 0) > 0
100
101 if (hasAccount) {
102 // 有账号:切换过滤
103 setSelectedPlatform(platform)
104 }
105 else {
106 // 无账号:进入授权流程
107 const defaultSpace = accountGroupList.find(g => g.isDefault)
108 if (defaultSpace) {
109 setTargetSpaceId(defaultSpace.id)
110 }
111 startAuth(platform, defaultSpace?.id)
112 }
113 }
114
115 // 处理"连接新频道"按钮点击
116 const handleConnectNewChannel = () => {
117 setCurrentView('connect-list')
118 }
119
120 return (
121 <div data-testid="cm-sidebar" className="flex h-full w-full flex-col overflow-hidden rounded-lg border border-border/70 bg-background shadow-sm">
122 <ScrollArea className="flex-1">
123 <div className="space-y-1 px-2 pb-2 pt-3">
124 {/* 全部频道选项 */}
125 <button
126 data-testid="cm-sidebar-all-channels"
127 type="button"
128 onClick={() => setSelectedPlatform('all')}
129 className={cn(
130 'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-all',
131 selectedPlatform === 'all'
132 ? 'bg-gradient-back text-gradient-foreground shadow-sm shadow-primary/20'
133 : 'text-foreground hover:bg-muted/70',
134 )}
135 >
136 <span
137 className={cn(
138 'flex h-6 w-6 shrink-0 items-center justify-center rounded-md border',
139 selectedPlatform === 'all'
140 ? 'border-primary-foreground/20 bg-primary-foreground/15'
141 : 'border-border bg-background',
142 )}
143 >
144 <Layers className="h-4 w-4" />
145 </span>
146 <span className="flex-1 truncate text-left font-semibold">{t('channelManager.allChannels')}</span>
147 <span
148 className={cn(
149 'min-w-7 rounded-full px-2 py-0.5 text-center text-xs font-medium',
150 selectedPlatform === 'all'
151 ? 'bg-primary-foreground/20 text-gradient-foreground'
152 : 'bg-muted text-muted-foreground',
153 )}
154 >
155 {accountList.length}
156 </span>
157 </button>
158
159 {/* 分隔线 */}
160 {platformsWithAccounts.length > 0 && <div className="my-1.5 border-t border-border/70" />}
161
162 {/* 有账号的平台 */}
163 {platformsWithAccounts.map((platform) => {
164 const info = platformInfoMap.get(platform)
165 const count = platformAccountCounts.get(platform) || 0
166 const needsPlugin = info?.authType === 'plugin'
167
168 if (!info)
169 return null
170
171 const isRegionLimited = isPlatformRegionLimited(info)
172 const regionLimitedLabel = isRegionLimited
173 ? t(isChina ? 'channelManager.internationalSiteOnly' : 'channelManager.chinaSiteOnly')
174 : undefined
175
176 return (
177 <button
178 data-testid="cm-sidebar-platform-item"
179 key={platform}
180 type="button"
181 onClick={() => handlePlatformClick(platform)}
182 className={cn(
183 'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-all',
184 selectedPlatform === platform
185 ? 'bg-gradient-back text-gradient-foreground shadow-sm shadow-primary/20'
186 : 'text-foreground hover:bg-muted/70',
187 isRegionLimited ? 'opacity-45 grayscale' : '',
188 )}
189 >
190 <div
191 className={cn(
192 'relative flex h-6 w-6 shrink-0 items-center justify-center rounded-md border',
193 selectedPlatform === platform
194 ? 'border-primary-foreground/20 bg-primary-foreground/15'
195 : 'border-border bg-background',
196 )}
197 >
198 <PlatformIcon
199 platform={platform}
200 width={16}
201 height={16}
202 className="h-4 w-4 object-contain"
203 />
204 {needsPlugin && (
205 <Puzzle className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 text-muted-foreground" />
206 )}
207 </div>
208 <span className="flex-1 truncate text-left font-medium">{info.name}</span>
209 {regionLimitedLabel && (
210 <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
211 {regionLimitedLabel}
212 </span>
213 )}
214 <span
215 className={cn(
216 'min-w-7 rounded-full px-2 py-0.5 text-center text-xs font-medium',
217 selectedPlatform === platform
218 ? 'bg-primary-foreground/20 text-gradient-foreground'
219 : 'bg-muted text-muted-foreground',
220 )}
221 >
222 {count}
223 </span>
224 </button>
225 )
226 })}
227
228 {/* 无账号的平台(灰色显示) */}
229 {platformsWithoutAccounts.length > 0 && (
230 <>
231 <div className="my-1.5 border-t border-border/70" />
232 <div className="px-2.5 py-1 text-xs font-medium text-muted-foreground">
233 {t('channelManager.availablePlatforms')}
234 </div>
235 {platformsWithoutAccounts.map((platform) => {
236 const info = platformInfoMap.get(platform)
237 const isDisabled = isPlatformComingSoon(info)
238
239 if (!info)
240 return null
241
242 const needsPlugin = info.authType === 'plugin'
243 const isRegionLimited = isPlatformRegionLimited(info)
244 const regionLimitedLabel = isRegionLimited
245 ? t(isChina ? 'channelManager.internationalSiteOnly' : 'channelManager.chinaSiteOnly')
246 : undefined
247
248 return (
249 <button
250 key={platform}
251 type="button"
252 onClick={() => handlePlatformClick(platform)}
253 className={cn(
254 'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-muted-foreground transition-all',
255 isDisabled
256 ? 'opacity-30 grayscale'
257 : 'opacity-70 hover:bg-muted/70 hover:text-foreground hover:opacity-100',
258 isRegionLimited ? 'opacity-45 grayscale' : '',
259 )}
260 >
261 <div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-border bg-background">
262 <PlatformIcon
263 platform={platform}
264 width={16}
265 height={16}
266 className="h-4 w-4 object-contain grayscale"
267 />
268 {needsPlugin && (
269 <Puzzle className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5" />
270 )}
271 </div>
272 <span className="flex-1 truncate text-left font-medium">{info.name}</span>
273 {regionLimitedLabel && (
274 <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
275 {regionLimitedLabel}
276 </span>
277 )}
278 <Plus className="h-3.5 w-3.5" />
279 </button>
280 )
281 })}
282 </>
283 )}
284
285 {comingSoonPlatforms.length > 0 && (
286 <>
287 <div className="my-1.5 border-t border-border/70" />
288 {comingSoonPlatforms.map((platform) => {
289 const info = platformInfoMap.get(platform)
290
291 if (!info)
292 return null
293
294 const needsPlugin = info.authType === 'plugin'
295
296 return (
297 <button
298 key={platform}
299 type="button"
300 onClick={() => handlePlatformClick(platform)}
301 className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-muted-foreground opacity-30 grayscale transition-all hover:bg-muted/70 hover:text-foreground"
302 >
303 <div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-border bg-background">
304 <PlatformIcon
305 platform={platform}
306 width={16}
307 height={16}
308 className="h-4 w-4 object-contain grayscale"
309 />
310 {needsPlugin && (
311 <Puzzle className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5" />
312 )}
313 </div>
314 <span className="flex-1 truncate text-left font-medium">{info.name}</span>
315 <Plus className="h-3.5 w-3.5" />
316 </button>
317 )
318 })}
319 </>
320 )}
321 </div>
322 </ScrollArea>
323
324 {/* 底部:连接新频道按钮 */}
325 <div className="border-t border-border/70 p-3">
326 <Button
327 data-testid="cm-sidebar-connect-btn"
328 variant="outline"
329 className="h-10 w-full cursor-pointer rounded-lg border-primary/45 bg-background text-primary shadow-sm hover:border-primary/70 hover:bg-primary/5"
330 onClick={handleConnectNewChannel}
331 >
332 <Plus className="mr-2 h-4 w-4" />
333 {t('channelManager.connectNewChannel')}
334 </Button>
335 </div>
336 </div>
337 )
338 }
339
339 lines Plain Text