@zhin.js/console 1.0.50 → 1.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +22 -0
  3. package/browser.tsconfig.json +19 -0
  4. package/client/src/components/PageHeader.tsx +26 -0
  5. package/client/src/components/ui/accordion.tsx +2 -1
  6. package/client/src/components/ui/badge.tsx +1 -3
  7. package/client/src/components/ui/scroll-area.tsx +5 -2
  8. package/client/src/components/ui/select.tsx +7 -3
  9. package/client/src/components/ui/separator.tsx +5 -2
  10. package/client/src/components/ui/tabs.tsx +4 -2
  11. package/client/src/layouts/dashboard.tsx +223 -121
  12. package/client/src/main.tsx +34 -34
  13. package/client/src/pages/bot-detail/MessageBody.tsx +110 -0
  14. package/client/src/pages/bot-detail/date-utils.ts +8 -0
  15. package/client/src/pages/bot-detail/index.tsx +798 -0
  16. package/client/src/pages/bot-detail/types.ts +92 -0
  17. package/client/src/pages/bot-detail/useBotConsole.tsx +600 -0
  18. package/client/src/pages/bots.tsx +111 -73
  19. package/client/src/pages/database/constants.ts +16 -0
  20. package/client/src/pages/database/database-page.tsx +170 -0
  21. package/client/src/pages/database/document-collection-view.tsx +155 -0
  22. package/client/src/pages/database/index.tsx +1 -0
  23. package/client/src/pages/database/json-field.tsx +11 -0
  24. package/client/src/pages/database/kv-bucket-view.tsx +169 -0
  25. package/client/src/pages/database/related-table-view.tsx +221 -0
  26. package/client/src/pages/env.tsx +38 -28
  27. package/client/src/pages/files/code-editor.tsx +85 -0
  28. package/client/src/pages/files/editor-constants.ts +9 -0
  29. package/client/src/pages/files/file-editor.tsx +133 -0
  30. package/client/src/pages/files/file-icons.tsx +25 -0
  31. package/client/src/pages/files/files-page.tsx +92 -0
  32. package/client/src/pages/files/hljs-global.d.ts +10 -0
  33. package/client/src/pages/files/index.tsx +1 -0
  34. package/client/src/pages/files/language.ts +18 -0
  35. package/client/src/pages/files/tree-node.tsx +69 -0
  36. package/client/src/pages/files/use-hljs-theme.ts +23 -0
  37. package/client/src/pages/logs.tsx +77 -22
  38. package/client/src/style.css +144 -0
  39. package/client/src/utils/parseComposerContent.ts +57 -0
  40. package/client/tailwind.config.js +1 -0
  41. package/client/tsconfig.json +3 -1
  42. package/dist/assets/index-COKXlFo2.js +124 -0
  43. package/dist/assets/style-kkLO-vsa.css +3 -0
  44. package/dist/client.js +4262 -1
  45. package/dist/index.html +2 -2
  46. package/dist/radix-ui.js +1261 -1262
  47. package/dist/react-dom-client.js +2243 -2240
  48. package/dist/react-dom.js +15 -15
  49. package/dist/style.css +1 -3
  50. package/lib/index.js +1010 -81
  51. package/lib/transform.js +16 -2
  52. package/lib/websocket.js +845 -28
  53. package/node.tsconfig.json +18 -0
  54. package/package.json +15 -16
  55. package/src/bin.ts +24 -0
  56. package/src/bot-db-models.ts +74 -0
  57. package/src/bot-hub.ts +240 -0
  58. package/src/bot-persistence.ts +270 -0
  59. package/src/build.ts +90 -0
  60. package/src/dev.ts +107 -0
  61. package/src/index.ts +337 -0
  62. package/src/transform.ts +199 -0
  63. package/src/websocket.ts +1369 -0
  64. package/client/src/pages/database.tsx +0 -708
  65. package/client/src/pages/files.tsx +0 -470
  66. package/client/src/pages/login-assist.tsx +0 -225
  67. package/dist/index.js +0 -124
