koishi-plugin-chat-patch 1.2.0 → 2.0.0
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 -9
- package/client/icons/index.ts +4 -4
- package/client/index.scss +40 -4
- package/client/index.ts +23 -22
- package/client/vue/chat-logic.ts +217 -3784
- package/client/vue/composables/useChatActions.ts +61 -0
- package/client/vue/composables/useChatData.ts +184 -0
- package/client/vue/composables/useImageCache.ts +140 -0
- package/client/vue/index.vue +434 -362
- package/client/vue/types.ts +71 -0
- package/dist/index.js +5 -27
- package/dist/style.css +1 -1
- package/lib/index.js +86 -37
- package/package.json +49 -40
- package/readme.md +25 -25
- package/src/api-handlers.ts +664 -657
- package/src/config.ts +47 -49
- package/src/file-manager.ts +168 -168
- package/src/index.ts +125 -125
- package/src/message-handler.ts +305 -282
- package/src/types.ts +59 -59
- package/src/utils.ts +143 -99
- package/client/vue/style.css +0 -1973
- package/lib/config.d.ts +0 -14
package/client/vue/index.vue
CHANGED
|
@@ -1,362 +1,434 @@
|
|
|
1
|
-
<
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
</div>
|
|
54
|
-
</
|
|
55
|
-
</
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
</div>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
</div>
|
|
210
|
-
</
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
</
|
|
232
|
-
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
selectedChannel,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="chat-patch-wrapper absolute inset-0 flex overflow-hidden bg-[var(--k-page-bg)] text-[var(--k-text-color)] font-sans">
|
|
4
|
+
<el-container class="h-full w-full">
|
|
5
|
+
<!-- 机器人列表 -->
|
|
6
|
+
<el-aside v-show="!isMobile || mobileView === 'bots'" :width="isMobile ? '100%' : '280px'"
|
|
7
|
+
class="flex flex-col border-r border-[var(--k-border-color)] bg-[var(--k-page-bg)] brightness-95 dark:brightness-90">
|
|
8
|
+
<div
|
|
9
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] text-lg text-[var(--k-text-color)]">
|
|
10
|
+
机器人</div>
|
|
11
|
+
<el-scrollbar class="flex-1">
|
|
12
|
+
<div v-for="bot in bots" :key="bot.selfId"
|
|
13
|
+
:class="['flex items-center p-4 cursor-pointer transition-all hover:bg-[var(--k-button-hover-bg)] border-l-4 border-transparent', { '!border-[var(--k-color-primary)] bg-[var(--k-button-active-bg)] text-[var(--k-color-primary)]': selectedBot === bot.selfId }]"
|
|
14
|
+
@click="selectBot(bot.selfId)" @contextmenu.prevent="onBotMenu($event, bot)">
|
|
15
|
+
<el-avatar :size="44" :src="bot.avatar" class="flex-shrink-0 shadow-sm">{{ bot.username[0] }}</el-avatar>
|
|
16
|
+
<div class="ml-3 flex-1 overflow-hidden">
|
|
17
|
+
<div class="flex items-center gap-1 text-sm font-bold truncate">
|
|
18
|
+
{{ bot.username }}
|
|
19
|
+
<el-icon v-if="pinnedBots.has(bot.selfId)" class="text-orange-400">
|
|
20
|
+
<StarFilled />
|
|
21
|
+
</el-icon>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="text-xs text-[var(--k-text-color-secondary)] truncate opacity-80">{{ bot.platform }}</div>
|
|
24
|
+
</div>
|
|
25
|
+
<div
|
|
26
|
+
:class="['w-2.5 h-2.5 rounded-full ml-2 shadow-inner', bot.status === 'online' ? 'bg-green-500' : 'bg-gray-400']">
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</el-scrollbar>
|
|
30
|
+
</el-aside>
|
|
31
|
+
|
|
32
|
+
<!-- 频道列表 -->
|
|
33
|
+
<el-aside v-show="(!isMobile && selectedBot) || (isMobile && mobileView === 'channels')"
|
|
34
|
+
:width="isMobile ? '100%' : '260px'"
|
|
35
|
+
class="flex flex-col border-r border-[var(--k-border-color)] bg-[var(--k-page-bg)] brightness-100 dark:brightness-95">
|
|
36
|
+
<div
|
|
37
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] text-lg text-[var(--k-text-color)]">
|
|
38
|
+
<el-button v-if="isMobile" icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
39
|
+
频道
|
|
40
|
+
</div>
|
|
41
|
+
<el-scrollbar class="flex-1">
|
|
42
|
+
<div v-for="channel in currentChannels" :key="channel.id"
|
|
43
|
+
:class="['flex items-center p-4 cursor-pointer transition-all hover:bg-[var(--k-button-hover-bg)] border-l-4 border-transparent', { '!border-[var(--k-color-primary)] bg-[var(--k-button-active-bg)] text-[var(--k-color-primary)]': selectedChannel === channel.id }]"
|
|
44
|
+
@click="selectChannel(channel.id)" @contextmenu.prevent="onChannelMenu($event, channel)">
|
|
45
|
+
<div class="flex-1 overflow-hidden">
|
|
46
|
+
<div class="flex items-center gap-1 text-sm font-medium truncate">
|
|
47
|
+
{{ channel.name }}
|
|
48
|
+
<el-icon v-if="pinnedChannels.has(`${selectedBot}:${channel.id}`)" class="text-orange-400">
|
|
49
|
+
<StarFilled />
|
|
50
|
+
</el-icon>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</el-scrollbar>
|
|
55
|
+
</el-aside>
|
|
56
|
+
|
|
57
|
+
<!-- 消息主区域 -->
|
|
58
|
+
<el-main v-show="(!isMobile && selectedBot && selectedChannel) || (isMobile && mobileView === 'messages')"
|
|
59
|
+
class="flex flex-col p-0 bg-[var(--k-page-bg)] relative brightness-105 dark:brightness-100">
|
|
60
|
+
<template v-if="selectedBot && selectedChannel">
|
|
61
|
+
<div
|
|
62
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] bg-[var(--k-card-bg)] shadow-sm z-10 text-[var(--k-text-color)]">
|
|
63
|
+
<el-button v-if="isMobile" icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
64
|
+
<span class="truncate">{{ currentChannelName }}</span>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="flex-1 overflow-hidden relative bg-opacity-50 bg-gray-100 dark:bg-black/20">
|
|
68
|
+
<el-scrollbar ref="scrollRef" @scroll="handleScroll">
|
|
69
|
+
<div class="p-6 flex flex-col min-h-full">
|
|
70
|
+
<div v-if="isLoadingHistory" class="flex justify-center py-4">
|
|
71
|
+
<el-icon class="is-loading text-[var(--k-color-primary)]">
|
|
72
|
+
<Loading />
|
|
73
|
+
</el-icon>
|
|
74
|
+
</div>
|
|
75
|
+
<div v-for="msg in currentMessages" :key="msg.id"
|
|
76
|
+
:class="['flex mb-6 gap-3 group', (msg.isBot || msg.userId === selectedBot) ? 'flex-row-reverse' : 'flex-row']">
|
|
77
|
+
<el-avatar :size="40" :src="msg.avatar" class="flex-shrink-0 shadow-sm">
|
|
78
|
+
{{ msg.username[0] }}
|
|
79
|
+
</el-avatar>
|
|
80
|
+
<div
|
|
81
|
+
:class="['max-w-[85%] md:max-w-[75%] flex flex-col', (msg.isBot || msg.userId === selectedBot) ? 'items-end' : 'items-start']">
|
|
82
|
+
<div class="flex items-center gap-2 mb-1.5 text-xs text-[var(--k-text-color-secondary)]">
|
|
83
|
+
<span class="font-bold">{{ msg.username }}</span>
|
|
84
|
+
<span class="opacity-60">{{ formatTime(msg.timestamp) }}</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div
|
|
87
|
+
:class="['p-3.5 rounded-2xl shadow-sm text-[15px] leading-relaxed break-all relative', (msg.isBot || msg.userId === selectedBot) ? 'bg-[#95ec69] text-black rounded-tr-none' : 'bg-[var(--k-card-bg)] text-[var(--k-text-color)] rounded-tl-none border border-[var(--k-border-color)]']">
|
|
88
|
+
<!-- 引用消息渲染 -->
|
|
89
|
+
<div v-if="msg.quote"
|
|
90
|
+
class="mb-2.5 p-2.5 text-sm bg-black/5 dark:bg-white/5 rounded-lg border-l-4 border-gray-400/50 text-left">
|
|
91
|
+
<div class="font-bold opacity-70 mb-1 text-xs">{{ msg.quote.user.username }}:</div>
|
|
92
|
+
<div class="opacity-90">
|
|
93
|
+
<template v-if="msg.quote.elements && msg.quote.elements.length">
|
|
94
|
+
<render-element v-for="(el, i) in msg.quote.elements" :key="i" :element="el"
|
|
95
|
+
:bot-id="selectedBot" :channel-id="selectedChannel" />
|
|
96
|
+
</template>
|
|
97
|
+
<template v-else>{{ msg.quote.content }}</template>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<!-- 消息内容渲染 -->
|
|
101
|
+
<div class="text-left min-w-[20px]">
|
|
102
|
+
<template v-if="msg.elements && msg.elements.length">
|
|
103
|
+
<render-element v-for="(el, i) in msg.elements" :key="i" :element="el" :bot-id="selectedBot"
|
|
104
|
+
:channel-id="selectedChannel" />
|
|
105
|
+
</template>
|
|
106
|
+
<template v-else>{{ msg.content }}</template>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</el-scrollbar>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- 输入区域 -->
|
|
116
|
+
<div
|
|
117
|
+
class="p-4 border-t border-[var(--k-border-color)] bg-[var(--k-card-bg)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
|
|
118
|
+
<!-- 图片预览区域 -->
|
|
119
|
+
<div v-if="uploadedImages.length" class="flex gap-3 mb-3 overflow-x-auto pb-2 scrollbar-hide">
|
|
120
|
+
<div v-for="img in uploadedImages" :key="img.tempId" class="relative w-20 h-20 flex-shrink-0 group">
|
|
121
|
+
<el-image :src="img.preview" fit="cover"
|
|
122
|
+
class="w-full h-full rounded-lg border-2 border-[var(--k-border-color)] shadow-sm" />
|
|
123
|
+
<el-icon
|
|
124
|
+
class="absolute -top-2 -right-2 cursor-pointer text-red-500 bg-white rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity"
|
|
125
|
+
@click="removeImage(img.tempId)">
|
|
126
|
+
<CircleCloseFilled />
|
|
127
|
+
</el-icon>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="flex gap-3 items-end">
|
|
131
|
+
<!-- 图片上传按钮 -->
|
|
132
|
+
<el-upload action="#" :auto-upload="false" :show-file-list="false" :on-change="handleFileChange" multiple>
|
|
133
|
+
<el-button circle class="!w-10 !h-10 !text-xl shadow-sm hover:scale-105 transition-transform">
|
|
134
|
+
<el-icon>
|
|
135
|
+
<Picture />
|
|
136
|
+
</el-icon>
|
|
137
|
+
</el-button>
|
|
138
|
+
</el-upload>
|
|
139
|
+
<!-- 文本输入框,使用 autosize 以适应单行显示 -->
|
|
140
|
+
<el-input v-model="inputText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="输入消息..."
|
|
141
|
+
class="flex-1 !text-base" @keydown.enter.prevent="handleSend" />
|
|
142
|
+
<!-- 发送按钮 -->
|
|
143
|
+
<el-button type="primary" :loading="isSending" @click="handleSend"
|
|
144
|
+
class="px-6 h-10 !text-base font-bold shadow-md">发送</el-button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|
|
148
|
+
<el-empty v-else description="请选择频道开始对话" class="h-full flex items-center justify-center opacity-60" />
|
|
149
|
+
</el-main>
|
|
150
|
+
|
|
151
|
+
<!-- 合并转发详情页 (手机端全屏) -->
|
|
152
|
+
<el-main v-if="isMobile && mobileView === 'forward'"
|
|
153
|
+
class="flex flex-col p-0 bg-[var(--k-page-bg)] absolute inset-0 z-50">
|
|
154
|
+
<div
|
|
155
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] bg-[var(--k-card-bg)] shadow-sm">
|
|
156
|
+
<el-button icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
157
|
+
<span>聊天记录</span>
|
|
158
|
+
</div>
|
|
159
|
+
<el-scrollbar class="flex-1 bg-gray-50 dark:bg-black/10">
|
|
160
|
+
<div class="p-4 space-y-6">
|
|
161
|
+
<div v-for="(msg, idx) in forwardData.messages" :key="idx" class="flex gap-3">
|
|
162
|
+
<el-avatar :size="36" :src="msg.attrs?.avatar" class="flex-shrink-0 shadow-sm">{{ msg.attrs?.nickname?.[0]
|
|
163
|
+
}}</el-avatar>
|
|
164
|
+
<div class="flex-1 overflow-hidden">
|
|
165
|
+
<div class="text-xs text-[var(--k-text-color-secondary)] mb-1 font-bold">{{ msg.attrs?.nickname }}</div>
|
|
166
|
+
<div
|
|
167
|
+
class="p-3 bg-[var(--k-card-bg)] rounded-2xl rounded-tl-none shadow-sm text-sm break-all border border-[var(--k-border-color)]">
|
|
168
|
+
<render-element v-for="(el, i) in msg.children" :key="i" :element="el" :bot-id="selectedBot"
|
|
169
|
+
:channel-id="selectedChannel" />
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</el-scrollbar>
|
|
175
|
+
</el-main>
|
|
176
|
+
|
|
177
|
+
<!-- 图片查看器 (手机端全屏) -->
|
|
178
|
+
<el-main v-if="isMobile && mobileView === 'image'" class="flex flex-col p-0 bg-black absolute inset-0 z-[60]">
|
|
179
|
+
<div class="flex h-14 items-center px-4 font-bold border-b border-white/10 bg-black text-white shadow-sm">
|
|
180
|
+
<el-button icon="ArrowLeft" circle size="small" class="mr-3 !bg-white/10 !border-none !text-white"
|
|
181
|
+
@click="goBack" />
|
|
182
|
+
<span>查看图片</span>
|
|
183
|
+
<div class="flex-1"></div>
|
|
184
|
+
<el-button type="primary" icon="Download" size="small" @click="downloadImage(imageViewer.url)">下载</el-button>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="flex-1 flex items-center justify-center p-4">
|
|
187
|
+
<img :src="imageViewer.url" class="max-w-full max-h-full object-contain shadow-2xl" />
|
|
188
|
+
</div>
|
|
189
|
+
</el-main>
|
|
190
|
+
</el-container>
|
|
191
|
+
|
|
192
|
+
<!-- 合并转发详情弹窗 (桌面端) -->
|
|
193
|
+
<el-dialog v-if="!isMobile" v-model="forwardDialogShow" title="聊天记录" width="550px" class="forward-dialog"
|
|
194
|
+
teleported>
|
|
195
|
+
<el-scrollbar max-height="70vh">
|
|
196
|
+
<div class="p-6 space-y-6 bg-gray-50 dark:bg-black/10">
|
|
197
|
+
<div v-for="(msg, idx) in forwardData.messages" :key="idx" class="flex gap-3">
|
|
198
|
+
<el-avatar :size="36" :src="msg.attrs?.avatar" class="flex-shrink-0 shadow-sm">{{ msg.attrs?.nickname?.[0]
|
|
199
|
+
}}</el-avatar>
|
|
200
|
+
<div class="flex-1 overflow-hidden">
|
|
201
|
+
<div class="text-xs text-[var(--k-text-color-secondary)] mb-1 font-bold">{{ msg.attrs?.nickname }}</div>
|
|
202
|
+
<div
|
|
203
|
+
class="p-3 bg-[var(--k-card-bg)] rounded-2xl rounded-tl-none shadow-sm text-sm break-all border border-[var(--k-border-color)]">
|
|
204
|
+
<render-element v-for="(el, i) in msg.children" :key="i" :element="el" :bot-id="selectedBot"
|
|
205
|
+
:channel-id="selectedChannel" />
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</el-scrollbar>
|
|
211
|
+
</el-dialog>
|
|
212
|
+
|
|
213
|
+
<!-- 图片查看器弹窗 (桌面端) -->
|
|
214
|
+
<el-dialog v-if="!isMobile" v-model="imageViewerShow" title="查看图片" width="fit-content" class="image-viewer-dialog"
|
|
215
|
+
teleported center>
|
|
216
|
+
<div class="flex flex-col items-center gap-4">
|
|
217
|
+
<img :src="imageViewer.url" class="max-w-full max-h-[70vh] rounded shadow-lg" />
|
|
218
|
+
<el-button type="primary" icon="Download" @click="downloadImage(imageViewer.url)">下载图片</el-button>
|
|
219
|
+
</div>
|
|
220
|
+
</el-dialog>
|
|
221
|
+
|
|
222
|
+
<!-- 右键菜单 -->
|
|
223
|
+
<div v-if="menu.show"
|
|
224
|
+
class="fixed bg-[var(--k-card-bg)] border border-[var(--k-border-color)] shadow-xl z-[2000] py-1.5 rounded-lg min-w-[140px] backdrop-blur-sm bg-opacity-90"
|
|
225
|
+
:style="{ left: menu.x + 'px', top: menu.y + 'px' }">
|
|
226
|
+
<div
|
|
227
|
+
class="px-4 py-2.5 cursor-pointer text-sm hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
228
|
+
@click="handleMenuAction('pin')">
|
|
229
|
+
<el-icon>
|
|
230
|
+
<Star />
|
|
231
|
+
</el-icon> {{ menu.isPinned ? '取消置顶' : '置顶' }}
|
|
232
|
+
</div>
|
|
233
|
+
<div
|
|
234
|
+
class="px-4 py-2.5 cursor-pointer text-sm text-red-500 hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
235
|
+
@click="handleMenuAction('delete')">
|
|
236
|
+
<el-icon>
|
|
237
|
+
<Delete />
|
|
238
|
+
</el-icon> 删除数据
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</template>
|
|
243
|
+
|
|
244
|
+
<script setup lang="ts">
|
|
245
|
+
import { ref, h, defineComponent, onMounted, reactive, watch, computed } from 'vue'
|
|
246
|
+
import { StarFilled, Star, Picture, CircleCloseFilled, ArrowLeft, Delete, Collection, Download, Loading } from '@element-plus/icons-vue'
|
|
247
|
+
import { ElMessageBox, ElMessage } from 'element-plus'
|
|
248
|
+
import { send } from '@koishijs/client'
|
|
249
|
+
import { useChatLogic } from './chat-logic'
|
|
250
|
+
|
|
251
|
+
const {
|
|
252
|
+
bots, selectedBot, selectedChannel, currentChannels, currentMessages, currentChannelName,
|
|
253
|
+
inputText, uploadedImages, isSending, pinnedBots, pinnedChannels, scrollRef,
|
|
254
|
+
isMobile, mobileView, goBack, forwardData, imageViewer, isLoadingHistory,
|
|
255
|
+
selectBot, selectChannel, handleSend, togglePinBot, togglePinChannel, deleteBotData, deleteChannelData,
|
|
256
|
+
getCachedImageUrl, cacheImage, showForward, openImageViewer, downloadImage, handleScroll
|
|
257
|
+
} = useChatLogic()
|
|
258
|
+
|
|
259
|
+
const formatTime = (ts: number) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
260
|
+
|
|
261
|
+
const forwardDialogShow = computed({
|
|
262
|
+
get: () => !isMobile.value && mobileView.value === 'forward',
|
|
263
|
+
set: (val) => { if (!val) goBack() }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const imageViewerShow = computed({
|
|
267
|
+
get: () => !isMobile.value && mobileView.value === 'image',
|
|
268
|
+
set: (val) => { if (!val) goBack() }
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// 消息渲染组件
|
|
272
|
+
const RenderElement = defineComponent({
|
|
273
|
+
props: ['element', 'botId', 'channelId'],
|
|
274
|
+
setup(props) {
|
|
275
|
+
const imgUrl = ref('')
|
|
276
|
+
const isMedia = ['img', 'image', 'mface', 'audio', 'video'].includes(props.element.type)
|
|
277
|
+
|
|
278
|
+
const loadMedia = async () => {
|
|
279
|
+
const url = props.element.attrs.src || props.element.attrs.url || props.element.attrs.file
|
|
280
|
+
if (url) {
|
|
281
|
+
const res = await getCachedImageUrl(`${props.botId}:${props.channelId}`, url)
|
|
282
|
+
if (res) imgUrl.value = res
|
|
283
|
+
else {
|
|
284
|
+
const r = await cacheImage(`${props.botId}:${props.channelId}`, url)
|
|
285
|
+
imgUrl.value = r || url
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (isMedia) loadMedia()
|
|
291
|
+
watch(() => props.element.attrs.src || props.element.attrs.url || props.element.attrs.file, loadMedia)
|
|
292
|
+
|
|
293
|
+
return () => {
|
|
294
|
+
const { element, botId, channelId } = props
|
|
295
|
+
const type = element.type
|
|
296
|
+
const attrs = element.attrs
|
|
297
|
+
|
|
298
|
+
if (type === 'text') return h('span', attrs.content)
|
|
299
|
+
if (type === 'img' || type === 'image' || type === 'mface') {
|
|
300
|
+
const isMface = type === 'mface'
|
|
301
|
+
return h('div', { class: 'block my-1.5' }, [
|
|
302
|
+
h('img', {
|
|
303
|
+
src: imgUrl.value,
|
|
304
|
+
class: ['rounded-lg shadow-sm border border-black/5 block cursor-pointer hover:opacity-90 transition-opacity', isMface ? 'max-w-[100px] max-h-[100px]' : 'max-w-full max-h-[400px]'],
|
|
305
|
+
style: `min-width: ${isMface ? '30px' : '50px'}; min-height: ${isMface ? '30px' : '50px'}; object-fit: contain; background: rgba(0,0,0,0.05)`,
|
|
306
|
+
onClick: (e: Event) => {
|
|
307
|
+
e.stopPropagation()
|
|
308
|
+
openImageViewer(imgUrl.value)
|
|
309
|
+
},
|
|
310
|
+
onError: (e: any) => {
|
|
311
|
+
e.target.src = ''
|
|
312
|
+
e.target.alt = '图片加载失败'
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
])
|
|
316
|
+
}
|
|
317
|
+
if (type === 'audio') {
|
|
318
|
+
return h('audio', { src: imgUrl.value, controls: true, class: 'max-w-full my-1.5 block' })
|
|
319
|
+
}
|
|
320
|
+
if (type === 'video') {
|
|
321
|
+
return h('video', { src: imgUrl.value, controls: true, class: 'max-w-full my-1.5 rounded-lg shadow-sm block' })
|
|
322
|
+
}
|
|
323
|
+
if (type === 'at') return h('span', { class: 'text-blue-500 font-bold hover:underline cursor-default mx-0.5' }, `@${attrs.name || attrs.id}`)
|
|
324
|
+
|
|
325
|
+
// 处理 p 元素和 i18n
|
|
326
|
+
if (type === 'p') {
|
|
327
|
+
return h('div', { class: 'my-1 block min-h-[1em]' }, (element.children || []).map((child: any, i: number) => h(RenderElement, { key: i, element: child, botId, channelId })))
|
|
328
|
+
}
|
|
329
|
+
if (type === 'i18n') {
|
|
330
|
+
return h('span', { class: 'opacity-80 italic' }, `[${attrs.path || 'i18n'}]`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 合并转发渲染
|
|
334
|
+
if (type === 'figure' || type === 'forward') {
|
|
335
|
+
return h('div', {
|
|
336
|
+
class: 'my-2 p-3 bg-black/5 dark:bg-white/5 rounded-xl border border-black/10 dark:border-white/10 max-w-[300px] cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-colors',
|
|
337
|
+
onClick: (e: Event) => { e.stopPropagation(); showForward(element.children || []) }
|
|
338
|
+
}, [
|
|
339
|
+
h('div', { class: 'flex items-center gap-2 mb-2 font-bold text-sm opacity-80' }, [
|
|
340
|
+
h('el-icon', null, { default: () => h(Collection) }),
|
|
341
|
+
h('span', '合并转发记录')
|
|
342
|
+
]),
|
|
343
|
+
h('div', { class: 'space-y-1.5' }, (element.children || []).filter((c: any) => c.type === 'message').slice(0, 4).map((child: any) => {
|
|
344
|
+
return h('div', { class: 'text-xs truncate opacity-70' }, [
|
|
345
|
+
h('span', { class: 'font-bold mr-1' }, `${child.attrs?.nickname || '用户'}:`),
|
|
346
|
+
h('span', child.children?.[0]?.attrs?.content || '[富媒体内容]')
|
|
347
|
+
])
|
|
348
|
+
})),
|
|
349
|
+
(element.children?.length > 4) ? h('div', { class: 'mt-2 pt-2 border-t border-black/5 dark:border-white/5 text-[10px] opacity-50' }, `查看更多 ${element.children.length} 条内容...`) : null
|
|
350
|
+
])
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (type === 'quote') return null
|
|
354
|
+
|
|
355
|
+
return h('span', { class: 'text-gray-400 italic text-xs' }, `[${type}]`)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const handleFileChange = async (file: any) => {
|
|
361
|
+
const reader = new FileReader()
|
|
362
|
+
reader.onload = async (e) => {
|
|
363
|
+
const base64 = e.target?.result as string
|
|
364
|
+
const res = await (send as any)('upload-image', {
|
|
365
|
+
file: base64,
|
|
366
|
+
filename: file.name,
|
|
367
|
+
mimeType: file.raw.type
|
|
368
|
+
})
|
|
369
|
+
if (res.success) {
|
|
370
|
+
uploadedImages.value.push({
|
|
371
|
+
tempId: res.tempId,
|
|
372
|
+
preview: URL.createObjectURL(file.raw),
|
|
373
|
+
filename: file.name
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
reader.readAsDataURL(file.raw)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const removeImage = (id: string) => {
|
|
381
|
+
uploadedImages.value = uploadedImages.value.filter(i => i.tempId !== id)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const menu = ref({ show: false, x: 0, y: 0, type: '', id: '', isPinned: false })
|
|
385
|
+
const onBotMenu = (e: MouseEvent, bot: any) => {
|
|
386
|
+
menu.value = { show: true, x: e.clientX, y: e.clientY, type: 'bot', id: bot.selfId, isPinned: pinnedBots.value.has(bot.selfId) }
|
|
387
|
+
}
|
|
388
|
+
const onChannelMenu = (e: MouseEvent, channel: any) => {
|
|
389
|
+
menu.value = { show: true, x: e.clientX, y: e.clientY, type: 'channel', id: channel.id, isPinned: pinnedChannels.value.has(`${selectedBot.value}:${channel.id}`) }
|
|
390
|
+
}
|
|
391
|
+
const handleMenuAction = async (action: string) => {
|
|
392
|
+
menu.value.show = false
|
|
393
|
+
if (action === 'pin') {
|
|
394
|
+
if (menu.value.type === 'bot') await togglePinBot(menu.value.id, menu.value.isPinned, pinnedBots.value)
|
|
395
|
+
else await togglePinChannel(selectedBot.value, menu.value.id, menu.value.isPinned, pinnedChannels.value)
|
|
396
|
+
} else if (action === 'delete') {
|
|
397
|
+
await ElMessageBox.confirm('确定删除所有数据吗?此操作不可恢复。', '警告', {
|
|
398
|
+
type: 'warning',
|
|
399
|
+
confirmButtonClass: 'el-button--danger'
|
|
400
|
+
})
|
|
401
|
+
if (menu.value.type === 'bot') await deleteBotData(menu.value.id)
|
|
402
|
+
else await deleteChannelData(selectedBot.value, menu.value.id)
|
|
403
|
+
location.reload()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
onMounted(() => {
|
|
408
|
+
window.addEventListener('click', () => menu.value.show = false)
|
|
409
|
+
})
|
|
410
|
+
</script>
|
|
411
|
+
|
|
412
|
+
<style>
|
|
413
|
+
.scrollbar-hide::-webkit-scrollbar {
|
|
414
|
+
display: none;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.chat-patch-wrapper .el-textarea__inner {
|
|
418
|
+
border-radius: 12px;
|
|
419
|
+
padding: 10px 12px;
|
|
420
|
+
resize: none;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.chat-patch-wrapper .el-button--primary {
|
|
424
|
+
border-radius: 10px;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.forward-dialog .el-dialog__body {
|
|
428
|
+
padding: 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.image-viewer-dialog .el-dialog__header {
|
|
432
|
+
padding-bottom: 10px;
|
|
433
|
+
}
|
|
434
|
+
</style>
|