koishi-plugin-chat-patch 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2315 @@
1
+ <style scoped src="./style.css"></style>
2
+
3
+ <template>
4
+ <div class="chat-container" :class="mobileViewClass" :style="chatContainerStyle" @touchstart="handleTouchStart"
5
+ @touchmove="handleTouchMove" @touchend="handleTouchEnd">
6
+ <!-- 左侧机器人列表 -->
7
+ <div class="bot-list">
8
+ <div class="panel-header">
9
+ <h3>机器人</h3>
10
+ </div>
11
+ <div class="bot-items">
12
+ <div v-for="bot in bots" :key="bot.selfId"
13
+ :class="['bot-item', { active: selectedBot === bot.selfId, pinned: pinnedBots.has(bot.selfId) }]"
14
+ @click="selectBot(bot.selfId)" @contextmenu="handleBotRightClick($event, bot.selfId)">
15
+ <div class="bot-avatar">
16
+ <AvatarComponent v-if="bot.avatar" :src="bot.avatar" :alt="bot.username" :channel-key="'bot-list'" />
17
+ <div v-else class="avatar-placeholder">{{ bot.username.charAt(0).toUpperCase() }}</div>
18
+ </div>
19
+ <div class="bot-info">
20
+ <div class="bot-name">{{ bot.username }}</div>
21
+ <div class="bot-platform">{{ bot.platform }}</div>
22
+ </div>
23
+ <div :class="['bot-status', bot.status]"></div>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- 中间频道列表 -->
29
+ <div class="channel-list">
30
+ <div class="panel-header">
31
+ <h3>频道</h3>
32
+ </div>
33
+ <div v-if="!selectedBot" class="empty-state">
34
+ 请选择一个机器人
35
+ </div>
36
+ <div v-else class="channel-items">
37
+ <div v-for="channel in currentChannels" :key="channel.id"
38
+ :class="['channel-item', { active: selectedChannel === channel.id, pinned: pinnedChannels.has(`${selectedBot}:${channel.id}`) }]"
39
+ :data-channel-id="channel.id" @click="selectChannel(channel.id)"
40
+ @contextmenu="handleChannelRightClick($event, channel.id)">
41
+ <div class="channel-info">
42
+ <div class="channel-name">{{ channel.name }}</div>
43
+ <div class="channel-type">{{ getChannelTypeText(channel.type) }}</div>
44
+ </div>
45
+ <div v-if="getChannelMessageCount(channel.id) > 0" class="channel-message-count draggable-bubble" :class="{
46
+ 'dragging': draggingChannel === channel.id,
47
+ 'will-delete': draggingChannel === channel.id && getDragDistance(channel.id) > dragThreshold
48
+ }" @mousedown="startDrag($event, channel.id)" @touchstart="startDrag($event, channel.id)"
49
+ :style="getDragStyle(channel.id)"
50
+ :title="draggingChannel === channel.id ? (getDragDistance(channel.id) > dragThreshold ? '松开清理历史记录' : '拖拽更远以清理历史记录') : '拖拽清理历史记录'">
51
+ {{ getChannelMessageCount(channel.id) }}
52
+
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ <!-- 右侧消息区域 -->
59
+ <div class="message-area">
60
+ <div class="panel-header">
61
+ <h3>{{ currentChannelName || '选择频道' }}</h3>
62
+ </div>
63
+ <div v-if="!selectedBot || !selectedChannel" class="empty-state">
64
+ 请选择机器人和频道
65
+ </div>
66
+ <div v-else class="message-content">
67
+ <!-- 消息历史 -->
68
+ <div class="message-history" ref="messageHistory">
69
+ <div v-for="message in currentMessages" :key="message.id"
70
+ :class="['message-item', { 'bot-message': message.isBot }]">
71
+ <div class="message-avatar">
72
+ <AvatarComponent v-if="message.avatar" :src="message.avatar" :alt="message.username"
73
+ :channel-key="currentChannelKey" />
74
+ <div v-else class="avatar-placeholder">{{ message.username.charAt(0).toUpperCase() }}</div>
75
+ </div>
76
+ <div class="message-content-wrapper">
77
+ <div class="message-header">
78
+ <span class="message-username">{{ message.username }}</span>
79
+ <span class="message-time">{{ formatTime(message.timestamp) }}</span>
80
+ </div>
81
+
82
+ <!-- 引用消息显示 -->
83
+ <div v-if="message.quote" class="message-quote">
84
+ <div class="quote-header">
85
+ <div class="quote-avatar">
86
+ <AvatarComponent v-if="message.quote.user.avatar" :src="message.quote.user.avatar"
87
+ :alt="message.quote.user.username" :channel-key="currentChannelKey" />
88
+ <div v-else class="avatar-placeholder">{{
89
+ message.quote.user.username.charAt(0).toUpperCase() }}
90
+ </div>
91
+ </div>
92
+ <span class="quote-username">{{ message.quote.user.username }}</span>
93
+ <span class="quote-time">{{ formatTime(message.quote.timestamp) }}</span>
94
+ </div>
95
+ <div class="quote-content">
96
+ <template v-if="message.quote.elements && message.quote.elements.length > 0">
97
+ <MessageElement v-for="(element, index) in message.quote.elements" :key="`quote-${index}`"
98
+ :element="element" :channel-key="currentChannelKey" />
99
+ </template>
100
+ <template v-else>
101
+ {{ message.quote.content }}
102
+ </template>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="message-text">
107
+ <template v-if="message.elements && message.elements.length > 0">
108
+ <MessageElement v-for="(element, index) in message.elements" :key="index" :element="element"
109
+ :channel-key="currentChannelKey" />
110
+ </template>
111
+ <template v-else>
112
+ {{ message.content }}
113
+ </template>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+
119
+ <!-- 悬浮的滚动到底部按钮 -->
120
+ <div class="floating-scroll-button" v-show="showScrollButton" @click="scrollToBottom">
121
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
122
+ <path d="M7 10l5 5 5-5z" />
123
+ </svg>
124
+ </div>
125
+
126
+ <!-- 输入框 -->
127
+ <div class="message-input">
128
+ <div class="input-row">
129
+ <input v-model="inputMessage" type="text" :placeholder="inputPlaceholder" @keyup.enter="sendMessage"
130
+ :disabled="!canInputMessage" ref="messageInput" />
131
+ <button @click="sendMessage" :disabled="!canSendMessage" :class="{ 'is-sending': isSending }">
132
+ {{ isSending ? '发送中...' : '发送' }}
133
+ </button>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <!-- 右键菜单 -->
140
+ <div v-if="contextMenu.show" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
141
+ @click.stop>
142
+ <!-- 机器人右键菜单 -->
143
+ <template v-if="contextMenu.type === 'bot'">
144
+ <div class="context-menu-item" @click="toggleBotPin(contextMenu.targetId)">
145
+ {{ pinnedBots.has(contextMenu.targetId) ? '取消置顶' : '置顶' }}
146
+ </div>
147
+ <div class="context-menu-item danger" @click="deleteBotMessages(contextMenu.targetId)">
148
+ 彻底删除此机器人所有数据
149
+ </div>
150
+ </template>
151
+
152
+ <!-- 频道右键菜单 -->
153
+ <template v-if="contextMenu.type === 'channel'">
154
+ <div class="context-menu-item" @click="toggleChannelPin(contextMenu.targetId)">
155
+ {{ pinnedChannels.has(`${selectedBot}:${contextMenu.targetId}`) ? '取消置顶' : '置顶' }}
156
+ </div>
157
+ <div class="context-menu-item danger" @click="deleteChannelMessages(contextMenu.targetId)">
158
+ 彻底删除此频道所有数据
159
+ </div>
160
+ </template>
161
+ </div>
162
+
163
+ <!-- 滑动指示器 -->
164
+ <div class="swipe-indicator" :class="{ show: swipeIndicator.show }">
165
+ {{ swipeIndicator.text }}
166
+ </div>
167
+ </div>
168
+ </template>
169
+
170
+ <script setup lang="ts">
171
+ import { ref, computed, onMounted, onUnmounted, nextTick, watch, defineComponent, h } from 'vue'
172
+ import { useContext, receive, send } from '@koishijs/client'
173
+
174
+
175
+ // 头像组件
176
+ const AvatarComponent = defineComponent({
177
+ props: {
178
+ src: { type: String, required: true },
179
+ alt: { type: String, default: '头像' },
180
+ channelKey: { type: String, required: true }
181
+ },
182
+ setup(props) {
183
+ const imageState = ref<'loading' | 'loaded' | 'error' | 'caching'>('loading')
184
+ const imageSrc = ref(props.src)
185
+ const errorMessage = ref('')
186
+
187
+ const loadImage = async () => {
188
+ try {
189
+ imageState.value = 'loading'
190
+
191
+ // 首先检查缓存
192
+ const cachedUrl = await getCachedImageUrl(props.channelKey, props.src)
193
+ if (cachedUrl) {
194
+ imageSrc.value = cachedUrl
195
+ imageState.value = 'loaded'
196
+ return
197
+ }
198
+
199
+ // 尝试直接加载原图
200
+ const testImg = new Image()
201
+ testImg.crossOrigin = 'anonymous'
202
+ testImg.referrerPolicy = 'no-referrer'
203
+ testImg.draggable = false
204
+
205
+ const loadPromise = new Promise<void>((resolve, reject) => {
206
+ testImg.onload = () => resolve()
207
+ testImg.onerror = () => reject(new Error('Direct load failed'))
208
+ testImg.src = props.src
209
+ })
210
+
211
+ const timeoutPromise = new Promise<void>((_, reject) => {
212
+ setTimeout(() => reject(new Error('Timeout')), 3000)
213
+ })
214
+
215
+ try {
216
+ await Promise.race([loadPromise, timeoutPromise])
217
+ // 直接加载成功,但仍然缓存图片以备后用
218
+ imageSrc.value = props.src
219
+ imageState.value = 'loaded'
220
+
221
+ // 异步缓存图片,不阻塞显示
222
+ cacheImage(props.channelKey, props.src).catch(error => {
223
+ console.warn('异步缓存头像失败:', error)
224
+ })
225
+ } catch {
226
+ // 直接加载失败,使用缓存系统
227
+ await loadWithCache()
228
+ }
229
+ } catch (error) {
230
+ console.error('头像加载失败:', error)
231
+ imageState.value = 'error'
232
+ errorMessage.value = '头像加载失败'
233
+ }
234
+ }
235
+
236
+ const loadWithCache = async () => {
237
+ try {
238
+ imageState.value = 'caching'
239
+
240
+ const cachedUrl = await cacheImage(props.channelKey, props.src)
241
+
242
+ if (cachedUrl) {
243
+ imageSrc.value = cachedUrl
244
+ imageState.value = 'loaded'
245
+ } else {
246
+ throw new Error('缓存系统加载失败')
247
+ }
248
+ } catch (error: any) {
249
+ console.error('缓存系统加载头像失败:', error)
250
+ imageState.value = 'error'
251
+ errorMessage.value = error?.message || '缓存加载失败'
252
+ }
253
+ }
254
+
255
+ // 组件挂载时开始加载图片
256
+ onMounted(() => {
257
+ loadImage()
258
+ })
259
+
260
+ return () => {
261
+ switch (imageState.value) {
262
+ case 'loading':
263
+ case 'caching':
264
+ return h('div', { class: 'avatar-placeholder' }, props.alt.charAt(0).toUpperCase())
265
+
266
+ case 'loaded':
267
+ return h('img', {
268
+ src: imageSrc.value,
269
+ alt: props.alt,
270
+ draggable: false,
271
+ style: {
272
+ width: '100%',
273
+ height: '100%',
274
+ 'object-fit': 'cover'
275
+ }
276
+ })
277
+
278
+ case 'error':
279
+ return h('div', { class: 'avatar-placeholder' }, props.alt.charAt(0).toUpperCase())
280
+
281
+ default:
282
+ return h('div', { class: 'avatar-placeholder' }, props.alt.charAt(0).toUpperCase())
283
+ }
284
+ }
285
+ }
286
+ })
287
+
288
+ // 图片组件
289
+ const ImageComponent = defineComponent({
290
+ props: {
291
+ src: { type: String, required: true },
292
+ alt: { type: String, default: '图片' },
293
+ filename: { type: String, default: '' },
294
+ channelKey: { type: String, required: true }
295
+ },
296
+ setup(props) {
297
+ const imageState = ref<'loading' | 'loaded' | 'error' | 'caching'>('loading')
298
+ const imageSrc = ref(props.src)
299
+ const errorMessage = ref('')
300
+ const imgRef = ref<HTMLImageElement | null>(null)
301
+
302
+ const loadImage = async () => {
303
+ try {
304
+ imageState.value = 'loading'
305
+
306
+ // 检查缓存
307
+ const cachedUrl = await getCachedImageUrl(props.channelKey, props.src)
308
+ if (cachedUrl) {
309
+ imageSrc.value = cachedUrl
310
+ imageState.value = 'loaded'
311
+ return
312
+ }
313
+
314
+ // 尝试直接加载原图
315
+ const testImg = new Image()
316
+ testImg.crossOrigin = 'anonymous'
317
+ testImg.referrerPolicy = 'no-referrer'
318
+ testImg.draggable = false
319
+
320
+ const loadPromise = new Promise<void>((resolve, reject) => {
321
+ testImg.onload = () => resolve()
322
+ testImg.onerror = () => reject(new Error('Direct load failed'))
323
+ testImg.src = props.src
324
+ })
325
+
326
+ const timeoutPromise = new Promise<void>((_, reject) => {
327
+ setTimeout(() => reject(new Error('Timeout')), 3000)
328
+ })
329
+
330
+ try {
331
+ await Promise.race([loadPromise, timeoutPromise])
332
+ // 直接加载成功,但仍然缓存图片以备后用
333
+ imageSrc.value = props.src
334
+ imageState.value = 'loaded'
335
+
336
+ // 异步缓存图片,不阻塞显示
337
+ cacheImage(props.channelKey, props.src).catch(error => {
338
+ console.warn('异步缓存图片失败:', error)
339
+ })
340
+ } catch {
341
+ // 直接加载失败,使用缓存系统
342
+ await loadWithCache()
343
+ }
344
+ } catch (error) {
345
+ console.error('图片加载失败:', error)
346
+ imageState.value = 'error'
347
+ errorMessage.value = '图片加载失败'
348
+ }
349
+ }
350
+
351
+ const loadWithCache = async () => {
352
+ try {
353
+ imageState.value = 'caching'
354
+
355
+ const cachedUrl = await cacheImage(props.channelKey, props.src)
356
+
357
+ if (cachedUrl) {
358
+ imageSrc.value = cachedUrl
359
+ imageState.value = 'loaded'
360
+ } else {
361
+ throw new Error('缓存系统加载失败')
362
+ }
363
+ } catch (error: any) {
364
+ console.error('缓存系统加载图片失败:', error)
365
+ imageState.value = 'error'
366
+ errorMessage.value = error?.message || '缓存加载失败'
367
+ }
368
+ }
369
+
370
+ // 组件挂载时开始加载图片
371
+ onMounted(() => {
372
+ loadImage()
373
+ })
374
+ return () => {
375
+ switch (imageState.value) {
376
+ case 'loading':
377
+ return h('div', { class: 'message-image-loading' }, '加载中...')
378
+
379
+ case 'caching':
380
+ return h('div', { class: 'message-image-loading' }, '[图片加载缓存中...]')
381
+
382
+ case 'loaded':
383
+ return h('img', {
384
+ src: imageSrc.value,
385
+ alt: props.alt,
386
+ class: 'message-image',
387
+ loading: 'lazy',
388
+ ref: imgRef,
389
+ draggable: false,
390
+ style: {
391
+ 'max-width': '400px',
392
+ 'max-height': '200px',
393
+ 'width': 'auto',
394
+ 'height': 'auto',
395
+ 'object-fit': 'contain'
396
+ }
397
+ })
398
+
399
+ case 'error':
400
+ return h('div', { class: 'message-image-error' }, [
401
+ '图片加载失败',
402
+ h('br'),
403
+ h('small', props.filename || props.alt || '未知图片'),
404
+ h('br'),
405
+ h('small', { style: 'color: #ff9800;' }, errorMessage.value)
406
+ ])
407
+
408
+ default:
409
+ return h('div', { class: 'message-image-error' }, '未知状态')
410
+ }
411
+ }
412
+ }
413
+ })
414
+
415
+ // JSON卡片组件
416
+ const JsonCardComponent = defineComponent({
417
+ props: {
418
+ data: { type: String, required: true },
419
+ channelKey: { type: String, required: true }
420
+ },
421
+ setup(props) {
422
+ const parseJsonData = () => {
423
+ try {
424
+ const jsonData = JSON.parse(props.data)
425
+
426
+ // 检查是否是QQ小程序或类似的分享卡片
427
+ if (jsonData.meta && jsonData.meta.detail_1) {
428
+ const detail = jsonData.meta.detail_1
429
+ return {
430
+ type: 'share_card',
431
+ title: detail.title || jsonData.prompt || '分享内容',
432
+ desc: detail.desc || '',
433
+ preview: detail.preview ? detail.preview.replace(/\\\//g, '/') : '',
434
+ icon: detail.icon ? detail.icon.replace(/\\\//g, '/') : '',
435
+ url: detail.qqdocurl ? detail.qqdocurl.replace(/\\\//g, '/') : (detail.url ? detail.url.replace(/\\\//g, '/') : ''),
436
+ appName: detail.title || '应用'
437
+ }
438
+ }
439
+
440
+ // 其他类型的JSON数据
441
+ return {
442
+ type: 'raw',
443
+ data: jsonData
444
+ }
445
+ } catch (error) {
446
+ console.error('解析JSON数据失败:', error)
447
+ return {
448
+ type: 'error',
449
+ error: '无法解析的JSON数据'
450
+ }
451
+ }
452
+ }
453
+
454
+ const cardData = parseJsonData()
455
+
456
+ const handleCardClick = () => {
457
+ if (cardData.type === 'share_card' && cardData.url) {
458
+ window.open(cardData.url, '_blank', 'noopener,noreferrer')
459
+ }
460
+ }
461
+
462
+ return () => {
463
+ if (cardData.type === 'share_card' && cardData.preview) {
464
+ // 返回一个带跳转链接的图片
465
+ return h('img', {
466
+ src: cardData.preview,
467
+ alt: cardData.title || '[分享小程序]',
468
+ class: 'message-image',
469
+ loading: 'lazy',
470
+ draggable: false,
471
+ onClick: handleCardClick,
472
+ style: {
473
+ 'max-width': '400px',
474
+ 'max-height': '200px',
475
+ 'width': 'auto',
476
+ 'height': 'auto',
477
+ 'object-fit': 'contain',
478
+ cursor: cardData.url ? 'pointer' : 'default'
479
+ },
480
+ title: cardData.url ? `点击打开: ${cardData.title || '链接'}` : cardData.title,
481
+ onError: (e: Event) => {
482
+ // 图片加载失败时隐藏图片容器
483
+ const target = e.target as HTMLElement
484
+ const container = target.parentElement
485
+ if (container) {
486
+ container.style.display = 'none'
487
+ }
488
+ }
489
+ })
490
+ } else if (cardData.type === 'error') {
491
+ return h('div', { class: 'message-json-error' }, [
492
+ h('span', { class: 'json-error-text' }, cardData.error),
493
+ h('details', { class: 'json-raw-data' }, [
494
+ h('summary', '查看原始数据'),
495
+ h('pre', { class: 'json-raw-content' }, props.data)
496
+ ])
497
+ ])
498
+ } else {
499
+ // 原始JSON数据显示
500
+ return h('div', { class: 'message-json-raw' }, [
501
+ h('div', { class: 'json-label' }, '[JSON数据]'),
502
+ h('details', { class: 'json-raw-data' }, [
503
+ h('summary', '查看详情'),
504
+ h('pre', { class: 'json-raw-content' }, JSON.stringify(cardData.data, null, 2))
505
+ ])
506
+ ])
507
+ }
508
+ }
509
+ }
510
+ })
511
+
512
+ const MessageElement = defineComponent({
513
+ props: {
514
+ element: {
515
+ type: Object as () => MessageElement,
516
+ required: true
517
+ },
518
+ channelKey: {
519
+ type: String,
520
+ required: true
521
+ }
522
+ },
523
+ setup(props) {
524
+ const renderElement = (element: MessageElement) => {
525
+ switch (element.type) {
526
+ case 'text':
527
+ return h('span', { class: 'message-text-content' }, element.attrs.content || '')
528
+
529
+ case 'forward':
530
+ return h('span', { class: 'message-text-content' }, `[转发消息 ${element.attrs.id}]` || '[转发消息]')
531
+
532
+ case 'img':
533
+ case 'image':
534
+ const imageUrl = element.attrs.src || element.attrs.url || element.attrs.file
535
+ return h('div', { class: 'message-image-container' }, [
536
+ h(ImageComponent, {
537
+ src: imageUrl,
538
+ alt: element.attrs.summary || '图片',
539
+ filename: element.attrs.filename || element.attrs.summary || '',
540
+ channelKey: props.channelKey
541
+ })
542
+ ])
543
+
544
+ case 'mface':
545
+ const mfaceimageUrl = element.attrs.src || element.attrs.url || element.attrs.file
546
+ return h('div', { class: 'message-image-container' }, [
547
+ h(ImageComponent, {
548
+ src: mfaceimageUrl,
549
+ alt: element.attrs.summary || '表情',
550
+ filename: element.attrs.emojiId || element.attrs.summary || '',
551
+ channelKey: props.channelKey
552
+ })
553
+ ])
554
+
555
+ case 'face':
556
+ if (element.children[0]?.attrs?.src) {
557
+ const faceimageUrl = element.children[0]?.attrs?.src || element.children[0]?.attrs?.url
558
+ return h('div', { class: 'message-image-container' }, [
559
+ h(ImageComponent, {
560
+ src: faceimageUrl,
561
+ alt: element.attrs.name || element.attrs.id || '[表情]',
562
+ filename: element.attrs.name || element.attrs.id || '[表情]',
563
+ channelKey: props.channelKey
564
+ })
565
+ ])
566
+ } else {
567
+ return h('span', { class: 'message-text-content' }, `[${element.attrs.name || element.attrs.id}]` || '[表情]')
568
+ }
569
+
570
+ case 'at':
571
+ return h('span', {
572
+ class: 'message-at',
573
+ title: element.attrs.name
574
+ }, `${element.attrs.name || element.attrs.id}`)
575
+
576
+ case 'json':
577
+ return h('div', { class: 'message-image-container' }, [
578
+ h(JsonCardComponent, {
579
+ data: element.attrs.data || '',
580
+ channelKey: props.channelKey
581
+ })
582
+ ])
583
+ default:
584
+ // 未知类型
585
+ return h('span', {
586
+ class: 'message-unknown',
587
+ title: `未知消息类型: ${element.type}`
588
+ }, element.attrs.content || `[${element.type}]`)
589
+ }
590
+ }
591
+
592
+ return () => renderElement(props.element)
593
+ }
594
+ })
595
+
596
+ interface SendMessageResponse {
597
+ success: boolean
598
+ messageId?: string
599
+ error?: string
600
+ }
601
+
602
+ interface BotInfo {
603
+ selfId: string
604
+ platform: string
605
+ username: string
606
+ avatar?: string
607
+ status: 'online' | 'offline'
608
+ }
609
+
610
+ interface ChannelInfo {
611
+ id: string
612
+ name: string
613
+ type: number | string
614
+ guildId?: string
615
+ guildName?: string
616
+ }
617
+
618
+ interface MessageElement {
619
+ type: string
620
+ attrs: Record<string, any>
621
+ children: MessageElement[]
622
+ }
623
+
624
+ interface QuoteInfo {
625
+ messageId: string
626
+ id: string
627
+ content: string
628
+ elements?: MessageElement[]
629
+ user: {
630
+ id: string
631
+ name: string
632
+ userId: string
633
+ avatar?: string
634
+ username: string
635
+ }
636
+ timestamp: number
637
+ }
638
+
639
+ interface MessageInfo {
640
+ id: string
641
+ content: string
642
+ userId: string
643
+ username: string
644
+ avatar?: string
645
+ timestamp: number
646
+ channelId: string
647
+ selfId: string
648
+ elements?: MessageElement[]
649
+ isBot?: boolean
650
+ quote?: QuoteInfo
651
+ }
652
+
653
+ interface ChatData {
654
+ bots: Record<string, BotInfo>
655
+ channels: Record<string, Record<string, ChannelInfo>>
656
+ messages: Record<string, MessageInfo[]>
657
+ }
658
+
659
+ const chatData = ref<ChatData>({
660
+ bots: {},
661
+ channels: {},
662
+ messages: {}
663
+ })
664
+
665
+ // 存储每个频道的真实消息数量
666
+ const channelMessageCounts = ref<Record<string, number>>({})
667
+
668
+ const pluginConfig = ref<{
669
+ maxMessagesPerChannel: number
670
+ keepMessagesOnClear: number
671
+ loggerinfo: boolean
672
+ blockedPlatforms: Array<{
673
+ platformName: string
674
+ exactMatch: boolean
675
+ }>
676
+ chatContainerHeight: number
677
+ }>({
678
+ maxMessagesPerChannel: 1000,
679
+ keepMessagesOnClear: 50,
680
+ loggerinfo: false,
681
+ blockedPlatforms: [],
682
+ chatContainerHeight: 80
683
+ })
684
+
685
+ // 图片缓存 - IndexedDB
686
+ interface ImageCacheItem {
687
+ url: string
688
+ blob: Blob
689
+ timestamp: number
690
+ size: number
691
+ channelKey: string
692
+ }
693
+
694
+ // 内存中的URL缓存
695
+ const imageBlobUrls = ref<Record<string, string>>({})
696
+ const maxImagesPerChannel = 200 // 每个频道最大缓存图片数量
697
+
698
+ // IndexedDB
699
+ let imageDB: IDBDatabase | null = null
700
+ const DB_NAME = 'ChatImageCache'
701
+ const DB_VERSION = 1
702
+ const STORE_NAME = 'images'
703
+
704
+ const selectedBot = ref<string>('')
705
+ const selectedChannel = ref<string>('')
706
+ const inputMessage = ref<string>('')
707
+
708
+ // 手机端状态管理
709
+ const isMobile = ref<boolean>(false)
710
+ const mobileView = ref<'bots' | 'channels' | 'messages'>('bots')
711
+
712
+ // 滑动手势状态
713
+ const touchStart = ref<{ x: number, y: number, time: number } | null>(null)
714
+ const touchCurrent = ref<{ x: number, y: number } | null>(null)
715
+ const isSwipeActive = ref<boolean>(false)
716
+ const swipeIndicator = ref<{ show: boolean, text: string }>({ show: false, text: '' })
717
+ const messageHistory = ref<HTMLElement>()
718
+ const messageInput = ref<HTMLInputElement>()
719
+ const showScrollButton = ref<boolean>(false)
720
+ const isUserScrolling = ref<boolean>(false)
721
+ const isSending = ref<boolean>(false)
722
+
723
+ // 拖拽相关状态
724
+ const draggingChannel = ref<string>('')
725
+ const dragStartPos = ref<{ x: number, y: number }>({ x: 0, y: 0 })
726
+ const dragCurrentPos = ref<{ x: number, y: number }>({ x: 0, y: 0 })
727
+ const dragElementInitialPos = ref<{ x: number, y: number }>({ x: 0, y: 0 })
728
+ const dragOffset = ref<{ x: number, y: number }>({ x: 0, y: 0 }) // 新增:触摸点相对于元素左上角的偏移
729
+ const dragThreshold = 80 // 拖拽阈值,超过这个距离就清理历史记录
730
+ const dragStartTime = ref<number>(0)
731
+ const dragDelayTimer = ref<number | null>(null)
732
+ const isDragReady = ref<boolean>(false)
733
+ const draggedBubbleElement = ref<HTMLElement | null>(null) // 新增:存储被拖拽的气泡元素克隆体
734
+
735
+ // 右键菜单相关状态
736
+ const contextMenu = ref<{
737
+ show: boolean
738
+ x: number
739
+ y: number
740
+ type: 'bot' | 'channel' | null
741
+ targetId: string
742
+ isSecondClick: boolean
743
+ }>({
744
+ show: false,
745
+ x: 0,
746
+ y: 0,
747
+ type: null,
748
+ targetId: '',
749
+ isSecondClick: false
750
+ })
751
+
752
+ // 置顶状态管理
753
+ const pinnedBots = ref<Set<string>>(new Set())
754
+ const pinnedChannels = ref<Set<string>>(new Set())
755
+
756
+ const bots = computed(() => {
757
+ const botList = Object.values(chatData.value.bots)
758
+ // 按置顶状态排序,置顶的在前面
759
+ return botList.sort((a, b) => {
760
+ const aPinned = pinnedBots.value.has(a.selfId)
761
+ const bPinned = pinnedBots.value.has(b.selfId)
762
+ if (aPinned && !bPinned) return -1
763
+ if (!aPinned && bPinned) return 1
764
+ return 0
765
+ })
766
+ })
767
+
768
+ const currentChannels = computed(() => {
769
+ if (!selectedBot.value || !chatData.value.channels[selectedBot.value]) {
770
+ return []
771
+ }
772
+ const channelList = Object.values(chatData.value.channels[selectedBot.value])
773
+ // 按置顶状态排序,置顶的在前面
774
+ return channelList.sort((a, b) => {
775
+ const aPinned = pinnedChannels.value.has(`${selectedBot.value}:${a.id}`)
776
+ const bPinned = pinnedChannels.value.has(`${selectedBot.value}:${b.id}`)
777
+ if (aPinned && !bPinned) return -1
778
+ if (!aPinned && bPinned) return 1
779
+ return 0
780
+ })
781
+ })
782
+
783
+ const currentMessages = computed(() => {
784
+ if (!selectedBot.value || !selectedChannel.value) {
785
+ return []
786
+ }
787
+ const channelKey = `${selectedBot.value}:${selectedChannel.value}`
788
+ const messages = chatData.value.messages[channelKey] || []
789
+
790
+ const messagesWithQuote = messages.filter(m => m.quote)
791
+ return messages
792
+ })
793
+
794
+ const currentChannelName = computed(() => {
795
+ if (!selectedBot.value || !selectedChannel.value) {
796
+ return ''
797
+ }
798
+ const channels = chatData.value.channels[selectedBot.value]
799
+ return channels?.[selectedChannel.value]?.name || ''
800
+ })
801
+
802
+ const currentChannelKey = computed(() => {
803
+ if (!selectedBot.value || !selectedChannel.value) {
804
+ return ''
805
+ }
806
+ return `${selectedBot.value}:${selectedChannel.value}`
807
+ })
808
+
809
+ const canSendMessage = computed(() => {
810
+ return selectedBot.value && selectedChannel.value && inputMessage.value.trim() && !isSending.value
811
+ })
812
+
813
+ const canInputMessage = computed(() => {
814
+ return selectedBot.value && selectedChannel.value && !isSending.value
815
+ })
816
+
817
+ // 手机端视图状态计算属性
818
+ const mobileViewClass = computed(() => {
819
+ if (!isMobile.value) return ''
820
+
821
+ switch (mobileView.value) {
822
+ case 'channels':
823
+ return 'show-channels'
824
+ case 'messages':
825
+ return 'show-messages'
826
+ default:
827
+ return ''
828
+ }
829
+ })
830
+
831
+ // 输入框提示文字
832
+ const inputPlaceholder = computed(() => {
833
+ if (isMobile.value) {
834
+ return '屏幕左滑返回,上滑唤起聊天框'
835
+ } else {
836
+ return '输入消息...'
837
+ }
838
+ })
839
+
840
+ const chatContainerStyle = computed(() => {
841
+ if (isMobile.value) {
842
+ const height = pluginConfig.value.chatContainerHeight || 80; // 默认80
843
+ const marginTop = 100 - height - 19; // 19vh 是输入框高度,100是总高度
844
+ return {
845
+ height: `${height}vh`,
846
+ marginTop: `${marginTop}vh`
847
+ };
848
+ }
849
+ return {};
850
+ });
851
+
852
+ function selectBot(botId: string) {
853
+ selectedBot.value = botId
854
+ selectedChannel.value = ''
855
+
856
+ // 手机端:选择机器人后切换到频道视图
857
+ if (isMobile.value) {
858
+ mobileView.value = 'channels'
859
+ }
860
+ }
861
+
862
+ // 右键菜单相关方法
863
+ function handleBotRightClick(event: MouseEvent, botId: string) {
864
+ event.preventDefault()
865
+ event.stopPropagation()
866
+
867
+ // 检查是否是第二次右键点击同一个目标
868
+ const isSecondClick = contextMenu.value.show &&
869
+ contextMenu.value.type === 'bot' &&
870
+ contextMenu.value.targetId === botId
871
+
872
+ if (isSecondClick) {
873
+ // 第二次右键,隐藏自定义菜单,让浏览器显示原生菜单
874
+ hideContextMenu()
875
+ return
876
+ }
877
+ showContextMenu(event, 'bot', botId)
878
+ }
879
+
880
+ function handleChannelRightClick(event: MouseEvent, channelId: string) {
881
+ event.preventDefault()
882
+ event.stopPropagation()
883
+
884
+ // 检查是否是第二次右键点击同一个目标
885
+ const isSecondClick = contextMenu.value.show &&
886
+ contextMenu.value.type === 'channel' &&
887
+ contextMenu.value.targetId === channelId
888
+
889
+ if (isSecondClick) {
890
+ // 第二次右键,隐藏自定义菜单,让浏览器显示原生菜单
891
+ hideContextMenu()
892
+ return
893
+ }
894
+ showContextMenu(event, 'channel', channelId)
895
+ }
896
+
897
+ function showContextMenu(event: MouseEvent, type: 'bot' | 'channel', targetId: string) {
898
+ // 确保菜单不会超出屏幕边界
899
+ const menuWidth = 180
900
+ const menuHeight = 80
901
+ let x = event.clientX
902
+ let y = event.clientY
903
+
904
+ if (x + menuWidth > window.innerWidth) {
905
+ x = window.innerWidth - menuWidth - 10
906
+ }
907
+ if (y + menuHeight > window.innerHeight) {
908
+ y = window.innerHeight - menuHeight - 10
909
+ }
910
+
911
+ contextMenu.value = {
912
+ show: true,
913
+ x,
914
+ y,
915
+ type,
916
+ targetId,
917
+ isSecondClick: false
918
+ }
919
+
920
+ // 添加全局事件监听器来隐藏菜单
921
+ document.addEventListener('click', hideContextMenu, { once: true })
922
+ document.addEventListener('keydown', handleKeyDown)
923
+ }
924
+
925
+ function hideContextMenu() {
926
+ contextMenu.value.show = false
927
+ document.removeEventListener('click', hideContextMenu)
928
+ document.removeEventListener('keydown', handleKeyDown)
929
+ }
930
+
931
+ // 处理键盘事件
932
+ function handleKeyDown(event: KeyboardEvent) {
933
+ if (event.key === 'Escape' && contextMenu.value.show) {
934
+ hideContextMenu()
935
+ }
936
+ }
937
+
938
+ // 置顶相关方法
939
+ async function toggleBotPin(botId: string) {
940
+ if (pinnedBots.value.has(botId)) {
941
+ pinnedBots.value.delete(botId)
942
+ } else {
943
+ pinnedBots.value.add(botId)
944
+ }
945
+ // 持久化置顶状态到后端
946
+ await (send as any)('set-pinned-bots', { pinnedBots: Array.from(pinnedBots.value) })
947
+ hideContextMenu()
948
+ }
949
+
950
+ async function toggleChannelPin(channelId: string) {
951
+ const channelKey = `${selectedBot.value}:${channelId}`
952
+ if (pinnedChannels.value.has(channelKey)) {
953
+ pinnedChannels.value.delete(channelKey)
954
+ } else {
955
+ pinnedChannels.value.add(channelKey)
956
+ }
957
+ // 持久化置顶状态到后端
958
+ await (send as any)('set-pinned-channels', { pinnedChannels: Array.from(pinnedChannels.value) })
959
+ hideContextMenu()
960
+ }
961
+
962
+ // 删除消息相关方法
963
+ async function deleteBotMessages(botId: string) {
964
+ try {
965
+ // 调用后端API删除机器人数据
966
+ const result = await (send as any)('delete-bot-data', {
967
+ selfId: botId
968
+ })
969
+
970
+ if (result.success) {
971
+ // 前端同步删除数据
972
+ const channelsToDelete = Object.keys(chatData.value.messages).filter(key => key.startsWith(`${botId}:`))
973
+
974
+ for (const channelKey of channelsToDelete) {
975
+ delete chatData.value.messages[channelKey]
976
+ delete channelMessageCounts.value[channelKey]
977
+ // 清理图片缓存
978
+ await clearChannelImageCache(channelKey)
979
+ }
980
+
981
+ // 删除机器人信息
982
+ delete chatData.value.bots[botId]
983
+
984
+ // 删除频道信息
985
+ delete chatData.value.channels[botId]
986
+
987
+ // 如果当前选中的是被删除的机器人,清空选择
988
+ if (selectedBot.value === botId) {
989
+ selectedBot.value = ''
990
+ selectedChannel.value = ''
991
+ }
992
+
993
+ showNotification(result.message || '已删除该机器人的所有数据', 'success')
994
+ } else {
995
+ throw new Error(result.error || '删除失败')
996
+ }
997
+ } catch (error: any) {
998
+ console.error('删除机器人数据失败:', error)
999
+ showNotification('删除失败: ' + (error?.message || String(error)), 'error')
1000
+ }
1001
+ hideContextMenu()
1002
+ }
1003
+
1004
+ async function deleteChannelMessages(channelId: string) {
1005
+ try {
1006
+ const result = await (send as any)('delete-channel-data', {
1007
+ selfId: selectedBot.value,
1008
+ channelId: channelId
1009
+ })
1010
+
1011
+ if (result.success) {
1012
+ const channelKey = `${selectedBot.value}:${channelId}`
1013
+
1014
+ // 前端同步删除数据
1015
+ delete chatData.value.messages[channelKey]
1016
+ delete channelMessageCounts.value[channelKey]
1017
+
1018
+ // 删除频道信息
1019
+ if (chatData.value.channels[selectedBot.value]) {
1020
+ delete chatData.value.channels[selectedBot.value][channelId]
1021
+ }
1022
+
1023
+ // 清理图片缓存
1024
+ await clearChannelImageCache(channelKey)
1025
+
1026
+ // 如果当前选中的是被删除的频道,清空选择
1027
+ if (selectedChannel.value === channelId) {
1028
+ selectedChannel.value = ''
1029
+ }
1030
+
1031
+ showNotification(result.message || '已删除该频道的所有数据', 'success')
1032
+ } else {
1033
+ throw new Error(result.error || '删除失败')
1034
+ }
1035
+ } catch (error: any) {
1036
+ console.error('删除频道数据失败:', error)
1037
+ showNotification('删除失败: ' + (error?.message || String(error)), 'error')
1038
+ }
1039
+ hideContextMenu()
1040
+ }
1041
+
1042
+ async function selectChannel(channelId: string) {
1043
+ selectedChannel.value = channelId
1044
+ isUserScrolling.value = false
1045
+
1046
+ // 手机端:选择频道后切换到消息视图
1047
+ if (isMobile.value) {
1048
+ mobileView.value = 'messages'
1049
+ }
1050
+
1051
+ // 先获取历史消息,然后再滚动到底部
1052
+ if (selectedBot.value) {
1053
+ await loadHistoryMessages(selectedBot.value, channelId)
1054
+ }
1055
+
1056
+ nextTick(() => {
1057
+ scrollToBottom()
1058
+ // 只有在非手机端才自动聚焦输入框
1059
+ if (!isMobile.value && messageInput.value) {
1060
+ messageInput.value.focus()
1061
+ }
1062
+ })
1063
+ }
1064
+
1065
+ async function sendMessage() {
1066
+ if (!canSendMessage.value) return
1067
+
1068
+ const messageContent = inputMessage.value.trim()
1069
+ if (!messageContent) return
1070
+
1071
+ // 设置发送状态
1072
+ isSending.value = true
1073
+
1074
+ try {
1075
+ // 调用后端 API 发送消息
1076
+ const result = await (send as any)('send-message', {
1077
+ selfId: selectedBot.value,
1078
+ channelId: selectedChannel.value,
1079
+ content: messageContent
1080
+ }) as SendMessageResponse
1081
+
1082
+ if (result.success) {
1083
+ // 清空输入框
1084
+ inputMessage.value = ''
1085
+ } else {
1086
+ console.error('消息发送失败:', result.error)
1087
+ // 使用showNotification显示错误提示
1088
+ showNotification('发送失败: ' + result.error, 'error')
1089
+ }
1090
+ } catch (error: any) {
1091
+ console.error('发送消息时出错:', error)
1092
+ showNotification('发送失败: ' + (error?.message || String(error)), 'error')
1093
+ } finally {
1094
+ // 重置发送状态
1095
+ isSending.value = false
1096
+ }
1097
+ }
1098
+
1099
+ function formatTime(timestamp: number): string {
1100
+ const date = new Date(timestamp)
1101
+ return date.toLocaleTimeString('zh-CN', {
1102
+ hour: '2-digit',
1103
+ minute: '2-digit'
1104
+ })
1105
+ }
1106
+
1107
+ function getChannelTypeText(type: number | string): string {
1108
+ if (typeof type === 'number') {
1109
+ switch (type) {
1110
+ case 0: return '文本'
1111
+ case 1: return '私聊'
1112
+ default: return '未知'
1113
+ }
1114
+ }
1115
+ return String(type)
1116
+ }
1117
+
1118
+ function scrollToBottom() {
1119
+ if (messageHistory.value) {
1120
+ messageHistory.value.scrollTop = messageHistory.value.scrollHeight
1121
+ showScrollButton.value = false
1122
+ isUserScrolling.value = false
1123
+ }
1124
+ }
1125
+
1126
+ function checkScrollPosition() {
1127
+ if (messageHistory.value) {
1128
+ const { scrollTop, scrollHeight, clientHeight } = messageHistory.value
1129
+ const distanceFromBottom = scrollHeight - (scrollTop + clientHeight)
1130
+ const isAtBottom = distanceFromBottom <= 50
1131
+
1132
+ const shouldShowButton = !isAtBottom
1133
+
1134
+ showScrollButton.value = shouldShowButton
1135
+
1136
+ // 检测用户是否在主动滚动(向上滚动查看历史消息)
1137
+ if (!isAtBottom) {
1138
+ isUserScrolling.value = true
1139
+ } else {
1140
+ // 用户滚动到底部时,重置滚动状态
1141
+ isUserScrolling.value = false
1142
+ }
1143
+ }
1144
+ }
1145
+
1146
+ function isNearBottom(): boolean {
1147
+ if (!messageHistory.value) return true // 如果没有消息容器,默认应该滚动
1148
+ const { scrollTop, scrollHeight, clientHeight } = messageHistory.value
1149
+ const distanceFromBottom = scrollHeight - (scrollTop + clientHeight)
1150
+ const isNear = distanceFromBottom <= 200 // 距离底部200px内认为是在底部附近
1151
+
1152
+ return isNear
1153
+ }
1154
+
1155
+ function getChannelMessageCount(channelId: string): number {
1156
+ if (!selectedBot.value) return 0
1157
+ const channelKey = `${selectedBot.value}:${channelId}`
1158
+
1159
+ // 优先使用缓存的消息数量信息
1160
+ const cachedCount = channelMessageCounts.value[channelKey]
1161
+ if (cachedCount !== undefined) {
1162
+ return cachedCount
1163
+ }
1164
+ // 如果没有缓存,使用当前加载的消息数量作为备用
1165
+ return chatData.value.messages[channelKey]?.length || 0
1166
+ }
1167
+
1168
+ // 拖拽相关方法
1169
+ function startDrag(event: MouseEvent | TouchEvent, channelId: string) {
1170
+ event.preventDefault()
1171
+ event.stopPropagation()
1172
+
1173
+ const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
1174
+ const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
1175
+
1176
+ // 记录开始时间和位置
1177
+ dragStartTime.value = Date.now()
1178
+ dragStartPos.value = { x: clientX, y: clientY }
1179
+ dragCurrentPos.value = { x: clientX, y: clientY }
1180
+ isDragReady.value = false
1181
+
1182
+ // 获取元素的初始位置
1183
+ const element = event.target as HTMLElement
1184
+ const rect = element.getBoundingClientRect()
1185
+ dragElementInitialPos.value = { x: rect.left, y: rect.top }
1186
+ dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top } // 计算触摸点相对于元素左上角的偏移
1187
+
1188
+ // 设置60ms延迟
1189
+ dragDelayTimer.value = window.setTimeout(() => {
1190
+ if (dragStartTime.value > 0) { // 确保还在按住状态
1191
+ isDragReady.value = true
1192
+ draggingChannel.value = channelId
1193
+
1194
+ // 获取原始元素
1195
+ const originalElement = event.target as HTMLElement
1196
+ // 克隆元素
1197
+ const clonedElement = originalElement.cloneNode(true) as HTMLElement
1198
+ clonedElement.classList.add('dragging-clone') // 添加一个类以便样式控制
1199
+ clonedElement.style.position = 'fixed'
1200
+ clonedElement.style.zIndex = '1000'
1201
+ clonedElement.style.pointerEvents = 'none' // 克隆体不响应事件
1202
+
1203
+ // 设置克隆体的初始位置
1204
+ const rect = originalElement.getBoundingClientRect()
1205
+ clonedElement.style.left = `${rect.left}px`
1206
+ clonedElement.style.top = `${rect.top}px`
1207
+ clonedElement.style.width = `${rect.width}px`
1208
+ clonedElement.style.height = `${rect.height}px`
1209
+
1210
+ document.body.appendChild(clonedElement)
1211
+ draggedBubbleElement.value = clonedElement
1212
+
1213
+ // 添加全局拖拽样式
1214
+ document.body.style.userSelect = 'none'
1215
+ document.body.style.cursor = 'grabbing'
1216
+ document.body.classList.add('dragging-bubble-global') // 全局拖拽样式
1217
+
1218
+ // 创建阈值圆圈(固定在原始位置)
1219
+ createThresholdCircle(dragStartPos.value.x, dragStartPos.value.y)
1220
+ }
1221
+ }, 60)
1222
+
1223
+ // 添加全局事件监听器
1224
+ document.addEventListener('mousemove', handleDragMove)
1225
+ document.addEventListener('mouseup', handleDragEnd)
1226
+ document.addEventListener('touchmove', handleDragMove)
1227
+ document.addEventListener('touchend', handleDragEnd)
1228
+ }
1229
+
1230
+ function handleDragMove(event: MouseEvent | TouchEvent) {
1231
+ if (!isDragReady.value || !draggedBubbleElement.value) return
1232
+
1233
+ event.preventDefault()
1234
+
1235
+ const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
1236
+ const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
1237
+
1238
+ dragCurrentPos.value = { x: clientX, y: clientY }
1239
+
1240
+ // 更新克隆体的位置和样式
1241
+ const deltaX = dragCurrentPos.value.x - dragStartPos.value.x
1242
+ const deltaY = dragCurrentPos.value.y - dragStartPos.value.y
1243
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
1244
+
1245
+ const opacity = Math.max(0.3, 1 - distance / (dragThreshold * 2))
1246
+ const scale = Math.max(0.8, 1 - distance / (dragThreshold * 3))
1247
+ const willDelete = distance > dragThreshold
1248
+
1249
+ const finalX = dragCurrentPos.value.x - dragOffset.value.x
1250
+ const finalY = dragCurrentPos.value.y - dragOffset.value.y
1251
+
1252
+ draggedBubbleElement.value.style.left = `${finalX}px`
1253
+ draggedBubbleElement.value.style.top = `${finalY}px`
1254
+ draggedBubbleElement.value.style.transform = `scale(${scale})`
1255
+ draggedBubbleElement.value.style.opacity = `${opacity}`
1256
+ draggedBubbleElement.value.style.backgroundColor = willDelete ? '#f44336' : '#2196f3'
1257
+ draggedBubbleElement.value.style.boxShadow = willDelete ? '0 4px 12px rgba(244, 67, 54, 0.4)' : '0 4px 12px rgba(33, 150, 243, 0.4)'
1258
+
1259
+ // 更新克隆体的类
1260
+ if (willDelete) {
1261
+ draggedBubbleElement.value.classList.add('will-delete')
1262
+ } else {
1263
+ draggedBubbleElement.value.classList.remove('will-delete')
1264
+ }
1265
+ }
1266
+
1267
+ function handleDragEnd(event: MouseEvent | TouchEvent) {
1268
+ // 清除延迟定时器
1269
+ if (dragDelayTimer.value) {
1270
+ clearTimeout(dragDelayTimer.value)
1271
+ dragDelayTimer.value = null
1272
+ }
1273
+
1274
+ // 清除延迟定时器
1275
+ if (dragDelayTimer.value) {
1276
+ clearTimeout(dragDelayTimer.value)
1277
+ dragDelayTimer.value = null
1278
+ }
1279
+
1280
+ // 如果还没有开始拖拽,直接重置
1281
+ if (!isDragReady.value || !draggingChannel.value) {
1282
+ resetDragState()
1283
+ return
1284
+ }
1285
+
1286
+ const channelId = draggingChannel.value
1287
+ const distance = Math.sqrt(
1288
+ Math.pow(dragCurrentPos.value.x - dragStartPos.value.x, 2) +
1289
+ Math.pow(dragCurrentPos.value.y - dragStartPos.value.y, 2)
1290
+ )
1291
+
1292
+ // 如果拖拽距离超过阈值,清理历史记录
1293
+ if (distance > dragThreshold) {
1294
+ clearChannelHistory(channelId)
1295
+ // 立即重置状态
1296
+ resetDragState()
1297
+ } else {
1298
+ // 距离不够,添加回弹动画到克隆体
1299
+ if (draggedBubbleElement.value) {
1300
+ draggedBubbleElement.value.style.transition = 'all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55)'
1301
+ // 回弹到原始位置
1302
+ const originalElement = document.querySelector(`[data-channel-id="${channelId}"] .channel-message-count`)
1303
+ if (originalElement) {
1304
+ const rect = originalElement.getBoundingClientRect()
1305
+ draggedBubbleElement.value.style.left = `${rect.left}px`
1306
+ draggedBubbleElement.value.style.top = `${rect.top}px`
1307
+ draggedBubbleElement.value.style.transform = 'scale(1)'
1308
+ draggedBubbleElement.value.style.opacity = '1'
1309
+ draggedBubbleElement.value.style.backgroundColor = '#2196f3'
1310
+ draggedBubbleElement.value.style.boxShadow = '0 4px 12px rgba(33, 150, 243, 0.4)'
1311
+ }
1312
+ setTimeout(() => {
1313
+ resetDragState()
1314
+ }, 300)
1315
+ } else {
1316
+ resetDragState()
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ function resetDragState() {
1322
+ // 清除延迟定时器
1323
+ if (dragDelayTimer.value) {
1324
+ clearTimeout(dragDelayTimer.value)
1325
+ dragDelayTimer.value = null
1326
+ }
1327
+
1328
+ // 重置拖拽状态
1329
+ draggingChannel.value = ''
1330
+ dragStartPos.value = { x: 0, y: 0 }
1331
+ dragCurrentPos.value = { x: 0, y: 0 }
1332
+ dragElementInitialPos.value = { x: 0, y: 0 }
1333
+ dragStartTime.value = 0
1334
+ isDragReady.value = false
1335
+
1336
+ // 移除全局事件监听器
1337
+ document.removeEventListener('mousemove', handleDragMove)
1338
+ document.removeEventListener('mouseup', handleDragEnd)
1339
+ document.removeEventListener('touchmove', handleDragMove)
1340
+ document.removeEventListener('touchend', handleDragEnd)
1341
+
1342
+ // 恢复样式
1343
+ document.body.style.userSelect = ''
1344
+ document.body.style.cursor = ''
1345
+ document.body.classList.remove('dragging-bubble-global') // 移除全局拖拽样式
1346
+
1347
+ // 移除克隆体
1348
+ if (draggedBubbleElement.value && draggedBubbleElement.value.parentNode) {
1349
+ draggedBubbleElement.value.parentNode.removeChild(draggedBubbleElement.value)
1350
+ draggedBubbleElement.value = null
1351
+ }
1352
+
1353
+ // 移除阈值圆圈
1354
+ removeThresholdCircle()
1355
+ }
1356
+
1357
+ // getDragStyle 不再直接用于拖拽中的元素,而是用于原始元素隐藏
1358
+ function getDragStyle(channelId: string) {
1359
+ if (draggingChannel.value === channelId && isDragReady.value) {
1360
+ // 当拖拽开始且准备就绪时,隐藏原始气泡
1361
+ return {
1362
+ visibility: 'hidden' as const,
1363
+ pointerEvents: 'none' as const,
1364
+ transition: 'none' as const, // 确保隐藏时没有动画
1365
+ }
1366
+ }
1367
+ return {}
1368
+ }
1369
+
1370
+ function getDragDistance(channelId: string): number {
1371
+ if (draggingChannel.value !== channelId) return 0
1372
+
1373
+ const deltaX = dragCurrentPos.value.x - dragStartPos.value.x
1374
+ const deltaY = dragCurrentPos.value.y - dragStartPos.value.y
1375
+ return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
1376
+ }
1377
+
1378
+
1379
+
1380
+ async function clearChannelHistory(channelId: string) {
1381
+ if (!selectedBot.value) return
1382
+
1383
+ try {
1384
+ const channelKey = `${selectedBot.value}:${channelId}`
1385
+
1386
+ // 先检查当前消息数量
1387
+ const currentCount = getChannelMessageCount(channelId)
1388
+ const keepCount = pluginConfig.value.keepMessagesOnClear
1389
+
1390
+ if (keepCount > 0 && currentCount <= keepCount) {
1391
+ showNotification('当前消息还很少诶~ 无需清理', 'success')
1392
+ return
1393
+ }
1394
+
1395
+ // 调用后端API清理历史记录
1396
+ const result = await (send as any)('clear-channel-history', {
1397
+ selfId: selectedBot.value,
1398
+ channelId: channelId
1399
+ // 不传keepCount,让后端使用配置的默认值
1400
+ })
1401
+
1402
+ if (result.success) {
1403
+ // 检查是否真的进行了清理
1404
+ if (result.clearedCount && result.clearedCount > 0) {
1405
+ // 更新本地数据
1406
+ if (chatData.value.messages[channelKey]) {
1407
+ // 保留最新的消息
1408
+ const messages = chatData.value.messages[channelKey]
1409
+ const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
1410
+ chatData.value.messages[channelKey] = sortedMessages.slice(-result.keptCount)
1411
+ }
1412
+
1413
+ // 更新消息数量缓存为实际保留的消息数量
1414
+ channelMessageCounts.value[channelKey] = result.keptCount
1415
+
1416
+ // 清理图片缓存
1417
+ await clearChannelImageCache(channelKey)
1418
+
1419
+ // 显示成功提示,显示实际清理的数量
1420
+ showNotification(`历史记录已清理,清理了 ${result.clearedCount} 条消息,保留最新 ${result.keptCount} 条`, 'success')
1421
+ } else if (keepCount === 0) {
1422
+ // 当keepCount为0时
1423
+ // 更新本地数据
1424
+ if (chatData.value.messages[channelKey]) {
1425
+ chatData.value.messages[channelKey] = []
1426
+ }
1427
+
1428
+ // 更新消息数量缓存为0
1429
+ channelMessageCounts.value[channelKey] = 0
1430
+
1431
+ // 清理图片缓存
1432
+ await clearChannelImageCache(channelKey)
1433
+
1434
+ // 显示成功提示
1435
+ showNotification('历史记录已清理,所有消息已删除', 'success')
1436
+ } else {
1437
+ showNotification('当前消息还很少诶~ 无需清理', 'success')
1438
+ }
1439
+ } else {
1440
+ console.error('清理历史记录失败:', result.error)
1441
+ showNotification('清理失败: ' + result.error, 'error')
1442
+ }
1443
+ } catch (error: any) {
1444
+ console.error('清理历史记录时出错:', error)
1445
+ showNotification('清理失败: ' + (error?.message || String(error)), 'error')
1446
+ }
1447
+ }
1448
+
1449
+ function showNotification(message: string, type: 'info' | 'warn' | 'error' | 'success' = 'success') {
1450
+ // 创建通知元素
1451
+ const notification = document.createElement('div')
1452
+ notification.className = `notification ${type}`
1453
+ notification.textContent = message
1454
+
1455
+ let backgroundColor = '#4caf50' // success - 绿色
1456
+ switch (type) {
1457
+ case 'info':
1458
+ backgroundColor = '#2196f3' // 蓝色
1459
+ break
1460
+ case 'warn':
1461
+ backgroundColor = '#ff9800' // 橙色
1462
+ break
1463
+ case 'error':
1464
+ backgroundColor = '#f44336' // 红色
1465
+ break
1466
+ case 'success':
1467
+ backgroundColor = '#4caf50' // 绿色
1468
+ break
1469
+ }
1470
+
1471
+ notification.style.cssText = `
1472
+ position: fixed;
1473
+ top: 20px;
1474
+ right: 20px;
1475
+ padding: 12px 20px;
1476
+ border-radius: 6px;
1477
+ color: white;
1478
+ font-weight: 500;
1479
+ z-index: 10000;
1480
+ animation: slideIn 0.3s ease-out;
1481
+ background: ${backgroundColor};
1482
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1483
+ `
1484
+
1485
+ // 添加动画样式
1486
+ const style = document.createElement('style')
1487
+ style.textContent = `
1488
+ @keyframes slideIn {
1489
+ from { transform: translateX(100%); opacity: 0; }
1490
+ to { transform: translateX(0); opacity: 1; }
1491
+ }
1492
+ @keyframes slideOut {
1493
+ from { transform: translateX(0); opacity: 1; }
1494
+ to { transform: translateX(100%); opacity: 0; }
1495
+ }
1496
+ `
1497
+ document.head.appendChild(style)
1498
+
1499
+ document.body.appendChild(notification)
1500
+
1501
+ // 3秒后自动移除
1502
+ setTimeout(() => {
1503
+ notification.style.animation = 'slideOut 0.3s ease-in'
1504
+ setTimeout(() => {
1505
+ if (notification.parentNode) {
1506
+ notification.parentNode.removeChild(notification)
1507
+ }
1508
+ if (style.parentNode) {
1509
+ style.parentNode.removeChild(style)
1510
+ }
1511
+ }, 300)
1512
+ }, 3000)
1513
+ }
1514
+
1515
+ function createThresholdCircle(centerX: number, centerY: number) {
1516
+ const circle = document.createElement('div')
1517
+ circle.className = 'drag-threshold-circle'
1518
+ circle.style.cssText = `
1519
+ left: ${centerX - dragThreshold}px;
1520
+ top: ${centerY - dragThreshold}px;
1521
+ width: ${dragThreshold * 2}px;
1522
+ height: ${dragThreshold * 2}px;
1523
+ `
1524
+ document.body.appendChild(circle);
1525
+
1526
+ (window as any).dragThresholdCircle = circle
1527
+ }
1528
+
1529
+ function removeThresholdCircle() {
1530
+ const circle = (window as any).dragThresholdCircle
1531
+ if (circle && circle.parentNode) {
1532
+ circle.parentNode.removeChild(circle);
1533
+ (window as any).dragThresholdCircle = null
1534
+ }
1535
+ }
1536
+
1537
+ // IndexedDB初始化
1538
+ async function initImageDB(): Promise<boolean> {
1539
+ return new Promise((resolve, reject) => {
1540
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
1541
+
1542
+ request.onerror = () => {
1543
+ console.error('IndexedDB初始化失败:', request.error)
1544
+ resolve(false)
1545
+ }
1546
+
1547
+ request.onsuccess = () => {
1548
+ imageDB = request.result
1549
+ resolve(true)
1550
+ }
1551
+
1552
+ request.onupgradeneeded = (event) => {
1553
+ const db = (event.target as IDBOpenDBRequest).result
1554
+
1555
+ // 创建对象存储
1556
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
1557
+ const store = db.createObjectStore(STORE_NAME, { keyPath: 'url' })
1558
+ store.createIndex('channelKey', 'channelKey', { unique: false })
1559
+ store.createIndex('timestamp', 'timestamp', { unique: false })
1560
+ }
1561
+ }
1562
+ })
1563
+ }
1564
+
1565
+ // 从IndexedDB获取图片
1566
+ async function getImageFromDB(url: string): Promise<ImageCacheItem | null> {
1567
+ if (!imageDB) return null
1568
+
1569
+ return new Promise((resolve, reject) => {
1570
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
1571
+ const store = transaction.objectStore(STORE_NAME)
1572
+ const request = store.get(url)
1573
+
1574
+ request.onsuccess = () => {
1575
+ resolve(request.result || null)
1576
+ }
1577
+
1578
+ request.onerror = () => {
1579
+ console.error('从IndexedDB获取图片失败:', request.error)
1580
+ resolve(null)
1581
+ }
1582
+ })
1583
+ }
1584
+
1585
+ // 保存图片到IndexedDB
1586
+ async function saveImageToDB(item: ImageCacheItem): Promise<boolean> {
1587
+ if (!imageDB) return false
1588
+
1589
+ return new Promise((resolve) => {
1590
+ const transaction = imageDB!.transaction([STORE_NAME], 'readwrite')
1591
+ const store = transaction.objectStore(STORE_NAME)
1592
+ const request = store.put(item)
1593
+
1594
+ request.onsuccess = () => {
1595
+ resolve(true)
1596
+ }
1597
+
1598
+ request.onerror = () => {
1599
+ console.error('保存图片到IndexedDB失败:', request.error)
1600
+ resolve(false)
1601
+ }
1602
+ })
1603
+ }
1604
+
1605
+ // 从IndexedDB删除图片
1606
+ async function deleteImageFromDB(url: string): Promise<boolean> {
1607
+ if (!imageDB) return false
1608
+
1609
+ return new Promise((resolve) => {
1610
+ const transaction = imageDB!.transaction([STORE_NAME], 'readwrite')
1611
+ const store = transaction.objectStore(STORE_NAME)
1612
+ const request = store.delete(url)
1613
+
1614
+ request.onsuccess = () => {
1615
+ resolve(true)
1616
+ }
1617
+
1618
+ request.onerror = () => {
1619
+ console.error('从IndexedDB删除图片失败:', request.error)
1620
+ resolve(false)
1621
+ }
1622
+ })
1623
+ }
1624
+
1625
+ // 获取频道的所有图片
1626
+ async function getChannelImagesFromDB(channelKey: string): Promise<ImageCacheItem[]> {
1627
+ if (!imageDB) return []
1628
+
1629
+ return new Promise((resolve) => {
1630
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
1631
+ const store = transaction.objectStore(STORE_NAME)
1632
+ const index = store.index('channelKey')
1633
+ const request = index.getAll(channelKey)
1634
+
1635
+ request.onsuccess = () => {
1636
+ resolve(request.result || [])
1637
+ }
1638
+
1639
+ request.onerror = () => {
1640
+ console.error('获取频道图片失败:', request.error)
1641
+ resolve([])
1642
+ }
1643
+ })
1644
+ }
1645
+
1646
+ // 图片缓存管理函数
1647
+ async function getCachedImageUrl(channelKey: string, originalUrl: string): Promise<string | null> {
1648
+ // 首先检查内存中的blob URL
1649
+ const existingBlobUrl = imageBlobUrls.value[originalUrl]
1650
+ if (existingBlobUrl) {
1651
+ return existingBlobUrl
1652
+ }
1653
+
1654
+ // 从IndexedDB获取
1655
+ const cacheItem = await getImageFromDB(originalUrl)
1656
+ if (!cacheItem) return null
1657
+
1658
+ // 创建blob URL并缓存到内存
1659
+ const blobUrl = URL.createObjectURL(cacheItem.blob)
1660
+ imageBlobUrls.value[originalUrl] = blobUrl
1661
+
1662
+ // 更新访问时间
1663
+ cacheItem.timestamp = Date.now()
1664
+ await saveImageToDB(cacheItem)
1665
+
1666
+ return blobUrl
1667
+ }
1668
+
1669
+ async function cacheImage(channelKey: string, originalUrl: string): Promise<string | null> {
1670
+ try {
1671
+ // 检查是否已经缓存
1672
+ const cached = await getCachedImageUrl(channelKey, originalUrl)
1673
+ if (cached) {
1674
+ return cached
1675
+ }
1676
+
1677
+ // 获取图片数据
1678
+ const result = await (send as any)('fetch-image', { url: originalUrl })
1679
+
1680
+ if (!result.success) {
1681
+ console.error('获取图片失败:', result.error)
1682
+ return null
1683
+ }
1684
+
1685
+ // 将base64转换为blob
1686
+ const base64Data = result.base64
1687
+ const contentType = result.contentType || 'image/jpeg'
1688
+
1689
+ // 解码base64
1690
+ const byteCharacters = atob(base64Data)
1691
+ const byteNumbers = new Array(byteCharacters.length)
1692
+ for (let i = 0; i < byteCharacters.length; i++) {
1693
+ byteNumbers[i] = byteCharacters.charCodeAt(i)
1694
+ }
1695
+ const byteArray = new Uint8Array(byteNumbers)
1696
+ const blob = new Blob([byteArray], { type: contentType })
1697
+
1698
+ // 检查频道缓存数量限制
1699
+ const channelImages = await getChannelImagesFromDB(channelKey)
1700
+
1701
+ if (channelImages.length >= maxImagesPerChannel) {
1702
+ // 清理最旧的图片缓存
1703
+ const sortedImages = channelImages.sort((a, b) => a.timestamp - b.timestamp)
1704
+ const toDelete = sortedImages.slice(0, channelImages.length - maxImagesPerChannel + 1)
1705
+
1706
+ for (const item of toDelete) {
1707
+ await deleteImageFromDB(item.url)
1708
+ // 清理内存中的blob URL
1709
+ if (imageBlobUrls.value[item.url]) {
1710
+ URL.revokeObjectURL(imageBlobUrls.value[item.url])
1711
+ delete imageBlobUrls.value[item.url]
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ // 创建缓存项
1717
+ const cacheItem: ImageCacheItem = {
1718
+ url: originalUrl,
1719
+ blob: blob,
1720
+ timestamp: Date.now(),
1721
+ size: blob.size,
1722
+ channelKey: channelKey
1723
+ }
1724
+
1725
+ // 保存到IndexedDB
1726
+ const saved = await saveImageToDB(cacheItem)
1727
+ if (!saved) {
1728
+ console.error('保存图片到IndexedDB失败')
1729
+ return null
1730
+ }
1731
+
1732
+ // 创建blob URL并缓存到内存
1733
+ const blobUrl = URL.createObjectURL(blob)
1734
+ imageBlobUrls.value[originalUrl] = blobUrl
1735
+ return blobUrl
1736
+
1737
+ } catch (error) {
1738
+ console.error('缓存图片失败:', error)
1739
+ return null
1740
+ }
1741
+ }
1742
+
1743
+ // 清理频道的所有图片缓存
1744
+ async function clearChannelImageCache(channelKey: string) {
1745
+ try {
1746
+ // 获取频道的所有图片
1747
+ const channelImages = await getChannelImagesFromDB(channelKey)
1748
+
1749
+ // 删除IndexedDB中的数据
1750
+ for (const item of channelImages) {
1751
+ await deleteImageFromDB(item.url)
1752
+ // 清理内存中的blob URL
1753
+ if (imageBlobUrls.value[item.url]) {
1754
+ URL.revokeObjectURL(imageBlobUrls.value[item.url])
1755
+ delete imageBlobUrls.value[item.url]
1756
+ }
1757
+ }
1758
+ } catch (error) {
1759
+ console.error('清理频道图片缓存失败:', error)
1760
+ }
1761
+ }
1762
+
1763
+ // 获取缓存统计信息
1764
+ async function getCacheStats() {
1765
+ if (!imageDB) return { totalImages: 0, totalSize: 0, channels: 0 }
1766
+
1767
+ return new Promise<{ totalImages: number, totalSize: number, channels: number }>((resolve) => {
1768
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
1769
+ const store = transaction.objectStore(STORE_NAME)
1770
+ const request = store.getAll()
1771
+
1772
+ request.onsuccess = () => {
1773
+ const allImages = request.result || []
1774
+ const channelSet = new Set<string>()
1775
+ let totalSize = 0
1776
+
1777
+ allImages.forEach(item => {
1778
+ channelSet.add(item.channelKey)
1779
+ totalSize += item.size
1780
+ })
1781
+
1782
+ resolve({
1783
+ totalImages: allImages.length,
1784
+ totalSize: totalSize,
1785
+ channels: channelSet.size
1786
+ })
1787
+ }
1788
+
1789
+ request.onerror = () => {
1790
+ console.error('获取缓存统计失败:', request.error)
1791
+ resolve({ totalImages: 0, totalSize: 0, channels: 0 })
1792
+ }
1793
+ })
1794
+ }
1795
+
1796
+ // 处理消息事件
1797
+ function handleMessageEvent(messageEvent: any) {
1798
+ // 更新机器人信息
1799
+ if (!chatData.value.bots[messageEvent.selfId]) {
1800
+ chatData.value.bots[messageEvent.selfId] = {
1801
+ selfId: messageEvent.selfId,
1802
+ platform: messageEvent.platform,
1803
+ username: messageEvent.bot?.name || `Bot-${messageEvent.selfId}`,
1804
+ avatar: messageEvent.bot?.avatar,
1805
+ status: 'online'
1806
+ }
1807
+ } else {
1808
+ // 更新机器人状态和信息
1809
+ const existingBot = chatData.value.bots[messageEvent.selfId]
1810
+ existingBot.status = 'online'
1811
+ if (messageEvent.bot?.name && existingBot.username !== messageEvent.bot.name) {
1812
+ existingBot.username = messageEvent.bot.name
1813
+ }
1814
+ if (messageEvent.bot?.avatar && existingBot.avatar !== messageEvent.bot.avatar) {
1815
+ existingBot.avatar = messageEvent.bot.avatar
1816
+ }
1817
+ }
1818
+
1819
+ // 更新频道信息
1820
+ if (!chatData.value.channels[messageEvent.selfId]) {
1821
+ chatData.value.channels[messageEvent.selfId] = {}
1822
+ }
1823
+
1824
+ if (messageEvent.channelId && !chatData.value.channels[messageEvent.selfId][messageEvent.channelId]) {
1825
+ const channelName = messageEvent.guildId
1826
+ ? `${messageEvent.guildName || messageEvent.guildId} (${messageEvent.channelId})`
1827
+ : `私信 ${messageEvent.channelId}`
1828
+
1829
+ chatData.value.channels[messageEvent.selfId][messageEvent.channelId] = {
1830
+ id: messageEvent.channelId,
1831
+ name: channelName,
1832
+ type: messageEvent.channelType || 0,
1833
+ guildId: messageEvent.guildId,
1834
+ guildName: messageEvent.guildName || messageEvent.guildId || '私聊'
1835
+ }
1836
+ }
1837
+
1838
+ // 添加消息
1839
+ if (messageEvent.messageId && messageEvent.content && messageEvent.channelId) {
1840
+ const channelKey = `${messageEvent.selfId}:${messageEvent.channelId}`
1841
+ if (!chatData.value.messages[channelKey]) {
1842
+ chatData.value.messages[channelKey] = []
1843
+ }
1844
+
1845
+ // 检查消息是否已存在
1846
+ const exists = chatData.value.messages[channelKey].find(m => m.id === messageEvent.messageId)
1847
+ if (!exists) {
1848
+ const message: MessageInfo = {
1849
+ id: messageEvent.messageId,
1850
+ content: messageEvent.content,
1851
+ userId: messageEvent.userId,
1852
+ username: messageEvent.username,
1853
+ avatar: messageEvent.avatar,
1854
+ timestamp: messageEvent.timestamp,
1855
+ channelId: messageEvent.channelId,
1856
+ selfId: messageEvent.selfId,
1857
+ elements: messageEvent.elements,
1858
+ isBot: false, // 接收到的消息标记为非机器人消息
1859
+ quote: messageEvent.quote
1860
+ }
1861
+
1862
+ // 按时间戳排序插入消息
1863
+ const messages = chatData.value.messages[channelKey]
1864
+ let insertIndex = messages.length
1865
+
1866
+ // 找到正确的插入位置(按时间戳排序)
1867
+ for (let i = messages.length - 1; i >= 0; i--) {
1868
+ if (messages[i].timestamp <= messageEvent.timestamp) {
1869
+ insertIndex = i + 1
1870
+ break
1871
+ }
1872
+ if (i === 0) {
1873
+ insertIndex = 0
1874
+ }
1875
+ }
1876
+
1877
+ messages.splice(insertIndex, 0, message)
1878
+
1879
+ // 保持消息数量限制
1880
+ if (messages.length > 100) {
1881
+ chatData.value.messages[channelKey] = messages.slice(-100)
1882
+ }
1883
+
1884
+ // 更新频道消息数量缓存
1885
+ channelMessageCounts.value[channelKey] = messages.length
1886
+
1887
+ // 在添加新消息前检查用户是否在底部附近
1888
+ const wasNearBottom = isNearBottom()
1889
+
1890
+ // 智能滚动:基于添加消息前的位置状态来决定是否滚动
1891
+ nextTick(() => {
1892
+ // 再次等待,确保新消息的DOM已经渲染
1893
+ setTimeout(() => {
1894
+ if (wasNearBottom) {
1895
+ scrollToBottom()
1896
+ }
1897
+ }, 10)
1898
+ })
1899
+ }
1900
+ }
1901
+
1902
+ // 异步预缓存消息中的图片
1903
+ if (messageEvent.elements && messageEvent.elements.length > 0) {
1904
+ const channelKey = `${messageEvent.selfId}:${messageEvent.channelId}`
1905
+ messageEvent.elements.forEach((element: any) => {
1906
+ if ((element.type === 'img' || element.type === 'image' || element.type === 'mface') && element.attrs) {
1907
+ const imageUrl = element.attrs.src || element.attrs.url || element.attrs.file
1908
+ if (imageUrl) {
1909
+ // 异步缓存,不阻塞消息显示
1910
+ cacheImage(channelKey, imageUrl).catch(error => {
1911
+ console.warn('预缓存图片失败:', imageUrl, error)
1912
+ })
1913
+ }
1914
+ }
1915
+ })
1916
+ }
1917
+
1918
+ // 触发响应式更新
1919
+ chatData.value = { ...chatData.value }
1920
+ }
1921
+
1922
+ // 处理机器人发送消息成功事件
1923
+ function handleBotMessageSentEvent(sentEvent: any) {
1924
+ const channelKey = `${sentEvent.selfId}:${sentEvent.channelId}`
1925
+ if (!chatData.value.messages[channelKey]) {
1926
+ chatData.value.messages[channelKey] = []
1927
+ }
1928
+
1929
+ // 检查消息是否已存在
1930
+ const exists = chatData.value.messages[channelKey].find(m => m.id === sentEvent.messageId)
1931
+ if (!exists) {
1932
+ const botMessage: MessageInfo = {
1933
+ id: sentEvent.messageId,
1934
+ content: sentEvent.content,
1935
+ userId: sentEvent.selfId,
1936
+ username: sentEvent.botUsername,
1937
+ avatar: sentEvent.botAvatar,
1938
+ timestamp: sentEvent.timestamp,
1939
+ channelId: sentEvent.channelId,
1940
+ selfId: sentEvent.selfId,
1941
+ elements: sentEvent.elements,
1942
+ isBot: true, // 标记为机器人发送的消息
1943
+ quote: sentEvent.quote
1944
+ }
1945
+
1946
+ // 按时间戳排序插入消息
1947
+ const messages = chatData.value.messages[channelKey]
1948
+ let insertIndex = messages.length
1949
+
1950
+ // 找到正确的插入位置(按时间戳排序)
1951
+ for (let i = messages.length - 1; i >= 0; i--) {
1952
+ if (messages[i].timestamp <= sentEvent.timestamp) {
1953
+ insertIndex = i + 1
1954
+ break
1955
+ }
1956
+ if (i === 0) {
1957
+ insertIndex = 0
1958
+ }
1959
+ }
1960
+
1961
+ messages.splice(insertIndex, 0, botMessage)
1962
+
1963
+ // 保持消息数量限制
1964
+ if (messages.length > 100) {
1965
+ chatData.value.messages[channelKey] = messages.slice(-100)
1966
+ }
1967
+
1968
+ // 更新频道消息数量缓存
1969
+ channelMessageCounts.value[channelKey] = messages.length
1970
+
1971
+ // 在添加新消息前检查用户是否在底部附近
1972
+ const wasNearBottom = isNearBottom()
1973
+
1974
+ // 智能滚动:基于添加消息前的位置状态来决定是否滚动
1975
+ nextTick(() => {
1976
+ // 再次等待,确保新消息的DOM已经渲染
1977
+ setTimeout(() => {
1978
+ if (wasNearBottom) {
1979
+ scrollToBottom()
1980
+ }
1981
+ }, 10)
1982
+ })
1983
+ }
1984
+
1985
+ // 触发响应式更新
1986
+ chatData.value = { ...chatData.value }
1987
+ }
1988
+
1989
+ // 监听消息变化,只在切换频道时自动滚动到底部
1990
+ watch(currentMessages, (newMessages, oldMessages) => {
1991
+ // 只有在切换频道时(消息数组完全不同)才自动滚动
1992
+ if (oldMessages.length === 0 && newMessages.length > 0) {
1993
+ nextTick(() => {
1994
+ scrollToBottom()
1995
+ })
1996
+ }
1997
+ })
1998
+
1999
+ // 获取完整聊天数据
2000
+ async function loadChatData() {
2001
+ try {
2002
+ const result = await (send as any)('get-chat-data')
2003
+
2004
+ if (result.success && result.data) {
2005
+ // 转换消息格式
2006
+ const convertedMessages: Record<string, MessageInfo[]> = {}
2007
+
2008
+ // 初始化置顶状态
2009
+ pinnedBots.value = new Set(result.data.pinnedBots || [])
2010
+ pinnedChannels.value = new Set(result.data.pinnedChannels || [])
2011
+
2012
+ for (const [channelKey, messages] of Object.entries(result.data.messages || {})) {
2013
+ const convertedChannelMessages = (messages as any[]).map((msg: any) => ({
2014
+ id: msg.id,
2015
+ content: msg.content,
2016
+ userId: msg.userId,
2017
+ username: msg.username,
2018
+ avatar: msg.avatar,
2019
+ timestamp: msg.timestamp,
2020
+ channelId: msg.channelId,
2021
+ selfId: msg.selfId,
2022
+ elements: msg.elements,
2023
+ isBot: msg.type === 'bot',
2024
+ quote: msg.quote
2025
+ }))
2026
+
2027
+ // 按时间戳排序
2028
+ convertedChannelMessages.sort((a, b) => a.timestamp - b.timestamp)
2029
+
2030
+ // 转换channelKey格式:从 "selfId-channelId" 到 "selfId:channelId"
2031
+ const [selfId, channelId] = channelKey.split('-')
2032
+ const frontendChannelKey = `${selfId}:${channelId}`
2033
+ convertedMessages[frontendChannelKey] = convertedChannelMessages
2034
+ }
2035
+
2036
+ // 更新聊天数据
2037
+ chatData.value = {
2038
+ bots: result.data.bots || {},
2039
+ channels: result.data.channels || {},
2040
+ messages: convertedMessages
2041
+ }
2042
+
2043
+ // 加载所有频道的消息数量
2044
+ await loadAllChannelMessageCounts()
2045
+
2046
+ return true
2047
+ } else {
2048
+ console.warn('获取聊天数据失败:', result.error)
2049
+ return false
2050
+ }
2051
+ } catch (error) {
2052
+ console.error('获取聊天数据时出错:', error)
2053
+ return false
2054
+ }
2055
+ }
2056
+
2057
+ // 获取所有频道的消息数量
2058
+ async function loadAllChannelMessageCounts() {
2059
+ try {
2060
+ const result = await (send as any)('get-all-channel-message-counts')
2061
+
2062
+ if (result.success && result.counts) {
2063
+ // 转换格式:从 "selfId-channelId" 到 "selfId:channelId"
2064
+ const convertedCounts: Record<string, number> = {}
2065
+ for (const [channelKey, count] of Object.entries(result.counts)) {
2066
+ const [selfId, channelId] = channelKey.split('-')
2067
+ const frontendChannelKey = `${selfId}:${channelId}`
2068
+ convertedCounts[frontendChannelKey] = count as number
2069
+ }
2070
+
2071
+ channelMessageCounts.value = convertedCounts
2072
+
2073
+ } else {
2074
+ console.warn('获取频道消息数量失败:', result.error)
2075
+ }
2076
+ } catch (error) {
2077
+ console.error('获取频道消息数量时出错:', error)
2078
+ }
2079
+ }
2080
+
2081
+ // 获取插件配置
2082
+ async function loadPluginConfig() {
2083
+ try {
2084
+ const result = await (send as any)('get-plugin-config')
2085
+
2086
+ if (result.success && result.config) {
2087
+ pluginConfig.value = result.config
2088
+ } else {
2089
+ console.warn('获取插件配置失败:', result.error)
2090
+ }
2091
+ } catch (error) {
2092
+ console.error('获取插件配置时出错:', error)
2093
+ }
2094
+ }
2095
+
2096
+ // 获取历史消息
2097
+ async function loadHistoryMessages(botId: string, channelId: string) {
2098
+ try {
2099
+
2100
+ const result = await (send as any)('get-history-messages', {
2101
+ selfId: botId,
2102
+ channelId: channelId
2103
+ })
2104
+
2105
+ if (result.success && result.messages) {
2106
+ const channelKey = `${botId}:${channelId}`
2107
+
2108
+ // 转换消息格式
2109
+ const messages: MessageInfo[] = result.messages.map((msg: any) => ({
2110
+ id: msg.id,
2111
+ content: msg.content,
2112
+ userId: msg.userId,
2113
+ username: msg.username,
2114
+ avatar: msg.avatar,
2115
+ timestamp: msg.timestamp,
2116
+ channelId: msg.channelId,
2117
+ selfId: msg.selfId,
2118
+ elements: msg.elements,
2119
+ isBot: msg.type === 'bot',
2120
+ quote: msg.quote
2121
+ }))
2122
+
2123
+ // 按时间戳排序
2124
+ messages.sort((a, b) => a.timestamp - b.timestamp)
2125
+
2126
+ // 设置历史消息
2127
+ chatData.value.messages[channelKey] = messages
2128
+
2129
+ // 更新频道消息数量缓存
2130
+ channelMessageCounts.value[channelKey] = messages.length
2131
+
2132
+ // 触发响应式更新
2133
+ chatData.value = { ...chatData.value }
2134
+
2135
+ return true
2136
+ } else {
2137
+ console.warn('获取历史消息失败:', result.error)
2138
+ return false
2139
+ }
2140
+ } catch (error) {
2141
+ console.error('获取历史消息时出错:', error)
2142
+ return false
2143
+ }
2144
+ }
2145
+
2146
+ // 手机端返回按钮处理
2147
+ // 滑动手势处理
2148
+ function handleTouchStart(event: TouchEvent) {
2149
+ if (!isMobile.value || event.touches.length !== 1) return
2150
+
2151
+ const touch = event.touches[0]
2152
+ touchStart.value = {
2153
+ x: touch.clientX,
2154
+ y: touch.clientY,
2155
+ time: Date.now()
2156
+ }
2157
+ touchCurrent.value = { x: touch.clientX, y: touch.clientY }
2158
+ isSwipeActive.value = false
2159
+ }
2160
+
2161
+ function handleTouchMove(event: TouchEvent) {
2162
+ if (!isMobile.value || !touchStart.value || event.touches.length !== 1) return
2163
+
2164
+ const touch = event.touches[0]
2165
+ touchCurrent.value = { x: touch.clientX, y: touch.clientY }
2166
+
2167
+ const deltaX = touch.clientX - touchStart.value.x
2168
+ const deltaY = touch.clientY - touchStart.value.y
2169
+
2170
+ // 检查是否是水平滑动(水平距离大于垂直距离)
2171
+ if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30) {
2172
+ // 检查滑动方向和当前视图状态
2173
+ const isRightSwipe = deltaX > 0
2174
+ const canGoBack = (mobileView.value === 'messages' || mobileView.value === 'channels')
2175
+
2176
+ if (isRightSwipe && canGoBack) {
2177
+ isSwipeActive.value = true
2178
+
2179
+ // 显示滑动指示器
2180
+ const swipeDistance = Math.min(deltaX, 150)
2181
+ const threshold = 100
2182
+
2183
+ if (swipeDistance > threshold) {
2184
+ swipeIndicator.value = { show: true, text: '松开返回' }
2185
+ } else {
2186
+ swipeIndicator.value = { show: true, text: `滑动返回 ${Math.round(swipeDistance / threshold * 100)}%` }
2187
+ }
2188
+
2189
+ // 阻止默认滚动行为
2190
+ event.preventDefault()
2191
+ } else {
2192
+ swipeIndicator.value = { show: false, text: '' }
2193
+ }
2194
+ } else {
2195
+ swipeIndicator.value = { show: false, text: '' }
2196
+ }
2197
+ }
2198
+
2199
+ function handleTouchEnd(event: TouchEvent) {
2200
+ if (!isMobile.value || !touchStart.value) return
2201
+
2202
+ const endTime = Date.now()
2203
+ const duration = endTime - touchStart.value.time
2204
+
2205
+ if (touchCurrent.value) {
2206
+ const deltaX = touchCurrent.value.x - touchStart.value.x
2207
+ const deltaY = touchCurrent.value.y - touchStart.value.y
2208
+
2209
+ // 检查是否满足返回条件 (水平滑动)
2210
+ const isRightSwipe = deltaX > 100 // 滑动距离超过100px
2211
+ const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) // 水平滑动
2212
+ const isFastHorizontalSwipe = duration < 300 && deltaX > 50 // 快速水平滑动
2213
+
2214
+ if ((isRightSwipe && isHorizontal) || isFastHorizontalSwipe) {
2215
+ performSwipeBack()
2216
+ }
2217
+ }
2218
+
2219
+ // 重置状态
2220
+ touchStart.value = null
2221
+ touchCurrent.value = null
2222
+ isSwipeActive.value = false
2223
+ swipeIndicator.value = { show: false, text: '' }
2224
+ }
2225
+
2226
+ function performSwipeBack() {
2227
+ switch (mobileView.value) {
2228
+ case 'messages':
2229
+ mobileView.value = 'channels'
2230
+ break
2231
+ case 'channels':
2232
+ mobileView.value = 'bots'
2233
+ selectedBot.value = ''
2234
+ selectedChannel.value = ''
2235
+ break
2236
+ default:
2237
+ // 在机器人列表页面,不做任何操作
2238
+ break
2239
+ }
2240
+ }
2241
+
2242
+ // 检测是否为手机端
2243
+ function checkMobile() {
2244
+ isMobile.value = window.innerWidth <= 768
2245
+ }
2246
+
2247
+ // 生命周期
2248
+ onMounted(async () => {
2249
+ // 检测手机端
2250
+ checkMobile()
2251
+ window.addEventListener('resize', checkMobile)
2252
+
2253
+ // 初始化IndexedDB
2254
+ const dbInitialized = await initImageDB()
2255
+ if (!dbInitialized) {
2256
+ console.warn('IndexedDB初始化失败,图片缓存功能将不可用')
2257
+ }
2258
+
2259
+ // 首先加载插件配置
2260
+ await loadPluginConfig()
2261
+
2262
+ // 然后加载历史数据
2263
+ await loadChatData()
2264
+
2265
+ // 然后开始监听消息事件
2266
+ const dispose1 = receive('chat-message-event', handleMessageEvent) as (() => void) | undefined
2267
+ const dispose2 = receive('bot-message-sent-event', handleBotMessageSentEvent) as (() => void) | undefined
2268
+
2269
+ // 添加滚动监听
2270
+ watch(selectedChannel, (newChannelId) => {
2271
+ if (newChannelId) {
2272
+ nextTick(() => {
2273
+ if (messageHistory.value) {
2274
+ // 移除旧的监听器,防止重复添加
2275
+ messageHistory.value.removeEventListener('scroll', checkScrollPosition);
2276
+ messageHistory.value.addEventListener('scroll', checkScrollPosition);
2277
+ checkScrollPosition();
2278
+ }
2279
+ if (!isMobile.value && messageInput.value) { // 只有在非手机端才自动聚焦输入框
2280
+ messageInput.value.focus();
2281
+ }
2282
+ });
2283
+ }
2284
+ }, { immediate: true }); // immediate: true 确保在组件挂载时也执行一次
2285
+
2286
+ // 在组件卸载时清理监听器
2287
+ onUnmounted(() => {
2288
+ window.removeEventListener('resize', checkMobile)
2289
+
2290
+ if (dispose1 && typeof dispose1 === 'function') {
2291
+ dispose1()
2292
+ }
2293
+ if (dispose2 && typeof dispose2 === 'function') {
2294
+ dispose2()
2295
+ }
2296
+
2297
+ // 确保在卸载时移除监听器
2298
+ if (messageHistory.value) {
2299
+ messageHistory.value.removeEventListener('scroll', checkScrollPosition)
2300
+ }
2301
+
2302
+ // 清理内存中的blob URL
2303
+ Object.values(imageBlobUrls.value).forEach(blobUrl => {
2304
+ URL.revokeObjectURL(blobUrl)
2305
+ })
2306
+ imageBlobUrls.value = {}
2307
+
2308
+ // 关闭IndexedDB连接
2309
+ if (imageDB) {
2310
+ imageDB.close()
2311
+ imageDB = null
2312
+ }
2313
+ })
2314
+ })
2315
+ </script>