koishi-plugin-chat-patch 1.0.12 → 1.0.14

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,3156 @@
1
+
2
+ import { ref, computed, onMounted, onUnmounted, nextTick, watch, defineComponent, h } from 'vue'
3
+ import { useContext, receive, send } from '@koishijs/client'
4
+
5
+ export function useChatLogic() {
6
+
7
+ interface SendMessageResponse {
8
+ success: boolean
9
+ messageId?: string
10
+ error?: string
11
+ tempImageIds?: string[]
12
+ }
13
+
14
+ interface BotInfo {
15
+ selfId: string
16
+ platform: string
17
+ username: string
18
+ avatar?: string
19
+ status: 'online' | 'offline'
20
+ }
21
+
22
+ interface ChannelInfo {
23
+ id: string
24
+ name: string
25
+ type: number | string
26
+ guildId?: string
27
+ guildName?: string
28
+ }
29
+
30
+ interface MessageElement {
31
+ type: string
32
+ attrs: Record<string, any>
33
+ children: MessageElement[]
34
+ }
35
+
36
+ interface QuoteInfo {
37
+ messageId: string
38
+ id: string
39
+ content: string
40
+ elements?: MessageElement[]
41
+ user: {
42
+ id: string
43
+ name: string
44
+ userId: string
45
+ avatar?: string
46
+ username: string
47
+ }
48
+ timestamp: number
49
+ }
50
+
51
+ interface MessageInfo {
52
+ id: string
53
+ content: string
54
+ userId: string
55
+ username: string
56
+ avatar?: string
57
+ timestamp: number
58
+ channelId: string
59
+ selfId: string
60
+ elements?: MessageElement[]
61
+ isBot?: boolean
62
+ quote?: QuoteInfo
63
+ }
64
+
65
+ interface ChatData {
66
+ bots: Record<string, BotInfo>
67
+ channels: Record<string, Record<string, ChannelInfo>>
68
+ messages: Record<string, MessageInfo[]>
69
+ }
70
+
71
+ // 检查是否为文件 URL
72
+ function isFileUrl(url: string): boolean {
73
+ try {
74
+ const parsedUrl = new URL(url)
75
+ return parsedUrl.protocol === 'file:'
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ // 头像组件
82
+ const AvatarComponent = defineComponent({
83
+ props: {
84
+ src: { type: String, required: true },
85
+ alt: { type: String, default: '头像' },
86
+ channelKey: { type: String, required: true }
87
+ },
88
+ setup(props) {
89
+ const imageState = ref<'loading' | 'loaded' | 'error' | 'caching'>('loading')
90
+ const imageSrc = ref(props.src)
91
+ const errorMessage = ref('')
92
+
93
+ const loadImage = async () => {
94
+ try {
95
+ imageState.value = 'loading'
96
+
97
+ // 首先检查缓存
98
+ const cachedUrl = await getCachedImageUrl(props.channelKey, props.src)
99
+ if (cachedUrl) {
100
+ imageSrc.value = cachedUrl
101
+ imageState.value = 'loaded'
102
+ return
103
+ }
104
+
105
+ // 尝试直接加载原图
106
+ const testImg = new Image()
107
+ testImg.crossOrigin = 'anonymous'
108
+ testImg.referrerPolicy = 'no-referrer'
109
+ testImg.draggable = false
110
+
111
+ const loadPromise = new Promise<void>((resolve, reject) => {
112
+ testImg.onload = () => resolve()
113
+ testImg.onerror = () => reject(new Error('Direct load failed'))
114
+ testImg.src = props.src
115
+ })
116
+
117
+ const timeoutPromise = new Promise<void>((_, reject) => {
118
+ setTimeout(() => reject(new Error('Timeout')), 3000)
119
+ })
120
+
121
+ try {
122
+ await Promise.race([loadPromise, timeoutPromise])
123
+ // 直接加载成功,但仍然缓存图片以备后用
124
+ imageSrc.value = props.src
125
+ imageState.value = 'loaded'
126
+
127
+ // 异步缓存图片,不阻塞显示
128
+ cacheImage(props.channelKey, props.src).catch(error => {
129
+ console.warn('异步缓存头像失败:', error)
130
+ })
131
+ } catch {
132
+ // 直接加载失败,使用缓存系统
133
+ await loadWithCache()
134
+ }
135
+ } catch (error) {
136
+ console.error('头像加载失败:', error)
137
+ imageState.value = 'error'
138
+ errorMessage.value = '头像加载失败'
139
+ }
140
+ }
141
+
142
+ const loadWithCache = async () => {
143
+ try {
144
+ imageState.value = 'caching'
145
+
146
+ const cachedUrl = await cacheImage(props.channelKey, props.src)
147
+
148
+ if (cachedUrl) {
149
+ imageSrc.value = cachedUrl
150
+ imageState.value = 'loaded'
151
+ } else {
152
+ throw new Error('缓存系统加载失败')
153
+ }
154
+ } catch (error: any) {
155
+ console.error('缓存系统加载头像失败:', error)
156
+ imageState.value = 'error'
157
+ errorMessage.value = error?.message || '缓存加载失败'
158
+ }
159
+ }
160
+
161
+ // 组件挂载时开始加载图片
162
+ onMounted(() => {
163
+ loadImage()
164
+ })
165
+
166
+ return () => {
167
+ switch (imageState.value) {
168
+ case 'loading':
169
+ case 'caching':
170
+ return h('div', { class: 'avatar-placeholder' }, props.alt.charAt(0).toUpperCase())
171
+
172
+ case 'loaded':
173
+ return h('img', {
174
+ src: imageSrc.value,
175
+ alt: props.alt,
176
+ draggable: false,
177
+ style: {
178
+ width: '100%',
179
+ height: '100%',
180
+ 'object-fit': 'cover'
181
+ }
182
+ })
183
+
184
+ case 'error':
185
+ return h('div', { class: 'avatar-placeholder' }, props.alt.charAt(0).toUpperCase())
186
+
187
+ default:
188
+ return h('div', { class: 'avatar-placeholder' }, props.alt.charAt(0).toUpperCase())
189
+ }
190
+ }
191
+ }
192
+ })
193
+
194
+ // 图片组件
195
+ const ImageComponent = defineComponent({
196
+ props: {
197
+ src: { type: String, required: true },
198
+ alt: { type: String, default: '图片' },
199
+ filename: { type: String, default: '' },
200
+ channelKey: { type: String, required: true }
201
+ },
202
+ setup(props) {
203
+ const imageState = ref<'loading' | 'loaded' | 'error' | 'caching'>('loading')
204
+ const imageSrc = ref(props.src)
205
+ const errorMessage = ref('')
206
+ const imgRef = ref<HTMLImageElement | null>(null)
207
+
208
+ const loadImage = async () => {
209
+ try {
210
+ imageState.value = 'loading'
211
+
212
+ // 检查缓存
213
+ const cachedUrl = await getCachedImageUrl(props.channelKey, props.src)
214
+ if (cachedUrl) {
215
+ imageSrc.value = cachedUrl
216
+ imageState.value = 'loaded'
217
+ return
218
+ }
219
+
220
+ // 检查是否是本地文件路径,如果是则直接使用代理请求
221
+ if (isFileUrl(props.src)) {
222
+ console.log('ImageComponent: 检测到本地文件,使用代理请求:', props.src)
223
+ await loadWithCache()
224
+ return
225
+ }
226
+
227
+ // 尝试直接加载原图
228
+ const testImg = new Image()
229
+ testImg.crossOrigin = 'anonymous'
230
+ testImg.referrerPolicy = 'no-referrer'
231
+ testImg.draggable = false
232
+
233
+ const loadPromise = new Promise<void>((resolve, reject) => {
234
+ testImg.onload = () => resolve()
235
+ testImg.onerror = () => reject(new Error('Direct load failed'))
236
+ testImg.src = props.src
237
+ })
238
+
239
+ const timeoutPromise = new Promise<void>((_, reject) => {
240
+ setTimeout(() => reject(new Error('Timeout')), 3000)
241
+ })
242
+
243
+ try {
244
+ await Promise.race([loadPromise, timeoutPromise])
245
+ // 直接加载成功,但仍然缓存图片以备后用
246
+ imageSrc.value = props.src
247
+ imageState.value = 'loaded'
248
+
249
+ // 异步缓存图片,不阻塞显示
250
+ cacheImage(props.channelKey, props.src).catch(error => {
251
+ console.warn('异步缓存图片失败:', error)
252
+ })
253
+ } catch {
254
+ // 直接加载失败,使用缓存系统
255
+ await loadWithCache()
256
+ }
257
+ } catch (error) {
258
+ console.error('图片加载失败:', error)
259
+ imageState.value = 'error'
260
+ errorMessage.value = '图片加载失败'
261
+ }
262
+ }
263
+
264
+ const loadWithCache = async () => {
265
+ try {
266
+ imageState.value = 'caching'
267
+
268
+ const cachedUrl = await cacheImage(props.channelKey, props.src)
269
+
270
+ if (cachedUrl) {
271
+ imageSrc.value = cachedUrl
272
+ imageState.value = 'loaded'
273
+ } else {
274
+ throw new Error('缓存系统加载失败')
275
+ }
276
+ } catch (error: any) {
277
+ console.error('缓存系统加载图片失败:', error)
278
+ imageState.value = 'error'
279
+ errorMessage.value = error?.message || '缓存加载失败'
280
+ }
281
+ }
282
+
283
+ // 组件挂载时开始加载图片
284
+ onMounted(() => {
285
+ loadImage()
286
+ })
287
+ return () => {
288
+ switch (imageState.value) {
289
+ case 'loading':
290
+ return h('div', { class: 'message-image-loading' }, '加载中...')
291
+
292
+ case 'caching':
293
+ return h('div', { class: 'message-image-loading' }, '[图片加载缓存中...]')
294
+
295
+ case 'loaded':
296
+ return h('img', {
297
+ src: imageSrc.value,
298
+ alt: props.alt,
299
+ class: 'message-image',
300
+ loading: 'lazy',
301
+ ref: imgRef,
302
+ draggable: false,
303
+ style: {
304
+ 'max-width': 'min(400px, 66.67vw)',
305
+ 'max-height': '200px',
306
+ 'width': 'auto',
307
+ 'height': 'auto',
308
+ 'object-fit': 'contain'
309
+ }
310
+ })
311
+
312
+ case 'error':
313
+ return h('div', { class: 'message-image-error' }, [
314
+ '图片加载失败',
315
+ h('br'),
316
+ h('small', props.filename || props.alt || '未知图片'),
317
+ h('br'),
318
+ h('small', { style: 'color: #ff9800;' }, errorMessage.value)
319
+ ])
320
+
321
+ default:
322
+ return h('div', { class: 'message-image-error' }, '未知状态')
323
+ }
324
+ }
325
+ }
326
+ })
327
+
328
+ // JSON卡片组件
329
+ const JsonCardComponent = defineComponent({
330
+ props: {
331
+ data: { type: String, required: true },
332
+ channelKey: { type: String, required: true }
333
+ },
334
+ setup(props) {
335
+ const parseJsonData = () => {
336
+ try {
337
+ const jsonData = JSON.parse(props.data)
338
+
339
+ // 检查是否是QQ小程序或类似的分享卡片
340
+ if (jsonData.meta && jsonData.meta.detail_1) {
341
+ const detail = jsonData.meta.detail_1
342
+ return {
343
+ type: 'share_card',
344
+ title: detail.title || jsonData.prompt || '分享内容',
345
+ desc: detail.desc || '',
346
+ preview: detail.preview ? detail.preview.replace(/\\\//g, '/') : '',
347
+ icon: detail.icon ? detail.icon.replace(/\\\//g, '/') : '',
348
+ url: detail.qqdocurl ? detail.qqdocurl.replace(/\\\//g, '/') : (detail.url ? detail.url.replace(/\\\//g, '/') : ''),
349
+ appName: detail.title || '应用'
350
+ }
351
+ }
352
+
353
+ // 其他类型的JSON数据
354
+ return {
355
+ type: 'raw',
356
+ data: jsonData
357
+ }
358
+ } catch (error) {
359
+ console.error('解析JSON数据失败:', error)
360
+ return {
361
+ type: 'error',
362
+ error: '无法解析的JSON数据'
363
+ }
364
+ }
365
+ }
366
+
367
+ const cardData = parseJsonData()
368
+
369
+ const handleCardClick = () => {
370
+ if (cardData.type === 'share_card' && cardData.url) {
371
+ window.open(cardData.url, '_blank', 'noopener,noreferrer')
372
+ }
373
+ }
374
+
375
+ return () => {
376
+ if (cardData.type === 'share_card' && cardData.preview) {
377
+ // 返回一个带跳转链接的图片
378
+ return h('img', {
379
+ src: cardData.preview,
380
+ alt: cardData.title || '[分享小程序]',
381
+ class: 'message-image',
382
+ loading: 'lazy',
383
+ draggable: false,
384
+ onClick: handleCardClick,
385
+ style: {
386
+ 'max-width': '400px',
387
+ 'max-height': '200px',
388
+ 'width': 'auto',
389
+ 'height': 'auto',
390
+ 'object-fit': 'contain',
391
+ cursor: cardData.url ? 'pointer' : 'default'
392
+ },
393
+ title: cardData.url ? `点击打开: ${cardData.title || '链接'}` : cardData.title,
394
+ onError: (e: Event) => {
395
+ // 图片加载失败时隐藏图片容器
396
+ const target = e.target as HTMLElement
397
+ const container = target.parentElement
398
+ if (container) {
399
+ container.style.display = 'none'
400
+ }
401
+ }
402
+ })
403
+ } else if (cardData.type === 'error') {
404
+ return h('div', { class: 'message-json-error' }, [
405
+ h('span', { class: 'json-error-text' }, cardData.error),
406
+ h('details', { class: 'json-raw-data' }, [
407
+ h('summary', '查看原始数据'),
408
+ h('pre', { class: 'json-raw-content' }, props.data)
409
+ ])
410
+ ])
411
+ } else {
412
+ // 原始JSON数据显示
413
+ return h('div', { class: 'message-json-raw' }, [
414
+ h('div', { class: 'json-label' }, '[JSON数据]'),
415
+ h('details', { class: 'json-raw-data' }, [
416
+ h('summary', '查看详情'),
417
+ h('pre', { class: 'json-raw-content' }, JSON.stringify(cardData.data, null, 2))
418
+ ])
419
+ ])
420
+ }
421
+ }
422
+ }
423
+ })
424
+
425
+ // 合并转发消息组件
426
+ const ForwardMessageComponent = defineComponent({
427
+ props: {
428
+ element: {
429
+ type: Object as () => MessageElement,
430
+ required: true
431
+ },
432
+ channelKey: {
433
+ type: String,
434
+ required: true
435
+ }
436
+ },
437
+ setup(props) {
438
+ const isExpanded = ref(false)
439
+
440
+ const toggleExpanded = () => {
441
+ isExpanded.value = !isExpanded.value
442
+ }
443
+
444
+ const getPreviewText = () => {
445
+ if (!props.element.children || props.element.children.length === 0) {
446
+ return {
447
+ previews: [],
448
+ messageCount: 0
449
+ }
450
+ }
451
+
452
+ const messages = props.element.children.filter((child: any) => child.type === 'message')
453
+ const messageCount = messages.length
454
+
455
+ // 生成预览文本
456
+ const previews = messages.slice(0, 3).map((msg: any) => {
457
+ const nickname = msg.attrs?.nickname || '用户'
458
+ let content = ''
459
+
460
+ if (msg.children && msg.children.length > 0) {
461
+ const firstChild = msg.children[0]
462
+ if (firstChild.type === 'text') {
463
+ content = (firstChild.attrs?.content || '').substring(0, 20)
464
+ if (content.length > 15) content += '...'
465
+ } else if (firstChild.type === 'img') {
466
+ content = '[图片]'
467
+ } else if (firstChild.type === 'video') {
468
+ content = '[视频]'
469
+ } else {
470
+ content = `[${firstChild.type}]`
471
+ }
472
+ }
473
+
474
+ return `${nickname}:${content}`
475
+ })
476
+
477
+ return {
478
+ previews,
479
+ messageCount
480
+ }
481
+ }
482
+
483
+ const renderForwardedMessage = (message: any, index: number) => {
484
+ const nickname = message.attrs?.nickname || '用户'
485
+ const userId = message.attrs?.userId || 'unknown'
486
+
487
+ return h('div', {
488
+ key: index,
489
+ class: 'forwarded-message-item'
490
+ }, [
491
+ h('div', { class: 'forwarded-message-header' }, [
492
+ h('span', { class: 'forwarded-message-nickname' }, nickname),
493
+ h('span', { class: 'forwarded-message-userid' }, `(${userId})`)
494
+ ]),
495
+ h('div', { class: 'forwarded-message-content' },
496
+ message.children?.map((child: any, childIndex: number) =>
497
+ h(MessageElement, {
498
+ key: childIndex,
499
+ element: child,
500
+ channelKey: props.channelKey
501
+ })
502
+ ) || []
503
+ )
504
+ ])
505
+ }
506
+
507
+ return () => {
508
+ const { previews, messageCount } = getPreviewText()
509
+
510
+ return h('div', { class: 'forward-message-container' }, [
511
+ h('div', {
512
+ class: 'forward-message-preview',
513
+ onClick: toggleExpanded
514
+ }, [
515
+ h('div', { class: 'forward-message-title' }, '聊天记录'),
516
+ ...previews.map((preview, index) =>
517
+ h('div', { key: index, class: 'forward-message-preview-item' }, preview)
518
+ ),
519
+ h('div', { class: 'forward-message-footer' }, [
520
+ h('span', { class: 'forward-message-count' }, `查看${messageCount}条转发消息`),
521
+ h('span', { class: 'forward-message-toggle' }, isExpanded.value ? '▲' : '▼')
522
+ ])
523
+ ]),
524
+ isExpanded.value && h('div', { class: 'forward-message-expanded' },
525
+ props.element.children
526
+ ?.filter((child: any) => child.type === 'message')
527
+ .map((message: any, index: number) => renderForwardedMessage(message, index)) || []
528
+ )
529
+ ])
530
+ }
531
+ }
532
+ })
533
+
534
+ const MessageElement = defineComponent({
535
+ props: {
536
+ element: {
537
+ type: Object as () => MessageElement,
538
+ required: true
539
+ },
540
+ channelKey: {
541
+ type: String,
542
+ required: true
543
+ }
544
+ },
545
+ setup(props) {
546
+ const renderElement = (element: MessageElement) => {
547
+ switch (element.type) {
548
+ case 'text':
549
+ return h('span', { class: 'message-text-content' }, element.attrs.content || '')
550
+
551
+ case 'forward':
552
+ return h('span', { class: 'message-text-content' }, `[转发消息 ${element.attrs.id}]` || '[转发消息]')
553
+
554
+ case 'img':
555
+ case 'image':
556
+ const imageUrl = element.attrs.src || element.attrs.url || element.attrs.file
557
+ return h('div', { class: 'message-image-container' }, [
558
+ h(ImageComponent, {
559
+ src: imageUrl,
560
+ alt: element.attrs.summary || '图片',
561
+ filename: element.attrs.filename || element.attrs.summary || '',
562
+ channelKey: props.channelKey
563
+ })
564
+ ])
565
+
566
+ case 'mface':
567
+ const mfaceimageUrl = element.attrs.src || element.attrs.url || element.attrs.file
568
+ return h('div', { class: 'message-image-container' }, [
569
+ h(ImageComponent, {
570
+ src: mfaceimageUrl,
571
+ alt: element.attrs.summary || '表情',
572
+ filename: element.attrs.emojiId || element.attrs.summary || '',
573
+ channelKey: props.channelKey
574
+ })
575
+ ])
576
+
577
+ case 'face':
578
+ if (element.children[0]?.attrs?.src) {
579
+ const faceimageUrl = element.children[0]?.attrs?.src || element.children[0]?.attrs?.url
580
+ return h('div', { class: 'message-image-container' }, [
581
+ h(ImageComponent, {
582
+ src: faceimageUrl,
583
+ alt: element.attrs.name || element.attrs.id || '[表情]',
584
+ filename: element.attrs.name || element.attrs.id || '[表情]',
585
+ channelKey: props.channelKey
586
+ })
587
+ ])
588
+ } else {
589
+ return h('span', { class: 'message-text-content' }, `[${element.attrs.name || element.attrs.id}]` || '[表情]')
590
+ }
591
+
592
+ case 'at':
593
+ return h('span', {
594
+ class: 'message-at',
595
+ title: element.attrs.name
596
+ }, `${element.attrs.name || element.attrs.id}`)
597
+
598
+ case 'json':
599
+ return h('div', { class: 'message-image-container' }, [
600
+ h(JsonCardComponent, {
601
+ data: element.attrs.data || '',
602
+ channelKey: props.channelKey
603
+ })
604
+ ])
605
+
606
+ case 'p':
607
+ // 处理段落元素,递归渲染子元素
608
+ if (element.children && element.children.length > 0) {
609
+ const childElements = element.children.map((child, index) =>
610
+ h(MessageElement, {
611
+ key: index,
612
+ element: child,
613
+ channelKey: props.channelKey
614
+ })
615
+ )
616
+ return h('div', { class: 'message-paragraph' }, childElements)
617
+ } else {
618
+ return h('div', { class: 'message-paragraph' }, '')
619
+ }
620
+ case 'figure':
621
+ // 处理合并转发消息,创建可折叠的消息组件
622
+ return h(ForwardMessageComponent, {
623
+ element: element,
624
+ channelKey: props.channelKey
625
+ })
626
+ default:
627
+ // 未知类型
628
+ return h('span', {
629
+ class: 'message-unknown',
630
+ title: `未知消息类型: ${element.type}`
631
+ }, element.attrs.content || `[${element.type}]`)
632
+
633
+ }
634
+ }
635
+
636
+ return () => renderElement(props.element)
637
+ }
638
+ })
639
+
640
+ const chatData = ref<ChatData>({
641
+ bots: {},
642
+ channels: {},
643
+ messages: {}
644
+ })
645
+
646
+ // 存储每个频道的真实消息数量
647
+ const channelMessageCounts = ref<Record<string, number>>({})
648
+
649
+ const pluginConfig = ref<{
650
+ maxMessagesPerChannel: number
651
+ keepMessagesOnClear: number
652
+ loggerinfo: boolean
653
+ blockedPlatforms: Array<{
654
+ platformName: string
655
+ exactMatch: boolean
656
+ }>
657
+ chatContainerHeight: number
658
+ }>({
659
+ maxMessagesPerChannel: 1000,
660
+ keepMessagesOnClear: 50,
661
+ loggerinfo: false,
662
+ blockedPlatforms: [],
663
+ chatContainerHeight: 80
664
+ })
665
+
666
+ // 图片缓存 - IndexedDB
667
+ interface ImageCacheItem {
668
+ url: string
669
+ blob: Blob
670
+ timestamp: number
671
+ size: number
672
+ channelKey: string
673
+ }
674
+
675
+ // 内存中的URL缓存
676
+ const imageBlobUrls = ref<Record<string, string>>({})
677
+
678
+ // 内存管理配置
679
+ const MAX_MEMORY_USAGE = 100 * 1024 * 1024 // 100MB 最大内存使用量
680
+ const MAX_BLOB_COUNT = 50 // 最大blob URL数量
681
+ let currentMemoryUsage = 0 // 当前内存使用量估算
682
+
683
+ // IndexedDB 配置和限制
684
+ let imageDB: IDBDatabase | null = null
685
+ const DB_NAME = 'ChatImageCache'
686
+ const DB_VERSION = 2 // 版本号
687
+ const STORE_NAME = 'images'
688
+
689
+ // 存储限制
690
+ const MAX_DB_SIZE = 50 * 1024 * 1024 // 50MB 最大数据库大小
691
+ const MAX_IMAGES_PER_CHANNEL = 100 // 每个频道最多缓存100张图片
692
+ const MAX_TOTAL_IMAGES = 500 // 总共最多缓存500张图片
693
+ const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 单张图片最大2MB
694
+ const CLEANUP_THRESHOLD = 0.8 // 当达到80%限制时开始清理
695
+ const DB_HEALTH_CHECK_INTERVAL = 60 * 1000 // 每分钟检查一次数据库健康状态
696
+
697
+ // 数据库状态跟踪
698
+ let currentDbSize = 0
699
+ let currentImageCount = 0
700
+ let lastHealthCheck = 0
701
+
702
+ const selectedBot = ref<string>('')
703
+ const selectedChannel = ref<string>('')
704
+ const inputMessage = ref<string>('')
705
+
706
+ // 图片上传相关状态
707
+ const uploadedImages = ref<Array<{
708
+ tempId: string
709
+ filename: string
710
+ preview: string
711
+ size: number
712
+ }>>([])
713
+
714
+ const showActionMenu = ref<boolean>(false)
715
+ const fileInput = ref<HTMLInputElement>()
716
+
717
+ // 手机端状态管理
718
+ const isMobile = ref<boolean>(false)
719
+ const mobileView = ref<'bots' | 'channels' | 'messages'>('bots')
720
+
721
+ // 滑动手势状态
722
+ const touchStart = ref<{ x: number, y: number, time: number } | null>(null)
723
+ const touchCurrent = ref<{ x: number, y: number } | null>(null)
724
+ const isSwipeActive = ref<boolean>(false)
725
+ const swipeIndicator = ref<{ show: boolean, text: string }>({ show: false, text: '' })
726
+ const messageHistory = ref<HTMLElement>()
727
+ const messageInput = ref<HTMLInputElement>()
728
+ const showScrollButton = ref<boolean>(false)
729
+ const isUserScrolling = ref<boolean>(false)
730
+ const isSending = ref<boolean>(false)
731
+
732
+ // 拖拽相关状态
733
+ const draggingChannel = ref<string>('')
734
+ const dragStartPos = ref<{ x: number, y: number }>({ x: 0, y: 0 })
735
+ const dragCurrentPos = ref<{ x: number, y: number }>({ x: 0, y: 0 })
736
+ const dragElementInitialPos = ref<{ x: number, y: number }>({ x: 0, y: 0 })
737
+ const dragOffset = ref<{ x: number, y: number }>({ x: 0, y: 0 })
738
+ const dragThreshold = 80 // 拖拽阈值,超过这个距离就清理历史记录
739
+ const dragStartTime = ref<number>(0)
740
+ const dragDelayTimer = ref<number | null>(null)
741
+ const isDragReady = ref<boolean>(false)
742
+ const draggedBubbleElement = ref<HTMLElement | null>(null)
743
+
744
+ // 右键菜单相关状态
745
+ const contextMenu = ref<{
746
+ show: boolean
747
+ x: number
748
+ y: number
749
+ type: 'bot' | 'channel' | null
750
+ targetId: string
751
+ isSecondClick: boolean
752
+ }>({
753
+ show: false,
754
+ x: 0,
755
+ y: 0,
756
+ type: null,
757
+ targetId: '',
758
+ isSecondClick: false
759
+ })
760
+
761
+ // 置顶状态管理
762
+ const pinnedBots = ref<Set<string>>(new Set())
763
+ const pinnedChannels = ref<Set<string>>(new Set())
764
+
765
+ const bots = computed(() => {
766
+ const botList = Object.values(chatData.value.bots)
767
+ // 按置顶状态排序,置顶的在前面
768
+ return botList.sort((a, b) => {
769
+ const aPinned = pinnedBots.value.has(a.selfId)
770
+ const bPinned = pinnedBots.value.has(b.selfId)
771
+ if (aPinned && !bPinned) return -1
772
+ if (!aPinned && bPinned) return 1
773
+ return 0
774
+ })
775
+ })
776
+
777
+ const currentChannels = computed(() => {
778
+ if (!selectedBot.value || !chatData.value.channels[selectedBot.value]) {
779
+ return []
780
+ }
781
+ const channelList = Object.values(chatData.value.channels[selectedBot.value])
782
+ // 按置顶状态排序,置顶的在前面
783
+ return channelList.sort((a, b) => {
784
+ const aPinned = pinnedChannels.value.has(`${selectedBot.value}:${a.id}`)
785
+ const bPinned = pinnedChannels.value.has(`${selectedBot.value}:${b.id}`)
786
+ if (aPinned && !bPinned) return -1
787
+ if (!aPinned && bPinned) return 1
788
+ return 0
789
+ })
790
+ })
791
+
792
+ const currentMessages = computed(() => {
793
+ if (!selectedBot.value || !selectedChannel.value) {
794
+ return []
795
+ }
796
+ const channelKey = `${selectedBot.value}:${selectedChannel.value}`
797
+ const messages = chatData.value.messages[channelKey] || []
798
+
799
+ const messagesWithQuote = messages.filter(m => m.quote)
800
+ return messages
801
+ })
802
+
803
+ const currentChannelName = computed(() => {
804
+ if (!selectedBot.value || !selectedChannel.value) {
805
+ return ''
806
+ }
807
+ const channels = chatData.value.channels[selectedBot.value]
808
+ return channels?.[selectedChannel.value]?.name || ''
809
+ })
810
+
811
+ const currentChannelKey = computed(() => {
812
+ if (!selectedBot.value || !selectedChannel.value) {
813
+ return ''
814
+ }
815
+ return `${selectedBot.value}:${selectedChannel.value}`
816
+ })
817
+
818
+ const canSendMessage = computed(() => {
819
+ return selectedBot.value && selectedChannel.value && (inputMessage.value.trim() || uploadedImages.value.length > 0) && !isSending.value
820
+ })
821
+
822
+ const canInputMessage = computed(() => {
823
+ return selectedBot.value && selectedChannel.value && !isSending.value
824
+ })
825
+
826
+ // 手机端视图状态计算属性
827
+ const mobileViewClass = computed(() => {
828
+ if (!isMobile.value) return ''
829
+
830
+ switch (mobileView.value) {
831
+ case 'channels':
832
+ return 'show-channels'
833
+ case 'messages':
834
+ return 'show-messages'
835
+ default:
836
+ return ''
837
+ }
838
+ })
839
+
840
+ // 输入框提示文字
841
+ const inputPlaceholder = computed(() => {
842
+ if (isMobile.value) {
843
+ return '输入消息...(屏幕左滑返回)'
844
+ } else {
845
+ return '输入消息...'
846
+ }
847
+ })
848
+
849
+ // 容器样式,但主要由CSS处理
850
+ const chatContainerStyle = computed(() => {
851
+ // 移除复杂的高度计算,让CSS的dvh单位自动处理
852
+ return {};
853
+ });
854
+
855
+ // 内存管理函数
856
+ function estimateBlobSize(blob: Blob): number {
857
+ return blob.size || 0
858
+ }
859
+
860
+ function updateMemoryUsage(sizeChange: number) {
861
+ currentMemoryUsage += sizeChange
862
+ if (pluginConfig.value.loggerinfo) {
863
+ console.log(`内存使用量变化: ${sizeChange > 0 ? '+' : ''}${(sizeChange / 1024 / 1024).toFixed(2)}MB, 总计: ${(currentMemoryUsage / 1024 / 1024).toFixed(2)}MB`)
864
+ }
865
+ }
866
+
867
+ // 清理最旧的blob URL以释放内存
868
+ function cleanupOldestBlobs(targetCount: number = 10) {
869
+ const blobEntries = Object.entries(imageBlobUrls.value)
870
+ if (blobEntries.length <= targetCount) return
871
+
872
+ // 简单的LRU策略:清理最早创建的blob
873
+ const toRemove = blobEntries.slice(0, blobEntries.length - targetCount)
874
+
875
+ let freedMemory = 0
876
+ toRemove.forEach(([url, blobUrl]) => {
877
+ URL.revokeObjectURL(blobUrl)
878
+ delete imageBlobUrls.value[url]
879
+ freedMemory += 500 * 1024 // 估算每个图片500KB
880
+ if (pluginConfig.value.loggerinfo) {
881
+ console.log('清理旧blob URL:', url)
882
+ }
883
+ })
884
+
885
+ updateMemoryUsage(-freedMemory)
886
+ }
887
+
888
+ // 检查内存使用情况并清理
889
+ function checkAndCleanupMemory() {
890
+ const blobCount = Object.keys(imageBlobUrls.value).length
891
+
892
+ // 如果blob数量过多,清理一些
893
+ if (blobCount > MAX_BLOB_COUNT) {
894
+ cleanupOldestBlobs(Math.floor(MAX_BLOB_COUNT * 0.7)) // 清理到70%
895
+ }
896
+
897
+ // 如果估算内存使用过高,也进行清理
898
+ if (currentMemoryUsage > MAX_MEMORY_USAGE) {
899
+ cleanupOldestBlobs(Math.floor(MAX_BLOB_COUNT * 0.5)) // 清理到50%
900
+ }
901
+ }
902
+
903
+ function selectBot(botId: string) {
904
+ selectedBot.value = botId
905
+ selectedChannel.value = ''
906
+
907
+ // 手机端:选择机器人后切换到频道视图
908
+ if (isMobile.value) {
909
+ mobileView.value = 'channels'
910
+ }
911
+ }
912
+
913
+ // 右键菜单相关方法
914
+ function handleBotRightClick(event: MouseEvent, botId: string) {
915
+ event.preventDefault()
916
+ event.stopPropagation()
917
+
918
+ // 检查是否是第二次右键点击同一个目标
919
+ const isSecondClick = contextMenu.value.show &&
920
+ contextMenu.value.type === 'bot' &&
921
+ contextMenu.value.targetId === botId
922
+
923
+ if (isSecondClick) {
924
+ // 第二次右键,隐藏自定义菜单,让浏览器显示原生菜单
925
+ hideContextMenu()
926
+ return
927
+ }
928
+ showContextMenu(event, 'bot', botId)
929
+ }
930
+
931
+ function handleChannelRightClick(event: MouseEvent, channelId: string) {
932
+ event.preventDefault()
933
+ event.stopPropagation()
934
+
935
+ // 检查是否是第二次右键点击同一个目标
936
+ const isSecondClick = contextMenu.value.show &&
937
+ contextMenu.value.type === 'channel' &&
938
+ contextMenu.value.targetId === channelId
939
+
940
+ if (isSecondClick) {
941
+ // 第二次右键,隐藏自定义菜单,让浏览器显示原生菜单
942
+ hideContextMenu()
943
+ return
944
+ }
945
+ showContextMenu(event, 'channel', channelId)
946
+ }
947
+
948
+ function showContextMenu(event: MouseEvent, type: 'bot' | 'channel', targetId: string) {
949
+ // 确保菜单不会超出屏幕边界
950
+ const menuWidth = 180
951
+ const menuHeight = 80
952
+ let x = event.clientX
953
+ let y = event.clientY
954
+
955
+ if (x + menuWidth > window.innerWidth) {
956
+ x = window.innerWidth - menuWidth - 10
957
+ }
958
+ if (y + menuHeight > window.innerHeight) {
959
+ y = window.innerHeight - menuHeight - 10
960
+ }
961
+
962
+ contextMenu.value = {
963
+ show: true,
964
+ x,
965
+ y,
966
+ type,
967
+ targetId,
968
+ isSecondClick: false
969
+ }
970
+
971
+ // 添加全局事件监听器来隐藏菜单
972
+ document.addEventListener('click', hideContextMenu, { once: true })
973
+ document.addEventListener('keydown', handleKeyDown)
974
+ }
975
+
976
+ function hideContextMenu() {
977
+ contextMenu.value.show = false
978
+ document.removeEventListener('click', hideContextMenu)
979
+ document.removeEventListener('keydown', handleKeyDown)
980
+ }
981
+
982
+ // 处理键盘事件
983
+ function handleKeyDown(event: KeyboardEvent) {
984
+ if (event.key === 'Escape' && contextMenu.value.show) {
985
+ hideContextMenu()
986
+ }
987
+ }
988
+
989
+ // 置顶相关方法
990
+ async function toggleBotPin(botId: string) {
991
+ if (pinnedBots.value.has(botId)) {
992
+ pinnedBots.value.delete(botId)
993
+ } else {
994
+ pinnedBots.value.add(botId)
995
+ }
996
+ // 持久化置顶状态到后端
997
+ await (send as any)('set-pinned-bots', { pinnedBots: Array.from(pinnedBots.value) })
998
+ hideContextMenu()
999
+ }
1000
+
1001
+ async function toggleChannelPin(channelId: string) {
1002
+ const channelKey = `${selectedBot.value}:${channelId}`
1003
+ if (pinnedChannels.value.has(channelKey)) {
1004
+ pinnedChannels.value.delete(channelKey)
1005
+ } else {
1006
+ pinnedChannels.value.add(channelKey)
1007
+ }
1008
+ // 持久化置顶状态到后端
1009
+ await (send as any)('set-pinned-channels', { pinnedChannels: Array.from(pinnedChannels.value) })
1010
+ hideContextMenu()
1011
+ }
1012
+
1013
+ // 删除消息相关方法
1014
+ async function deleteBotMessages(botId: string) {
1015
+ try {
1016
+ // 调用后端API删除机器人数据
1017
+ const result = await (send as any)('delete-bot-data', {
1018
+ selfId: botId
1019
+ })
1020
+
1021
+ if (result.success) {
1022
+ // 前端同步删除数据
1023
+ const channelsToDelete = Object.keys(chatData.value.messages).filter(key => key.startsWith(`${botId}:`))
1024
+
1025
+ for (const channelKey of channelsToDelete) {
1026
+ delete chatData.value.messages[channelKey]
1027
+ delete channelMessageCounts.value[channelKey]
1028
+ // 清理图片缓存
1029
+ await clearChannelImageCache(channelKey)
1030
+ }
1031
+
1032
+ // 删除机器人信息
1033
+ delete chatData.value.bots[botId]
1034
+
1035
+ // 删除频道信息
1036
+ delete chatData.value.channels[botId]
1037
+
1038
+ // 如果当前选中的是被删除的机器人,清空选择
1039
+ if (selectedBot.value === botId) {
1040
+ selectedBot.value = ''
1041
+ selectedChannel.value = ''
1042
+ }
1043
+
1044
+ showNotification(result.message || '已删除该机器人的所有数据', 'success')
1045
+ } else {
1046
+ throw new Error(result.error || '删除失败')
1047
+ }
1048
+ } catch (error: any) {
1049
+ console.error('删除机器人数据失败:', error)
1050
+ showNotification('删除失败: ' + (error?.message || String(error)), 'error')
1051
+ }
1052
+ hideContextMenu()
1053
+ }
1054
+
1055
+ async function deleteChannelMessages(channelId: string) {
1056
+ try {
1057
+ const result = await (send as any)('delete-channel-data', {
1058
+ selfId: selectedBot.value,
1059
+ channelId: channelId
1060
+ })
1061
+
1062
+ if (result.success) {
1063
+ const channelKey = `${selectedBot.value}:${channelId}`
1064
+
1065
+ // 前端同步删除数据
1066
+ delete chatData.value.messages[channelKey]
1067
+ delete channelMessageCounts.value[channelKey]
1068
+
1069
+ // 删除频道信息
1070
+ if (chatData.value.channels[selectedBot.value]) {
1071
+ delete chatData.value.channels[selectedBot.value][channelId]
1072
+ }
1073
+
1074
+ // 清理图片缓存
1075
+ await clearChannelImageCache(channelKey)
1076
+
1077
+ // 如果当前选中的是被删除的频道,清空选择
1078
+ if (selectedChannel.value === channelId) {
1079
+ selectedChannel.value = ''
1080
+ }
1081
+
1082
+ showNotification(result.message || '已删除该频道的所有数据', 'success')
1083
+ } else {
1084
+ throw new Error(result.error || '删除失败')
1085
+ }
1086
+ } catch (error: any) {
1087
+ console.error('删除频道数据失败:', error)
1088
+ showNotification('删除失败: ' + (error?.message || String(error)), 'error')
1089
+ }
1090
+ hideContextMenu()
1091
+ }
1092
+
1093
+ async function selectChannel(channelId: string) {
1094
+ selectedChannel.value = channelId
1095
+ isUserScrolling.value = false
1096
+
1097
+ // 手机端:选择频道后切换到消息视图
1098
+ if (isMobile.value) {
1099
+ mobileView.value = 'messages'
1100
+ }
1101
+
1102
+ // 先获取历史消息,然后再滚动到底部
1103
+ if (selectedBot.value) {
1104
+ await loadHistoryMessages(selectedBot.value, channelId)
1105
+ }
1106
+
1107
+ nextTick(() => {
1108
+ scrollToBottom()
1109
+ // 只有在非手机端才自动聚焦输入框
1110
+ if (!isMobile.value && messageInput.value) {
1111
+ messageInput.value.focus()
1112
+ }
1113
+ })
1114
+ }
1115
+
1116
+ async function sendMessage() {
1117
+ if (!canSendMessage.value) return
1118
+
1119
+ const messageContent = inputMessage.value.trim()
1120
+ if (!messageContent && uploadedImages.value.length === 0) return
1121
+
1122
+ // 设置发送状态
1123
+ isSending.value = true
1124
+
1125
+ // 保存当前的图片信息,用于后续清理
1126
+ const currentImages = [...uploadedImages.value]
1127
+
1128
+ try {
1129
+ // 调用后端 API 发送消息
1130
+ const result = await (send as any)('send-message', {
1131
+ selfId: selectedBot.value,
1132
+ channelId: selectedChannel.value,
1133
+ content: messageContent,
1134
+ images: currentImages.map(img => ({
1135
+ tempId: img.tempId,
1136
+ filename: img.filename
1137
+ }))
1138
+ }) as SendMessageResponse & { tempImageIds?: string[] }
1139
+
1140
+ if (result.success) {
1141
+ // 清空输入框和图片预览
1142
+ inputMessage.value = ''
1143
+
1144
+ // 释放blob URL
1145
+ currentImages.forEach(img => {
1146
+ URL.revokeObjectURL(img.preview)
1147
+ })
1148
+ uploadedImages.value = []
1149
+ showActionMenu.value = false
1150
+
1151
+ // 消息发送成功后,主动通知后端清理临时文件
1152
+ if (result.tempImageIds && result.tempImageIds.length > 0) {
1153
+ try {
1154
+ await (send as any)('cleanup-temp-images', {
1155
+ tempImageIds: result.tempImageIds
1156
+ })
1157
+ console.log('临时图片清理完成:', result.tempImageIds)
1158
+ } catch (cleanupError) {
1159
+ console.warn('清理临时图片失败:', cleanupError)
1160
+ }
1161
+ }
1162
+ } else {
1163
+ console.error('消息发送失败:', result.error)
1164
+ // 使用showNotification显示错误提示
1165
+ showNotification('发送失败: ' + result.error, 'error')
1166
+ }
1167
+ } catch (error: any) {
1168
+ console.error('发送消息时出错:', error)
1169
+ showNotification('发送失败: ' + (error?.message || String(error)), 'error')
1170
+ } finally {
1171
+ // 重置发送状态
1172
+ isSending.value = false
1173
+ }
1174
+ }
1175
+
1176
+ // 图片上传相关方法
1177
+ function toggleActionMenu() {
1178
+ showActionMenu.value = !showActionMenu.value
1179
+ }
1180
+
1181
+ function triggerImageUpload() {
1182
+ if (fileInput.value) {
1183
+ fileInput.value.click()
1184
+ }
1185
+ showActionMenu.value = false
1186
+ }
1187
+
1188
+ async function handleFileSelect(event: Event) {
1189
+ const target = event.target as HTMLInputElement
1190
+ const files = target.files
1191
+ if (!files || files.length === 0) return
1192
+
1193
+ for (const file of Array.from(files)) {
1194
+ await uploadImage(file)
1195
+ }
1196
+
1197
+ // 清空文件输入,允许重复选择同一文件
1198
+ target.value = ''
1199
+ }
1200
+
1201
+ async function handlePaste(event: ClipboardEvent) {
1202
+ const items = event.clipboardData?.items
1203
+ if (!items) return
1204
+
1205
+ for (const item of Array.from(items)) {
1206
+ if (item.type.startsWith('image/')) {
1207
+ event.preventDefault()
1208
+ const file = item.getAsFile()
1209
+ if (file) {
1210
+ await uploadImage(file)
1211
+ }
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ async function uploadImage(file: File) {
1217
+ try {
1218
+ // 检查文件大小 (限制为10MB)
1219
+ if (file.size > 10 * 1024 * 1024) {
1220
+ showNotification('图片文件过大,请选择小于10MB的图片', 'error')
1221
+ return
1222
+ }
1223
+
1224
+ // 检查文件类型
1225
+ if (!file.type.startsWith('image/')) {
1226
+ showNotification('请选择图片文件', 'error')
1227
+ return
1228
+ }
1229
+
1230
+ // 转换为base64
1231
+ const base64 = await fileToBase64(file)
1232
+
1233
+ // 创建预览URL
1234
+ const preview = URL.createObjectURL(file)
1235
+
1236
+ // 调用后端API上传图片
1237
+ const result = await (send as any)('upload-image', {
1238
+ file: base64,
1239
+ filename: file.name,
1240
+ mimeType: file.type
1241
+ })
1242
+
1243
+ if (result.success) {
1244
+ uploadedImages.value.push({
1245
+ tempId: result.tempId,
1246
+ filename: file.name,
1247
+ preview: preview,
1248
+ size: file.size
1249
+ })
1250
+ } else {
1251
+ URL.revokeObjectURL(preview)
1252
+ showNotification('图片上传失败: ' + result.error, 'error')
1253
+ }
1254
+ } catch (error: any) {
1255
+ console.error('上传图片失败:', error)
1256
+ showNotification('图片上传失败: ' + (error?.message || String(error)), 'error')
1257
+ }
1258
+ }
1259
+
1260
+ async function removeImage(tempId: string) {
1261
+ try {
1262
+ // 从列表中移除
1263
+ const imageIndex = uploadedImages.value.findIndex(img => img.tempId === tempId)
1264
+ if (imageIndex !== -1) {
1265
+ const image = uploadedImages.value[imageIndex]
1266
+ // 释放预览URL
1267
+ URL.revokeObjectURL(image.preview)
1268
+ uploadedImages.value.splice(imageIndex, 1)
1269
+ }
1270
+
1271
+ // 调用后端API删除临时文件
1272
+ await (send as any)('delete-temp-image', { tempId })
1273
+ } catch (error: any) {
1274
+ console.error('删除图片失败:', error)
1275
+ }
1276
+ }
1277
+
1278
+ function fileToBase64(file: File): Promise<string> {
1279
+ return new Promise((resolve, reject) => {
1280
+ const reader = new FileReader()
1281
+ reader.onload = () => resolve(reader.result as string)
1282
+ reader.onerror = reject
1283
+ reader.readAsDataURL(file)
1284
+ })
1285
+ }
1286
+
1287
+ // 点击外部关闭菜单
1288
+ function handleClickOutside(event: Event) {
1289
+ const target = event.target as HTMLElement
1290
+ if (!target.closest('.input-actions')) {
1291
+ showActionMenu.value = false
1292
+ }
1293
+ if (!target.closest('.context-menu')) {
1294
+ hideContextMenu()
1295
+ }
1296
+ }
1297
+
1298
+ function formatTime(timestamp: number): string {
1299
+ const date = new Date(timestamp)
1300
+ return date.toLocaleTimeString('zh-CN', {
1301
+ hour: '2-digit',
1302
+ minute: '2-digit'
1303
+ })
1304
+ }
1305
+
1306
+ function getChannelTypeText(type: number | string): string {
1307
+ if (typeof type === 'number') {
1308
+ switch (type) {
1309
+ case 0: return '文本'
1310
+ case 1: return '私聊'
1311
+ default: return '未知'
1312
+ }
1313
+ }
1314
+ return String(type)
1315
+ }
1316
+
1317
+ function scrollToBottom() {
1318
+ if (messageHistory.value) {
1319
+ messageHistory.value.scrollTop = messageHistory.value.scrollHeight
1320
+ showScrollButton.value = false
1321
+ isUserScrolling.value = false
1322
+ }
1323
+ }
1324
+
1325
+ function checkScrollPosition() {
1326
+ if (messageHistory.value) {
1327
+ const { scrollTop, scrollHeight, clientHeight } = messageHistory.value
1328
+ const distanceFromBottom = scrollHeight - (scrollTop + clientHeight)
1329
+ const isAtBottom = distanceFromBottom <= 50
1330
+
1331
+ const shouldShowButton = !isAtBottom
1332
+
1333
+ showScrollButton.value = shouldShowButton
1334
+
1335
+ // 检测是否在主动滚动(向上滚动查看历史消息)
1336
+ if (!isAtBottom) {
1337
+ isUserScrolling.value = true
1338
+ } else {
1339
+ // 滚动到底部时,重置滚动状态
1340
+ isUserScrolling.value = false
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ function isNearBottom(): boolean {
1346
+ if (!messageHistory.value) return true // 如果没有消息容器,默认应该滚动
1347
+ const { scrollTop, scrollHeight, clientHeight } = messageHistory.value
1348
+ const distanceFromBottom = scrollHeight - (scrollTop + clientHeight)
1349
+ const isNear = distanceFromBottom <= 200 // 距离底部200px内认为是在底部附近
1350
+
1351
+ return isNear
1352
+ }
1353
+
1354
+ function getChannelMessageCount(channelId: string): number {
1355
+ if (!selectedBot.value) return 0
1356
+ const channelKey = `${selectedBot.value}:${channelId}`
1357
+
1358
+ // 优先使用缓存的消息数量信息
1359
+ const cachedCount = channelMessageCounts.value[channelKey]
1360
+ if (cachedCount !== undefined) {
1361
+ return cachedCount
1362
+ }
1363
+ // 如果没有缓存,使用当前加载的消息数量作为备用
1364
+ return chatData.value.messages[channelKey]?.length || 0
1365
+ }
1366
+
1367
+ // 拖拽相关方法
1368
+ function startDrag(event: MouseEvent | TouchEvent, channelId: string) {
1369
+ event.preventDefault()
1370
+ event.stopPropagation()
1371
+
1372
+ const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
1373
+ const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
1374
+
1375
+ // 记录开始时间和位置
1376
+ dragStartTime.value = Date.now()
1377
+ dragStartPos.value = { x: clientX, y: clientY }
1378
+ dragCurrentPos.value = { x: clientX, y: clientY }
1379
+ isDragReady.value = false
1380
+
1381
+ // 获取元素的初始位置
1382
+ const element = event.target as HTMLElement
1383
+ const rect = element.getBoundingClientRect()
1384
+ dragElementInitialPos.value = { x: rect.left, y: rect.top }
1385
+ dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top } // 计算触摸点相对于元素左上角的偏移
1386
+
1387
+ // 设置60ms延迟
1388
+ dragDelayTimer.value = window.setTimeout(() => {
1389
+ if (dragStartTime.value > 0) { // 确保还在按住状态
1390
+ isDragReady.value = true
1391
+ draggingChannel.value = channelId
1392
+
1393
+ // 获取原始元素
1394
+ const originalElement = event.target as HTMLElement
1395
+ // 克隆元素
1396
+ const clonedElement = originalElement.cloneNode(true) as HTMLElement
1397
+ clonedElement.classList.add('dragging-clone') // 添加一个类以便样式控制
1398
+ clonedElement.style.position = 'fixed'
1399
+ clonedElement.style.zIndex = '1000'
1400
+ clonedElement.style.pointerEvents = 'none' // 克隆体不响应事件
1401
+
1402
+ // 设置克隆体的初始位置
1403
+ const rect = originalElement.getBoundingClientRect()
1404
+ clonedElement.style.left = `${rect.left}px`
1405
+ clonedElement.style.top = `${rect.top}px`
1406
+ clonedElement.style.width = `${rect.width}px`
1407
+ clonedElement.style.height = `${rect.height}px`
1408
+
1409
+ document.body.appendChild(clonedElement)
1410
+ draggedBubbleElement.value = clonedElement
1411
+
1412
+ // 添加全局拖拽样式
1413
+ document.body.style.userSelect = 'none'
1414
+ document.body.style.cursor = 'grabbing'
1415
+ document.body.classList.add('dragging-bubble-global') // 全局拖拽样式
1416
+
1417
+ // 创建阈值圆圈(固定在原始位置)
1418
+ createThresholdCircle(dragStartPos.value.x, dragStartPos.value.y)
1419
+ }
1420
+ }, 60)
1421
+
1422
+ // 添加全局事件监听器
1423
+ document.addEventListener('mousemove', handleDragMove)
1424
+ document.addEventListener('mouseup', handleDragEnd)
1425
+ document.addEventListener('touchmove', handleDragMove)
1426
+ document.addEventListener('touchend', handleDragEnd)
1427
+ }
1428
+
1429
+ function handleDragMove(event: MouseEvent | TouchEvent) {
1430
+ if (!isDragReady.value || !draggedBubbleElement.value) return
1431
+
1432
+ event.preventDefault()
1433
+
1434
+ const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
1435
+ const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
1436
+
1437
+ dragCurrentPos.value = { x: clientX, y: clientY }
1438
+
1439
+ // 更新克隆体的位置和样式
1440
+ const deltaX = dragCurrentPos.value.x - dragStartPos.value.x
1441
+ const deltaY = dragCurrentPos.value.y - dragStartPos.value.y
1442
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
1443
+
1444
+ const opacity = Math.max(0.3, 1 - distance / (dragThreshold * 2))
1445
+ const scale = Math.max(0.8, 1 - distance / (dragThreshold * 3))
1446
+ const willDelete = distance > dragThreshold
1447
+
1448
+ const finalX = dragCurrentPos.value.x - dragOffset.value.x
1449
+ const finalY = dragCurrentPos.value.y - dragOffset.value.y
1450
+
1451
+ draggedBubbleElement.value.style.left = `${finalX}px`
1452
+ draggedBubbleElement.value.style.top = `${finalY}px`
1453
+ draggedBubbleElement.value.style.transform = `scale(${scale})`
1454
+ draggedBubbleElement.value.style.opacity = `${opacity}`
1455
+ draggedBubbleElement.value.style.backgroundColor = willDelete ? '#f44336' : '#2196f3'
1456
+ draggedBubbleElement.value.style.boxShadow = willDelete ? '0 4px 12px rgba(244, 67, 54, 0.4)' : '0 4px 12px rgba(33, 150, 243, 0.4)'
1457
+
1458
+ // 更新克隆体的类
1459
+ if (willDelete) {
1460
+ draggedBubbleElement.value.classList.add('will-delete')
1461
+ } else {
1462
+ draggedBubbleElement.value.classList.remove('will-delete')
1463
+ }
1464
+ }
1465
+
1466
+ function handleDragEnd(event: MouseEvent | TouchEvent) {
1467
+ // 清除延迟定时器
1468
+ if (dragDelayTimer.value) {
1469
+ clearTimeout(dragDelayTimer.value)
1470
+ dragDelayTimer.value = null
1471
+ }
1472
+
1473
+ // 清除延迟定时器
1474
+ if (dragDelayTimer.value) {
1475
+ clearTimeout(dragDelayTimer.value)
1476
+ dragDelayTimer.value = null
1477
+ }
1478
+
1479
+ // 如果还没有开始拖拽,直接重置
1480
+ if (!isDragReady.value || !draggingChannel.value) {
1481
+ resetDragState()
1482
+ return
1483
+ }
1484
+
1485
+ const channelId = draggingChannel.value
1486
+ const distance = Math.sqrt(
1487
+ Math.pow(dragCurrentPos.value.x - dragStartPos.value.x, 2) +
1488
+ Math.pow(dragCurrentPos.value.y - dragStartPos.value.y, 2)
1489
+ )
1490
+
1491
+ // 如果拖拽距离超过阈值,清理历史记录
1492
+ if (distance > dragThreshold) {
1493
+ clearChannelHistory(channelId)
1494
+ // 立即重置状态
1495
+ resetDragState()
1496
+ } else {
1497
+ // 距离不够,添加回弹动画到克隆体
1498
+ if (draggedBubbleElement.value) {
1499
+ draggedBubbleElement.value.style.transition = 'all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55)'
1500
+ // 回弹到原始位置
1501
+ const originalElement = document.querySelector(`[data-channel-id="${channelId}"] .channel-message-count`)
1502
+ if (originalElement) {
1503
+ const rect = originalElement.getBoundingClientRect()
1504
+ draggedBubbleElement.value.style.left = `${rect.left}px`
1505
+ draggedBubbleElement.value.style.top = `${rect.top}px`
1506
+ draggedBubbleElement.value.style.transform = 'scale(1)'
1507
+ draggedBubbleElement.value.style.opacity = '1'
1508
+ draggedBubbleElement.value.style.backgroundColor = '#2196f3'
1509
+ draggedBubbleElement.value.style.boxShadow = '0 4px 12px rgba(33, 150, 243, 0.4)'
1510
+ }
1511
+ setTimeout(() => {
1512
+ resetDragState()
1513
+ }, 300)
1514
+ } else {
1515
+ resetDragState()
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ function resetDragState() {
1521
+ // 清除延迟定时器
1522
+ if (dragDelayTimer.value) {
1523
+ clearTimeout(dragDelayTimer.value)
1524
+ dragDelayTimer.value = null
1525
+ }
1526
+
1527
+ // 重置拖拽状态
1528
+ draggingChannel.value = ''
1529
+ dragStartPos.value = { x: 0, y: 0 }
1530
+ dragCurrentPos.value = { x: 0, y: 0 }
1531
+ dragElementInitialPos.value = { x: 0, y: 0 }
1532
+ dragStartTime.value = 0
1533
+ isDragReady.value = false
1534
+
1535
+ // 移除全局事件监听器
1536
+ document.removeEventListener('mousemove', handleDragMove)
1537
+ document.removeEventListener('mouseup', handleDragEnd)
1538
+ document.removeEventListener('touchmove', handleDragMove)
1539
+ document.removeEventListener('touchend', handleDragEnd)
1540
+
1541
+ // 恢复样式
1542
+ document.body.style.userSelect = ''
1543
+ document.body.style.cursor = ''
1544
+ document.body.classList.remove('dragging-bubble-global') // 移除全局拖拽样式
1545
+
1546
+ // 移除克隆体
1547
+ if (draggedBubbleElement.value && draggedBubbleElement.value.parentNode) {
1548
+ draggedBubbleElement.value.parentNode.removeChild(draggedBubbleElement.value)
1549
+ draggedBubbleElement.value = null
1550
+ }
1551
+
1552
+ // 移除阈值圆圈
1553
+ removeThresholdCircle()
1554
+ }
1555
+
1556
+ function getDragStyle(channelId: string) {
1557
+ if (draggingChannel.value === channelId && isDragReady.value) {
1558
+ // 当拖拽开始且准备就绪时,隐藏原始气泡
1559
+ return {
1560
+ visibility: 'hidden' as const,
1561
+ pointerEvents: 'none' as const,
1562
+ transition: 'none' as const, // 确保隐藏时没有动画
1563
+ }
1564
+ }
1565
+ return {}
1566
+ }
1567
+
1568
+ function getDragDistance(channelId: string): number {
1569
+ if (draggingChannel.value !== channelId) return 0
1570
+
1571
+ const deltaX = dragCurrentPos.value.x - dragStartPos.value.x
1572
+ const deltaY = dragCurrentPos.value.y - dragStartPos.value.y
1573
+ return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
1574
+ }
1575
+
1576
+ async function clearChannelHistory(channelId: string) {
1577
+ if (!selectedBot.value) return
1578
+
1579
+ try {
1580
+ const channelKey = `${selectedBot.value}:${channelId}`
1581
+
1582
+ // 先检查当前消息数量
1583
+ const currentCount = getChannelMessageCount(channelId)
1584
+ const keepCount = pluginConfig.value.keepMessagesOnClear
1585
+
1586
+ if (keepCount > 0 && currentCount <= keepCount) {
1587
+ showNotification('当前消息还很少诶~ 无需清理', 'success')
1588
+ return
1589
+ }
1590
+
1591
+ // 调用后端API清理历史记录
1592
+ const result = await (send as any)('clear-channel-history', {
1593
+ selfId: selectedBot.value,
1594
+ channelId: channelId
1595
+ // 不传keepCount,让后端使用配置的默认值
1596
+ })
1597
+
1598
+ if (result.success) {
1599
+ // 检查是否真的进行了清理
1600
+ if (result.clearedCount && result.clearedCount > 0) {
1601
+ // 更新本地数据
1602
+ if (chatData.value.messages[channelKey]) {
1603
+ // 保留最新的消息
1604
+ const messages = chatData.value.messages[channelKey]
1605
+ const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
1606
+ chatData.value.messages[channelKey] = sortedMessages.slice(-result.keptCount)
1607
+ }
1608
+
1609
+ // 更新消息数量缓存为实际保留的消息数量
1610
+ channelMessageCounts.value[channelKey] = result.keptCount
1611
+
1612
+ // 清理图片缓存
1613
+ await clearChannelImageCache(channelKey)
1614
+
1615
+ // 显示成功提示,显示实际清理的数量
1616
+ showNotification(`历史记录已清理,清理了 ${result.clearedCount} 条消息,保留最新 ${result.keptCount} 条`, 'success')
1617
+ } else if (keepCount === 0) {
1618
+ // 当keepCount为0时
1619
+ // 更新本地数据
1620
+ if (chatData.value.messages[channelKey]) {
1621
+ chatData.value.messages[channelKey] = []
1622
+ }
1623
+
1624
+ // 更新消息数量缓存为0
1625
+ channelMessageCounts.value[channelKey] = 0
1626
+
1627
+ // 清理图片缓存
1628
+ await clearChannelImageCache(channelKey)
1629
+
1630
+ // 显示成功提示
1631
+ showNotification('历史记录已清理,所有消息已删除', 'success')
1632
+ } else {
1633
+ showNotification('当前消息还很少诶~ 无需清理', 'success')
1634
+ }
1635
+ } else {
1636
+ console.error('清理历史记录失败:', result.error)
1637
+ showNotification('清理失败: ' + result.error, 'error')
1638
+ }
1639
+ } catch (error: any) {
1640
+ console.error('清理历史记录时出错:', error)
1641
+ showNotification('清理失败: ' + (error?.message || String(error)), 'error')
1642
+ }
1643
+ }
1644
+
1645
+ function showNotification(message: string, type: 'info' | 'warn' | 'error' | 'success' = 'success') {
1646
+ // 创建通知元素
1647
+ const notification = document.createElement('div')
1648
+ notification.className = `notification ${type}`
1649
+ notification.textContent = message
1650
+
1651
+ let backgroundColor = '#4caf50' // success - 绿色
1652
+ switch (type) {
1653
+ case 'info':
1654
+ backgroundColor = '#2196f3' // 蓝色
1655
+ break
1656
+ case 'warn':
1657
+ backgroundColor = '#ff9800' // 橙色
1658
+ break
1659
+ case 'error':
1660
+ backgroundColor = '#f44336' // 红色
1661
+ break
1662
+ case 'success':
1663
+ backgroundColor = '#4caf50' // 绿色
1664
+ break
1665
+ }
1666
+
1667
+ notification.style.cssText = `
1668
+ position: fixed;
1669
+ top: 20px;
1670
+ right: 20px;
1671
+ padding: 12px 20px;
1672
+ border-radius: 6px;
1673
+ color: white;
1674
+ font-weight: 500;
1675
+ z-index: 10000;
1676
+ animation: slideIn 0.3s ease-out;
1677
+ background: ${backgroundColor};
1678
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1679
+ `
1680
+
1681
+ // 添加动画样式
1682
+ const style = document.createElement('style')
1683
+ style.textContent = `
1684
+ @keyframes slideIn {
1685
+ from { transform: translateX(100%); opacity: 0; }
1686
+ to { transform: translateX(0); opacity: 1; }
1687
+ }
1688
+ @keyframes slideOut {
1689
+ from { transform: translateX(0); opacity: 1; }
1690
+ to { transform: translateX(100%); opacity: 0; }
1691
+ }
1692
+ `
1693
+ document.head.appendChild(style)
1694
+
1695
+ document.body.appendChild(notification)
1696
+
1697
+ // 3秒后自动移除
1698
+ setTimeout(() => {
1699
+ notification.style.animation = 'slideOut 0.3s ease-in'
1700
+ setTimeout(() => {
1701
+ if (notification.parentNode) {
1702
+ notification.parentNode.removeChild(notification)
1703
+ }
1704
+ if (style.parentNode) {
1705
+ style.parentNode.removeChild(style)
1706
+ }
1707
+ }, 300)
1708
+ }, 3000)
1709
+ }
1710
+
1711
+ function createThresholdCircle(centerX: number, centerY: number) {
1712
+ const circle = document.createElement('div')
1713
+ circle.className = 'drag-threshold-circle'
1714
+ circle.style.cssText = `
1715
+ left: ${centerX - dragThreshold}px;
1716
+ top: ${centerY - dragThreshold}px;
1717
+ width: ${dragThreshold * 2}px;
1718
+ height: ${dragThreshold * 2}px;
1719
+ `
1720
+ document.body.appendChild(circle);
1721
+
1722
+ (window as any).dragThresholdCircle = circle
1723
+ }
1724
+
1725
+ function removeThresholdCircle() {
1726
+ const circle = (window as any).dragThresholdCircle
1727
+ if (circle && circle.parentNode) {
1728
+ circle.parentNode.removeChild(circle);
1729
+ (window as any).dragThresholdCircle = null
1730
+ }
1731
+ }
1732
+
1733
+ // 数据库健康检查
1734
+ async function checkDatabaseHealth(): Promise<boolean> {
1735
+ try {
1736
+ if (!imageDB) return false
1737
+
1738
+ const now = Date.now()
1739
+ if (now - lastHealthCheck < DB_HEALTH_CHECK_INTERVAL) {
1740
+ return true // 跳过频繁检查
1741
+ }
1742
+
1743
+ lastHealthCheck = now
1744
+
1745
+ // 获取数据库统计信息
1746
+ const stats = await getDatabaseStats()
1747
+ currentDbSize = stats.totalSize
1748
+ currentImageCount = stats.totalImages
1749
+
1750
+ console.log('数据库健康检查:', {
1751
+ 大小: `${(currentDbSize / 1024 / 1024).toFixed(2)}MB / ${(MAX_DB_SIZE / 1024 / 1024).toFixed(2)}MB`,
1752
+ 图片数量: `${currentImageCount} / ${MAX_TOTAL_IMAGES}`,
1753
+ 使用率: `${(currentDbSize / MAX_DB_SIZE * 100).toFixed(1)}%`
1754
+ })
1755
+
1756
+ // 检查是否需要清理
1757
+ const sizeRatio = currentDbSize / MAX_DB_SIZE
1758
+ const countRatio = currentImageCount / MAX_TOTAL_IMAGES
1759
+
1760
+ if (sizeRatio > CLEANUP_THRESHOLD || countRatio > CLEANUP_THRESHOLD) {
1761
+ console.warn('数据库使用率过高,开始自动清理')
1762
+ await performAutomaticCleanup()
1763
+ }
1764
+
1765
+ // 检查是否超过硬限制
1766
+ if (sizeRatio > 0.95 || countRatio > 0.95) {
1767
+ console.error('数据库接近极限,执行紧急清理')
1768
+ await performEmergencyCleanup()
1769
+ }
1770
+
1771
+ return true
1772
+ } catch (error) {
1773
+ console.error('数据库健康检查失败:', error)
1774
+ return false
1775
+ }
1776
+ }
1777
+
1778
+ // 获取数据库统计信息
1779
+ async function getDatabaseStats(): Promise<{ totalSize: number, totalImages: number, channelStats: Record<string, number> }> {
1780
+ if (!imageDB) return { totalSize: 0, totalImages: 0, channelStats: {} }
1781
+
1782
+ return new Promise((resolve) => {
1783
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
1784
+ const store = transaction.objectStore(STORE_NAME)
1785
+ const request = store.getAll()
1786
+
1787
+ request.onsuccess = () => {
1788
+ const items: ImageCacheItem[] = request.result || []
1789
+ let totalSize = 0
1790
+ const channelStats: Record<string, number> = {}
1791
+
1792
+ items.forEach(item => {
1793
+ totalSize += item.size || 0
1794
+ channelStats[item.channelKey] = (channelStats[item.channelKey] || 0) + 1
1795
+ })
1796
+
1797
+ resolve({
1798
+ totalSize,
1799
+ totalImages: items.length,
1800
+ channelStats
1801
+ })
1802
+ }
1803
+
1804
+ request.onerror = () => {
1805
+ console.error('获取数据库统计失败:', request.error)
1806
+ resolve({ totalSize: 0, totalImages: 0, channelStats: {} })
1807
+ }
1808
+ })
1809
+ }
1810
+
1811
+ // 自动清理
1812
+ async function performAutomaticCleanup() {
1813
+ try {
1814
+ console.log('开始自动清理...')
1815
+
1816
+ // 获取所有图片,按时间排序
1817
+ const allImages = await getAllImagesFromDB()
1818
+ if (allImages.length === 0) return
1819
+
1820
+ // 按频道分组
1821
+ const channelGroups: Record<string, ImageCacheItem[]> = {}
1822
+ allImages.forEach(item => {
1823
+ if (!channelGroups[item.channelKey]) {
1824
+ channelGroups[item.channelKey] = []
1825
+ }
1826
+ channelGroups[item.channelKey].push(item)
1827
+ })
1828
+
1829
+ let cleanedCount = 0
1830
+ let freedSize = 0
1831
+
1832
+ // 清理每个频道超出限制的图片
1833
+ for (const [channelKey, images] of Object.entries(channelGroups)) {
1834
+ if (images.length > MAX_IMAGES_PER_CHANNEL) {
1835
+ // 按时间排序,删除最旧的
1836
+ images.sort((a, b) => a.timestamp - b.timestamp)
1837
+ const toDelete = images.slice(0, images.length - MAX_IMAGES_PER_CHANNEL)
1838
+
1839
+ for (const item of toDelete) {
1840
+ await deleteImageFromDB(item.url)
1841
+ cleanedCount++
1842
+ freedSize += item.size || 0
1843
+
1844
+ // 清理内存中的blob URL
1845
+ if (imageBlobUrls.value[item.url]) {
1846
+ URL.revokeObjectURL(imageBlobUrls.value[item.url])
1847
+ delete imageBlobUrls.value[item.url]
1848
+ }
1849
+ }
1850
+ }
1851
+ }
1852
+
1853
+ console.log(`自动清理完成: 清理了 ${cleanedCount} 张图片,释放了 ${(freedSize / 1024 / 1024).toFixed(2)}MB`)
1854
+
1855
+ // 更新统计
1856
+ currentImageCount -= cleanedCount
1857
+ currentDbSize -= freedSize
1858
+
1859
+ } catch (error) {
1860
+ console.error('自动清理失败:', error)
1861
+ }
1862
+ }
1863
+
1864
+ // 紧急清理 // 激进
1865
+ async function performEmergencyCleanup() {
1866
+ try {
1867
+ console.log('开始紧急清理...')
1868
+
1869
+ // 获取所有图片
1870
+ const allImages = await getAllImagesFromDB()
1871
+ if (allImages.length === 0) return
1872
+
1873
+ // 按时间排序,只保留最新的一部分
1874
+ allImages.sort((a, b) => b.timestamp - a.timestamp)
1875
+ const keepCount = Math.floor(MAX_TOTAL_IMAGES * 0.3) // 只保留30%
1876
+ const toDelete = allImages.slice(keepCount)
1877
+
1878
+ let cleanedCount = 0
1879
+ let freedSize = 0
1880
+
1881
+ for (const item of toDelete) {
1882
+ await deleteImageFromDB(item.url)
1883
+ cleanedCount++
1884
+ freedSize += item.size || 0
1885
+
1886
+ // 清理内存中的blob URL
1887
+ if (imageBlobUrls.value[item.url]) {
1888
+ URL.revokeObjectURL(imageBlobUrls.value[item.url])
1889
+ delete imageBlobUrls.value[item.url]
1890
+ }
1891
+ }
1892
+
1893
+ console.log(`紧急清理完成: 清理了 ${cleanedCount} 张图片,释放了 ${(freedSize / 1024 / 1024).toFixed(2)}MB`)
1894
+
1895
+ // 更新统计
1896
+ currentImageCount = keepCount
1897
+ currentDbSize -= freedSize
1898
+
1899
+ } catch (error) {
1900
+ console.error('紧急清理失败:', error)
1901
+ }
1902
+ }
1903
+
1904
+ // 获取所有图片
1905
+ async function getAllImagesFromDB(): Promise<ImageCacheItem[]> {
1906
+ if (!imageDB) return []
1907
+
1908
+ return new Promise((resolve) => {
1909
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
1910
+ const store = transaction.objectStore(STORE_NAME)
1911
+ const request = store.getAll()
1912
+
1913
+ request.onsuccess = () => {
1914
+ resolve(request.result || [])
1915
+ }
1916
+
1917
+ request.onerror = () => {
1918
+ console.error('获取所有图片失败:', request.error)
1919
+ resolve([])
1920
+ }
1921
+ })
1922
+ }
1923
+
1924
+ // 清理所有IndexedDB数据 //紧急情况使用
1925
+ async function clearAllIndexedDBData(): Promise<boolean> {
1926
+ return new Promise((resolve) => {
1927
+ try {
1928
+ // 先关闭现有连接
1929
+ if (imageDB) {
1930
+ imageDB.close()
1931
+ imageDB = null
1932
+ }
1933
+
1934
+ // 删除整个数据库
1935
+ const deleteRequest = indexedDB.deleteDatabase(DB_NAME)
1936
+
1937
+ deleteRequest.onsuccess = () => {
1938
+ console.log('IndexedDB数据库已完全清理')
1939
+ currentDbSize = 0
1940
+ currentImageCount = 0
1941
+ resolve(true)
1942
+ }
1943
+
1944
+ deleteRequest.onerror = () => {
1945
+ console.error('清理IndexedDB数据库失败:', deleteRequest.error)
1946
+ resolve(false)
1947
+ }
1948
+
1949
+ deleteRequest.onblocked = () => {
1950
+ console.warn('IndexedDB数据库删除被阻塞,可能有其他连接正在使用')
1951
+ // 等待一段时间后重试
1952
+ setTimeout(() => {
1953
+ resolve(false)
1954
+ }, 5000)
1955
+ }
1956
+ } catch (error) {
1957
+ console.error('清理数据库时出错:', error)
1958
+ resolve(false)
1959
+ }
1960
+ })
1961
+ }
1962
+
1963
+ // IndexedDB初始化
1964
+ async function initImageDB(): Promise<boolean> {
1965
+ try {
1966
+ // 首先尝试打开数据库
1967
+ const success = await openDatabase()
1968
+ if (!success) {
1969
+ console.warn('数据库打开失败,尝试清理后重新初始化')
1970
+ await clearAllIndexedDBData()
1971
+ return await openDatabase()
1972
+ }
1973
+
1974
+ // 数据库打开成功,进行初始健康检查
1975
+ setTimeout(async () => {
1976
+ const stats = await getDatabaseStats()
1977
+ console.log('数据库初始状态:', {
1978
+ 大小: `${(stats.totalSize / 1024 / 1024).toFixed(2)}MB`,
1979
+ 图片数量: stats.totalImages,
1980
+ 频道分布: stats.channelStats
1981
+ })
1982
+
1983
+ // 如果初始状态就超过限制,执行清理
1984
+ if (stats.totalSize > MAX_DB_SIZE * 0.9 || stats.totalImages > MAX_TOTAL_IMAGES * 0.9) {
1985
+ console.warn('数据库初始状态接近限制,执行清理')
1986
+ await performAutomaticCleanup()
1987
+ }
1988
+ }, 1000)
1989
+
1990
+ return true
1991
+ } catch (error) {
1992
+ console.error('IndexedDB初始化出错:', error)
1993
+ return false
1994
+ }
1995
+ }
1996
+
1997
+ // 打开数据库的内部函数
1998
+ async function openDatabase(): Promise<boolean> {
1999
+ return new Promise((resolve) => {
2000
+ try {
2001
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
2002
+
2003
+ request.onerror = () => {
2004
+ console.error('IndexedDB打开失败:', request.error)
2005
+ resolve(false)
2006
+ }
2007
+
2008
+ request.onsuccess = () => {
2009
+ imageDB = request.result
2010
+
2011
+ // 添加错误处理
2012
+ imageDB.onerror = (event) => {
2013
+ console.error('IndexedDB运行时错误:', event)
2014
+ }
2015
+
2016
+ // 添加版本变更处理
2017
+ imageDB.onversionchange = () => {
2018
+ console.warn('IndexedDB版本变更,关闭连接')
2019
+ imageDB?.close()
2020
+ imageDB = null
2021
+ }
2022
+
2023
+ resolve(true)
2024
+ }
2025
+
2026
+ request.onupgradeneeded = (event) => {
2027
+ const db = (event.target as IDBOpenDBRequest).result
2028
+
2029
+ // 创建对象存储
2030
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
2031
+ const store = db.createObjectStore(STORE_NAME, { keyPath: 'url' })
2032
+ store.createIndex('channelKey', 'channelKey', { unique: false })
2033
+ store.createIndex('timestamp', 'timestamp', { unique: false })
2034
+ store.createIndex('size', 'size', { unique: false })
2035
+ console.log('IndexedDB对象存储创建完成')
2036
+ }
2037
+ }
2038
+
2039
+ request.onblocked = () => {
2040
+ console.warn('IndexedDB打开被阻塞')
2041
+ resolve(false)
2042
+ }
2043
+ } catch (error) {
2044
+ console.error('打开数据库时出错:', error)
2045
+ resolve(false)
2046
+ }
2047
+ })
2048
+ }
2049
+
2050
+ // 从IndexedDB获取图片
2051
+ async function getImageFromDB(url: string): Promise<ImageCacheItem | null> {
2052
+ if (!imageDB) return null
2053
+
2054
+ return new Promise((resolve, reject) => {
2055
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
2056
+ const store = transaction.objectStore(STORE_NAME)
2057
+ const request = store.get(url)
2058
+
2059
+ request.onsuccess = () => {
2060
+ resolve(request.result || null)
2061
+ }
2062
+
2063
+ request.onerror = () => {
2064
+ console.error('从IndexedDB获取图片失败:', request.error)
2065
+ resolve(null)
2066
+ }
2067
+ })
2068
+ }
2069
+
2070
+ // 保存图片到IndexedDB
2071
+ async function saveImageToDB(item: ImageCacheItem): Promise<boolean> {
2072
+ if (!imageDB) return false
2073
+
2074
+ try {
2075
+ // 检查单张图片大小
2076
+ if (item.size > MAX_IMAGE_SIZE) {
2077
+ console.warn(`图片过大,跳过缓存: ${(item.size / 1024 / 1024).toFixed(2)}MB > ${(MAX_IMAGE_SIZE / 1024 / 1024).toFixed(2)}MB`)
2078
+ return false
2079
+ }
2080
+
2081
+ // 执行健康检查
2082
+ await checkDatabaseHealth()
2083
+
2084
+ // 检查是否会超过限制
2085
+ if (currentDbSize + item.size > MAX_DB_SIZE) {
2086
+ console.warn('添加图片会超过数据库大小限制,执行清理')
2087
+ await performAutomaticCleanup()
2088
+
2089
+ // 清理后再次检查
2090
+ if (currentDbSize + item.size > MAX_DB_SIZE) {
2091
+ console.warn('清理后仍会超过限制,跳过此图片')
2092
+ return false
2093
+ }
2094
+ }
2095
+
2096
+ if (currentImageCount >= MAX_TOTAL_IMAGES) {
2097
+ console.warn('图片数量已达上限,执行清理')
2098
+ await performAutomaticCleanup()
2099
+
2100
+ // 清理后再次检查
2101
+ if (currentImageCount >= MAX_TOTAL_IMAGES) {
2102
+ console.warn('清理后仍达上限,跳过此图片')
2103
+ return false
2104
+ }
2105
+ }
2106
+
2107
+ return new Promise((resolve) => {
2108
+ const transaction = imageDB!.transaction([STORE_NAME], 'readwrite')
2109
+ const store = transaction.objectStore(STORE_NAME)
2110
+ const request = store.put(item)
2111
+
2112
+ request.onsuccess = () => {
2113
+ // 更新统计
2114
+ currentDbSize += item.size
2115
+ currentImageCount += 1
2116
+ resolve(true)
2117
+ }
2118
+
2119
+ request.onerror = () => {
2120
+ console.error('保存图片到IndexedDB失败:', request.error)
2121
+ resolve(false)
2122
+ }
2123
+ })
2124
+ } catch (error) {
2125
+ console.error('保存图片时出错:', error)
2126
+ return false
2127
+ }
2128
+ }
2129
+
2130
+ // 从IndexedDB删除图片
2131
+ async function deleteImageFromDB(url: string): Promise<boolean> {
2132
+ if (!imageDB) return false
2133
+
2134
+ return new Promise((resolve) => {
2135
+ const transaction = imageDB!.transaction([STORE_NAME], 'readwrite')
2136
+ const store = transaction.objectStore(STORE_NAME)
2137
+ const request = store.delete(url)
2138
+
2139
+ request.onsuccess = () => {
2140
+ resolve(true)
2141
+ }
2142
+
2143
+ request.onerror = () => {
2144
+ console.error('从IndexedDB删除图片失败:', request.error)
2145
+ resolve(false)
2146
+ }
2147
+ })
2148
+ }
2149
+
2150
+ // 获取频道的所有图片
2151
+ async function getChannelImagesFromDB(channelKey: string): Promise<ImageCacheItem[]> {
2152
+ if (!imageDB) return []
2153
+
2154
+ return new Promise((resolve) => {
2155
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
2156
+ const store = transaction.objectStore(STORE_NAME)
2157
+ const index = store.index('channelKey')
2158
+ const request = index.getAll(channelKey)
2159
+
2160
+ request.onsuccess = () => {
2161
+ resolve(request.result || [])
2162
+ }
2163
+
2164
+ request.onerror = () => {
2165
+ console.error('获取频道图片失败:', request.error)
2166
+ resolve([])
2167
+ }
2168
+ })
2169
+ }
2170
+
2171
+ // 图片缓存管理函数
2172
+ async function getCachedImageUrl(channelKey: string, originalUrl: string): Promise<string | null> {
2173
+ // 首先检查内存中的blob URL
2174
+ const existingBlobUrl = imageBlobUrls.value[originalUrl]
2175
+ if (existingBlobUrl) {
2176
+ return existingBlobUrl
2177
+ }
2178
+
2179
+ // 从IndexedDB获取
2180
+ const cacheItem = await getImageFromDB(originalUrl)
2181
+ if (!cacheItem) return null
2182
+
2183
+ // 检查内存使用情况
2184
+ checkAndCleanupMemory()
2185
+
2186
+ // 创建blob URL并缓存到内存
2187
+ const blobUrl = URL.createObjectURL(cacheItem.blob)
2188
+ imageBlobUrls.value[originalUrl] = blobUrl
2189
+
2190
+ // 更新内存使用量
2191
+ updateMemoryUsage(estimateBlobSize(cacheItem.blob))
2192
+
2193
+ // 更新访问时间
2194
+ cacheItem.timestamp = Date.now()
2195
+ await saveImageToDB(cacheItem)
2196
+
2197
+ return blobUrl
2198
+ }
2199
+
2200
+ async function cacheImage(channelKey: string, originalUrl: string): Promise<string | null> {
2201
+ try {
2202
+ // 检查是否已经缓存
2203
+ const cached = await getCachedImageUrl(channelKey, originalUrl)
2204
+ if (cached) {
2205
+ return cached
2206
+ }
2207
+
2208
+ // 获取图片数据
2209
+ const result = await (send as any)('fetch-image', { url: originalUrl })
2210
+
2211
+ if (!result.success) {
2212
+ console.error('获取图片失败:', result.error)
2213
+ return null
2214
+ }
2215
+
2216
+ // 将base64转换为blob
2217
+ const base64Data = result.base64
2218
+ const contentType = result.contentType || 'image/jpeg'
2219
+
2220
+ // 解码base64
2221
+ const byteCharacters = atob(base64Data)
2222
+ const byteNumbers = new Array(byteCharacters.length)
2223
+ for (let i = 0; i < byteCharacters.length; i++) {
2224
+ byteNumbers[i] = byteCharacters.charCodeAt(i)
2225
+ }
2226
+ const byteArray = new Uint8Array(byteNumbers)
2227
+ const blob = new Blob([byteArray], { type: contentType })
2228
+
2229
+ // 检查blob大小
2230
+ if (blob.size > MAX_IMAGE_SIZE) {
2231
+ console.warn(`图片过大,跳过缓存: ${(blob.size / 1024 / 1024).toFixed(2)}MB`)
2232
+ return null
2233
+ }
2234
+
2235
+ // 创建缓存项
2236
+ const cacheItem: ImageCacheItem = {
2237
+ url: originalUrl,
2238
+ blob: blob,
2239
+ timestamp: Date.now(),
2240
+ size: blob.size,
2241
+ channelKey: channelKey
2242
+ }
2243
+
2244
+ // 保存到IndexedDB
2245
+ const saved = await saveImageToDB(cacheItem)
2246
+ if (!saved) {
2247
+ console.error('保存图片到IndexedDB失败')
2248
+ return null
2249
+ }
2250
+
2251
+ // 检查内存使用情况
2252
+ checkAndCleanupMemory()
2253
+
2254
+ // 创建blob URL并缓存到内存
2255
+ const blobUrl = URL.createObjectURL(blob)
2256
+ imageBlobUrls.value[originalUrl] = blobUrl
2257
+
2258
+ // 更新内存使用量
2259
+ updateMemoryUsage(estimateBlobSize(blob))
2260
+
2261
+ return blobUrl
2262
+
2263
+ } catch (error) {
2264
+ console.error('缓存图片失败:', error)
2265
+ return null
2266
+ }
2267
+ }
2268
+
2269
+ // 清理频道的所有图片缓存
2270
+ async function clearChannelImageCache(channelKey: string) {
2271
+ try {
2272
+ // 获取频道的所有图片
2273
+ const channelImages = await getChannelImagesFromDB(channelKey)
2274
+
2275
+ let freedMemory = 0
2276
+ // 删除IndexedDB中的数据
2277
+ for (const item of channelImages) {
2278
+ await deleteImageFromDB(item.url)
2279
+ // 清理内存中的blob URL
2280
+ if (imageBlobUrls.value[item.url]) {
2281
+ URL.revokeObjectURL(imageBlobUrls.value[item.url])
2282
+ delete imageBlobUrls.value[item.url]
2283
+ freedMemory += item.size || 0
2284
+ }
2285
+ }
2286
+
2287
+ // 更新内存使用量
2288
+ if (freedMemory > 0) {
2289
+ updateMemoryUsage(-freedMemory)
2290
+ }
2291
+ } catch (error) {
2292
+ console.error('清理频道图片缓存失败:', error)
2293
+ }
2294
+ }
2295
+
2296
+ // 获取内存使用统计
2297
+ function getMemoryStats() {
2298
+ const blobCount = Object.keys(imageBlobUrls.value).length
2299
+ return {
2300
+ blobCount,
2301
+ estimatedMemoryUsage: currentMemoryUsage,
2302
+ maxMemoryLimit: MAX_MEMORY_USAGE,
2303
+ maxBlobLimit: MAX_BLOB_COUNT,
2304
+ memoryUsagePercent: (currentMemoryUsage / MAX_MEMORY_USAGE * 100).toFixed(1),
2305
+ blobUsagePercent: (blobCount / MAX_BLOB_COUNT * 100).toFixed(1)
2306
+ }
2307
+ }
2308
+
2309
+ // 获取缓存统计信息
2310
+ async function getCacheStats() {
2311
+ if (!imageDB) return { totalImages: 0, totalSize: 0, channels: 0 }
2312
+
2313
+ return new Promise<{ totalImages: number, totalSize: number, channels: number }>((resolve) => {
2314
+ const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
2315
+ const store = transaction.objectStore(STORE_NAME)
2316
+ const request = store.getAll()
2317
+
2318
+ request.onsuccess = () => {
2319
+ const allImages = request.result || []
2320
+ const channelSet = new Set<string>()
2321
+ let totalSize = 0
2322
+
2323
+ allImages.forEach(item => {
2324
+ channelSet.add(item.channelKey)
2325
+ totalSize += item.size
2326
+ })
2327
+
2328
+ resolve({
2329
+ totalImages: allImages.length,
2330
+ totalSize: totalSize,
2331
+ channels: channelSet.size
2332
+ })
2333
+ }
2334
+
2335
+ request.onerror = () => {
2336
+ console.error('获取缓存统计失败:', request.error)
2337
+ resolve({ totalImages: 0, totalSize: 0, channels: 0 })
2338
+ }
2339
+ })
2340
+ }
2341
+
2342
+ // 处理消息事件
2343
+ function handleMessageEvent(messageEvent: any) {
2344
+ // 更新机器人信息
2345
+ if (!chatData.value.bots[messageEvent.selfId]) {
2346
+ chatData.value.bots[messageEvent.selfId] = {
2347
+ selfId: messageEvent.selfId,
2348
+ platform: messageEvent.platform,
2349
+ username: messageEvent.bot?.name || `Bot-${messageEvent.selfId}`,
2350
+ avatar: messageEvent.bot?.avatar,
2351
+ status: 'online'
2352
+ }
2353
+ } else {
2354
+ // 更新机器人状态和信息
2355
+ const existingBot = chatData.value.bots[messageEvent.selfId]
2356
+ existingBot.status = 'online'
2357
+ if (messageEvent.bot?.name && existingBot.username !== messageEvent.bot.name) {
2358
+ existingBot.username = messageEvent.bot.name
2359
+ }
2360
+ if (messageEvent.bot?.avatar && existingBot.avatar !== messageEvent.bot.avatar) {
2361
+ existingBot.avatar = messageEvent.bot.avatar
2362
+ }
2363
+ }
2364
+
2365
+ // 更新频道信息
2366
+ if (!chatData.value.channels[messageEvent.selfId]) {
2367
+ chatData.value.channels[messageEvent.selfId] = {}
2368
+ }
2369
+
2370
+ if (messageEvent.channelId && !chatData.value.channels[messageEvent.selfId][messageEvent.channelId]) {
2371
+ const channelName = messageEvent.guildId
2372
+ ? `${messageEvent.guildName || messageEvent.guildId} (${messageEvent.channelId})`
2373
+ : `私信 ${messageEvent.channelId}`
2374
+
2375
+ chatData.value.channels[messageEvent.selfId][messageEvent.channelId] = {
2376
+ id: messageEvent.channelId,
2377
+ name: channelName,
2378
+ type: messageEvent.channelType || 0,
2379
+ guildId: messageEvent.guildId,
2380
+ guildName: messageEvent.guildName || messageEvent.guildId || '私聊'
2381
+ }
2382
+ }
2383
+
2384
+ // 添加消息
2385
+ if (messageEvent.messageId && messageEvent.content && messageEvent.channelId) {
2386
+ const channelKey = `${messageEvent.selfId}:${messageEvent.channelId}`
2387
+ if (!chatData.value.messages[channelKey]) {
2388
+ chatData.value.messages[channelKey] = []
2389
+ }
2390
+
2391
+ // 检查消息是否已存在
2392
+ const exists = chatData.value.messages[channelKey].find(m => m.id === messageEvent.messageId)
2393
+ if (!exists) {
2394
+ const message: MessageInfo = {
2395
+ id: messageEvent.messageId,
2396
+ content: messageEvent.content,
2397
+ userId: messageEvent.userId,
2398
+ username: messageEvent.username,
2399
+ avatar: messageEvent.avatar,
2400
+ timestamp: messageEvent.timestamp,
2401
+ channelId: messageEvent.channelId,
2402
+ selfId: messageEvent.selfId,
2403
+ elements: messageEvent.elements,
2404
+ isBot: false, // 接收到的消息标记为非机器人消息
2405
+ quote: messageEvent.quote
2406
+ }
2407
+
2408
+ // 按时间戳排序插入消息
2409
+ const messages = chatData.value.messages[channelKey]
2410
+ let insertIndex = messages.length
2411
+
2412
+ // 找到正确的插入位置(按时间戳排序)
2413
+ for (let i = messages.length - 1; i >= 0; i--) {
2414
+ if (messages[i].timestamp <= messageEvent.timestamp) {
2415
+ insertIndex = i + 1
2416
+ break
2417
+ }
2418
+ if (i === 0) {
2419
+ insertIndex = 0
2420
+ }
2421
+ }
2422
+
2423
+ messages.splice(insertIndex, 0, message)
2424
+
2425
+ // 保持消息数量限制
2426
+ if (messages.length > 100) {
2427
+ chatData.value.messages[channelKey] = messages.slice(-100)
2428
+ }
2429
+
2430
+ // 更新频道消息数量缓存
2431
+ channelMessageCounts.value[channelKey] = messages.length
2432
+
2433
+ // 在添加新消息前检查是否在底部附近
2434
+ const wasNearBottom = isNearBottom()
2435
+
2436
+ // 基于添加消息前的位置状态来决定是否滚动
2437
+ nextTick(() => {
2438
+ // 再次等待,确保新消息的DOM已经渲染
2439
+ setTimeout(() => {
2440
+ if (wasNearBottom) {
2441
+ scrollToBottom()
2442
+ }
2443
+ }, 10)
2444
+ })
2445
+ }
2446
+ }
2447
+
2448
+ // 异步预缓存消息中的图片
2449
+ if (messageEvent.elements && messageEvent.elements.length > 0) {
2450
+ const channelKey = `${messageEvent.selfId}:${messageEvent.channelId}`
2451
+ messageEvent.elements.forEach((element: any) => {
2452
+ if ((element.type === 'img' || element.type === 'image' || element.type === 'mface') && element.attrs) {
2453
+ const imageUrl = element.attrs.src || element.attrs.url || element.attrs.file
2454
+ if (imageUrl) {
2455
+ // 异步缓存,不阻塞消息显示
2456
+ cacheImage(channelKey, imageUrl).catch(error => {
2457
+ console.warn('预缓存图片失败:', imageUrl, error)
2458
+ })
2459
+ }
2460
+ }
2461
+ })
2462
+ }
2463
+
2464
+ // 触发响应式更新
2465
+ chatData.value = { ...chatData.value }
2466
+ }
2467
+
2468
+ // 处理机器人发送消息成功事件
2469
+ function handleBotMessageSentEvent(sentEvent: any) {
2470
+ const channelKey = `${sentEvent.selfId}:${sentEvent.channelId}`
2471
+ if (!chatData.value.messages[channelKey]) {
2472
+ chatData.value.messages[channelKey] = []
2473
+ }
2474
+
2475
+ // 检查消息是否已存在
2476
+ const exists = chatData.value.messages[channelKey].find(m => m.id === sentEvent.messageId)
2477
+ if (!exists) {
2478
+ const botMessage: MessageInfo = {
2479
+ id: sentEvent.messageId,
2480
+ content: sentEvent.content,
2481
+ userId: sentEvent.selfId,
2482
+ username: sentEvent.botUsername,
2483
+ avatar: sentEvent.botAvatar,
2484
+ timestamp: sentEvent.timestamp,
2485
+ channelId: sentEvent.channelId,
2486
+ selfId: sentEvent.selfId,
2487
+ elements: sentEvent.elements,
2488
+ isBot: true, // 标记为机器人发送的消息
2489
+ quote: sentEvent.quote
2490
+ }
2491
+
2492
+ // 按时间戳排序插入消息
2493
+ const messages = chatData.value.messages[channelKey]
2494
+ let insertIndex = messages.length
2495
+
2496
+ // 找到正确的插入位置(按时间戳排序)
2497
+ for (let i = messages.length - 1; i >= 0; i--) {
2498
+ if (messages[i].timestamp <= sentEvent.timestamp) {
2499
+ insertIndex = i + 1
2500
+ break
2501
+ }
2502
+ if (i === 0) {
2503
+ insertIndex = 0
2504
+ }
2505
+ }
2506
+
2507
+ messages.splice(insertIndex, 0, botMessage)
2508
+
2509
+ // 保持消息数量限制
2510
+ if (messages.length > 100) {
2511
+ chatData.value.messages[channelKey] = messages.slice(-100)
2512
+ }
2513
+
2514
+ // 更新频道消息数量缓存
2515
+ channelMessageCounts.value[channelKey] = messages.length
2516
+
2517
+ // 在添加新消息前检查是否在底部附近
2518
+ const wasNearBottom = isNearBottom()
2519
+
2520
+ // 智能滚动:基于添加消息前的位置状态来决定是否滚动
2521
+ nextTick(() => {
2522
+ // 再次等待,确保新消息的DOM已经渲染
2523
+ setTimeout(() => {
2524
+ if (wasNearBottom) {
2525
+ scrollToBottom()
2526
+ }
2527
+ }, 10)
2528
+ })
2529
+ }
2530
+
2531
+ // 触发响应式更新
2532
+ chatData.value = { ...chatData.value }
2533
+ }
2534
+
2535
+ // 处理机器人消息事件
2536
+ function handleBotMessageEvent(botMessageEvent: any) {
2537
+ // 更新机器人信息
2538
+ if (!chatData.value.bots[botMessageEvent.selfId]) {
2539
+ chatData.value.bots[botMessageEvent.selfId] = {
2540
+ selfId: botMessageEvent.selfId,
2541
+ platform: botMessageEvent.platform,
2542
+ username: botMessageEvent.bot?.name || `Bot-${botMessageEvent.selfId}`,
2543
+ avatar: botMessageEvent.bot?.avatar,
2544
+ status: 'online'
2545
+ }
2546
+ } else {
2547
+ // 更新机器人状态和信息
2548
+ const existingBot = chatData.value.bots[botMessageEvent.selfId]
2549
+ existingBot.status = 'online'
2550
+ if (botMessageEvent.bot?.name && existingBot.username !== botMessageEvent.bot.name) {
2551
+ existingBot.username = botMessageEvent.bot.name
2552
+ }
2553
+ if (botMessageEvent.bot?.avatar && existingBot.avatar !== botMessageEvent.bot.avatar) {
2554
+ existingBot.avatar = botMessageEvent.bot.avatar
2555
+ }
2556
+ }
2557
+
2558
+ // 更新频道信息
2559
+ if (!chatData.value.channels[botMessageEvent.selfId]) {
2560
+ chatData.value.channels[botMessageEvent.selfId] = {}
2561
+ }
2562
+
2563
+ if (botMessageEvent.channelId && !chatData.value.channels[botMessageEvent.selfId][botMessageEvent.channelId]) {
2564
+ const channelName = botMessageEvent.guildId
2565
+ ? `${botMessageEvent.guildName || botMessageEvent.guildId} (${botMessageEvent.channelId})`
2566
+ : `私信 ${botMessageEvent.channelId}`
2567
+
2568
+ chatData.value.channels[botMessageEvent.selfId][botMessageEvent.channelId] = {
2569
+ id: botMessageEvent.channelId,
2570
+ name: channelName,
2571
+ type: botMessageEvent.channelType || 0,
2572
+ guildId: botMessageEvent.guildId,
2573
+ guildName: botMessageEvent.guildName || botMessageEvent.guildId || '私聊'
2574
+ }
2575
+ }
2576
+
2577
+ // 添加机器人消息
2578
+ if (botMessageEvent.messageId && botMessageEvent.content && botMessageEvent.channelId) {
2579
+ const channelKey = `${botMessageEvent.selfId}:${botMessageEvent.channelId}`
2580
+ if (!chatData.value.messages[channelKey]) {
2581
+ chatData.value.messages[channelKey] = []
2582
+ }
2583
+
2584
+ // 检查消息是否已存在
2585
+ const exists = chatData.value.messages[channelKey].find(m => m.id === botMessageEvent.messageId)
2586
+ if (!exists) {
2587
+ const message: MessageInfo = {
2588
+ id: botMessageEvent.messageId,
2589
+ content: botMessageEvent.content,
2590
+ userId: botMessageEvent.userId,
2591
+ username: botMessageEvent.username,
2592
+ avatar: botMessageEvent.avatar,
2593
+ timestamp: botMessageEvent.timestamp,
2594
+ channelId: botMessageEvent.channelId,
2595
+ selfId: botMessageEvent.selfId,
2596
+ elements: botMessageEvent.elements,
2597
+ isBot: true, // 标记为机器人消息
2598
+ quote: botMessageEvent.quote
2599
+ }
2600
+
2601
+ // 按时间戳排序插入消息
2602
+ const messages = chatData.value.messages[channelKey]
2603
+ let insertIndex = messages.length
2604
+
2605
+ // 找到正确的插入位置(按时间戳排序)
2606
+ for (let i = messages.length - 1; i >= 0; i--) {
2607
+ if (messages[i].timestamp <= botMessageEvent.timestamp) {
2608
+ insertIndex = i + 1
2609
+ break
2610
+ }
2611
+ if (i === 0) {
2612
+ insertIndex = 0
2613
+ }
2614
+ }
2615
+
2616
+ messages.splice(insertIndex, 0, message)
2617
+
2618
+ // 保持消息数量限制
2619
+ if (messages.length > 100) {
2620
+ chatData.value.messages[channelKey] = messages.slice(-100)
2621
+ }
2622
+
2623
+ // 更新频道消息数量缓存
2624
+ channelMessageCounts.value[channelKey] = messages.length
2625
+
2626
+ // 在添加新消息前检查是否在底部附近
2627
+ const wasNearBottom = isNearBottom()
2628
+
2629
+ nextTick(() => {
2630
+ // 再次等待,确保新消息的DOM已经渲染
2631
+ setTimeout(() => {
2632
+ if (wasNearBottom) {
2633
+ scrollToBottom()
2634
+ }
2635
+ }, 10)
2636
+ })
2637
+ }
2638
+ }
2639
+
2640
+ // 异步预缓存消息中的图片
2641
+ if (botMessageEvent.elements && botMessageEvent.elements.length > 0) {
2642
+ const channelKey = `${botMessageEvent.selfId}:${botMessageEvent.channelId}`
2643
+ botMessageEvent.elements.forEach((element: any) => {
2644
+ if ((element.type === 'img' || element.type === 'image' || element.type === 'mface') && element.attrs) {
2645
+ const imageUrl = element.attrs.src || element.attrs.url || element.attrs.file
2646
+ if (imageUrl) {
2647
+ // 异步缓存,不阻塞消息显示
2648
+ cacheImage(channelKey, imageUrl).catch(error => {
2649
+ console.warn('预缓存图片失败:', imageUrl, error)
2650
+ })
2651
+ }
2652
+ }
2653
+ })
2654
+ }
2655
+
2656
+ // 触发响应式更新
2657
+ chatData.value = { ...chatData.value }
2658
+ }
2659
+
2660
+ // 获取完整聊天数据
2661
+ async function loadChatData() {
2662
+ try {
2663
+ const result = await (send as any)('get-chat-data')
2664
+
2665
+ if (result.success && result.data) {
2666
+ // 转换消息格式
2667
+ const convertedMessages: Record<string, MessageInfo[]> = {}
2668
+
2669
+ // 初始化置顶状态
2670
+ pinnedBots.value = new Set(result.data.pinnedBots || [])
2671
+ pinnedChannels.value = new Set(result.data.pinnedChannels || [])
2672
+
2673
+ for (const [channelKey, messages] of Object.entries(result.data.messages || {})) {
2674
+ const convertedChannelMessages = (messages as any[]).map((msg: any) => ({
2675
+ id: msg.id,
2676
+ content: msg.content,
2677
+ userId: msg.userId,
2678
+ username: msg.username,
2679
+ avatar: msg.avatar,
2680
+ timestamp: msg.timestamp,
2681
+ channelId: msg.channelId,
2682
+ selfId: msg.selfId,
2683
+ elements: msg.elements,
2684
+ isBot: msg.type === 'bot',
2685
+ quote: msg.quote
2686
+ }))
2687
+
2688
+ // 按时间戳排序
2689
+ convertedChannelMessages.sort((a, b) => a.timestamp - b.timestamp)
2690
+
2691
+ // 现在后端已经使用冒号格式,直接使用即可
2692
+ convertedMessages[channelKey] = convertedChannelMessages
2693
+ }
2694
+
2695
+ // 更新聊天数据
2696
+ chatData.value = {
2697
+ bots: result.data.bots || {},
2698
+ channels: result.data.channels || {},
2699
+ messages: convertedMessages
2700
+ }
2701
+
2702
+ // 加载所有频道的消息数量
2703
+ await loadAllChannelMessageCounts()
2704
+
2705
+ return true
2706
+ } else {
2707
+ console.warn('获取聊天数据失败:', result.error)
2708
+ return false
2709
+ }
2710
+ } catch (error) {
2711
+ console.error('获取聊天数据时出错:', error)
2712
+ return false
2713
+ }
2714
+ }
2715
+
2716
+ // 获取所有频道的消息数量
2717
+ async function loadAllChannelMessageCounts() {
2718
+ try {
2719
+ const result = await (send as any)('get-all-channel-message-counts')
2720
+
2721
+ if (result.success && result.counts) {
2722
+ // 转换格式:从 "selfId-channelId" 到 "selfId:channelId"
2723
+ const convertedCounts: Record<string, number> = {}
2724
+ for (const [channelKey, count] of Object.entries(result.counts)) {
2725
+ // 现在后端已经使用冒号格式,直接使用即可
2726
+ convertedCounts[channelKey] = count as number
2727
+ }
2728
+
2729
+ channelMessageCounts.value = convertedCounts
2730
+
2731
+ } else {
2732
+ console.warn('获取频道消息数量失败:', result.error)
2733
+ }
2734
+ } catch (error) {
2735
+ console.error('获取频道消息数量时出错:', error)
2736
+ }
2737
+ }
2738
+
2739
+ // 获取插件配置
2740
+ async function loadPluginConfig() {
2741
+ try {
2742
+ const result = await (send as any)('get-plugin-config')
2743
+
2744
+ if (result.success && result.config) {
2745
+ pluginConfig.value = result.config
2746
+ } else {
2747
+ console.warn('获取插件配置失败:', result.error)
2748
+ }
2749
+ } catch (error) {
2750
+ console.error('获取插件配置时出错:', error)
2751
+ }
2752
+ }
2753
+
2754
+ // 获取历史消息
2755
+ async function loadHistoryMessages(botId: string, channelId: string) {
2756
+ try {
2757
+
2758
+ const result = await (send as any)('get-history-messages', {
2759
+ selfId: botId,
2760
+ channelId: channelId
2761
+ })
2762
+
2763
+ if (result.success && result.messages) {
2764
+ const channelKey = `${botId}:${channelId}`
2765
+
2766
+ // 转换消息格式
2767
+ const messages: MessageInfo[] = result.messages.map((msg: any) => ({
2768
+ id: msg.id,
2769
+ content: msg.content,
2770
+ userId: msg.userId,
2771
+ username: msg.username,
2772
+ avatar: msg.avatar,
2773
+ timestamp: msg.timestamp,
2774
+ channelId: msg.channelId,
2775
+ selfId: msg.selfId,
2776
+ elements: msg.elements,
2777
+ isBot: msg.type === 'bot',
2778
+ quote: msg.quote
2779
+ }))
2780
+
2781
+ // 按时间戳排序
2782
+ messages.sort((a, b) => a.timestamp - b.timestamp)
2783
+
2784
+ // 设置历史消息
2785
+ chatData.value.messages[channelKey] = messages
2786
+
2787
+ // 更新频道消息数量缓存
2788
+ channelMessageCounts.value[channelKey] = messages.length
2789
+
2790
+ // 触发响应式更新
2791
+ chatData.value = { ...chatData.value }
2792
+
2793
+ return true
2794
+ } else {
2795
+ console.warn('获取历史消息失败:', result.error)
2796
+ return false
2797
+ }
2798
+ } catch (error) {
2799
+ console.error('获取历史消息时出错:', error)
2800
+ return false
2801
+ }
2802
+ }
2803
+
2804
+ // 手机端返回按钮处理
2805
+ // 滑动手势处理
2806
+ function handleTouchStart(event: TouchEvent) {
2807
+ if (!isMobile.value || event.touches.length !== 1) return
2808
+
2809
+ const touch = event.touches[0]
2810
+ touchStart.value = {
2811
+ x: touch.clientX,
2812
+ y: touch.clientY,
2813
+ time: Date.now()
2814
+ }
2815
+ touchCurrent.value = { x: touch.clientX, y: touch.clientY }
2816
+ isSwipeActive.value = false
2817
+ }
2818
+
2819
+ function handleTouchMove(event: TouchEvent) {
2820
+ if (!isMobile.value || !touchStart.value || event.touches.length !== 1) return
2821
+
2822
+ const touch = event.touches[0]
2823
+ touchCurrent.value = { x: touch.clientX, y: touch.clientY }
2824
+
2825
+ const deltaX = touch.clientX - touchStart.value.x
2826
+ const deltaY = touch.clientY - touchStart.value.y
2827
+
2828
+ // 检查是否是水平滑动(水平距离大于垂直距离)
2829
+ if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30) {
2830
+ // 检查滑动方向和当前视图状态
2831
+ const isRightSwipe = deltaX > 0
2832
+ const canGoBack = (mobileView.value === 'messages' || mobileView.value === 'channels')
2833
+
2834
+ if (isRightSwipe && canGoBack) {
2835
+ isSwipeActive.value = true
2836
+
2837
+ // 显示滑动指示器
2838
+ const swipeDistance = Math.min(deltaX, 200)
2839
+ const threshold = 150 // 增加阈值,减少误触
2840
+
2841
+ if (swipeDistance > threshold) {
2842
+ swipeIndicator.value = { show: true, text: '松开返回' }
2843
+ } else {
2844
+ swipeIndicator.value = { show: true, text: `滑动返回 ${Math.round(swipeDistance / threshold * 100)}%` }
2845
+ }
2846
+
2847
+ // 阻止默认滚动行为
2848
+ event.preventDefault()
2849
+ } else {
2850
+ swipeIndicator.value = { show: false, text: '' }
2851
+ }
2852
+ } else {
2853
+ swipeIndicator.value = { show: false, text: '' }
2854
+ }
2855
+ }
2856
+
2857
+ function handleTouchEnd(event: TouchEvent) {
2858
+ if (!isMobile.value || !touchStart.value) return
2859
+
2860
+ const endTime = Date.now()
2861
+ const duration = endTime - touchStart.value.time
2862
+
2863
+ if (touchCurrent.value) {
2864
+ const deltaX = touchCurrent.value.x - touchStart.value.x
2865
+ const deltaY = touchCurrent.value.y - touchStart.value.y
2866
+
2867
+ // 检查是否满足返回条件 (水平滑动)
2868
+ const isRightSwipe = deltaX > 150 // 滑动距离超过150px,减少误触
2869
+ const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) // 水平滑动
2870
+ const isFastHorizontalSwipe = duration < 300 && deltaX > 80 // 快速水平滑动,也增加阈值
2871
+
2872
+ if ((isRightSwipe && isHorizontal) || isFastHorizontalSwipe) {
2873
+ performSwipeBack()
2874
+ }
2875
+ }
2876
+
2877
+ // 重置状态
2878
+ touchStart.value = null
2879
+ touchCurrent.value = null
2880
+ isSwipeActive.value = false
2881
+ swipeIndicator.value = { show: false, text: '' }
2882
+ }
2883
+
2884
+ function performSwipeBack() {
2885
+ switch (mobileView.value) {
2886
+ case 'messages':
2887
+ mobileView.value = 'channels'
2888
+ break
2889
+ case 'channels':
2890
+ mobileView.value = 'bots'
2891
+ selectedBot.value = ''
2892
+ selectedChannel.value = ''
2893
+ break
2894
+ default:
2895
+ // 在机器人列表页面,不做任何操作
2896
+ break
2897
+ }
2898
+ }
2899
+
2900
+ // 检测是否为手机端
2901
+ function checkMobile() {
2902
+ isMobile.value = window.innerWidth <= 768
2903
+ }
2904
+
2905
+ // 判断是否应该自动滚动
2906
+ function shouldAutoScroll(): boolean {
2907
+ // 如果没有主动滚动,或者已经在底部附近,则应该自动滚动
2908
+ return !isUserScrolling.value || isNearBottom()
2909
+ }
2910
+
2911
+ // 简单的视口高度管理
2912
+ const handleViewportChange = () => {
2913
+ if (isMobile.value && messageHistory.value) {
2914
+ // 当视口变化时,确保消息区域滚动到底部
2915
+ nextTick(() => {
2916
+ if (shouldAutoScroll()) {
2917
+ scrollToBottom()
2918
+ }
2919
+ })
2920
+ }
2921
+ }
2922
+
2923
+ // 输入框焦点处理
2924
+ const handleInputFocus = () => {
2925
+ if (isMobile.value) {
2926
+ // 延迟滚动,等待键盘完全出现
2927
+ setTimeout(() => {
2928
+ if (messageHistory.value && shouldAutoScroll()) {
2929
+ scrollToBottom()
2930
+ }
2931
+ }, 300)
2932
+ }
2933
+ }
2934
+
2935
+ // 监听消息变化
2936
+ watch(currentMessages, (newMessages, oldMessages) => {
2937
+ // 只有在切换频道时(消息数组完全不同)才自动滚动
2938
+ if (oldMessages.length === 0 && newMessages.length > 0) {
2939
+ nextTick(() => {
2940
+ scrollToBottom()
2941
+ })
2942
+ }
2943
+ })
2944
+
2945
+ // 生命周期
2946
+ onMounted(async () => {
2947
+ // 检测手机端
2948
+ checkMobile()
2949
+ window.addEventListener('resize', checkMobile)
2950
+
2951
+ // 添加视口变化监听(处理键盘弹出)
2952
+ if (window.visualViewport) {
2953
+ window.visualViewport.addEventListener('resize', handleViewportChange)
2954
+ }
2955
+
2956
+ // 添加点击外部关闭菜单的监听器
2957
+ document.addEventListener('click', handleClickOutside)
2958
+
2959
+ // 初始化IndexedDB
2960
+ const dbInitialized = await initImageDB()
2961
+ if (!dbInitialized) {
2962
+ console.warn('IndexedDB初始化失败,图片缓存功能将不可用')
2963
+ } else {
2964
+ console.log('IndexedDB初始化成功')
2965
+
2966
+ // 启动时进行健康检查
2967
+ setTimeout(async () => {
2968
+ await checkDatabaseHealth()
2969
+ }, 2000) // 延迟2秒,避免影响页面加载
2970
+
2971
+ // 设置定期健康检查(每5分钟)
2972
+ setInterval(async () => {
2973
+ await checkDatabaseHealth()
2974
+ }, 5 * 60 * 1000)
2975
+ }
2976
+
2977
+ // 首先加载插件配置
2978
+ await loadPluginConfig()
2979
+
2980
+ // 然后加载历史数据
2981
+ await loadChatData()
2982
+
2983
+ // 然后开始监听消息事件
2984
+ const dispose1 = receive('chat-message-event', handleMessageEvent) as (() => void) | undefined
2985
+ const dispose2 = receive('bot-message-sent-event', handleBotMessageSentEvent) as (() => void) | undefined
2986
+ const dispose3 = receive('chat-bot-message-event', handleBotMessageEvent) as (() => void) | undefined
2987
+
2988
+ // 添加滚动监听
2989
+ watch(selectedChannel, (newChannelId) => {
2990
+ if (newChannelId) {
2991
+ nextTick(() => {
2992
+ if (messageHistory.value) {
2993
+ // 移除旧的监听器,防止重复添加
2994
+ messageHistory.value.removeEventListener('scroll', checkScrollPosition);
2995
+ messageHistory.value.addEventListener('scroll', checkScrollPosition);
2996
+ checkScrollPosition();
2997
+ }
2998
+ if (!isMobile.value && messageInput.value) { // 只有在非手机端才自动聚焦输入框
2999
+ messageInput.value.focus();
3000
+ }
3001
+ });
3002
+ }
3003
+ }, { immediate: true }); // immediate: true 确保在组件挂载时也执行一次
3004
+
3005
+ // 定期检查和清理内存(每2分钟)
3006
+ setInterval(() => {
3007
+ checkAndCleanupMemory()
3008
+ }, 2 * 60 * 1000)
3009
+
3010
+ // 在组件卸载时清理监听器
3011
+ onUnmounted(() => {
3012
+ window.removeEventListener('resize', checkMobile)
3013
+ if (window.visualViewport) {
3014
+ window.visualViewport.removeEventListener('resize', handleViewportChange)
3015
+ }
3016
+ document.removeEventListener('click', handleClickOutside)
3017
+
3018
+ if (dispose1 && typeof dispose1 === 'function') {
3019
+ dispose1()
3020
+ }
3021
+ if (dispose2 && typeof dispose2 === 'function') {
3022
+ dispose2()
3023
+ }
3024
+ if (dispose3 && typeof dispose3 === 'function') {
3025
+ dispose3()
3026
+ }
3027
+
3028
+ // 确保在卸载时移除监听器
3029
+ if (messageHistory.value) {
3030
+ messageHistory.value.removeEventListener('scroll', checkScrollPosition)
3031
+ }
3032
+
3033
+ // 清理内存中的blob URL
3034
+ Object.values(imageBlobUrls.value).forEach(blobUrl => {
3035
+ URL.revokeObjectURL(blobUrl)
3036
+ })
3037
+ imageBlobUrls.value = {}
3038
+
3039
+ // 关闭IndexedDB连接
3040
+ if (imageDB) {
3041
+ imageDB.close()
3042
+ imageDB = null
3043
+ }
3044
+ })
3045
+ })
3046
+
3047
+ // 返回所有需要在Vue组件中使用的响应式数据和方法
3048
+ return {
3049
+ // 组件
3050
+ AvatarComponent,
3051
+ ImageComponent,
3052
+ JsonCardComponent,
3053
+ ForwardMessageComponent,
3054
+ MessageElement,
3055
+
3056
+ // 响应式数据
3057
+ chatData,
3058
+ channelMessageCounts,
3059
+ pluginConfig,
3060
+ selectedBot,
3061
+ selectedChannel,
3062
+ inputMessage,
3063
+ imageBlobUrls,
3064
+ pinnedBots,
3065
+ pinnedChannels,
3066
+ uploadedImages,
3067
+ showActionMenu,
3068
+ isMobile,
3069
+ mobileView,
3070
+ touchStart,
3071
+ touchCurrent,
3072
+ isSwipeActive,
3073
+ swipeIndicator,
3074
+ messageHistory,
3075
+ messageInput,
3076
+ showScrollButton,
3077
+ isUserScrolling,
3078
+ isSending,
3079
+ draggingChannel,
3080
+ dragStartPos,
3081
+ dragCurrentPos,
3082
+ dragElementInitialPos,
3083
+ dragOffset,
3084
+ dragThreshold,
3085
+ isDragReady,
3086
+ draggedBubbleElement,
3087
+ contextMenu,
3088
+ fileInput,
3089
+
3090
+ // 计算属性
3091
+ bots,
3092
+ currentChannels,
3093
+ currentMessages,
3094
+ currentChannelName,
3095
+ currentChannelKey,
3096
+ canSendMessage,
3097
+ canInputMessage,
3098
+ mobileViewClass,
3099
+ inputPlaceholder,
3100
+ chatContainerStyle,
3101
+
3102
+ // 方法
3103
+ selectBot,
3104
+ selectChannel,
3105
+ handleBotRightClick,
3106
+ handleChannelRightClick,
3107
+ showContextMenu,
3108
+ hideContextMenu,
3109
+ handleKeyDown,
3110
+ toggleBotPin,
3111
+ toggleChannelPin,
3112
+ deleteBotMessages,
3113
+ deleteChannelMessages,
3114
+ sendMessage,
3115
+ toggleActionMenu,
3116
+ triggerImageUpload,
3117
+ handleFileSelect,
3118
+ handlePaste,
3119
+ uploadImage,
3120
+ removeImage,
3121
+ fileToBase64,
3122
+ handleClickOutside,
3123
+ formatTime,
3124
+ getChannelTypeText,
3125
+ scrollToBottom,
3126
+ checkScrollPosition,
3127
+ isNearBottom,
3128
+ getChannelMessageCount,
3129
+ startDrag,
3130
+ handleDragMove,
3131
+ handleDragEnd,
3132
+ resetDragState,
3133
+ getDragStyle,
3134
+ getDragDistance,
3135
+ clearChannelHistory,
3136
+ showNotification,
3137
+ createThresholdCircle,
3138
+ removeThresholdCircle,
3139
+ handleTouchStart,
3140
+ handleTouchMove,
3141
+ handleTouchEnd,
3142
+ handleInputFocus,
3143
+
3144
+ // 图片缓存相关
3145
+ getCachedImageUrl,
3146
+ cacheImage,
3147
+ clearChannelImageCache,
3148
+ getMemoryStats,
3149
+ getCacheStats,
3150
+
3151
+ // 其他工具函数
3152
+ isFileUrl,
3153
+ loadHistoryMessages,
3154
+ handleMessageEvent
3155
+ }
3156
+ }