@@ -0,0 +1,798 @@
1
+ import { Fragment, useRef, useState } from 'react'
2
+ import { Link } from 'react-router'
3
+ import {
4
+ ArrowLeft,
5
+ Bell,
6
+ Bot,
7
+ Check,
8
+ Image,
9
+ Loader2,
10
+ MessageSquare,
11
+ MoreHorizontal,
12
+ Music,
13
+ Send,
14
+ UserMinus,
15
+ UserPlus,
16
+ Video,
17
+ Wifi,
18
+ WifiOff,
19
+ } from 'lucide-react'
20
+ import { cn } from '@zhin.js/client'
21
+ import { Button } from '../../components/ui/button'
22
+ import { Input } from '../../components/ui/input'
23
+ import { Textarea } from '../../components/ui/textarea'
24
+ import { Badge } from '../../components/ui/badge'
25
+ import { Alert, AlertDescription } from '../../components/ui/alert'
26
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs'
27
+ import { MessageBody } from './MessageBody'
28
+ import { useBotConsole } from './useBotConsole'
29
+ import { hasRenderableComposerSegments, parseComposerToSegments } from '../../utils/parseComposerContent'
30
+ import { dayKey, dayLabel } from './date-utils'
31
+
32
+ export default function BotDetailPage() {
33
+ const ctx = useBotConsole()
34
+
35
+ if (!ctx.valid) {
36
+ return (
37
+ <div className="p-4">
38
+ <Alert>
39
+ <AlertDescription>参数无效</AlertDescription>
40
+ </Alert>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ const {
46
+ adapter,
47
+ botId,
48
+ connected,
49
+ info,
50
+ loadErr,
51
+ msgContent,
52
+ setMsgContent,
53
+ sending,
54
+ listLoading,
55
+ listErr,
56
+ selection,
57
+ setSelection,
58
+ showChannelList,
59
+ setShowChannelList,
60
+ listSearch,
61
+ setListSearch,
62
+ members,
63
+ membersLoading,
64
+ channelMessages,
65
+ inboxMessagesLoading,
66
+ inboxMessagesHasMore,
67
+ inboxMessagesEnabled,
68
+ loadInboxMessages,
69
+ inboxMessages,
70
+ requestList,
71
+ noticeList,
72
+ requestsTab,
73
+ setRequestsTab,
74
+ noticesTab,
75
+ setNoticesTab,
76
+ inboxRequests,
77
+ inboxRequestsLoading,
78
+ inboxRequestsEnabled,
79
+ loadInboxRequests,
80
+ inboxNotices,
81
+ inboxNoticesLoading,
82
+ inboxNoticesEnabled,
83
+ loadInboxNotices,
84
+ filteredChannels,
85
+ deleteFriend,
86
+ handleSend,
87
+ approve,
88
+ dismissRequest,
89
+ dismissNotice,
90
+ loadMembers,
91
+ groupAction,
92
+ loadLists,
93
+ loadRequestsFromServer,
94
+ getChannelIcon,
95
+ showRightPanel,
96
+ } = ctx
97
+
98
+ const [mediaPanel, setMediaPanel] = useState<null | 'image' | 'video' | 'audio'>(null)
99
+ const [mediaUrl, setMediaUrl] = useState('')
100
+ const imageFileRef = useRef<HTMLInputElement>(null)
101
+
102
+ const appendComposerToken = (token: string) => {
103
+ setMsgContent((c) => {
104
+ if (!c) return token
105
+ const needsSpace = !/\s$/.test(c) && !/^[\s[,]/.test(token)
106
+ return c + (needsSpace ? ' ' : '') + token
107
+ })
108
+ }
109
+
110
+ const commitMediaUrl = () => {
111
+ const u = mediaUrl.trim()
112
+ if (!u || !mediaPanel) return
113
+ const tag =
114
+ mediaPanel === 'image' ? `[image:${u}]` : mediaPanel === 'video' ? `[video:${u}]` : `[audio:${u}]`
115
+ appendComposerToken(tag)
116
+ setMediaUrl('')
117
+ setMediaPanel(null)
118
+ }
119
+
120
+ const onPickImageFile = (e: React.ChangeEvent<HTMLInputElement>) => {
121
+ const f = e.target.files?.[0]
122
+ e.target.value = ''
123
+ if (!f?.type.startsWith('image/')) return
124
+ const r = new FileReader()
125
+ r.onload = () => {
126
+ const dataUrl = String(r.result || '')
127
+ if (dataUrl) appendComposerToken(`[image:${dataUrl}]`)
128
+ }
129
+ r.readAsDataURL(f)
130
+ }
131
+
132
+ const canSend = connected && !sending && hasRenderableComposerSegments(parseComposerToSegments(msgContent))
133
+
134
+ let lastDay = ''
135
+
136
+ return (
137
+ <div className="sandbox-container im-layout">
138
+ <button
139
+ type="button"
140
+ className="mobile-channel-toggle md:hidden"
141
+ onClick={() => setShowChannelList(!showChannelList)}
142
+ >
143
+ <MessageSquare size={20} /> 会话列表
144
+ </button>
145
+
146
+ <div className={cn('channel-sidebar', showChannelList && 'show')}>
147
+ <div className="p-3 border-b border-border/60">
148
+ <div className="flex items-center gap-2">
149
+ <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" asChild>
150
+ <Link to="/bots">
151
+ <ArrowLeft className="h-4 w-4" />
152
+ </Link>
153
+ </Button>
154
+ <div className="min-w-0 flex-1">
155
+ <div className="flex items-center gap-2">
156
+ <Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
157
+ <span className="font-semibold truncate text-sm">{info?.name || botId}</span>
158
+ </div>
159
+ <div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
160
+ <Badge variant="outline" className="text-[10px] px-1 font-normal">
161
+ {adapter}
162
+ </Badge>
163
+ <span
164
+ className={cn(
165
+ 'inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0 rounded-full border',
166
+ connected
167
+ ? 'bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
168
+ : 'bg-muted text-muted-foreground',
169
+ )}
170
+ >
171
+ {connected ? <Wifi size={10} /> : <WifiOff size={10} />}
172
+ {connected ? '已连接' : '未连接'}
173
+ </span>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </div>
178
+
179
+ {loadErr && (
180
+ <div className="px-3 py-2">
181
+ <p className="text-xs text-destructive">{loadErr}</p>
182
+ </div>
183
+ )}
184
+
185
+ <div className="px-2 pt-2 pb-1">
186
+ <Input
187
+ value={listSearch}
188
+ onChange={(e) => setListSearch(e.target.value)}
189
+ placeholder="搜索会话…"
190
+ className="h-9 text-sm bg-background/80"
191
+ />
192
+ </div>
193
+
194
+ <div className="flex-1 overflow-y-auto p-2 space-y-0.5 min-h-0">
195
+ {listLoading && (
196
+ <div className="flex items-center justify-center py-6">
197
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
198
+ </div>
199
+ )}
200
+ {!listLoading && listErr && <p className="text-xs text-muted-foreground px-2 py-1">{listErr}</p>}
201
+ {filteredChannels.length === 0 && !listLoading && !listErr && (
202
+ <p className="text-xs text-muted-foreground px-2 py-4 text-center">
203
+ {listSearch.trim() ? `无匹配「${listSearch.trim()}」` : '暂无会话'}
204
+ </p>
205
+ )}
206
+ {filteredChannels.map((ch) => {
207
+ const isActive = selection?.type === 'channel' && selection.id === ch.id
208
+ return (
209
+ <div
210
+ key={`${ch.channelType}-${ch.id}`}
211
+ role="button"
212
+ tabIndex={0}
213
+ className={cn('menu-item im-row-compact', isActive && 'active')}
214
+ onClick={() => {
215
+ setSelection({ type: 'channel', id: ch.id, name: ch.name, channelType: ch.channelType })
216
+ if (typeof window !== 'undefined' && window.innerWidth < 768) setShowChannelList(false)
217
+ }}
218
+ onKeyDown={(e) => {
219
+ if (e.key === 'Enter' || e.key === ' ') {
220
+ e.preventDefault()
221
+ setSelection({ type: 'channel', id: ch.id, name: ch.name, channelType: ch.channelType })
222
+ if (typeof window !== 'undefined' && window.innerWidth < 768) setShowChannelList(false)
223
+ }
224
+ }}
225
+ >
226
+ <span className="shrink-0 opacity-90">{getChannelIcon(ch.channelType)}</span>
227
+ <div className="flex-1 min-w-0 text-left">
228
+ <div className="text-sm font-medium truncate leading-tight">{ch.name}</div>
229
+ <div className="text-[11px] text-muted-foreground truncate mt-0.5">
230
+ {ch.channelType === 'private' ? '私聊' : ch.channelType === 'group' ? '群聊' : '频道'}
231
+ </div>
232
+ </div>
233
+ </div>
234
+ )
235
+ })}
236
+
237
+ <div className="pt-2 mt-2 border-t border-border/50 space-y-0.5">
238
+ <div
239
+ role="button"
240
+ tabIndex={0}
241
+ className={cn('menu-item im-row-compact', selection?.type === 'requests' && 'active')}
242
+ onClick={() => {
243
+ setSelection({ type: 'requests' })
244
+ if (typeof window !== 'undefined' && window.innerWidth < 768) setShowChannelList(false)
245
+ }}
246
+ >
247
+ <UserPlus size={16} className="shrink-0" />
248
+ <div className="flex-1 min-w-0 text-left">
249
+ <div className="text-sm font-medium">请求</div>
250
+ <div className="text-[11px] text-muted-foreground">好友/群邀请</div>
251
+ </div>
252
+ {requestList.length > 0 && (
253
+ <span className="inline-flex items-center justify-center h-5 min-w-5 rounded-full bg-destructive text-destructive-foreground text-[10px] font-medium px-1">
254
+ {requestList.length}
255
+ </span>
256
+ )}
257
+ </div>
258
+ <div
259
+ role="button"
260
+ tabIndex={0}
261
+ className={cn('menu-item im-row-compact', selection?.type === 'notices' && 'active')}
262
+ onClick={() => {
263
+ setSelection({ type: 'notices' })
264
+ if (typeof window !== 'undefined' && window.innerWidth < 768) setShowChannelList(false)
265
+ }}
266
+ >
267
+ <Bell size={16} className="shrink-0" />
268
+ <div className="flex-1 min-w-0 text-left">
269
+ <div className="text-sm font-medium">通知</div>
270
+ <div className="text-[11px] text-muted-foreground">群管/撤回等</div>
271
+ </div>
272
+ {noticeList.length > 0 && (
273
+ <span className="inline-flex items-center justify-center h-5 min-w-5 rounded-full bg-destructive text-destructive-foreground text-[10px] font-medium px-1">
274
+ {noticeList.length}
275
+ </span>
276
+ )}
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ <div className="p-2 border-t border-border/60">
282
+ <Button
283
+ variant="outline"
284
+ size="sm"
285
+ className="w-full border-dashed text-xs h-8"
286
+ onClick={() => void loadLists()}
287
+ disabled={listLoading || !connected}
288
+ >
289
+ 刷新好友/群
290
+ </Button>
291
+ </div>
292
+ </div>
293
+
294
+ {showChannelList && (
295
+ <div
296
+ className="channel-overlay md:hidden"
297
+ onClick={() => setShowChannelList(false)}
298
+ aria-hidden
299
+ />
300
+ )}
301
+
302
+ <div className="im-main-split">
303
+ <div className="im-center">
304
+ {selection?.type === 'channel' && (
305
+ <>
306
+ <header className="im-chat-header px-3 py-2.5 flex items-center justify-between gap-2">
307
+ <div className="flex items-center gap-3 min-w-0">
308
+ <div className="p-2 rounded-full bg-muted/80 text-muted-foreground shrink-0">
309
+ {getChannelIcon(selection.channelType)}
310
+ </div>
311
+ <div className="min-w-0">
312
+ <h2 className="text-[15px] font-semibold truncate leading-tight">{selection.name}</h2>
313
+ <p className="text-[11px] text-muted-foreground truncate mt-0.5">
314
+ {selection.channelType === 'private' ? '私聊' : selection.channelType === 'group' ? '群聊' : '频道'}{' '}
315
+ · {selection.id}
316
+ </p>
317
+ </div>
318
+ </div>
319
+ <div className="flex items-center gap-1 shrink-0">
320
+ {selection.channelType === 'private' && (
321
+ <Button
322
+ variant="ghost"
323
+ size="icon"
324
+ className="h-8 w-8 text-destructive hover:text-destructive"
325
+ title="删除好友"
326
+ onClick={() => void deleteFriend()}
327
+ >
328
+ <UserMinus className="h-4 w-4" />
329
+ </Button>
330
+ )}
331
+ <Button variant="ghost" size="icon" className="h-8 w-8 opacity-50" disabled title="更多">
332
+ <MoreHorizontal className="h-4 w-4" />
333
+ </Button>
334
+ </div>
335
+ </header>
336
+
337
+ <div className="flex-1 overflow-y-auto px-3 py-2 min-h-0 flex flex-col">
338
+ {inboxMessagesEnabled && inboxMessagesHasMore && (
339
+ <div className="flex-shrink-0 py-2 flex justify-center">
340
+ <Button
341
+ variant="ghost"
342
+ size="sm"
343
+ className="text-xs"
344
+ disabled={inboxMessagesLoading}
345
+ onClick={() => {
346
+ const oldest = Math.min(...inboxMessages.map((m) => m.created_at))
347
+ void loadInboxMessages(oldest)
348
+ }}
349
+ >
350
+ {inboxMessagesLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '加载更早消息'}
351
+ </Button>
352
+ </div>
353
+ )}
354
+ {channelMessages.length === 0 && !inboxMessagesLoading ? (
355
+ <div className="flex flex-col items-center justify-center flex-1 gap-2 text-muted-foreground text-sm py-12">
356
+ <MessageSquare className="h-10 w-10 opacity-35" />
357
+ <span>
358
+ {inboxMessagesEnabled ? '暂无消息' : '暂无消息,对方发送的消息会显示在此处'}
359
+ </span>
360
+ </div>
361
+ ) : (
362
+ <div className="flex flex-col gap-1 pb-2">
363
+ {channelMessages.map((m) => {
364
+ const dk = dayKey(m.timestamp)
365
+ const showDate = dk !== lastDay
366
+ if (showDate) lastDay = dk
367
+ const out = m.outgoing === true
368
+ return (
369
+ <Fragment key={m.id}>
370
+ {showDate && <div className="im-date-pill">{dayLabel(m.timestamp)}</div>}
371
+ <div className={cn('flex w-full', out ? 'justify-end' : 'justify-start')}>
372
+ <div className={cn(out ? 'im-bubble-out' : 'im-bubble-in')}>
373
+ <div
374
+ className={cn(
375
+ 'im-meta flex items-center gap-2 text-[10px] mb-0.5',
376
+ out ? '' : 'text-muted-foreground',
377
+ )}
378
+ >
379
+ <span className={cn('font-medium', out ? '' : 'text-foreground/90')}>
380
+ {out ? '我' : m.sender?.name || m.sender?.id || '未知'}
381
+ </span>
382
+ <span className="tabular-nums opacity-80">
383
+ {new Date(m.timestamp).toLocaleTimeString('zh-CN', {
384
+ hour: '2-digit',
385
+ minute: '2-digit',
386
+ })}
387
+ </span>
388
+ </div>
389
+ <div className="text-[14px] leading-snug break-words">
390
+ <MessageBody content={m.content} />
391
+ </div>
392
+ </div>
393
+ </div>
394
+ </Fragment>
395
+ )
396
+ })}
397
+ </div>
398
+ )}
399
+ </div>
400
+
401
+ <div className="im-composer p-3 shrink-0 space-y-2">
402
+ <input
403
+ ref={imageFileRef}
404
+ type="file"
405
+ accept="image/*"
406
+ className="hidden"
407
+ onChange={onPickImageFile}
408
+ />
409
+ <div className="flex flex-wrap items-center gap-1">
410
+ <Button
411
+ type="button"
412
+ variant="outline"
413
+ size="sm"
414
+ className="h-8 px-2 text-xs"
415
+ title="选择本地图片(插入为 data URL)"
416
+ onClick={() => imageFileRef.current?.click()}
417
+ >
418
+ <Image className="w-3.5 h-3.5 mr-1" />
419
+ 图片文件
420
+ </Button>
421
+ <Button
422
+ type="button"
423
+ variant={mediaPanel === 'image' ? 'secondary' : 'outline'}
424
+ size="sm"
425
+ className="h-8 px-2 text-xs"
426
+ onClick={() => {
427
+ setMediaPanel((p) => (p === 'image' ? null : 'image'))
428
+ }}
429
+ >
430
+ 图片链接
431
+ </Button>
432
+ <Button
433
+ type="button"
434
+ variant={mediaPanel === 'video' ? 'secondary' : 'outline'}
435
+ size="sm"
436
+ className="h-8 px-2 text-xs"
437
+ onClick={() => {
438
+ setMediaPanel((p) => (p === 'video' ? null : 'video'))
439
+ }}
440
+ >
441
+ <Video className="w-3.5 h-3.5 mr-1" />
442
+ 视频
443
+ </Button>
444
+ <Button
445
+ type="button"
446
+ variant={mediaPanel === 'audio' ? 'secondary' : 'outline'}
447
+ size="sm"
448
+ className="h-8 px-2 text-xs"
449
+ onClick={() => {
450
+ setMediaPanel((p) => (p === 'audio' ? null : 'audio'))
451
+ }}
452
+ >
453
+ <Music className="w-3.5 h-3.5 mr-1" />
454
+ 音频
455
+ </Button>
456
+ </div>
457
+ {mediaPanel && (
458
+ <div className="flex flex-wrap items-end gap-2 rounded-md border border-border/80 bg-muted/20 p-2">
459
+ <Input
460
+ value={mediaUrl}
461
+ onChange={(e) => setMediaUrl(e.target.value)}
462
+ placeholder={
463
+ mediaPanel === 'image'
464
+ ? '图片 URL 或 base64://…'
465
+ : mediaPanel === 'video'
466
+ ? '视频直链 URL'
467
+ : '音频直链 URL'
468
+ }
469
+ className="flex-1 min-w-[12rem] h-9 text-sm"
470
+ onKeyDown={(e) => {
471
+ if (e.key === 'Enter') {
472
+ e.preventDefault()
473
+ commitMediaUrl()
474
+ }
475
+ }}
476
+ />
477
+ <Button type="button" size="sm" className="h-9" onClick={commitMediaUrl} disabled={!mediaUrl.trim()}>
478
+ <Check className="w-3.5 h-3.5 mr-1" />
479
+ 插入
480
+ </Button>
481
+ </div>
482
+ )}
483
+ <div className="flex gap-2 items-end">
484
+ <Textarea
485
+ placeholder="文字消息,或使用上方插入图片/音视频… 也可手写 [image:URL]、[video:URL]、[audio:URL]"
486
+ value={msgContent}
487
+ onChange={(e) => setMsgContent(e.target.value)}
488
+ onKeyDown={(e) => {
489
+ if (e.key === 'Enter' && !e.shiftKey) {
490
+ e.preventDefault()
491
+ void handleSend()
492
+ }
493
+ }}
494
+ className="flex-1 min-h-[44px] max-h-[160px] text-sm resize-y bg-background font-mono text-[13px]"
495
+ rows={2}
496
+ />
497
+ <Button
498
+ className="shrink-0 h-10 w-10 p-0 rounded-full"
499
+ onClick={() => void handleSend()}
500
+ disabled={!canSend}
501
+ >
502
+ {sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
503
+ </Button>
504
+ </div>
505
+ <p className="text-[10px] text-muted-foreground">
506
+ Enter 发送 · Shift+Enter 换行 · 发送内容为消息段数组(含多媒体),由适配器实际发送
507
+ </p>
508
+ </div>
509
+ </>
510
+ )}
511
+
512
+ {selection?.type === 'requests' && (
513
+ <div className="flex flex-col flex-1 min-h-0 overflow-hidden bg-card m-0 border-0 rounded-none">
514
+ <header className="im-chat-header px-4 py-3 flex items-center justify-between">
515
+ <h2 className="text-base font-semibold flex items-center gap-2">
516
+ <UserPlus size={18} />
517
+ 请求
518
+ </h2>
519
+ <Button size="sm" variant="outline" onClick={() => void loadRequestsFromServer()}>
520
+ 刷新
521
+ </Button>
522
+ </header>
523
+ <Tabs
524
+ value={requestsTab}
525
+ onValueChange={(v) => {
526
+ setRequestsTab(v as 'pending' | 'history')
527
+ if (v === 'history' && inboxRequests.length === 0 && !inboxRequestsLoading)
528
+ void loadInboxRequests(false)
529
+ }}
530
+ className="flex flex-col flex-1 min-h-0"
531
+ >
532
+ <TabsList className="mx-3 mt-2 w-auto justify-start">
533
+ <TabsTrigger value="pending">待处理</TabsTrigger>
534
+ <TabsTrigger value="history">历史</TabsTrigger>
535
+ </TabsList>
536
+ <TabsContent value="pending" className="flex-1 overflow-y-auto p-4 space-y-3 mt-0">
537
+ {requestList.length === 0 && (
538
+ <div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground text-sm">
539
+ <UserPlus size={40} className="opacity-25" />
540
+ <span>暂无未处理请求</span>
541
+ </div>
542
+ )}
543
+ {requestList.map((r) => (
544
+ <div key={r.id} className="border border-border/80 rounded-lg p-3 space-y-2 bg-background/50">
545
+ <div className="flex flex-wrap gap-2 text-sm">
546
+ <Badge>{r.type}</Badge>
547
+ <span>来自 {r.sender.name || r.sender.id}</span>
548
+ <span className="text-muted-foreground text-xs">
549
+ {new Date(r.timestamp).toLocaleString()}
550
+ </span>
551
+ </div>
552
+ {r.comment && <p className="text-sm">{r.comment}</p>}
553
+ <div className="flex flex-wrap gap-2">
554
+ {r.canAct === true && (
555
+ <>
556
+ <Button size="sm" onClick={() => void approve(r.platformRequestId, true)}>
557
+ 同意
558
+ </Button>
559
+ <Button size="sm" variant="outline" onClick={() => void approve(r.platformRequestId, false)}>
560
+ 拒绝
561
+ </Button>
562
+ </>
563
+ )}
564
+ <Button size="sm" variant="ghost" onClick={() => void dismissRequest(r.id)}>
565
+ 标记已处理
566
+ </Button>
567
+ </div>
568
+ </div>
569
+ ))}
570
+ </TabsContent>
571
+ <TabsContent value="history" className="flex-1 overflow-y-auto p-4 space-y-3 mt-0 min-h-0">
572
+ {!inboxRequestsEnabled && !inboxRequestsLoading && (
573
+ <div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground text-sm">
574
+ <span>未启用统一收件箱,无历史记录</span>
575
+ </div>
576
+ )}
577
+ {inboxRequestsLoading && inboxRequests.length === 0 && (
578
+ <div className="flex justify-center py-8">
579
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
580
+ </div>
581
+ )}
582
+ {inboxRequestsEnabled && inboxRequests.length === 0 && !inboxRequestsLoading && (
583
+ <div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground text-sm">
584
+ <span>暂无请求历史</span>
585
+ </div>
586
+ )}
587
+ {inboxRequests.length > 0 && (
588
+ <>
589
+ {inboxRequests.map((r) => (
590
+ <div key={r.id} className="border border-border/80 rounded-lg p-3 space-y-1 text-sm bg-background/50">
591
+ <div className="flex flex-wrap gap-2">
592
+ <Badge variant="outline">{r.type}</Badge>
593
+ <span>{r.sender_name || r.sender_id}</span>
594
+ <span className="text-muted-foreground text-xs">
595
+ {new Date(r.created_at).toLocaleString()}
596
+ </span>
597
+ {r.resolved ? <Badge variant="secondary">已处理</Badge> : null}
598
+ </div>
599
+ {r.comment && <p className="text-muted-foreground text-sm">{r.comment}</p>}
600
+ </div>
601
+ ))}
602
+ <div className="flex justify-center pt-2">
603
+ <Button
604
+ variant="ghost"
605
+ size="sm"
606
+ disabled={inboxRequestsLoading}
607
+ onClick={() => void loadInboxRequests(true)}
608
+ >
609
+ {inboxRequestsLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '加载更多'}
610
+ </Button>
611
+ </div>
612
+ </>
613
+ )}
614
+ </TabsContent>
615
+ </Tabs>
616
+ </div>
617
+ )}
618
+
619
+ {selection?.type === 'notices' && (
620
+ <div className="flex flex-col flex-1 min-h-0 overflow-hidden bg-card m-0 border-0 rounded-none">
621
+ <header className="im-chat-header px-4 py-3">
622
+ <h2 className="text-base font-semibold flex items-center gap-2">
623
+ <Bell size={18} />
624
+ 通知
625
+ </h2>
626
+ </header>
627
+ <Tabs
628
+ value={noticesTab}
629
+ onValueChange={(v) => {
630
+ setNoticesTab(v as 'unread' | 'history')
631
+ if (v === 'history' && inboxNotices.length === 0 && !inboxNoticesLoading)
632
+ void loadInboxNotices(false)
633
+ }}
634
+ className="flex flex-col flex-1 min-h-0"
635
+ >
636
+ <TabsList className="mx-3 mt-2 w-auto justify-start">
637
+ <TabsTrigger value="unread">未读</TabsTrigger>
638
+ <TabsTrigger value="history">历史</TabsTrigger>
639
+ </TabsList>
640
+ <TabsContent value="unread" className="flex-1 overflow-y-auto p-4 space-y-3 mt-0">
641
+ {noticeList.length === 0 && (
642
+ <div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground text-sm">
643
+ <Bell size={40} className="opacity-25" />
644
+ <span>暂无未读通知</span>
645
+ </div>
646
+ )}
647
+ {noticeList.map((n) => (
648
+ <div
649
+ key={n.id}
650
+ className="border border-border/80 rounded-lg p-3 flex justify-between gap-2 bg-background/50"
651
+ >
652
+ <div className="min-w-0">
653
+ <Badge className="mb-1">{n.noticeType}</Badge>
654
+ <p className="text-xs text-muted-foreground font-mono truncate max-w-md">
655
+ {n.payload.slice(0, 200)}
656
+ </p>
657
+ <p className="text-xs text-muted-foreground mt-1">
658
+ {new Date(n.timestamp).toLocaleString()}
659
+ </p>
660
+ </div>
661
+ <Button size="sm" variant="outline" onClick={() => void dismissNotice(n.id)}>
662
+ 已读
663
+ </Button>
664
+ </div>
665
+ ))}
666
+ </TabsContent>
667
+ <TabsContent value="history" className="flex-1 overflow-y-auto p-4 space-y-3 mt-0 min-h-0">
668
+ {!inboxNoticesEnabled && !inboxNoticesLoading && (
669
+ <div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground text-sm">
670
+ <span>未启用统一收件箱,无历史记录</span>
671
+ </div>
672
+ )}
673
+ {inboxNoticesLoading && inboxNotices.length === 0 && (
674
+ <div className="flex justify-center py-8">
675
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
676
+ </div>
677
+ )}
678
+ {inboxNoticesEnabled && inboxNotices.length === 0 && !inboxNoticesLoading && (
679
+ <div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground text-sm">
680
+ <span>暂无通知历史</span>
681
+ </div>
682
+ )}
683
+ {inboxNotices.length > 0 && (
684
+ <>
685
+ {inboxNotices.map((n) => (
686
+ <div key={n.id} className="border border-border/80 rounded-lg p-3 space-y-1 text-sm bg-background/50">
687
+ <div className="flex flex-wrap gap-2">
688
+ <Badge variant="outline">{n.type}</Badge>
689
+ <span className="text-muted-foreground text-xs">
690
+ {new Date(n.created_at).toLocaleString()}
691
+ </span>
692
+ </div>
693
+ <p className="text-muted-foreground font-mono text-xs truncate max-w-full">
694
+ {String(n.payload ?? '').slice(0, 200)}
695
+ </p>
696
+ </div>
697
+ ))}
698
+ <div className="flex justify-center pt-2">
699
+ <Button
700
+ variant="ghost"
701
+ size="sm"
702
+ disabled={inboxNoticesLoading}
703
+ onClick={() => void loadInboxNotices(true)}
704
+ >
705
+ {inboxNoticesLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '加载更多'}
706
+ </Button>
707
+ </div>
708
+ </>
709
+ )}
710
+ </TabsContent>
711
+ </Tabs>
712
+ </div>
713
+ )}
714
+
715
+ {!selection && (
716
+ <div className="flex flex-col items-center justify-center flex-1 gap-3 text-muted-foreground px-6 text-center">
717
+ <MessageSquare className="h-14 w-14 opacity-20" />
718
+ <p className="text-sm font-medium text-foreground/80">选择会话或查看请求 / 通知</p>
719
+ <p className="text-xs max-w-sm">
720
+ 左侧列表与 Telegram Web 类似:点选好友或群开始聊天;请求与通知在列表下方分组。
721
+ </p>
722
+ </div>
723
+ )}
724
+ </div>
725
+
726
+ {showRightPanel && (
727
+ <aside className={cn('im-right-panel im-right-visible')}>
728
+ <div className="p-3 border-b border-border/60">
729
+ <h3 className="text-sm font-semibold">群成员与管理</h3>
730
+ <p className="text-[11px] text-muted-foreground mt-0.5">仅 ICQQ 群聊</p>
731
+ </div>
732
+ <div className="p-2 border-b border-border/60">
733
+ <Button
734
+ size="sm"
735
+ variant="outline"
736
+ className="w-full"
737
+ onClick={() => void loadMembers()}
738
+ disabled={membersLoading}
739
+ >
740
+ {membersLoading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
741
+ 加载成员
742
+ </Button>
743
+ </div>
744
+ <div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
745
+ {members.map((m, i) => {
746
+ const uid = m.user_id ?? (m as { id?: string }).id ?? i
747
+ return (
748
+ <div
749
+ key={`${uid}-${i}`}
750
+ className="flex flex-col gap-1.5 text-xs p-2 border border-border/70 rounded-md bg-background/60"
751
+ >
752
+ <div className="flex flex-wrap items-center gap-1">
753
+ <span className="font-medium text-sm">
754
+ {m.nickname ?? (m as { name?: string }).name ?? uid}
755
+ </span>
756
+ <span className="text-muted-foreground">{uid}</span>
757
+ {(m.role ?? (m as { role?: string }).role) != null && (
758
+ <Badge variant="outline" className="text-[10px]">
759
+ {String(m.role ?? (m as { role?: string }).role)}
760
+ </Badge>
761
+ )}
762
+ </div>
763
+ <div className="flex flex-wrap gap-1">
764
+ <Button
765
+ size="sm"
766
+ variant="destructive"
767
+ className="h-7 text-[10px] px-2"
768
+ onClick={() => void groupAction('bot:groupKick', uid)}
769
+ >
770
+
771
+ </Button>
772
+ <Button
773
+ size="sm"
774
+ variant="outline"
775
+ className="h-7 text-[10px] px-2"
776
+ onClick={() => void groupAction('bot:groupMute', uid)}
777
+ >
778
+ 禁言
779
+ </Button>
780
+ <Button
781
+ size="sm"
782
+ variant="outline"
783
+ className="h-7 text-[10px] px-2"
784
+ onClick={() => void groupAction('bot:groupAdmin', uid, { enable: true })}
785
+ >
786
+ 管理
787
+ </Button>
788
+ </div>
789
+ </div>
790
+ )
791
+ })}
792
+ </div>
793
+ </aside>
794
+ )}
795
+ </div>
796
+ </div>
797
+ )
798
+ }