@zhin.js/adapter-sandbox 1.0.22

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.
@@ -0,0 +1,864 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { MessageSegment, cn } from '@zhin.js/client';
3
+ import { Flex, Box, Heading, Text, Badge, Button, Card, Tabs, TextField, Grid } from '@radix-ui/themes';
4
+ import { User, Users, Trash2, Send, Hash, MessageSquare, Wifi, WifiOff, Smile, Image, AtSign, X, Upload, Check, Info, Search, Bot } from 'lucide-react';
5
+ import RichTextEditor, { RichTextEditorRef } from './RichTextEditor';
6
+
7
+
8
+ interface Message {
9
+ id: string
10
+ type: 'sent' | 'received'
11
+ channelType: 'private' | 'group' | 'channel'
12
+ channelId: string
13
+ channelName: string
14
+ senderId: string
15
+ senderName: string
16
+ content: MessageSegment[]
17
+ timestamp: number
18
+ }
19
+
20
+ interface Channel {
21
+ id: string
22
+ name: string
23
+ type: 'private' | 'group' | 'channel'
24
+ unread: number
25
+ }
26
+
27
+ interface Face {
28
+ id: number
29
+ emojiId: number
30
+ stickerId: number
31
+ emojiType: string
32
+ name: string
33
+ describe: string
34
+ png: boolean
35
+ apng: boolean
36
+ lottie: boolean
37
+ }
38
+
39
+ export default function Sandbox() {
40
+ const [messages, setMessages] = useState<Message[]>([])
41
+ const [channels, setChannels] = useState<Channel[]>([
42
+ { id: 'user_1001', name: '测试用户', type: 'private', unread: 0 },
43
+ { id: 'group_2001', name: '测试群组', type: 'group', unread: 0 },
44
+ { id: 'channel_3001', name: '测试频道', type: 'channel', unread: 0 }
45
+ ])
46
+ const [faceList, setFaceList] = useState<Face[]>([])
47
+ const [activeChannel, setActiveChannel] = useState<Channel>(channels[0])
48
+ const [inputText, setInputText] = useState('')
49
+ const [botName, setBotName] = useState('ProcessBot')
50
+ const [connected, setConnected] = useState(false)
51
+ const [showFacePicker, setShowFacePicker] = useState(false)
52
+ const [showImageUpload, setShowImageUpload] = useState(false)
53
+ const [showAtPicker, setShowAtPicker] = useState(false)
54
+ const [atPopoverPosition, setAtPopoverPosition] = useState<{ top: number; left: number } | null>(null)
55
+ const [atSearchQuery, setAtSearchQuery] = useState('')
56
+ const [faceSearchQuery, setFaceSearchQuery] = useState('')
57
+ const [imageUrl, setImageUrl] = useState('')
58
+ const [atUserName, setAtUserName] = useState('')
59
+ const [atSuggestions] = useState([
60
+ { id: '10001', name: '张三' },
61
+ { id: '10002', name: '李四' },
62
+ { id: '10003', name: '王五' },
63
+ { id: '10004', name: '赵六' },
64
+ { id: '10005', name: '测试用户' },
65
+ { id: '10086', name: 'Admin' },
66
+ { id: '10010', name: 'Test User' }
67
+ ])
68
+ const [previewSegments, setPreviewSegments] = useState<MessageSegment[]>([])
69
+ const [showChannelList, setShowChannelList] = useState(false)
70
+ const messagesEndRef = useRef<HTMLDivElement>(null)
71
+ const wsRef = useRef<WebSocket | null>(null)
72
+ const fileInputRef = useRef<HTMLInputElement>(null)
73
+ const editorRef = useRef<RichTextEditorRef>(null)
74
+
75
+ // 获取表情列表
76
+ const fetchFaceList = async () => {
77
+ try {
78
+ const res = await fetch('https://face.viki.moe/metadata.json')
79
+ const data = await res.json()
80
+ setFaceList(data)
81
+ } catch (err) {
82
+ console.error('[ProcessSandbox] Failed to fetch face list:', err)
83
+ }
84
+ }
85
+
86
+ useEffect(() => {
87
+ fetchFaceList()
88
+ }, [])
89
+
90
+ // WebSocket 连接
91
+ useEffect(() => {
92
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
93
+ wsRef.current = new WebSocket(`${protocol}//${window.location.host}/sandbox`)
94
+
95
+ wsRef.current.onopen = () => {
96
+ setConnected(true)
97
+ }
98
+
99
+ wsRef.current.onmessage = (event) => {
100
+ try {
101
+ const data = JSON.parse(event.data)
102
+
103
+ // 如果 content 是字符串,转换为消息段数组
104
+ let content: MessageSegment[] = []
105
+ if (typeof data.content === 'string') {
106
+ content = parseTextToSegments(data.content)
107
+ } else if (Array.isArray(data.content)) {
108
+ content = data.content
109
+ } else {
110
+ // 如果都不是,尝试转换为字符串再解析
111
+ content = parseTextToSegments(String(data.content))
112
+ }
113
+
114
+ // 检查频道是否存在,如果不存在则创建
115
+ let targetChannel = channels.find((c: Channel) => c.id === data.id);
116
+ if (!targetChannel) {
117
+ // 根据消息类型创建新频道
118
+ const channelName = data.type === 'private'
119
+ ? `私聊-${data.bot || botName}`
120
+ : data.type === 'group'
121
+ ? `群组-${data.id}`
122
+ : `频道-${data.id}`;
123
+ targetChannel = {
124
+ id: data.id,
125
+ name: channelName,
126
+ type: data.type,
127
+ unread: 0
128
+ };
129
+ setChannels((prev: Channel[]) => [...prev, targetChannel!]);
130
+ // 自动切换到新频道
131
+ setActiveChannel(targetChannel);
132
+ }
133
+
134
+ const botMessage: Message = {
135
+ id: `bot_${data.timestamp}`,
136
+ type: 'received',
137
+ channelType: data.type,
138
+ channelId: data.id,
139
+ channelName: targetChannel.name,
140
+ senderId: 'bot',
141
+ senderName: data.bot || botName,
142
+ content: content,
143
+ timestamp: data.timestamp
144
+ }
145
+ setMessages((prev: Message[]) => [...prev, botMessage])
146
+ } catch (err) {
147
+ console.error('[Sandbox] Failed to parse message:', err)
148
+ }
149
+ }
150
+
151
+ wsRef.current.onclose = () => {
152
+ setConnected(false)
153
+ }
154
+
155
+ return () => {
156
+ wsRef.current?.close()
157
+ }
158
+ }, [botName, channels])
159
+
160
+ // 自动滚动
161
+ useEffect(() => {
162
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
163
+ }, [messages])
164
+
165
+ // 实时预览
166
+ useEffect(() => {
167
+ if (inputText.trim()) {
168
+ const segments = parseTextToSegments(inputText)
169
+ setPreviewSegments(segments)
170
+ } else {
171
+ setPreviewSegments([])
172
+ }
173
+ }, [inputText])
174
+
175
+ // 解析文本为消息段
176
+ const parseTextToSegments = (text: string): MessageSegment[] => {
177
+ const segments: MessageSegment[] = []
178
+ const regex = /\[@([^\]]+)\]|\[face:(\d+)\]|\[image:([^\]]+)\]/g
179
+ let lastIndex = 0
180
+ let match
181
+
182
+ while ((match = regex.exec(text)) !== null) {
183
+ if (match.index > lastIndex) {
184
+ const textContent = text.substring(lastIndex, match.index)
185
+ if (textContent) {
186
+ segments.push({ type: 'text', data: { text: textContent } })
187
+ }
188
+ }
189
+
190
+ if (match[1]) {
191
+ segments.push({ type: 'at', data: { qq: match[1], name: match[1] } })
192
+ } else if (match[2]) {
193
+ segments.push({ type: 'face', data: { id: parseInt(match[2]) } })
194
+ } else if (match[3]) {
195
+ segments.push({ type: 'image', data: { url: match[3] } })
196
+ }
197
+
198
+ lastIndex = regex.lastIndex
199
+ }
200
+
201
+ if (lastIndex < text.length) {
202
+ const remainingText = text.substring(lastIndex)
203
+ if (remainingText) {
204
+ segments.push({ type: 'text', data: { text: remainingText } })
205
+ }
206
+ }
207
+
208
+ return segments.length > 0 ? segments : [{ type: 'text' as const, data: { text: text } }]
209
+ }
210
+
211
+ // 渲染消息段
212
+ const renderMessageSegments = (segments: (MessageSegment|string)[]) => {
213
+ return segments.map((segment, index) => {
214
+ if(typeof segment==='string') {
215
+ // 将字符串中的换行符转换为 <br />
216
+ const parts = segment.split('\n')
217
+ return (
218
+ <span key={index}>
219
+ {parts.map((part, i) => (
220
+ <React.Fragment key={i}>
221
+ {part}
222
+ {i < parts.length - 1 && <br />}
223
+ </React.Fragment>
224
+ ))}
225
+ </span>
226
+ )
227
+ }
228
+ switch (segment.type) {
229
+ case 'text':
230
+ // 将文本中的换行符转换为 <br />
231
+ const textParts = segment.data.text.split('\n')
232
+ return (
233
+ <span key={index}>
234
+ {textParts.map((part, i) => (
235
+ <React.Fragment key={i}>
236
+ {part}
237
+ {i < textParts.length - 1 && <br />}
238
+ </React.Fragment>
239
+ ))}
240
+ </span>
241
+ )
242
+ case 'at':
243
+ return (
244
+ <Badge key={index} color="blue" variant="soft" style={{ margin: '0 2px' }}>
245
+ @{segment.data.name || segment.data.qq}
246
+ </Badge>
247
+ )
248
+ case 'face':
249
+ return (
250
+ <img
251
+ key={index}
252
+ src={`https://face.viki.moe/apng/${segment.data.id}.png`}
253
+ alt={`表情${segment.data.id}`}
254
+ style={{ width: '24px', height: '24px', display: 'inline-block', verticalAlign: 'middle', margin: '0 2px' }}
255
+ />
256
+ )
257
+ case 'image':
258
+ return (
259
+ <img
260
+ key={index}
261
+ src={segment.data.url}
262
+ alt="图片"
263
+ style={{ maxWidth: '300px', borderRadius: '8px', margin: '4px 0', display: 'block' }}
264
+ onError={(e) => { e.currentTarget.style.display = 'none' }}
265
+ />
266
+ )
267
+ case 'video':
268
+ return (
269
+ <Badge key={index} variant="outline" style={{ margin: '0 2px' }}>
270
+ 📹 视频
271
+ </Badge>
272
+ )
273
+ case 'audio':
274
+ return (
275
+ <Badge key={index} variant="outline" style={{ margin: '0 2px' }}>
276
+ 🎵 语音
277
+ </Badge>
278
+ )
279
+ case 'file':
280
+ return (
281
+ <Badge key={index} variant="outline" style={{ margin: '0 2px' }}>
282
+ 📎 {segment.data.name || '文件'}
283
+ </Badge>
284
+ )
285
+ default:
286
+ return <span key={index}>[未知消息类型]</span>
287
+ }
288
+ })
289
+ }
290
+
291
+ // 发送消息
292
+ const handleSendMessage = (text: string, segments: MessageSegment[]) => {
293
+ if (!text.trim() || segments.length === 0) return
294
+
295
+ const newMessage: Message = {
296
+ id: `msg_${Date.now()}`,
297
+ type: 'sent',
298
+ channelType: activeChannel.type,
299
+ channelId: activeChannel.id,
300
+ channelName: activeChannel.name,
301
+ senderId: 'test_user',
302
+ senderName: '测试用户',
303
+ content: segments,
304
+ timestamp: Date.now()
305
+ }
306
+
307
+ setMessages((prev: Message[]) => [...prev, newMessage])
308
+ setInputText('')
309
+ setPreviewSegments([])
310
+
311
+ // 清空编辑器
312
+ editorRef.current?.clear()
313
+
314
+ wsRef.current?.send(JSON.stringify({
315
+ type: activeChannel.type,
316
+ id: activeChannel.id,
317
+ content: segments,
318
+ timestamp: Date.now()
319
+ }))
320
+ }
321
+
322
+ // 清空消息
323
+ const clearMessages = () => {
324
+ if (confirm('确定清空所有消息记录?')) {
325
+ setMessages([])
326
+ }
327
+ }
328
+
329
+ // 切换频道
330
+ const switchChannel = (channel: Channel) => {
331
+ setActiveChannel(channel)
332
+ setChannels((prev: Channel[]) =>
333
+ prev.map((c: Channel) => (c.id === channel.id ? { ...c, unread: 0 } : c))
334
+ )
335
+ // 移动端切换频道后自动关闭侧边栏
336
+ if (window.innerWidth < 768) {
337
+ setShowChannelList(false)
338
+ }
339
+ }
340
+
341
+ // 添加新频道
342
+ const addChannel = () => {
343
+ const types: Array<'private' | 'group' | 'channel'> = ['private', 'group', 'channel']
344
+ const typeNames = { private: '私聊', group: '群聊', guild: '频道' }
345
+ const type = types[Math.floor(Math.random() * types.length)]
346
+ const id = `${type}_${Date.now()}`
347
+ const name = prompt(`请输入${typeNames[type]}名称:`)
348
+
349
+ if (name) {
350
+ const newChannel: Channel = { id, name, type, unread: 0 }
351
+ setChannels((prev: Channel[]) => [...prev, newChannel])
352
+ setActiveChannel(newChannel)
353
+ }
354
+ }
355
+
356
+ // 获取频道图标
357
+ const getChannelIcon = (type: string) => {
358
+ switch (type) {
359
+ case 'private': return <User size={16} />
360
+ case 'group': return <Users size={16} />
361
+ case 'channel': return <Hash size={16} />
362
+ default: return <MessageSquare size={16} />
363
+ }
364
+ }
365
+
366
+ // 插入表情
367
+ const insertFace = (faceId: number) => {
368
+ editorRef.current?.insertFace(faceId)
369
+ setShowFacePicker(false)
370
+ }
371
+
372
+ // 插入图片
373
+ const insertImageUrl = () => {
374
+ if (!imageUrl.trim()) return
375
+ editorRef.current?.insertImage(imageUrl.trim())
376
+ setImageUrl('')
377
+ setShowImageUpload(false)
378
+ }
379
+
380
+ // 插入 @ 某人(手动点击按钮)
381
+ const insertAtUser = () => {
382
+ if (!atUserName.trim()) return
383
+ editorRef.current?.insertAt(atUserName.trim())
384
+ setAtUserName('')
385
+ setShowAtPicker(false)
386
+ }
387
+
388
+ // 选择 @ 提及用户(自动触发)
389
+ const selectAtUser = (user: { id: string; name: string }) => {
390
+ editorRef.current?.replaceAtTrigger(user.name, user.id)
391
+ setAtPopoverPosition(null)
392
+ setAtSearchQuery('')
393
+ }
394
+
395
+ // 处理 @ 触发
396
+ const handleAtTrigger = (show: boolean, searchQuery: string, position?: { top: number; left: number }) => {
397
+ // 仅在群聊或频道中启用 @ 功能
398
+ if (activeChannel.type === 'private') {
399
+ setAtPopoverPosition(null)
400
+ setAtSearchQuery('')
401
+ return
402
+ }
403
+
404
+ if (show && position) {
405
+ setAtPopoverPosition(position)
406
+ setAtSearchQuery(searchQuery)
407
+ } else {
408
+ setAtPopoverPosition(null)
409
+ setAtSearchQuery('')
410
+ }
411
+ }
412
+
413
+ // 过滤用户列表(根据 @ 后面输入的内容)
414
+ const filteredAtSuggestions = atSuggestions.filter((user) => {
415
+ if (!atSearchQuery.trim()) return true
416
+ const query = atSearchQuery.toLowerCase()
417
+ return (
418
+ user.name.toLowerCase().includes(query) ||
419
+ user.id.toLowerCase().includes(query)
420
+ )
421
+ })
422
+
423
+ // 处理编辑器内容变化
424
+ const handleEditorChange = (text: string, segments: MessageSegment[]) => {
425
+ setInputText(text)
426
+ setPreviewSegments(segments)
427
+ }
428
+
429
+ // 处理文件上传
430
+ const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
431
+ const file = e.target.files?.[0]
432
+ if (!file) return
433
+
434
+ const reader = new FileReader()
435
+ reader.onload = (event) => {
436
+ const dataUrl = event.target?.result as string
437
+ setInputText(prev => prev + `[image:${dataUrl}]`)
438
+ setShowImageUpload(false)
439
+ }
440
+ reader.readAsDataURL(file)
441
+ }
442
+
443
+ // 筛选表情
444
+ const filteredFaces = faceList.filter(face =>
445
+ face.name.toLowerCase().includes(faceSearchQuery.toLowerCase()) ||
446
+ face.describe.toLowerCase().includes(faceSearchQuery.toLowerCase())
447
+ )
448
+
449
+ // 筛选当前频道的消息
450
+ const channelMessages = messages.filter(
451
+ (msg: Message) => msg.channelId === activeChannel.id
452
+ )
453
+
454
+ return (
455
+ <div className="sandbox-container">
456
+ {/* 移动端频道列表切换按钮 */}
457
+ <button
458
+ className="mobile-channel-toggle md:hidden"
459
+ onClick={() => setShowChannelList(!showChannelList)}
460
+ >
461
+ <MessageSquare size={20} />
462
+ 频道列表
463
+ </button>
464
+
465
+ {/* 左侧频道列表 */}
466
+ <Card className={cn("channel-sidebar", showChannelList && "show")}>
467
+ <Box p="3" style={{ borderBottom: '1px solid var(--gray-6)' }}>
468
+ <Flex justify="between" align="center" mb="2">
469
+ <Flex align="center" gap="2">
470
+ <Box p="1" style={{ borderRadius: '8px', backgroundColor: 'var(--purple-3)' }}>
471
+ <MessageSquare size={16} color="var(--purple-9)" />
472
+ </Box>
473
+ <Heading size="4">频道列表</Heading>
474
+ </Flex>
475
+ <Badge color={connected ? 'green' : 'gray'}>
476
+ <Flex align="center" gap="1">
477
+ {connected ? <Wifi size={12} /> : <WifiOff size={12} />}
478
+ {connected ? '已连接' : '未连接'}
479
+ </Flex>
480
+ </Badge>
481
+ </Flex>
482
+ </Box>
483
+
484
+ <Box style={{ flex: 1, overflowY: 'auto' }} p="3">
485
+ <Flex direction="column" gap="2">
486
+ {channels.map((channel: Channel) => {
487
+ const isActive = activeChannel.id === channel.id
488
+ return (
489
+ <div
490
+ key={channel.id}
491
+ className={cn("channel-item", isActive && "active")}
492
+ onClick={() => switchChannel(channel)}
493
+ >
494
+ <div className="icon">
495
+ {getChannelIcon(channel.type)}
496
+ </div>
497
+ <div className="text">
498
+ <div className="title">{channel.name}</div>
499
+ <div className="subtitle">
500
+ {channel.type === 'private' && '私聊'}
501
+ {channel.type === 'group' && '群聊'}
502
+ {channel.type === 'channel' && '频道'}
503
+ </div>
504
+ </div>
505
+ {channel.unread > 0 && (
506
+ <Badge color="red" size="1" className="badge">
507
+ {channel.unread}
508
+ </Badge>
509
+ )}
510
+ {isActive && <div className="indicator" />}
511
+ </div>
512
+ )
513
+ })}
514
+ </Flex>
515
+ </Box>
516
+
517
+ <Box p="2" style={{ borderTop: '1px solid var(--gray-6)' }}>
518
+ <Button variant="outline" onClick={addChannel} style={{ width: '100%' }}>
519
+ + 添加频道
520
+ </Button>
521
+ </Box>
522
+ </Card>
523
+
524
+ {/* 遮罩层 - 移动端点击关闭频道列表 */}
525
+ {showChannelList && (
526
+ <div
527
+ className="channel-overlay md:hidden"
528
+ onClick={() => setShowChannelList(false)}
529
+ />
530
+ )}
531
+
532
+ {/* 右侧聊天区域 */}
533
+ <div className="chat-area">
534
+ {/* 顶部信息栏 */}
535
+ <Card>
536
+ <Flex justify="between" align="center" p="3">
537
+ <Flex align="center" gap="3">
538
+ <Box p="2" style={{ borderRadius: '12px', backgroundColor: 'var(--blue-3)' }}>
539
+ {getChannelIcon(activeChannel.type)}
540
+ </Box>
541
+ <Box>
542
+ <Heading size="5">{activeChannel.name}</Heading>
543
+ <Flex align="center" gap="2">
544
+ <Text size="1" color="gray">{activeChannel.id}</Text>
545
+ <Badge variant="outline" size="1">{channelMessages.length}</Badge>
546
+ <Text size="1" color="gray">条消息</Text>
547
+ </Flex>
548
+ </Box>
549
+ <Badge color={activeChannel.type === 'private' ? 'blue' : activeChannel.type === 'group' ? 'green' : 'purple'}>
550
+ {activeChannel.type === 'private' && '私聊'}
551
+ {activeChannel.type === 'group' && '群聊'}
552
+ {activeChannel.type === 'channel' && '频道'}
553
+ </Badge>
554
+ </Flex>
555
+ <Flex align="center" gap="2">
556
+ <TextField.Root
557
+ value={botName}
558
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBotName(e.target.value)}
559
+ placeholder="机器人名称"
560
+ style={{ width: '120px' }}
561
+ />
562
+ <Button variant="soft" onClick={clearMessages}>
563
+ <Trash2 size={16} />
564
+ 清空
565
+ </Button>
566
+ </Flex>
567
+ </Flex>
568
+ </Card>
569
+
570
+ {/* 消息列表 */}
571
+ <Card style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
572
+ <Box style={{ flex: 1, overflowY: 'auto' }} p="4">
573
+ {channelMessages.length === 0 ? (
574
+ <Flex direction="column" align="center" justify="center" style={{ height: '100%' }}>
575
+ <MessageSquare size={64} color="var(--gray-6)" />
576
+ <Text color="gray" mt="3">暂无消息,开始对话吧!</Text>
577
+ </Flex>
578
+ ) : (
579
+ <Flex direction="column" gap="2">
580
+ {channelMessages.map((msg: Message) => (
581
+ <Flex
582
+ key={msg.id}
583
+ justify={msg.type === 'sent' ? 'end' : 'start'}
584
+ >
585
+ <Box
586
+ style={{
587
+ maxWidth: '70%',
588
+ padding: '12px',
589
+ borderRadius: '16px',
590
+ backgroundColor: msg.type === 'sent' ? 'var(--blue-9)' : 'var(--gray-3)',
591
+ color: msg.type === 'sent' ? 'white' : 'var(--gray-12)'
592
+ }}
593
+ >
594
+ <Flex align="center" gap="2" mb="1">
595
+ {msg.type === 'received' && <Bot size={14} />}
596
+ {msg.type === 'sent' && <User size={14} />}
597
+ <Text size="1" weight="medium" style={{ opacity: 0.9 }}>
598
+ {msg.senderName}
599
+ </Text>
600
+ <Text size="1" style={{ opacity: 0.7 }}>
601
+ {new Date(msg.timestamp).toLocaleTimeString()}
602
+ </Text>
603
+ </Flex>
604
+ <Text size="2">
605
+ {renderMessageSegments(msg.content)}
606
+ </Text>
607
+ </Box>
608
+ </Flex>
609
+ ))}
610
+ <div ref={messagesEndRef} />
611
+ </Flex>
612
+ )}
613
+ </Box>
614
+ </Card>
615
+
616
+ {/* 输入框 */}
617
+ <Card>
618
+ <Flex direction="column" gap="3" p="3">
619
+ {/* 工具栏 */}
620
+ <Flex gap="2" align="center">
621
+ <Button
622
+ variant={showFacePicker ? 'solid' : 'outline'}
623
+ size="2"
624
+ onClick={() => {
625
+ setShowFacePicker(!showFacePicker)
626
+ setShowImageUpload(false)
627
+ }}
628
+ title="插入表情"
629
+ >
630
+ <Smile size={16} />
631
+ </Button>
632
+
633
+ <Button
634
+ variant={showImageUpload ? 'solid' : 'outline'}
635
+ size="2"
636
+ onClick={() => {
637
+ setShowImageUpload(!showImageUpload)
638
+ setShowFacePicker(false)
639
+ }}
640
+ title="插入图片"
641
+ >
642
+ <Image size={16} />
643
+ </Button>
644
+ <Box style={{ flex: 1 }} />
645
+
646
+ {inputText && (
647
+ <Button
648
+ variant="ghost"
649
+ size="2"
650
+ onClick={() => {
651
+ setInputText('')
652
+ setPreviewSegments([])
653
+ }}
654
+ title="清空"
655
+ >
656
+ <X size={16} />
657
+ </Button>
658
+ )}
659
+ </Flex>
660
+
661
+ {/* 表情选择器 */}
662
+ {showFacePicker && (
663
+ <Box p="3" style={{ border: '1px solid var(--gray-6)', borderRadius: '8px', backgroundColor: 'var(--gray-1)', maxHeight: '256px', overflowY: 'auto' }}>
664
+ <TextField.Root
665
+ value={faceSearchQuery}
666
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFaceSearchQuery(e.target.value)}
667
+ placeholder="搜索表情..."
668
+ style={{ marginBottom: '8px' }}
669
+ />
670
+ <Grid columns="8" gap="2">
671
+ {filteredFaces.slice(0, 80).map((face) => (
672
+ <button
673
+ key={face.id}
674
+ onClick={() => insertFace(face.id)}
675
+ style={{
676
+ width: '40px',
677
+ height: '40px',
678
+ borderRadius: '8px',
679
+ border: '1px solid var(--gray-6)',
680
+ backgroundColor: 'transparent',
681
+ cursor: 'pointer',
682
+ display: 'flex',
683
+ alignItems: 'center',
684
+ justifyContent: 'center'
685
+ }}
686
+ title={face.name}
687
+ >
688
+ <img
689
+ src={`https://face.viki.moe/apng/${face.id}.png`}
690
+ alt={face.name}
691
+ style={{ width: '32px', height: '32px' }}
692
+ />
693
+ </button>
694
+ ))}
695
+ </Grid>
696
+ {filteredFaces.length === 0 && (
697
+ <Flex direction="column" align="center" gap="2" py="4">
698
+ <Search size={32} color="var(--gray-6)" />
699
+ <Text size="2" color="gray">未找到匹配的表情</Text>
700
+ </Flex>
701
+ )}
702
+ </Box>
703
+ )}
704
+
705
+ {/* 图片上传 */}
706
+ {showImageUpload && (
707
+ <Box p="3" style={{ border: '1px solid var(--gray-6)', borderRadius: '8px', backgroundColor: 'var(--gray-1)' }}>
708
+ <Tabs.Root defaultValue="url">
709
+ <Tabs.List>
710
+ <Tabs.Trigger value="url">图片链接</Tabs.Trigger>
711
+ <Tabs.Trigger value="upload">本地上传</Tabs.Trigger>
712
+ </Tabs.List>
713
+ <Box pt="3">
714
+ <Flex direction="column" gap="2">
715
+ <TextField.Root
716
+ value={imageUrl}
717
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImageUrl(e.target.value)}
718
+ placeholder="输入图片 URL..."
719
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
720
+ if (e.key === 'Enter') {
721
+ e.preventDefault()
722
+ insertImageUrl()
723
+ }
724
+ }}
725
+ />
726
+ <Button onClick={insertImageUrl} disabled={!imageUrl.trim()}>
727
+ <Check size={16} />
728
+ 插入
729
+ </Button>
730
+ </Flex>
731
+ </Box>
732
+ </Tabs.Root>
733
+ </Box>
734
+ )}
735
+
736
+ {/* @ 某人 */}
737
+ {showAtPicker && (
738
+ <Box p="3" style={{ border: '1px solid var(--gray-6)', borderRadius: '8px', backgroundColor: 'var(--gray-1)' }}>
739
+ <Flex direction="column" gap="2">
740
+ <TextField.Root
741
+ value={atUserName}
742
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAtUserName(e.target.value)}
743
+ placeholder="输入用户名..."
744
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
745
+ if (e.key === 'Enter') {
746
+ e.preventDefault()
747
+ insertAtUser()
748
+ }
749
+ }}
750
+ />
751
+ <Button onClick={insertAtUser} disabled={!atUserName.trim()}>
752
+ <Check size={16} />
753
+ 插入
754
+ </Button>
755
+ </Flex>
756
+ </Box>
757
+ )}
758
+
759
+ {/* 富文本编辑器和发送按钮 */}
760
+ <Flex gap="2" align="start">
761
+ <Box style={{ flex: 1, position: 'relative' }}>
762
+ <RichTextEditor
763
+ ref={editorRef}
764
+ placeholder={`向 ${activeChannel.name} 发送消息...`}
765
+ onSend={handleSendMessage}
766
+ onChange={handleEditorChange}
767
+ onAtTrigger={handleAtTrigger}
768
+ minHeight="44px"
769
+ maxHeight="200px"
770
+ />
771
+
772
+ {/* @ 用户选择 Popover */}
773
+ {atPopoverPosition && (
774
+ <Box
775
+ style={{
776
+ position: 'absolute',
777
+ top: `${atPopoverPosition.top}px`,
778
+ left: `${atPopoverPosition.left}px`,
779
+ zIndex: 1000,
780
+ backgroundColor: 'var(--gray-1)',
781
+ border: '1px solid var(--gray-6)',
782
+ borderRadius: '8px',
783
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
784
+ minWidth: '240px',
785
+ maxHeight: '280px',
786
+ overflowY: 'auto',
787
+ padding: '4px'
788
+ }}
789
+ >
790
+ {filteredAtSuggestions.length > 0 ? (
791
+ filteredAtSuggestions.map((user) => (
792
+ <Flex
793
+ key={user.id}
794
+ align="center"
795
+ gap="2"
796
+ p="2"
797
+ onClick={() => selectAtUser(user)}
798
+ style={{
799
+ cursor: 'pointer',
800
+ borderRadius: '6px',
801
+ transition: 'background-color 0.2s'
802
+ }}
803
+ onMouseEnter={(e) => {
804
+ e.currentTarget.style.backgroundColor = 'var(--blue-a3)'
805
+ }}
806
+ onMouseLeave={(e) => {
807
+ e.currentTarget.style.backgroundColor = 'transparent'
808
+ }}
809
+ >
810
+ <User size={16} color="var(--blue-9)" />
811
+ <Flex direction="column" gap="0" style={{ flex: 1 }}>
812
+ <Text size="2" weight="medium">{user.name}</Text>
813
+ <Text size="1" color="gray">ID: {user.id}</Text>
814
+ </Flex>
815
+ </Flex>
816
+ ))
817
+ ) : (
818
+ <Flex
819
+ align="center"
820
+ justify="center"
821
+ p="4"
822
+ direction="column"
823
+ gap="2"
824
+ >
825
+ <Search size={20} color="var(--gray-8)" />
826
+ <Text size="1" color="gray">未找到匹配的用户</Text>
827
+ </Flex>
828
+ )}
829
+ </Box>
830
+ )}
831
+ </Box>
832
+ <Button
833
+ onClick={() => {
834
+ const content = editorRef.current?.getContent()
835
+ if (content) {
836
+ handleSendMessage(content.text, content.segments)
837
+ }
838
+ }}
839
+ disabled={!inputText.trim() || previewSegments.length === 0}
840
+ size="3"
841
+ title="发送消息 (Enter)"
842
+ >
843
+ <Send size={16} />
844
+ 发送
845
+ </Button>
846
+ </Flex>
847
+
848
+ {/* 提示信息 */}
849
+ <Flex align="center" gap="2" wrap="wrap">
850
+ <Info size={12} color="var(--gray-9)" />
851
+ <Text size="1" color="gray">快捷操作:</Text>
852
+ <Badge variant="outline" size="1">Enter</Badge>
853
+ <Text size="1" color="gray">发送</Text>
854
+ <Badge variant="outline" size="1">Shift+Enter</Badge>
855
+ <Text size="1" color="gray">换行</Text>
856
+ <Badge variant="outline" size="1">[@名称]</Badge>
857
+ <Text size="1" color="gray">@某人</Text>
858
+ </Flex>
859
+ </Flex>
860
+ </Card>
861
+ </div>
862
+ </div>
863
+ )
864
+ }