@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.
- package/CHANGELOG.md +187 -0
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/client/RichTextEditor.tsx +359 -0
- package/client/Sandbox.tsx +864 -0
- package/client/index.tsx +11 -0
- package/client/tsconfig.json +19 -0
- package/dist/index.js +5 -0
- package/lib/index.d.ts +54 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +173 -0
- package/lib/index.js.map +1 -0
- package/package.json +65 -0
- package/src/index.ts +235 -0
|
@@ -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
|
+
}
|