@zhin.js/adapter-sandbox 1.0.30 → 1.0.32

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.
@@ -1,40 +1,16 @@
1
1
  import React, { useState, useEffect, useRef } from 'react';
2
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
3
  import { User, Users, Trash2, Send, Hash, MessageSquare, Wifi, WifiOff, Smile, Image, AtSign, X, Upload, Check, Info, Search, Bot } from 'lucide-react';
5
4
  import RichTextEditor, { RichTextEditorRef } from './RichTextEditor';
6
5
 
7
-
8
6
  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
7
+ id: string; type: 'sent' | 'received'; channelType: 'private' | 'group' | 'channel';
8
+ channelId: string; channelName: string; senderId: string; senderName: string;
9
+ content: MessageSegment[]; timestamp: number;
25
10
  }
26
11
 
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
- }
12
+ interface Channel { id: string; name: string; type: 'private' | 'group' | 'channel'; unread: number; }
13
+ interface Face { id: number; emojiId: number; stickerId: number; emojiType: string; name: string; describe: string; png: boolean; apng: boolean; lottie: boolean; }
38
14
 
39
15
  export default function Sandbox() {
40
16
  const [messages, setMessages] = useState<Message[]>([])
@@ -57,807 +33,316 @@ export default function Sandbox() {
57
33
  const [imageUrl, setImageUrl] = useState('')
58
34
  const [atUserName, setAtUserName] = useState('')
59
35
  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' },
36
+ { id: '10001', name: '张三' }, { id: '10002', name: '李四' }, { id: '10003', name: '王五' },
37
+ { id: '10004', name: '赵六' }, { id: '10005', name: '测试用户' }, { id: '10086', name: 'Admin' },
66
38
  { id: '10010', name: 'Test User' }
67
39
  ])
68
40
  const [previewSegments, setPreviewSegments] = useState<MessageSegment[]>([])
69
41
  const [showChannelList, setShowChannelList] = useState(false)
70
42
  const messagesEndRef = useRef<HTMLDivElement>(null)
71
43
  const wsRef = useRef<WebSocket | null>(null)
72
- const fileInputRef = useRef<HTMLInputElement>(null)
73
44
  const editorRef = useRef<RichTextEditorRef>(null)
74
45
 
75
- // 获取表情列表
76
46
  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
- }
47
+ try { const res = await fetch('https://face.viki.moe/metadata.json'); setFaceList(await res.json()) }
48
+ catch (err) { console.error('[Sandbox] Failed to fetch face list:', err) }
84
49
  }
85
50
 
86
- useEffect(() => {
87
- fetchFaceList()
88
- }, [])
51
+ useEffect(() => { fetchFaceList() }, [])
89
52
 
90
- // WebSocket 连接
91
53
  useEffect(() => {
92
54
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
93
55
  wsRef.current = new WebSocket(`${protocol}//${window.location.host}/sandbox`)
94
-
95
- wsRef.current.onopen = () => {
96
- setConnected(true)
97
- }
98
-
56
+ wsRef.current.onopen = () => setConnected(true)
99
57
  wsRef.current.onmessage = (event) => {
100
58
  try {
101
59
  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);
60
+ let content: MessageSegment[] = typeof data.content === 'string'
61
+ ? parseTextToSegments(data.content)
62
+ : Array.isArray(data.content) ? data.content : parseTextToSegments(String(data.content))
63
+
64
+ let targetChannel = channels.find((c) => c.id === data.id)
116
65
  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);
66
+ const channelName = data.type === 'private' ? `私聊-${data.bot || botName}` : data.type === 'group' ? `群组-${data.id}` : `频道-${data.id}`
67
+ targetChannel = { id: data.id, name: channelName, type: data.type, unread: 0 }
68
+ setChannels((prev) => [...prev, targetChannel!])
69
+ setActiveChannel(targetChannel)
132
70
  }
133
71
 
134
72
  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
73
+ id: `bot_${data.timestamp}`, type: 'received', channelType: data.type,
74
+ channelId: data.id, channelName: targetChannel.name, senderId: 'bot',
75
+ senderName: data.bot || botName, content, timestamp: data.timestamp
144
76
  }
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()
77
+ setMessages((prev) => [...prev, botMessage])
78
+ } catch (err) { console.error('[Sandbox] Failed to parse message:', err) }
157
79
  }
80
+ wsRef.current.onclose = () => setConnected(false)
81
+ return () => { wsRef.current?.close() }
158
82
  }, [botName, channels])
