@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.
- package/CHANGELOG.md +22 -0
- package/README.md +61 -55
- package/client/Sandbox.tsx +247 -762
- package/dist/index.js +7 -5
- package/package.json +8 -9
package/client/Sandbox.tsx
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
|
28
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
347
|
-
|
|
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
|
|
358
|
-
|
|
359
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
467
|
-
<
|
|
468
|
-
<
|
|
469
|
-
<
|
|
470
|
-
<
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
<
|
|
536
|
-
<
|
|
537
|
-
<
|
|
538
|
-
<
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
<
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
<
|
|
575
|
-
<MessageSquare size={64}
|
|
576
|
-
<
|
|
577
|
-
</
|
|
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
|
-
<
|
|
580
|
-
{channelMessages.map((msg
|
|
581
|
-
<
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
<
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
</
|
|
246
|
+
</div>
|
|
612
247
|
)}
|
|
613
|
-
</
|
|
614
|
-
</
|
|
615
|
-
|
|
616
|
-
{/*
|
|
617
|
-
<
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
<
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
<
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
)
|