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