159
83
 
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])
84
+ useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
85
+ useEffect(() => { setPreviewSegments(inputText.trim() ? parseTextToSegments(inputText) : []) }, [inputText])
174
86
 
175
- // 解析文本为消息段
176
87
  const parseTextToSegments = (text: string): MessageSegment[] => {
177
- const segments: MessageSegment[] = []
178
- const regex = /\[@([^\]]+)\]|\[face:(\d+)\]|\[image:([^\]]+)\]/g
179
- let lastIndex = 0
180
- let match
181
-
88
+ const segments: MessageSegment[] = []; const regex = /\[@([^\]]+)\]|\[face:(\d+)\]|\[image:([^\]]+)\]/g
89
+ let lastIndex = 0; let match
182
90
  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
-
91
+ if (match.index > lastIndex) { const t = text.substring(lastIndex, match.index); if (t) segments.push({ type: 'text', data: { text: t } }) }
92
+ if (match[1]) segments.push({ type: 'at', data: { qq: match[1], name: match[1] } })
93
+ else if (match[2]) segments.push({ type: 'face', data: { id: parseInt(match[2]) } })
94
+ else if (match[3]) segments.push({ type: 'image', data: { url: match[3] } })
198
95
  lastIndex = regex.lastIndex
199
96
  }
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 } }]
97
+ if (lastIndex < text.length) { const r = text.substring(lastIndex); if (r) segments.push({ type: 'text', data: { text: r } }) }
98
+ return segments.length > 0 ? segments : [{ type: 'text', data: { text } }]
209
99
  }
210
100
 
211
- // 渲染消息段
212
- const renderMessageSegments = (segments: (MessageSegment|string)[]) => {
101
+ const renderMessageSegments = (segments: (MessageSegment | string)[]) => {
213
102
  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
- )
103
+ if (typeof segment === 'string') {
104
+ return <span key={index}>{segment.split('\n').map((part, i) => <React.Fragment key={i}>{part}{i < segment.split('\n').length - 1 && <br />}</React.Fragment>)}</span>
227
105
  }
228
106
  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>
107
+ case 'text': return <span key={index}>{segment.data.text.split('\n').map((part: string, i: number) => <React.Fragment key={i}>{part}{i < segment.data.text.split('\n').length - 1 && <br />}</React.Fragment>)}</span>
108
+ case 'at': return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded bg-accent text-accent-foreground text-xs mx-0.5">@{segment.data.name || segment.data.qq}</span>
109
+ case 'face': return <img key={index} src={`https://face.viki.moe/apng/${segment.data.id}.png`} alt={`face${segment.data.id}`} className="w-6 h-6 inline-block align-middle mx-0.5" />
110
+ case 'image': return <img key={index} src={segment.data.url} alt="image" className="max-w-[300px] rounded-lg my-1 block" onError={(e) => { e.currentTarget.style.display = 'none' }} />
111
+ case 'video': return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded border text-xs mx-0.5">📹 视频</span>
112
+ case 'audio': return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded border text-xs mx-0.5">🎵 语音</span>
113
+ case 'file': return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded border text-xs mx-0.5">📎 {segment.data.name || '文件'}</span>
114
+ default: return <span key={index}>[未知消息类型]</span>
287
115
  }
288
116
  })
289
117
  }
290
118
 
291
- // 发送消息
292
119
  const handleSendMessage = (text: string, segments: MessageSegment[]) => {
293
120
  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
- // 清空编辑器
121
+ const newMessage: Message = { id: `msg_${Date.now()}`, type: 'sent', channelType: activeChannel.type, channelId: activeChannel.id, channelName: activeChannel.name, senderId: 'test_user', senderName: '测试用户', content: segments, timestamp: Date.now() }
122
+ setMessages((prev) => [...prev, newMessage]); setInputText(''); setPreviewSegments([])
312
123
  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
- }
124
+ wsRef.current?.send(JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() }))
327
125
  }
