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