koishi-plugin-chat-patch 1.0.13 → 1.0.15

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