koishi-plugin-chat-patch 0.8.2 → 1.0.6

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.
@@ -125,13 +125,54 @@
125
125
 
126
126
  <!-- 输入框 -->
127
127
  <div class="message-input">
128
+ <!-- 图片预览区域 -->
129
+ <div v-if="uploadedImages.length > 0" class="image-preview-container">
130
+ <div v-for="image in uploadedImages" :key="image.tempId" class="image-preview-item">
131
+ <img :src="image.preview" :alt="image.filename" class="preview-image" />
132
+ <button class="remove-image-btn" @click="removeImage(image.tempId)" title="删除图片">
133
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
134
+ <path
135
+ d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
136
+ </svg>
137
+ </button>
138
+ </div>
139
+ </div>
140
+
128
141
  <div class="input-row">
142
+ <!-- 加号按钮 -->
143
+ <div class="input-actions">
144
+ <button class="add-button" @click="toggleActionMenu" :class="{ active: showActionMenu }" title="更多操作">
145
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
146
+ stroke-linecap="round" stroke-linejoin="round">
147
+ <line x1="12" y1="5" x2="12" y2="19"></line>
148
+ <line x1="5" y1="12" x2="19" y2="12"></line>
149
+ </svg>
150
+ </button>
151
+
152
+ <!-- 操作菜单 -->
153
+ <div v-if="showActionMenu" class="action-menu" @click.stop>
154
+ <button class="action-menu-item" @click="triggerImageUpload">
155
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
156
+ stroke-linecap="round" stroke-linejoin="round">
157
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
158
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
159
+ <polyline points="21,15 16,10 5,21"></polyline>
160
+ </svg>
161
+ 上传图片
162
+ </button>
163
+ </div>
164
+ </div>
165
+
129
166
  <input v-model="inputMessage" type="text" :placeholder="inputPlaceholder" @keyup.enter="sendMessage"
130
- :disabled="!canInputMessage" ref="messageInput" />
167
+ :disabled="!canInputMessage" ref="messageInput" @paste="handlePaste" />
131
168
  <button @click="sendMessage" :disabled="!canSendMessage" :class="{ 'is-sending': isSending }">
132
169
  {{ isSending ? '发送中...' : '发送' }}
133
170
  </button>
134
171
  </div>
172
+
173
+ <!-- 隐藏的文件输入 -->
174
+ <input type="file" ref="fileInput" @change="handleFileSelect" accept="image/*" multiple
175
+ style="display: none;" />
135
176
  </div>
136
177
  </div>
137
178
  </div>
@@ -171,6 +212,16 @@
171
212
  import { ref, computed, onMounted, onUnmounted, nextTick, watch, defineComponent, h } from 'vue'
172
213
  import { useContext, receive, send } from '@koishijs/client'
173
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
+
174
225
 
175
226
  // 头像组件