328
126
 
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
- // 添加新频道
127
+ const clearMessages = () => { if (confirm('确定清空所有消息记录?')) setMessages([]) }
128
+ const switchChannel = (channel: Channel) => { setActiveChannel(channel); setChannels((prev) => prev.map((c) => c.id === channel.id ? { ...c, unread: 0 } : c)); if (window.innerWidth < 768) setShowChannelList(false) }
342
129
  const addChannel = () => {
343
130
  const types: Array<'private' | 'group' | 'channel'> = ['private', 'group', 'channel']
344
- const typeNames = { private: '私聊', group: '群聊', guild: '频道' }
345
131
  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
- }
132
+ const name = prompt(`请输入频道名称:`)
133
+ if (name) { const nc: Channel = { id: `${type}_${Date.now()}`, name, type, unread: 0 }; setChannels((p) => [...p, nc]); setActiveChannel(nc) }
354
134
  }
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
- // 处理 @ 触发
135
+ const getChannelIcon = (type: string) => { switch (type) { case 'private': return <User size={16} />; case 'group': return <Users size={16} />; case 'channel': return <Hash size={16} />; default: return <MessageSquare size={16} /> } }
136
+ const insertFace = (faceId: number) => { editorRef.current?.insertFace(faceId); setShowFacePicker(false) }
137
+ const insertImageUrl = () => { if (!imageUrl.trim()) return; editorRef.current?.insertImage(imageUrl.trim()); setImageUrl(''); setShowImageUpload(false) }
138
+ const insertAtUser = () => { if (!atUserName.trim()) return; editorRef.current?.insertAt(atUserName.trim()); setAtUserName(''); setShowAtPicker(false) }
139
+ const selectAtUser = (user: { id: string; name: string }) => { editorRef.current?.replaceAtTrigger(user.name, user.id); setAtPopoverPosition(null); setAtSearchQuery('') }
396
140
  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)
141
+ if (activeChannel.type === 'private') { setAtPopoverPosition(null); setAtSearchQuery(''); return }
142
+ if (show && position) { setAtPopoverPosition(position); setAtSearchQuery(searchQuery) } else { setAtPopoverPosition(null); setAtSearchQuery('') }
441
143
  }
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
- )
144
+ const filteredAtSuggestions = atSuggestions.filter((user) => { if (!atSearchQuery.trim()) return true; const q = atSearchQuery.toLowerCase(); return user.name.toLowerCase().includes(q) || user.id.toLowerCase().includes(q) })
145
+ const handleEditorChange = (text: string, segments: MessageSegment[]) => { setInputText(text); setPreviewSegments(segments) }
146
+ const filteredFaces = faceList.filter(face => face.name.toLowerCase().includes(faceSearchQuery.toLowerCase()) || face.describe.toLowerCase().includes(faceSearchQuery.toLowerCase()))
147
+ const channelMessages = messages.filter((msg) => msg.channelId === activeChannel.id)
453
148
 