176
227
  const AvatarComponent = defineComponent({
@@ -311,6 +362,13 @@ const ImageComponent = defineComponent({
311
362
  return
312
363
  }
313
364
 
365
+ // 检查是否是本地文件路径,如果是则直接使用代理请求
366
+ if (isFileUrl(props.src)) {
367
+ console.log('ImageComponent: 检测到本地文件,使用代理请求:', props.src)
368
+ await loadWithCache()
369
+ return
370
+ }
371
+
314
372
  // 尝试直接加载原图
315
373
  const testImg = new Image()
316
374
  testImg.crossOrigin = 'anonymous'
@@ -509,6 +567,115 @@ const JsonCardComponent = defineComponent({
509
567
  }
510
568
  })
511
569
 
570
+ // 合并转发消息组件
571
+ const ForwardMessageComponent = defineComponent({
572
+ props: {
573
+ element: {
574
+ type: Object as () => MessageElement,
575
+ required: true
576
+ },
577
+ channelKey: {
578
+ type: String,
579
+ required: true
580
+ }
581
+ },
582
+ setup(props) {
583
+ const isExpanded = ref(false)
584
+
585
+ const toggleExpanded = () => {
586
+ isExpanded.value = !isExpanded.value
587
+ }
588
+
589
+ const getPreviewText = () => {
590
+ if (!props.element.children || props.element.children.length === 0) {
591
+ return {
592
+ previews: [],
593
+ messageCount: 0
594
+ }
595
+ }
596
+
597
+ const messages = props.element.children.filter((child: any) => child.type === 'message')
598
+ const messageCount = messages.length
599
+
600
+ // 生成预览文本
601
+ const previews = messages.slice(0, 3).map((msg: any) => {
602
+ const nickname = msg.attrs?.nickname || '用户'
603
+ let content = ''
604
+
605
+ if (msg.children && msg.children.length > 0) {
606
+ const firstChild = msg.children[0]
607
+ if (firstChild.type === 'text') {
608
+ content = (firstChild.attrs?.content || '').substring(0, 20)
609
+ if (content.length > 15) content += '...'
610
+ } else if (firstChild.type === 'img') {
611
+ content = '[图片]'
612
+ } else if (firstChild.type === 'video') {
613
+ content = '[视频]'
614
+ } else {
615
+ content = `[${firstChild.type}]`
616
+ }
617
+ }
618
+
619
+ return `${nickname}:${content}`
620
+ })
621
+
622
+ return {
623
+ previews,
624
+ messageCount
625
+ }
626
+ }
627
+
628
+ const renderForwardedMessage = (message: any, index: number) => {
629
+ const nickname = message.attrs?.nickname || '用户'
630
+ const userId = message.attrs?.userId || 'unknown'
631
+
632
+ return h('div', {
633
+ key: index,
634
+ class: 'forwarded-message-item'
635
+ }, [
636
+ h('div', { class: 'forwarded-message-header' }, [
637
+ h('span', { class: 'forwarded-message-nickname' }, nickname),
638
+ h('span', { class: 'forwarded-message-userid' }, `(${userId})`)
639
+ ]),
640
+ h('div', { class: 'forwarded-message-content' },
641
+ message.children?.map((child: any, childIndex: number) =>
642
+ h(MessageElement, {
643
+ key: childIndex,
644
+ element: child,
645
+ channelKey: props.channelKey
646
+ })
647
+ ) || []
648
+ )
649
+ ])
650
+ }
651
+
652
+ return () => {
653
+ const { previews, messageCount } = getPreviewText()
654
+
655
+ return h('div', { class: 'forward-message-container' }, [
656
+ h('div', {
657
+ class: 'forward-message-preview',
658
+ onClick: toggleExpanded
659
+ }, [
660
+ h('div', { class: 'forward-message-title' }, '聊天记录'),
661
+ ...previews.map((preview, index) =>
662
+ h('div', { key: index, class: 'forward-message-preview-item' }, preview)
663
+ ),
664
+ h('div', { class: 'forward-message-footer' }, [
665
+ h('span', { class: 'forward-message-count' }, `查看${messageCount}条转发消息`),
666
+ h('span', { class: 'forward-message-toggle' }, isExpanded.value ? '▲' : '▼')
667
+ ])
668
+ ]),
669
+ isExpanded.value && h('div', { class: 'forward-message-expanded' },
670
+ props.element.children
671
+ ?.filter((child: any) => child.type === 'message')
672
+ .map((message: any, index: number) => renderForwardedMessage(message, index)) || []
673
+ )
674
+ ])
675
+ }
676
+ }
677
+ })
678
+
512
679
  const MessageElement = defineComponent({
513
680
  props: {
514
681
  element: {
@@ -532,6 +699,16 @@ const MessageElement = defineComponent({
532
699
  case 'img':
533
700
  case 'image':
534
701
  const imageUrl = element.attrs.src || element.attrs.url || element.attrs.file
702
+
703
+ // 调试信息:记录图片 URL 类型
704
+ if (imageUrl && isFileUrl(imageUrl)) {
705
+ console.log('检测到本地文件路径:', imageUrl)
706
+ } else if (imageUrl?.startsWith('http')) {
707
+ console.log('检测到网络图片:', imageUrl)
708
+ } else {
709
+ console.log('检测到其他类型图片URL:', imageUrl)
710
+ }
711
+
535
712
  return h('div', { class: 'message-image-container' }, [
536
713
  h(ImageComponent, {
537
714
  src: imageUrl,
@@ -543,6 +720,12 @@ const MessageElement = defineComponent({
543
720
 
544
721
  case 'mface':
545
722
  const mfaceimageUrl = element.attrs.src || element.attrs.url || element.attrs.file
723
+
724
+ // 调试信息:记录表情图片 URL 类型
725
+ if (mfaceimageUrl && isFileUrl(mfaceimageUrl)) {
726
+ console.log('检测到本地表情文件路径:', mfaceimageUrl)
727
+ }
728
+
546
729
  return h('div', { class: 'message-image-container' }, [
547
730
  h(ImageComponent, {
548
731
  src: mfaceimageUrl,
@@ -580,12 +763,34 @@ const MessageElement = defineComponent({
580
763
  channelKey: props.channelKey
581
764
  })
582
765
  ])
766
+
767
+ case 'p':
768
+ // 处理段落元素,递归渲染子元素
769
+ if (element.children && element.children.length > 0) {
770
+ const childElements = element.children.map((child, index) =>
771
+ h(MessageElement, {
772
+ key: index,
773
+ element: child,
774
+ channelKey: props.channelKey
775
+ })
776
+ )
777
+ return h('div', { class: 'message-paragraph' }, childElements)
778
+ } else {
779
+ return h('div', { class: 'message-paragraph' }, '')
780
+ }
781
+ case 'figure':
782
+ // 处理合并转发消息,创建可折叠的消息组件
783
+ return h(ForwardMessageComponent, {
784
+ element: element,
785
+ channelKey: props.channelKey
786
+ })
583
787
  default:
584
788
  // 未知类型
585
789
  return h('span', {
586
790
  class: 'message-unknown',
587
791
  title: `未知消息类型: ${element.type}`
588
792
  }, element.attrs.content || `[${element.type}]`)
793
+
589
794
  }
590
795
  }
591
796
 
@@ -597,6 +802,7 @@ interface SendMessageResponse {
597
802
  success: boolean
598
803
  messageId?: string
599
804
  error?: string
805
+ tempImageIds?: string[]
600
806
  }
601
807
 
602
808
  interface BotInfo {
@@ -673,13 +879,13 @@ const pluginConfig = ref<{
673
879
  platformName: string
674
880
  exactMatch: boolean
675
881
  }>
676
- chatContainerHeight: number
882
+ chatContainerHeight: number
677
883
  }>({
678
884
  maxMessagesPerChannel: 1000,
679
885
  keepMessagesOnClear: 50,
680
886
  loggerinfo: false,
681
887
  blockedPlatforms: [],
682
- chatContainerHeight: 80
888
+ chatContainerHeight: 80
683
889
  })
684
890
 
685
891
  // 图片缓存 - IndexedDB
@@ -695,6 +901,59 @@ interface ImageCacheItem {
695
901
  const imageBlobUrls = ref<Record<string, string>>({})
696
902
  const maxImagesPerChannel = 200 // 每个频道最大缓存图片数量
697
903
 
904
+ // 内存管理配置
905
+ const MAX_MEMORY_USAGE = 100 * 1024 * 1024 // 100MB 最大内存使用量
906
+ const MAX_BLOB_COUNT = 50 // 最大blob URL数量
907
+ let currentMemoryUsage = 0 // 当前内存使用量估算
908
+
909
+ // 内存管理函数
910
+ function estimateBlobSize(blob: Blob): number {
911
+ return blob.size || 0
912
+ }
913
+
914
+ function updateMemoryUsage(sizeChange: number) {
915
+ currentMemoryUsage += sizeChange
916
+ if (pluginConfig.value.loggerinfo) {
917
+ console.log(`内存使用量变化: ${sizeChange > 0 ? '+' : ''}${(sizeChange / 1024 / 1024).toFixed(2)}MB, 总计: ${(currentMemoryUsage / 1024 / 1024).toFixed(2)}MB`)
918
+ }
919
+ }
920
+
921
+ // 清理最旧的blob URL以释放内存
922
+ function cleanupOldestBlobs(targetCount: number = 10) {
923
+ const blobEntries = Object.entries(imageBlobUrls.value)
924
+ if (blobEntries.length <= targetCount) return
925
+
926
+ // 简单的LRU策略:清理最早创建的blob
927
+ const toRemove = blobEntries.slice(0, blobEntries.length - targetCount)
928
+
929
+ let freedMemory = 0
930
+ toRemove.forEach(([url, blobUrl]) => {
931
+ URL.revokeObjectURL(blobUrl)
932
+ delete imageBlobUrls.value[url]
933
+ freedMemory += 500 * 1024 // 估算每个图片500KB
934
+ if (pluginConfig.value.loggerinfo) {
935
+ console.log('清理旧blob URL:', url)
936
+ }
937
+ })
938
+
939
+ updateMemoryUsage(-freedMemory)
940
+ }
941
+
942
+ // 检查内存使用情况并清理
943
+ function checkAndCleanupMemory() {
944
+ const blobCount = Object.keys(imageBlobUrls.value).length
945
+
946
+ // 如果blob数量过多,清理一些
947
+ if (blobCount > MAX_BLOB_COUNT) {
948
+ cleanupOldestBlobs(Math.floor(MAX_BLOB_COUNT * 0.7)) // 清理到70%
949
+ }
950
+
951
+ // 如果估算内存使用过高,也进行清理
952
+ if (currentMemoryUsage > MAX_MEMORY_USAGE) {
953
+ cleanupOldestBlobs(Math.floor(MAX_BLOB_COUNT * 0.5)) // 清理到50%
954
+ }
955
+ }
956
+
698
957
  // IndexedDB
699
958
  let imageDB: IDBDatabase | null = null
700
959
  const DB_NAME = 'ChatImageCache'
@@ -705,6 +964,16 @@ const selectedBot = ref<string>('')
705
964
  const selectedChannel = ref<string>('')
706
965
  const inputMessage = ref<string>('')
707
966
 
967
+ // 图片上传相关状态
968
+ const uploadedImages = ref<Array<{
969
+ tempId: string
970
+ filename: string
971
+ preview: string
972
+ size: number
973
+ }>>([])
974
+ const showActionMenu = ref<boolean>(false)
975
+ const fileInput = ref<HTMLInputElement>()
976
+
708
977
  // 手机端状态管理
709
978
  const isMobile = ref<boolean>(false)
710
979
  const mobileView = ref<'bots' | 'channels' | 'messages'>('bots')
@@ -807,7 +1076,7 @@ const currentChannelKey = computed(() => {
807
1076
  })
808
1077
 
809
1078
  const canSendMessage = computed(() => {
810
- return selectedBot.value && selectedChannel.value && inputMessage.value.trim() && !isSending.value
1079
+ return selectedBot.value && selectedChannel.value && (inputMessage.value.trim() || uploadedImages.value.length > 0) && !isSending.value
811
1080
  })
812
1081
 
813
1082
  const canInputMessage = computed(() => {
@@ -831,7 +1100,7 @@ const mobileViewClass = computed(() => {
831
1100
  // 输入框提示文字
832
1101
  const inputPlaceholder = computed(() => {
833
1102
  if (isMobile.value) {
834
- return '屏幕左滑返回,上滑唤起聊天框'
1103
+ return '输入消息...(屏幕左滑返回)'
835
1104
  } else {
836
1105
  return '输入消息...'
837
1106
  }
@@ -1066,22 +1335,48 @@ async function sendMessage() {
1066
1335
  if (!canSendMessage.value) return
1067
1336
 
1068
1337
  const messageContent = inputMessage.value.trim()
1069
- if (!messageContent) return
1338
+ if (!messageContent && uploadedImages.value.length === 0) return
1070
1339
 
1071
1340
  // 设置发送状态
1072
1341
  isSending.value = true
1073
1342
 
1343
+ // 保存当前的图片信息,用于后续清理
1344
+ const currentImages = [...uploadedImages.value]
1345
+
1074
1346
  try {
1075
1347
  // 调用后端 API 发送消息
1076
1348
  const result = await (send as any)('send-message', {
1077
1349
  selfId: selectedBot.value,
1078
1350
  channelId: selectedChannel.value,
1079
- content: messageContent
1080
- }) as SendMessageResponse
1351
+ content: messageContent,
1352
+ images: currentImages.map(img => ({
1353
+ tempId: img.tempId,
1354
+ filename: img.filename
1355
+ }))
1356
+ }) as SendMessageResponse & { tempImageIds?: string[] }
1081
1357
 
1082
1358
  if (result.success) {
1083
- // 清空输入框
1359
+ // 清空输入框和图片预览
1084
1360
  inputMessage.value = ''
1361
+
1362
+ // 释放blob URL
1363
+ currentImages.forEach(img => {
1364
+ URL.revokeObjectURL(img.preview)
1365
+ })
1366
+ uploadedImages.value = []
1367
+ showActionMenu.value = false
1368
+
1369
+ // 消息发送成功后,主动通知后端清理临时文件
1370
+ if (result.tempImageIds && result.tempImageIds.length > 0) {
1371
+ try {
1372
+ await (send as any)('cleanup-temp-images', {
1373
+ tempImageIds: result.tempImageIds
1374
+ })
1375
+ console.log('临时图片清理完成:', result.tempImageIds)
1376
+ } catch (cleanupError) {
1377
+ console.warn('清理临时图片失败:', cleanupError)
1378
+ }
1379
+ }
1085
1380
  } else {
1086
1381
  console.error('消息发送失败:', result.error)
1087
1382
  // 使用showNotification显示错误提示
@@ -1096,6 +1391,128 @@ async function sendMessage() {
1096
1391
  }
1097
1392
  }
1098
1393
 
1394
+ // 图片上传相关方法
1395
+ function toggleActionMenu() {
1396
+ showActionMenu.value = !showActionMenu.value
1397
+ }
1398
+
1399
+ function triggerImageUpload() {
1400
+ if (fileInput.value) {
1401
+ fileInput.value.click()
1402
+ }
1403
+ showActionMenu.value = false
1404
+ }
1405
+
1406
+ async function handleFileSelect(event: Event) {
1407
+ const target = event.target as HTMLInputElement
1408
+ const files = target.files
1409
+ if (!files || files.length === 0) return
1410
+
1411
+ for (const file of Array.from(files)) {
1412
+ await uploadImage(file)
1413
+ }
1414
+
1415
+ // 清空文件输入,允许重复选择同一文件
1416
+ target.value = ''
1417
+ }
1418
+
1419
+ async function handlePaste(event: ClipboardEvent) {
1420
+ const items = event.clipboardData?.items
1421
+ if (!items) return
1422
+
1423
+ for (const item of Array.from(items)) {
1424
+ if (item.type.startsWith('image/')) {
1425
+ event.preventDefault()
1426
+ const file = item.getAsFile()
1427
+ if (file) {
1428
+ await uploadImage(file)
1429
+ }
1430
+ }
1431
+ }
1432
+ }
1433
+
1434
+ async function uploadImage(file: File) {
1435
+ try {
1436
+ // 检查文件大小 (限制为10MB)
1437
+ if (file.size > 10 * 1024 * 1024) {
1438
+ showNotification('图片文件过大,请选择小于10MB的图片', 'error')
1439
+ return
1440
+ }
1441
+
1442
+ // 检查文件类型
1443
+ if (!file.type.startsWith('image/')) {
1444
+ showNotification('请选择图片文件', 'error')
1445
+ return
1446
+ }
1447
+
1448
+ // 转换为base64
1449
+ const base64 = await fileToBase64(file)
1450
+
1451
+ // 创建预览URL
1452
+ const preview = URL.createObjectURL(file)
1453
+
1454
+ // 调用后端API上传图片
1455
+ const result = await (send as any)('upload-image', {
1456
+ file: base64,
1457
+ filename: file.name,
1458
+ mimeType: file.type
1459
+ })
1460
+
1461
+ if (result.success) {
1462
+ uploadedImages.value.push({
1463
+ tempId: result.tempId,
1464
+ filename: file.name,
1465
+ preview: preview,
1466
+ size: file.size
1467
+ })
1468
+ } else {
1469
+ URL.revokeObjectURL(preview)
1470
+ showNotification('图片上传失败: ' + result.error, 'error')
1471
+ }
1472
+ } catch (error: any) {
1473
+ console.error('上传图片失败:', error)
1474
+ showNotification('图片上传失败: ' + (error?.message || String(error)), 'error')
1475
+ }
1476
+ }
1477
+
1478
+ async function removeImage(tempId: string) {
1479
+ try {
1480
+ // 从列表中移除
1481
+ const imageIndex = uploadedImages.value.findIndex(img => img.tempId === tempId)
1482
+ if (imageIndex !== -1) {
1483
+ const image = uploadedImages.value[imageIndex]
1484
+ // 释放预览URL
1485
+ URL.revokeObjectURL(image.preview)
1486
+ uploadedImages.value.splice(imageIndex, 1)
1487
+ }
1488
+
1489
+ // 调用后端API删除临时文件
1490
+ await (send as any)('delete-temp-image', { tempId })
1491
+ } catch (error: any) {
1492
+ console.error('删除图片失败:', error)
1493
+ }
1494
+ }
1495
+
1496
+ function fileToBase64(file: File): Promise<string> {
1497
+ return new Promise((resolve, reject) => {
1498
+ const reader = new FileReader()
1499
+ reader.onload = () => resolve(reader.result as string)
1500
+ reader.onerror = reject
1501
+ reader.readAsDataURL(file)
1502
+ })
1503
+ }
1504
+
1505
+ // 点击外部关闭菜单
1506
+ function handleClickOutside(event: Event) {
1507
+ const target = event.target as HTMLElement
1508
+ if (!target.closest('.input-actions')) {
1509
+ showActionMenu.value = false
1510
+ }
1511
+ if (!target.closest('.context-menu')) {
1512
+ hideContextMenu()
1513
+ }
1514
+ }
1515
+
1099
1516
  function formatTime(timestamp: number): string {
1100
1517
  const date = new Date(timestamp)
1101
1518
  return date.toLocaleTimeString('zh-CN', {
@@ -1128,7 +1545,7 @@ function checkScrollPosition() {
1128
1545
  const { scrollTop, scrollHeight, clientHeight } = messageHistory.value
1129
1546
  const distanceFromBottom = scrollHeight - (scrollTop + clientHeight)
1130
1547
  const isAtBottom = distanceFromBottom <= 50
1131
-
1548
+
1132
1549
  const shouldShowButton = !isAtBottom
1133
1550
 
1134
1551
  showScrollButton.value = shouldShowButton
@@ -1655,10 +2072,16 @@ async function getCachedImageUrl(channelKey: string, originalUrl: string): Promi
1655
2072
  const cacheItem = await getImageFromDB(originalUrl)
1656
2073
  if (!cacheItem) return null
1657
2074
 
2075
+ // 检查内存使用情况
2076
+ checkAndCleanupMemory()
2077
+
1658
2078
  // 创建blob URL并缓存到内存
1659
2079
  const blobUrl = URL.createObjectURL(cacheItem.blob)
1660
2080
  imageBlobUrls.value[originalUrl] = blobUrl
1661
2081
 
2082
+ // 更新内存使用量
2083
+ updateMemoryUsage(estimateBlobSize(cacheItem.blob))
2084
+
1662
2085
  // 更新访问时间
1663
2086
  cacheItem.timestamp = Date.now()
1664
2087
  await saveImageToDB(cacheItem)
@@ -1729,9 +2152,16 @@ async function cacheImage(channelKey: string, originalUrl: string): Promise<stri
1729
2152
  return null
1730
2153
  }
1731
2154
 
2155
+ // 检查内存使用情况
2156
+ checkAndCleanupMemory()
2157
+
1732
2158
  // 创建blob URL并缓存到内存
1733
2159
  const blobUrl = URL.createObjectURL(blob)
1734
2160
  imageBlobUrls.value[originalUrl] = blobUrl
2161
+
2162
+ // 更新内存使用量
2163
+ updateMemoryUsage(estimateBlobSize(blob))
2164
+
1735
2165
  return blobUrl
1736
2166
 
1737
2167
  } catch (error) {
@@ -1746,6 +2176,7 @@ async function clearChannelImageCache(channelKey: string) {
1746
2176
  // 获取频道的所有图片
1747
2177
  const channelImages = await getChannelImagesFromDB(channelKey)
1748
2178
 
2179
+ let freedMemory = 0
1749
2180
  // 删除IndexedDB中的数据
1750
2181
  for (const item of channelImages) {
1751
2182
  await deleteImageFromDB(item.url)
@@ -1753,13 +2184,32 @@ async function clearChannelImageCache(channelKey: string) {
1753
2184
  if (imageBlobUrls.value[item.url]) {
1754
2185
  URL.revokeObjectURL(imageBlobUrls.value[item.url])
1755
2186
  delete imageBlobUrls.value[item.url]
2187
+ freedMemory += item.size || 0
1756
2188
  }
1757
2189
  }
2190
+
2191
+ // 更新内存使用量
2192
+ if (freedMemory > 0) {
2193
+ updateMemoryUsage(-freedMemory)
2194
+ }
1758
2195
  } catch (error) {
1759
2196
  console.error('清理频道图片缓存失败:', error)
1760
2197
  }
1761
2198
  }
1762
2199
 
2200
+ // 获取内存使用统计
2201
+ function getMemoryStats() {
2202
+ const blobCount = Object.keys(imageBlobUrls.value).length
2203
+ return {
2204
+ blobCount,
2205
+ estimatedMemoryUsage: currentMemoryUsage,
2206
+ maxMemoryLimit: MAX_MEMORY_USAGE,
2207
+ maxBlobLimit: MAX_BLOB_COUNT,
2208
+ memoryUsagePercent: (currentMemoryUsage / MAX_MEMORY_USAGE * 100).toFixed(1),
2209
+ blobUsagePercent: (blobCount / MAX_BLOB_COUNT * 100).toFixed(1)
2210
+ }
2211
+ }
2212
+
1763
2213
  // 获取缓存统计信息
1764
2214
  async function getCacheStats() {
1765
2215
  if (!imageDB) return { totalImages: 0, totalSize: 0, channels: 0 }
@@ -1919,7 +2369,7 @@ function handleMessageEvent(messageEvent: any) {
1919
2369
  chatData.value = { ...chatData.value }
1920
2370
  }
1921
2371
 
1922
- // 处理机器人发送消息成功事件
2372
+ // 处理机器人发送消息成功事件(通过前端发送消息API触发)
1923
2373
  function handleBotMessageSentEvent(sentEvent: any) {
1924
2374
  const channelKey = `${sentEvent.selfId}:${sentEvent.channelId}`
1925
2375
  if (!chatData.value.messages[channelKey]) {
@@ -1986,6 +2436,132 @@ function handleBotMessageSentEvent(sentEvent: any) {
1986
2436
  chatData.value = { ...chatData.value }
1987
2437
  }
1988
2438
 
2439
+ // 处理机器人消息事件(通过before-send事件监听触发)
2440
+ function handleBotMessageEvent(botMessageEvent: any) {
2441
+ // 更新机器人信息
2442
+ if (!chatData.value.bots[botMessageEvent.selfId]) {
2443
+ chatData.value.bots[botMessageEvent.selfId] = {
2444
+ selfId: botMessageEvent.selfId,
2445
+ platform: botMessageEvent.platform,
2446
+ username: botMessageEvent.bot?.name || `Bot-${botMessageEvent.selfId}`,
2447
+ avatar: botMessageEvent.bot?.avatar,
2448
+ status: 'online'
2449
+ }
2450
+ } else {
2451
+ // 更新机器人状态和信息
2452
+ const existingBot = chatData.value.bots[botMessageEvent.selfId]
2453
+ existingBot.status = 'online'
2454
+ if (botMessageEvent.bot?.name && existingBot.username !== botMessageEvent.bot.name) {
2455
+ existingBot.username = botMessageEvent.bot.name
2456
+ }
2457
+ if (botMessageEvent.bot?.avatar && existingBot.avatar !== botMessageEvent.bot.avatar) {
2458
+ existingBot.avatar = botMessageEvent.bot.avatar
2459
+ }
2460
+ }
2461
+
2462
+ // 更新频道信息
2463
+ if (!chatData.value.channels[botMessageEvent.selfId]) {
2464
+ chatData.value.channels[botMessageEvent.selfId] = {}
2465
+ }
2466
+
2467
+ if (botMessageEvent.channelId && !chatData.value.channels[botMessageEvent.selfId][botMessageEvent.channelId]) {
2468
+ const channelName = botMessageEvent.guildId
2469
+ ? `${botMessageEvent.guildName || botMessageEvent.guildId} (${botMessageEvent.channelId})`
2470
+ : `私信 ${botMessageEvent.channelId}`
2471
+
2472
+ chatData.value.channels[botMessageEvent.selfId][botMessageEvent.channelId] = {
2473
+ id: botMessageEvent.channelId,
2474
+ name: channelName,
2475
+ type: botMessageEvent.channelType || 0,
2476
+ guildId: botMessageEvent.guildId,
2477
+ guildName: botMessageEvent.guildName || botMessageEvent.guildId || '私聊'
2478
+ }
2479
+ }
2480
+
2481
+ // 添加机器人消息
2482
+ if (botMessageEvent.messageId && botMessageEvent.content && botMessageEvent.channelId) {
2483
+ const channelKey = `${botMessageEvent.selfId}:${botMessageEvent.channelId}`
2484
+ if (!chatData.value.messages[channelKey]) {
2485
+ chatData.value.messages[channelKey] = []
2486
+ }
2487
+
2488
+ // 检查消息是否已存在
2489
+ const exists = chatData.value.messages[channelKey].find(m => m.id === botMessageEvent.messageId)
2490
+ if (!exists) {
2491
+ const message: MessageInfo = {
2492
+ id: botMessageEvent.messageId,
2493
+ content: botMessageEvent.content,
2494
+ userId: botMessageEvent.userId,
2495
+ username: botMessageEvent.username,
2496
+ avatar: botMessageEvent.avatar,
2497
+ timestamp: botMessageEvent.timestamp,
2498
+ channelId: botMessageEvent.channelId,
2499
+ selfId: botMessageEvent.selfId,
2500
+ elements: botMessageEvent.elements,
2501
+ isBot: true, // 标记为机器人消息
2502
+ quote: botMessageEvent.quote
2503
+ }
2504
+
2505
+ // 按时间戳排序插入消息
2506
+ const messages = chatData.value.messages[channelKey]
2507
+ let insertIndex = messages.length
2508
+
2509
+ // 找到正确的插入位置(按时间戳排序)
2510
+ for (let i = messages.length - 1; i >= 0; i--) {
2511
+ if (messages[i].timestamp <= botMessageEvent.timestamp) {
2512
+ insertIndex = i + 1
2513
+ break
2514
+ }
2515
+ if (i === 0) {
2516
+ insertIndex = 0
2517
+ }
2518
+ }
2519
+
2520
+ messages.splice(insertIndex, 0, message)
2521
+
2522
+ // 保持消息数量限制
2523
+ if (messages.length > 100) {
2524
+ chatData.value.messages[channelKey] = messages.slice(-100)
2525
+ }
2526
+
2527
+ // 更新频道消息数量缓存
2528
+ channelMessageCounts.value[channelKey] = messages.length
2529
+
2530
+ // 在添加新消息前检查用户是否在底部附近
2531
+ const wasNearBottom = isNearBottom()
2532
+
2533
+ // 智能滚动:基于添加消息前的位置状态来决定是否滚动
2534
+ nextTick(() => {
2535
+ // 再次等待,确保新消息的DOM已经渲染
2536
+ setTimeout(() => {
2537
+ if (wasNearBottom) {
2538
+ scrollToBottom()
2539
+ }
2540
+ }, 10)
2541
+ })
2542
+ }
2543
+ }
2544
+
2545
+ // 异步预缓存消息中的图片
2546
+ if (botMessageEvent.elements && botMessageEvent.elements.length > 0) {
2547
+ const channelKey = `${botMessageEvent.selfId}:${botMessageEvent.channelId}`
2548
+ botMessageEvent.elements.forEach((element: any) => {
2549
+ if ((element.type === 'img' || element.type === 'image' || element.type === 'mface') && element.attrs) {
2550
+ const imageUrl = element.attrs.src || element.attrs.url || element.attrs.file
2551
+ if (imageUrl) {
2552
+ // 异步缓存,不阻塞消息显示
2553
+ cacheImage(channelKey, imageUrl).catch(error => {
2554
+ console.warn('预缓存图片失败:', imageUrl, error)
2555
+ })
2556
+ }
2557
+ }
2558
+ })
2559
+ }
2560
+
2561
+ // 触发响应式更新
2562
+ chatData.value = { ...chatData.value }
2563
+ }
2564
+
1989
2565
  // 监听消息变化,只在切换频道时自动滚动到底部
1990
2566
  watch(currentMessages, (newMessages, oldMessages) => {
1991
2567
  // 只有在切换频道时(消息数组完全不同)才自动滚动
@@ -2027,10 +2603,8 @@ async function loadChatData() {
2027
2603
  // 按时间戳排序
2028
2604
  convertedChannelMessages.sort((a, b) => a.timestamp - b.timestamp)
2029
2605
 
2030
- // 转换channelKey格式:从 "selfId-channelId" 到 "selfId:channelId"
2031
- const [selfId, channelId] = channelKey.split('-')
2032
- const frontendChannelKey = `${selfId}:${channelId}`
2033
- convertedMessages[frontendChannelKey] = convertedChannelMessages
2606
+ // 现在后端已经使用冒号格式,直接使用即可
2607
+ convertedMessages[channelKey] = convertedChannelMessages
2034
2608
  }
2035
2609
 
2036
2610
  // 更新聊天数据
@@ -2063,9 +2637,8 @@ async function loadAllChannelMessageCounts() {
2063
2637
  // 转换格式:从 "selfId-channelId" 到 "selfId:channelId"
2064
2638
  const convertedCounts: Record<string, number> = {}
2065
2639
  for (const [channelKey, count] of Object.entries(result.counts)) {
2066
- const [selfId, channelId] = channelKey.split('-')
2067
- const frontendChannelKey = `${selfId}:${channelId}`
2068
- convertedCounts[frontendChannelKey] = count as number
2640
+ // 现在后端已经使用冒号格式,直接使用即可
2641
+ convertedCounts[channelKey] = count as number
2069
2642
  }
2070
2643
 
2071
2644
  channelMessageCounts.value = convertedCounts
@@ -2177,8 +2750,8 @@ function handleTouchMove(event: TouchEvent) {
2177
2750
  isSwipeActive.value = true
2178
2751
 
2179
2752
  // 显示滑动指示器
2180
- const swipeDistance = Math.min(deltaX, 150)
2181
- const threshold = 100
2753
+ const swipeDistance = Math.min(deltaX, 200)
2754
+ const threshold = 150 // 增加阈值,减少误触
2182
2755
 
2183
2756
  if (swipeDistance > threshold) {
2184
2757
  swipeIndicator.value = { show: true, text: '松开返回' }
@@ -2207,9 +2780,9 @@ function handleTouchEnd(event: TouchEvent) {
2207
2780
  const deltaY = touchCurrent.value.y - touchStart.value.y
2208
2781
 
2209
2782
  // 检查是否满足返回条件 (水平滑动)
2210
- const isRightSwipe = deltaX > 100 // 滑动距离超过100px
2783
+ const isRightSwipe = deltaX > 150 // 滑动距离超过150px,减少误触
2211
2784
  const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) // 水平滑动
2212
- const isFastHorizontalSwipe = duration < 300 && deltaX > 50 // 快速水平滑动
2785
+ const isFastHorizontalSwipe = duration < 300 && deltaX > 80 // 快速水平滑动,也增加阈值
2213
2786
 
2214
2787
  if ((isRightSwipe && isHorizontal) || isFastHorizontalSwipe) {
2215
2788
  performSwipeBack()
@@ -2250,6 +2823,9 @@ onMounted(async () => {
2250
2823
  checkMobile()
2251
2824
  window.addEventListener('resize', checkMobile)
2252
2825
 
2826
+ // 添加点击外部关闭菜单的监听器
2827
+ document.addEventListener('click', handleClickOutside)
2828
+
2253
2829
  // 初始化IndexedDB
2254
2830
  const dbInitialized = await initImageDB()
2255
2831
  if (!dbInitialized) {
@@ -2265,6 +2841,7 @@ onMounted(async () => {
2265
2841
  // 然后开始监听消息事件
2266
2842
  const dispose1 = receive('chat-message-event', handleMessageEvent) as (() => void) | undefined
2267
2843
  const dispose2 = receive('bot-message-sent-event', handleBotMessageSentEvent) as (() => void) | undefined
2844
+ const dispose3 = receive('chat-bot-message-event', handleBotMessageEvent) as (() => void) | undefined
2268
2845
 
2269
2846
  // 添加滚动监听
2270
2847
  watch(selectedChannel, (newChannelId) => {
@@ -2283,9 +2860,15 @@ onMounted(async () => {
2283
2860
  }
2284
2861
  }, { immediate: true }); // immediate: true 确保在组件挂载时也执行一次
2285
2862
 
2863
+ // 定期检查和清理内存(每2分钟)
2864
+ setInterval(() => {
2865
+ checkAndCleanupMemory()
2866
+ }, 2 * 60 * 1000)
2867
+
2286
2868
  // 在组件卸载时清理监听器
2287
2869
  onUnmounted(() => {
2288
2870
  window.removeEventListener('resize', checkMobile)
2871
+ document.removeEventListener('click', handleClickOutside)
2289
2872
 
2290
2873
  if (dispose1 && typeof dispose1 === 'function') {
2291
2874
  dispose1()
@@ -2293,6 +2876,9 @@ onMounted(async () => {
2293
2876
  if (dispose2 && typeof dispose2 === 'function') {
2294
2877
  dispose2()
2295
2878
  }
2879
+ if (dispose3 && typeof dispose3 === 'function') {
2880
+ dispose3()
2881
+ }
2296
2882
 
2297
2883
  // 确保在卸载时移除监听器
2298
2884
  if (messageHistory.value) {