454
149
  return (
455
150
  <div className="sandbox-container">
456
- {/* 移动端频道列表切换按钮 */}
457
- <button
458
- className="mobile-channel-toggle md:hidden"
459
- onClick={() => setShowChannelList(!showChannelList)}
460
- >
461
- <MessageSquare size={20} />
462
- 频道列表
151
+ <button className="mobile-channel-toggle md:hidden" onClick={() => setShowChannelList(!showChannelList)}>
152
+ <MessageSquare size={20} /> 频道列表
463
153
  </button>
464
154
 
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" />}
155
+ {/* Channel sidebar */}
156
+ <div className={cn("channel-sidebar rounded-lg border bg-card", showChannelList && "show")}>
157
+ <div className="p-3 border-b">
158
+ <div className="flex justify-between items-center">
159
+ <div className="flex items-center gap-2">
160
+ <div className="p-1 rounded-md bg-secondary"><MessageSquare size={16} className="text-muted-foreground" /></div>
161
+ <h3 className="font-semibold">频道列表</h3>
162
+ </div>
163
+ <span className={cn("inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border", connected ? "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800" : "bg-muted text-muted-foreground")}>
164
+ {connected ? <Wifi size={12} /> : <WifiOff size={12} />}
165
+ {connected ? '已连接' : '未连接'}
166
+ </span>
167
+ </div>
168
+ </div>
169
+
170
+ <div className="flex-1 overflow-y-auto p-2 space-y-1">
171
+ {channels.map((channel) => {
172
+ const isActive = activeChannel.id === channel.id
173
+ return (
174
+ <div key={channel.id} className={cn("menu-item", isActive && "active")} onClick={() => switchChannel(channel)}>
175
+ <span className="shrink-0">{getChannelIcon(channel.type)}</span>
176
+ <div className="flex-1 min-w-0">
177
+ <div className="text-sm font-medium truncate">{channel.name}</div>
178
+ <div className="text-xs text-muted-foreground">{channel.type === 'private' ? '私聊' : channel.type === 'group' ? '群聊' : '频道'}</div>
511
179
  </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>
180
+ {channel.unread > 0 && <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">{channel.unread}</span>}
181
+ </div>
182
+ )
183
+ })}
184
+ </div>
185
+
186
+ <div className="p-2 border-t">
187
+ <button className="w-full py-2 px-3 rounded-md border border-dashed text-sm text-muted-foreground hover:bg-accent transition-colors" onClick={addChannel}>+ 添加频道</button>
188
+ </div>
189
+ </div>
523
190
 
524
- {/* 遮罩层 - 移动端点击关闭频道列表 */}
525
- {showChannelList && (
526
- <div
527
- className="channel-overlay md:hidden"
528
- onClick={() => setShowChannelList(false)}
529
- />
530
- )}
191
+ {showChannelList && <div className="channel-overlay md:hidden" onClick={() => setShowChannelList(false)} />}
531
192
 
532
- {/* 右侧聊天区域 */}
193
+ {/* Chat area */}
533
194
  <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">
195
+ {/* Top bar */}
196
+ <div className="rounded-lg border bg-card p-3 flex-shrink-0">
197
+ <div className="flex justify-between items-center flex-wrap gap-2">
198
+ <div className="flex items-center gap-3">
199
+ <div className="p-2 rounded-lg bg-secondary">{getChannelIcon(activeChannel.type)}</div>
200
+ <div>
201
+ <h2 className="text-lg font-bold">{activeChannel.name}</h2>
202
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
203
+ <span>{activeChannel.id}</span>
204
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded border text-[10px]">{channelMessages.length}</span>
205
+ <span>条消息</span>
206
+ </div>
207
+ </div>
208
+ <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
209
+ {activeChannel.type === 'private' ? '私聊' : activeChannel.type === 'group' ? '群聊' : '频道'}
210
+ </span>
211
+ </div>
212
+ <div className="flex items-center gap-2">
213
+ <input value={botName} onChange={(e) => setBotName(e.target.value)} placeholder="机器人名称"
214
+ className="h-8 w-28 rounded-md border bg-transparent px-2 text-sm" />
215
+ <button className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-secondary text-secondary-foreground text-sm hover:bg-secondary/80" onClick={clearMessages}>
216
+ <Trash2 size={14} /> 清空
217
+ </button>
218
+ </div>
219
+ </div>
220
+ </div>
221
+
222
+ {/* Messages */}
223
+ <div className="rounded-lg border bg-card flex-1 flex flex-col min-h-0">
224
+ <div className="flex-1 overflow-y-auto p-4">
573
225
  {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>
226
+ <div className="flex flex-col items-center justify-center h-full gap-3">
227
+ <MessageSquare size={64} className="text-muted-foreground/20" />
228
+ <span className="text-muted-foreground">暂无消息,开始对话吧!</span>
229
+ </div>
578
230
  ) : (
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">
231
+ <div className="space-y-2">
232
+ {channelMessages.map((msg) => (
233
+ <div key={msg.id} className={cn("flex", msg.type === 'sent' ? "justify-end" : "justify-start")}>
234
+ <div className={cn("max-w-[70%] p-3 rounded-2xl", msg.type === 'sent' ? "bg-primary text-primary-foreground" : "bg-muted")}>
235
+ <div className="flex items-center gap-2 mb-1">
595
236
  {msg.type === 'received' && <Bot size={14} />}
596
237
  {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>
238
+ <span className="text-xs font-medium opacity-90">{msg.senderName}</span>
239
+ <span className="text-xs opacity-70">{new Date(msg.timestamp).toLocaleTimeString()}</span>
240
+ </div>
241
+ <div className="text-sm">{renderMessageSegments(msg.content)}</div>
242
+ </div>
243
+ </div>
609
244
  ))}
610
245
  <div ref={messagesEndRef} />
611
- </Flex>
246
+ </div>
612
247
  )}
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>
248
+ </div>
249
+ </div>
250
+
251
+ {/* Input area */}
252
+ <div className="rounded-lg border bg-card p-3 flex-shrink-0 space-y-3">
253
+ {/* Toolbar */}
254
+ <div className="flex gap-2 items-center">
255
+ <button className={cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", showFacePicker ? "bg-primary text-primary-foreground" : "hover:bg-accent")}
256
+ onClick={() => { setShowFacePicker(!showFacePicker); setShowImageUpload(false) }} title="插入表情">
257
+ <Smile size={16} />
258
+ </button>
259
+ <button className={cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", showImageUpload ? "bg-primary text-primary-foreground" : "hover:bg-accent")}
260
+ onClick={() => { setShowImageUpload(!showImageUpload); setShowFacePicker(false) }} title="插入图片">
261
+ <Image size={16} />
262
+ </button>
263
+ <div className="flex-1" />
264
+ {inputText && (
265
+ <button className="h-8 w-8 rounded-md flex items-center justify-center hover:bg-accent transition-colors"
266
+ onClick={() => { setInputText(''); setPreviewSegments([]) }}><X size={16} /></button>
703
267
  )}
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>
268
+ </div>
269
+
270
+ {/* Face picker */}
271
+ {showFacePicker && (
272
+ <div className="p-3 rounded-md border bg-muted/30 max-h-64 overflow-y-auto space-y-2">
273
+ <input value={faceSearchQuery} onChange={(e) => setFaceSearchQuery(e.target.value)}
274
+ placeholder="搜索表情..." className="w-full h-8 rounded-md border bg-transparent px-2 text-sm" />
275
+ <div className="grid grid-cols-8 gap-1">
276
+ {filteredFaces.slice(0, 80).map((face) => (
277
+ <button key={face.id} onClick={() => insertFace(face.id)} title={face.name}
278
+ className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-accent transition-colors">
279
+ <img src={`https://face.viki.moe/apng/${face.id}.png`} alt={face.name} className="w-8 h-8" />
280
+ </button>
281
+ ))}
282
+ </div>
283
+ {filteredFaces.length === 0 && (
284
+ <div className="flex flex-col items-center gap-2 py-4">
285
+ <Search size={32} className="text-muted-foreground/30" />
286
+ <span className="text-sm text-muted-foreground">未找到匹配的表情</span>
287
+ </div>
288
+ )}
289
+ </div>
290
+ )}
291
+
292
+ {/* Image upload */}
293
+ {showImageUpload && (
294
+ <div className="p-3 rounded-md border bg-muted/30 space-y-2">
295
+ <input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="输入图片 URL..."
296
+ className="w-full h-8 rounded-md border bg-transparent px-2 text-sm"
297
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); insertImageUrl() } }} />
298
+ <button className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
299
+ onClick={insertImageUrl} disabled={!imageUrl.trim()}>
300
+ <Check size={14} /> 插入
301
+ </button>
302
+ </div>
303
+ )}
304
+
305
+ {/* Editor + send */}
306
+ <div className="flex gap-2 items-start">
307
+ <div className="flex-1 relative">
308
+ <RichTextEditor
309
+ ref={editorRef} placeholder={`向 ${activeChannel.name} 发送消息...`}
310
+ onSend={handleSendMessage} onChange={handleEditorChange} onAtTrigger={handleAtTrigger}
311
+ minHeight="44px" maxHeight="200px"
312
+ />
313
+ {atPopoverPosition && (
314
+ <div className="absolute z-50 rounded-lg border bg-popover shadow-md min-w-60 max-h-72 overflow-y-auto p-1"
315
+ style={{ top: `${atPopoverPosition.top}px`, left: `${atPopoverPosition.left}px` }}>
316
+ {filteredAtSuggestions.length > 0 ? filteredAtSuggestions.map((user) => (
317
+ <div key={user.id} className="flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-accent transition-colors" onClick={() => selectAtUser(user)}>
318
+ <User size={16} className="text-muted-foreground" />
319
+ <div className="flex-1"><div className="text-sm font-medium">{user.name}</div><div className="text-xs text-muted-foreground">ID: {user.id}</div></div>
320
+ </div>
321
+ )) : (
322
+ <div className="flex flex-col items-center gap-2 p-4">
323
+ <Search size={20} className="text-muted-foreground/50" />
324
+ <span className="text-xs text-muted-foreground">未找到匹配的用户</span>
325
+ </div>
326
+ )}
327
+ </div>
328
+ )}
329
+ </div>
330
+ <button
331
+ className="inline-flex items-center gap-1.5 h-10 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50 transition-colors hover:bg-primary/90"
332
+ onClick={() => { const c = editorRef.current?.getContent(); if (c) handleSendMessage(c.text, c.segments) }}
333
+ disabled={!inputText.trim() || previewSegments.length === 0}>
334
+ <Send size={16} /> 发送
335
+ </button>
336
+ </div>
337
+
338
+ {/* Hints */}
339
+ <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
340
+ <Info size={12} /> 快捷操作:
341
+ <span className="px-1 py-0.5 rounded border text-[10px]">Enter</span> 发送
342
+ <span className="px-1 py-0.5 rounded border text-[10px]">Shift+Enter</span> 换行
343
+ <span className="px-1 py-0.5 rounded border text-[10px]">[@名称]</span> @某人
344
+ </div>
345
+ </div>
861
346
  </div>
862
347
  </div>
863
348
  )