koishi-plugin-chat-patch 2.0.1 → 2.1.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/index.scss +50 -0
- package/client/vue/chat-logic.ts +471 -11
- package/client/vue/composables/useChatData.ts +4 -2
- package/client/vue/index.vue +279 -101
- package/client/vue/types.ts +2 -0
- package/dist/index.js +2 -2
- package/dist/style.css +1 -1
- package/lib/api-handlers.d.ts +19 -0
- package/lib/index.js +226 -132
- package/lib/message-handler.d.ts +21 -0
- package/lib/types.d.ts +57 -0
- package/package.json +1 -1
- package/src/api-handlers.ts +122 -94
- package/src/message-handler.ts +160 -62
- package/src/types.ts +2 -0
package/client/vue/index.vue
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
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
|
-
|
|
3
|
+
class="chat-patch-wrapper absolute inset-0 flex overflow-hidden bg-[var(--k-page-bg)] text-[var(--k-text-color)] font-sans"
|
|
4
|
+
:style="isMobile ? 'height: 100dvh; width: 100vw; position: fixed; top: 0; left: 0;' : 'height: 100%; width: 100%;'">
|
|
5
|
+
<el-container class="h-full w-full overflow-hidden">
|
|
5
6
|
<!-- 机器人列表 -->
|
|
6
7
|
<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
|
+
class="flex flex-col border-r border-[var(--k-border-color)] bg-[var(--k-page-bg)] brightness-95 dark:brightness-90 h-full overflow-hidden">
|
|
8
9
|
<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
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] text-lg text-[var(--k-text-color)] flex-shrink-0">
|
|
10
11
|
机器人</div>
|
|
11
|
-
<el-scrollbar class="flex-1">
|
|
12
|
+
<el-scrollbar class="flex-1 overflow-auto">
|
|
13
|
+
<div v-if="bots.length === 0" class="p-10 text-center opacity-40 text-sm">暂无机器人数据</div>
|
|
12
14
|
<div v-for="bot in bots" :key="bot.selfId"
|
|
13
15
|
: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
16
|
@click="selectBot(bot.selfId)" @contextmenu.prevent="onBotMenu($event, bot)">
|
|
@@ -32,13 +34,14 @@
|
|
|
32
34
|
<!-- 频道列表 -->
|
|
33
35
|
<el-aside v-show="(!isMobile && selectedBot) || (isMobile && mobileView === 'channels')"
|
|
34
36
|
: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">
|
|
37
|
+
class="flex flex-col border-r border-[var(--k-border-color)] bg-[var(--k-page-bg)] brightness-100 dark:brightness-95 h-full overflow-hidden">
|
|
36
38
|
<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)]">
|
|
39
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] text-lg text-[var(--k-text-color)] flex-shrink-0">
|
|
38
40
|
<el-button v-if="isMobile" icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
39
41
|
频道
|
|
40
42
|
</div>
|
|
41
|
-
<el-scrollbar class="flex-1">
|
|
43
|
+
<el-scrollbar class="flex-1 overflow-auto">
|
|
44
|
+
<div v-if="currentChannels.length === 0" class="p-10 text-center opacity-40 text-sm">暂无频道数据</div>
|
|
42
45
|
<div v-for="channel in currentChannels" :key="channel.id"
|
|
43
46
|
: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
47
|
@click="selectChannel(channel.id)" @contextmenu.prevent="onChannelMenu($event, channel)">
|
|
@@ -56,25 +59,29 @@
|
|
|
56
59
|
|
|
57
60
|
<!-- 消息主区域 -->
|
|
58
61
|
<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">
|
|
62
|
+
class="flex flex-col p-0 bg-[var(--k-page-bg)] relative brightness-105 dark:brightness-100 h-full overflow-hidden">
|
|
60
63
|
<template v-if="selectedBot && selectedChannel">
|
|
61
64
|
<div
|
|
62
65
|
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
66
|
<el-button v-if="isMobile" icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
64
67
|
<span class="truncate">{{ currentChannelName }}</span>
|
|
68
|
+
<span v-if="selectedChannel" class="ml-2 text-xs opacity-40 font-mono hidden md:inline">({{ selectedChannel
|
|
69
|
+
}})</span>
|
|
65
70
|
</div>
|
|
66
71
|
|
|
67
72
|
<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
|
|
73
|
+
<el-scrollbar ref="scrollRef" @scroll="handleScroll" class="h-full">
|
|
74
|
+
<div class="p-6 flex flex-col"
|
|
75
|
+
:style="isMobile ? { paddingBottom: 'calc(180px + env(safe-area-inset-bottom))' } : { minHeight: '100%' }">
|
|
70
76
|
<div v-if="isLoadingHistory" class="flex justify-center py-4">
|
|
71
77
|
<el-icon class="is-loading text-[var(--k-color-primary)]">
|
|
72
78
|
<Loading />
|
|
73
79
|
</el-icon>
|
|
74
80
|
</div>
|
|
75
|
-
<div v-for="msg in currentMessages" :key="msg.id"
|
|
81
|
+
<div v-for="msg in currentMessages" :key="msg.id" :data-id="msg.id"
|
|
76
82
|
: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"
|
|
83
|
+
<el-avatar :size="40" :src="msg.avatar"
|
|
84
|
+
class="flex-shrink-0 shadow-sm cursor-pointer hover:brightness-90" @click="showUserProfile(msg)">
|
|
78
85
|
{{ msg.username[0] }}
|
|
79
86
|
</el-avatar>
|
|
80
87
|
<div
|
|
@@ -84,26 +91,52 @@
|
|
|
84
91
|
<span class="opacity-60">{{ formatTime(msg.timestamp) }}</span>
|
|
85
92
|
</div>
|
|
86
93
|
<div
|
|
87
|
-
:class="['
|
|
88
|
-
<!--
|
|
89
|
-
<div
|
|
90
|
-
class="
|
|
91
|
-
|
|
92
|
-
<div class="
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
</template>
|
|
97
|
-
<template v-else>{{ msg.quote.content }}</template>
|
|
94
|
+
:class="['flex items-end gap-2 group/msg', (msg.isBot || msg.userId === selectedBot) ? 'flex-row' : 'flex-row-reverse']">
|
|
95
|
+
<!-- +1 按钮:靠近屏幕中心的一侧,底部对齐 -->
|
|
96
|
+
<div
|
|
97
|
+
:class="['transition-opacity cursor-pointer text-blue-500 hover:scale-110 active:scale-95 mb-1', isMobile ? 'opacity-100' : 'opacity-0 group-hover/msg:opacity-100']"
|
|
98
|
+
title="复读这条消息" @click.stop="repeatMessage(msg)">
|
|
99
|
+
<div v-if="msg.sending" class="w-8 h-8 flex items-center justify-center">
|
|
100
|
+
<el-icon class="is-loading">
|
|
101
|
+
<Loading />
|
|
102
|
+
</el-icon>
|
|
98
103
|
</div>
|
|
104
|
+
<div v-else
|
|
105
|
+
class="w-8 h-8 rounded-full border-2 border-blue-500 flex items-center justify-center font-bold text-xs bg-blue-50/10">
|
|
106
|
+
+1</div>
|
|
99
107
|
</div>
|
|
100
|
-
|
|
101
|
-
<div
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
|
|
109
|
+
<div
|
|
110
|
+
:class="['p-3.5 rounded-2xl shadow-sm text-[15px] leading-relaxed break-all relative cursor-context-menu', (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)]']"
|
|
111
|
+
@contextmenu.stop="onMessageMenu($event, msg)">
|
|
112
|
+
<!-- 引用消息渲染 -->
|
|
113
|
+
<div v-if="msg.quote"
|
|
114
|
+
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 relative group/quote">
|
|
115
|
+
<div class="font-bold opacity-70 mb-1 text-xs">{{ msg.quote.user.username }}:</div>
|
|
116
|
+
<div class="opacity-90">
|
|
117
|
+
<template v-if="msg.quote.elements && msg.quote.elements.length">
|
|
118
|
+
<render-element v-for="(el, i) in msg.quote.elements" :key="i" :element="el"
|
|
119
|
+
:bot-id="selectedBot" :channel-id="selectedChannel" :in-quote="true" />
|
|
120
|
+
</template>
|
|
121
|
+
<template v-else>{{ msg.quote.content }}</template>
|
|
122
|
+
</div>
|
|
123
|
+
<!-- 定位消息按钮 -->
|
|
124
|
+
<div
|
|
125
|
+
class="absolute top-1 right-1 opacity-0 group-hover/quote:opacity-100 transition-opacity cursor-pointer text-[var(--k-color-primary)] hover:scale-110"
|
|
126
|
+
title="定位到原消息" @click.stop="scrollToMessage(msg.quote.id)">
|
|
127
|
+
<el-icon>
|
|
128
|
+
<Top />
|
|
129
|
+
</el-icon>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<!-- 消息内容渲染 -->
|
|
133
|
+
<div class="text-left min-w-[20px]">
|
|
134
|
+
<template v-if="msg.elements && msg.elements.length">
|
|
135
|
+
<render-element v-for="(el, i) in msg.elements" :key="i" :element="el" :bot-id="selectedBot"
|
|
136
|
+
:channel-id="selectedChannel" />
|
|
137
|
+
</template>
|
|
138
|
+
<template v-else-if="msg.content">{{ msg.content }}</template>
|
|
139
|
+
</div>
|
|
107
140
|
</div>
|
|
108
141
|
</div>
|
|
109
142
|
</div>
|
|
@@ -114,19 +147,38 @@
|
|
|
114
147
|
|
|
115
148
|
<!-- 输入区域 -->
|
|
116
149
|
<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)]"
|
|
150
|
+
: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)]', isMobile ? 'fixed left-0 right-0 z-50 transition-all duration-200' : '']"
|
|
151
|
+
:style="isMobile ? {
|
|
152
|
+
bottom: keyboardHeight > 0 ? `${keyboardHeight}px` : '0',
|
|
153
|
+
paddingBottom: 'max(env(safe-area-inset-bottom), 16px)'
|
|
154
|
+
} : {}">
|
|
118
155
|
<!-- 图片预览区域 -->
|
|
119
156
|
<div v-if="uploadedImages.length" class="flex gap-3 mb-3 overflow-x-auto pb-2 scrollbar-hide">
|
|
120
157
|
<div v-for="img in uploadedImages" :key="img.tempId" class="relative w-20 h-20 flex-shrink-0 group">
|
|
121
158
|
<el-image :src="img.preview" fit="cover"
|
|
122
|
-
class="w-full h-full rounded-lg border-2 border-[var(--k-border-color)] shadow-sm"
|
|
159
|
+
class="w-full h-full rounded-lg border-2 border-[var(--k-border-color)] shadow-sm cursor-pointer hover:brightness-90"
|
|
160
|
+
@click="openImageViewer(img.preview)" />
|
|
123
161
|
<el-icon
|
|
124
|
-
class="absolute -top-
|
|
125
|
-
@click="removeImage(img.tempId)">
|
|
162
|
+
class="absolute -top-1.5 -right-1.5 cursor-pointer text-red-500 bg-white rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
|
163
|
+
@click.stop="removeImage(img.tempId)">
|
|
126
164
|
<CircleCloseFilled />
|
|
127
165
|
</el-icon>
|
|
128
166
|
</div>
|
|
129
167
|
</div>
|
|
168
|
+
<div v-if="replyingTo"
|
|
169
|
+
class="mb-2 px-3 py-1.5 bg-black/5 dark:bg-white/5 rounded-lg flex items-center justify-between text-xs">
|
|
170
|
+
<div class="flex items-center gap-2 truncate opacity-70">
|
|
171
|
+
<el-icon>
|
|
172
|
+
<ChatLineRound />
|
|
173
|
+
</el-icon>
|
|
174
|
+
<span>回复 {{ replyingTo.username }}: {{ replyingTo.content.slice(0, 20) }}{{ replyingTo.content.length >
|
|
175
|
+
20 ? '...'
|
|
176
|
+
: '' }}</span>
|
|
177
|
+
</div>
|
|
178
|
+
<el-icon class="cursor-pointer hover:text-red-500" @click="replyingTo = null">
|
|
179
|
+
<Close />
|
|
180
|
+
</el-icon>
|
|
181
|
+
</div>
|
|
130
182
|
<div class="flex gap-3 items-end">
|
|
131
183
|
<!-- 图片上传按钮 -->
|
|
132
184
|
<el-upload action="#" :auto-upload="false" :show-file-list="false" :on-change="handleFileChange" multiple>
|
|
@@ -137,8 +189,9 @@
|
|
|
137
189
|
</el-button>
|
|
138
190
|
</el-upload>
|
|
139
191
|
<!-- 文本输入框,使用 autosize 以适应单行显示 -->
|
|
140
|
-
<el-input v-model="inputText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }"
|
|
141
|
-
class="flex-1 !text-base" @keydown.enter.prevent="handleSend"
|
|
192
|
+
<el-input ref="inputRef" v-model="inputText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }"
|
|
193
|
+
placeholder="输入消息..." class="flex-1 !text-base" @keydown.enter.prevent="handleSend"
|
|
194
|
+
@paste="handlePaste" />
|
|
142
195
|
<!-- 发送按钮 -->
|
|
143
196
|
<el-button type="primary" :loading="isSending" @click="handleSend"
|
|
144
197
|
class="px-6 h-10 !text-base font-bold shadow-md">发送</el-button>
|
|
@@ -150,7 +203,7 @@
|
|
|
150
203
|
|
|
151
204
|
<!-- 合并转发详情页 (手机端全屏) -->
|
|
152
205
|
<el-main v-if="isMobile && mobileView === 'forward'"
|
|
153
|
-
class="flex flex-col p-0 bg-[var(--k-page-bg)] absolute inset-0 z-50">
|
|
206
|
+
class="flex flex-col p-0 bg-[var(--k-page-bg)] absolute inset-0 z-50 overflow-hidden" style="height: 100dvh;">
|
|
154
207
|
<div
|
|
155
208
|
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
209
|
<el-button icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
@@ -175,22 +228,86 @@
|
|
|
175
228
|
</el-main>
|
|
176
229
|
|
|
177
230
|
<!-- 图片查看器 (手机端全屏) -->
|
|
178
|
-
<el-main v-if="isMobile && mobileView === 'image'"
|
|
179
|
-
|
|
231
|
+
<el-main v-if="isMobile && mobileView === 'image'"
|
|
232
|
+
class="flex flex-col p-0 bg-black absolute inset-0 z-[60] overflow-hidden" style="height: 100dvh;">
|
|
233
|
+
<div
|
|
234
|
+
class="flex h-14 items-center px-4 font-bold border-b border-white/10 bg-black text-white shadow-sm flex-shrink-0">
|
|
180
235
|
<el-button icon="ArrowLeft" circle size="small" class="mr-3 !bg-white/10 !border-none !text-white"
|
|
181
236
|
@click="goBack" />
|
|
182
|
-
<span>查看图片</span>
|
|
237
|
+
<span class="truncate mr-2">查看图片</span>
|
|
183
238
|
<div class="flex-1"></div>
|
|
184
239
|
<el-button type="primary" icon="Download" size="small" @click="downloadImage(imageViewer.url)">下载</el-button>
|
|
185
240
|
</div>
|
|
186
|
-
<div class="flex-1 flex items-center justify-center
|
|
187
|
-
<img :src="imageViewer.url"
|
|
241
|
+
<div class="flex-1 overflow-hidden relative flex items-center justify-center" @wheel="handleImageWheel">
|
|
242
|
+
<img :src="imageViewer.url"
|
|
243
|
+
class="max-w-full max-h-full object-contain transition-transform duration-200 ease-out will-change-transform"
|
|
244
|
+
:style="{ transform: `scale(${imageZoom})` }" />
|
|
245
|
+
</div>
|
|
246
|
+
</el-main>
|
|
247
|
+
|
|
248
|
+
<!-- 原始消息查看页 (手机端全屏) -->
|
|
249
|
+
<el-main v-if="isMobile && mobileView === 'raw'"
|
|
250
|
+
class="flex flex-col p-0 bg-[var(--k-page-bg)] absolute inset-0 z-[80] overflow-hidden" style="height: 100dvh;">
|
|
251
|
+
<div
|
|
252
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] bg-[var(--k-card-bg)] shadow-sm">
|
|
253
|
+
<el-button icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
254
|
+
<div class="flex-1 text-center pr-8">原始消息</div>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="p-4 flex flex-col gap-4 h-full overflow-hidden">
|
|
257
|
+
<el-input v-model="rawMessage.content" type="textarea" :rows="15" readonly class="flex-1 raw-content-area" />
|
|
258
|
+
<div class="flex gap-3 pb-8">
|
|
259
|
+
<el-button type="primary" class="flex-1"
|
|
260
|
+
@click="copyToClipboard(rawMessage.content).then(() => ElMessage.success('已复制'))">复制内容</el-button>
|
|
261
|
+
<el-button class="flex-1" @click="goBack">返回</el-button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</el-main>
|
|
265
|
+
|
|
266
|
+
<!-- 用户资料页 (手机端全屏) -->
|
|
267
|
+
<el-main v-if="isMobile && mobileView === 'profile'"
|
|
268
|
+
class="flex flex-col p-0 bg-[var(--k-page-bg)] absolute inset-0 z-[70] overflow-hidden" style="height: 100dvh;">
|
|
269
|
+
<div
|
|
270
|
+
class="flex h-14 items-center px-4 font-bold border-b border-[var(--k-border-color)] bg-[var(--k-card-bg)] shadow-sm">
|
|
271
|
+
<el-button icon="ArrowLeft" circle size="small" class="mr-3" @click="goBack" />
|
|
272
|
+
<div class="flex-1 text-center pr-8">用户资料</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="p-8 flex flex-col items-center gap-6">
|
|
275
|
+
<el-avatar :size="120" :src="userProfile.data?.avatar" class="shadow-lg">{{ userProfile.data?.username?.[0]
|
|
276
|
+
}}</el-avatar>
|
|
277
|
+
<div class="text-center">
|
|
278
|
+
<div class="text-2xl font-bold mb-2">{{ userProfile.data?.username || userProfile.data?.name || '未知用户' }}
|
|
279
|
+
</div>
|
|
280
|
+
<div class="text-sm opacity-60 font-mono">ID: {{ userProfile.data?.userId || userProfile.data?.id }}</div>
|
|
281
|
+
</div>
|
|
282
|
+
<el-descriptions :column="1" border class="w-full mt-4">
|
|
283
|
+
<el-descriptions-item label="昵称">{{ userProfile.data?.username || userProfile.data?.name
|
|
284
|
+
}}</el-descriptions-item>
|
|
285
|
+
<el-descriptions-item label="平台">{{ selectedBotPlatform }}</el-descriptions-item>
|
|
286
|
+
</el-descriptions>
|
|
188
287
|
</div>
|
|
189
288
|
</el-main>
|
|
190
289
|
</el-container>
|
|
191
290
|
|
|
291
|
+
<!-- 用户资料弹窗 (桌面端) -->
|
|
292
|
+
<el-dialog v-if="!isMobile" v-model="userProfileVisible" title="用户资料" width="400px" center teleported align-center>
|
|
293
|
+
<div class="flex flex-col items-center gap-4 py-4">
|
|
294
|
+
<el-avatar :size="80" :src="userProfile.data?.avatar" class="shadow">{{ userProfile.data?.username?.[0]
|
|
295
|
+
}}</el-avatar>
|
|
296
|
+
<div class="text-center">
|
|
297
|
+
<div class="text-xl font-bold">{{ userProfile.data?.username || userProfile.data?.name || '未知用户' }}</div>
|
|
298
|
+
<div class="text-xs opacity-50 mt-1 font-mono">ID: {{ userProfile.data?.userId || userProfile.data?.id }}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
<el-descriptions :column="1" border class="w-full mt-4">
|
|
302
|
+
<el-descriptions-item label="昵称">{{ userProfile.data?.username || userProfile.data?.name
|
|
303
|
+
}}</el-descriptions-item>
|
|
304
|
+
<el-descriptions-item label="平台">{{ selectedBotPlatform }}</el-descriptions-item>
|
|
305
|
+
</el-descriptions>
|
|
306
|
+
</div>
|
|
307
|
+
</el-dialog>
|
|
308
|
+
|
|
192
309
|
<!-- 合并转发详情弹窗 (桌面端) -->
|
|
193
|
-
<el-dialog v-if="!isMobile" v-model="
|
|
310
|
+
<el-dialog v-if="!isMobile" v-model="forwardDialogVisible" title="聊天记录" width="550px" class="forward-dialog"
|
|
194
311
|
teleported>
|
|
195
312
|
<el-scrollbar max-height="70vh">
|
|
196
313
|
<div class="p-6 space-y-6 bg-gray-50 dark:bg-black/10">
|
|
@@ -211,39 +328,94 @@
|
|
|
211
328
|
</el-dialog>
|
|
212
329
|
|
|
213
330
|
<!-- 图片查看器弹窗 (桌面端) -->
|
|
214
|
-
<el-dialog v-if="!isMobile" v-model="
|
|
215
|
-
teleported center>
|
|
331
|
+
<el-dialog v-if="!isMobile" v-model="imageViewerVisible" title="查看图片" width="fit-content"
|
|
332
|
+
class="image-viewer-dialog" teleported center>
|
|
216
333
|
<div class="flex flex-col items-center gap-4">
|
|
217
334
|
<img :src="imageViewer.url" class="max-w-full max-h-[70vh] rounded shadow-lg" />
|
|
218
335
|
<el-button type="primary" icon="Download" @click="downloadImage(imageViewer.url)">下载图片</el-button>
|
|
219
336
|
</div>
|
|
220
337
|
</el-dialog>
|
|
221
338
|
|
|
339
|
+
<!-- 原始消息查看弹窗 (桌面端) -->
|
|
340
|
+
<el-dialog v-if="!isMobile" v-model="rawMessageVisible" title="原始消息内容" width="600px" center teleported align-center>
|
|
341
|
+
<div class="flex flex-col gap-4">
|
|
342
|
+
<el-input v-model="rawMessage.content" type="textarea" :rows="12" readonly class="raw-content-area" />
|
|
343
|
+
<div class="flex justify-center gap-3">
|
|
344
|
+
<el-button type="primary"
|
|
345
|
+
@click="copyToClipboard(rawMessage.content).then(() => ElMessage.success('已复制到剪贴板'))">复制全部内容</el-button>
|
|
346
|
+
<el-button @click="rawMessageVisible = false">关闭</el-button>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</el-dialog>
|
|
350
|
+
|
|
222
351
|
<!-- 右键菜单 -->
|
|
223
352
|
<div v-if="menu.show"
|
|
224
353
|
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
354
|
:style="{ left: menu.x + 'px', top: menu.y + 'px' }">
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
355
|
+
|
|
356
|
+
<!-- 机器人/频道菜单 -->
|
|
357
|
+
<template v-if="menu.type === 'bot' || menu.type === 'channel'">
|
|
358
|
+
<div
|
|
359
|
+
class="px-4 py-2.5 cursor-pointer text-sm hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
360
|
+
@click="onHandleMenuAction('pin')">
|
|
361
|
+
<el-icon>
|
|
362
|
+
<Star />
|
|
363
|
+
</el-icon> {{ menu.isPinned ? '取消置顶' : '置顶' }}
|
|
364
|
+
</div>
|
|
365
|
+
<div
|
|
366
|
+
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"
|
|
367
|
+
@click="onHandleMenuAction('delete')">
|
|
368
|
+
<el-icon>
|
|
369
|
+
<Delete />
|
|
370
|
+
</el-icon> 删除数据
|
|
371
|
+
</div>
|
|
372
|
+
</template>
|
|
373
|
+
|
|
374
|
+
<!-- 消息菜单 -->
|
|
375
|
+
<template v-else-if="menu.type === 'message'">
|
|
376
|
+
<div
|
|
377
|
+
class="px-4 py-2.5 cursor-pointer text-sm hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
378
|
+
@click="handleMessageAction('copy')">
|
|
379
|
+
<el-icon>
|
|
380
|
+
<DocumentCopy />
|
|
381
|
+
</el-icon> 复制文本
|
|
382
|
+
</div>
|
|
383
|
+
<div
|
|
384
|
+
class="px-4 py-2.5 cursor-pointer text-sm hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
385
|
+
@click="handleMessageAction('copy-raw')">
|
|
386
|
+
<el-icon>
|
|
387
|
+
<Collection />
|
|
388
|
+
</el-icon> 查看原始消息
|
|
389
|
+
</div>
|
|
390
|
+
<div
|
|
391
|
+
class="px-4 py-2.5 cursor-pointer text-sm hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
392
|
+
@click="handleMessageAction('plus1')">
|
|
393
|
+
<el-icon>
|
|
394
|
+
<CirclePlus />
|
|
395
|
+
</el-icon> +1 复读
|
|
396
|
+
</div>
|
|
397
|
+
<div
|
|
398
|
+
class="px-4 py-2.5 cursor-pointer text-sm hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
399
|
+
@click="handleMessageAction('reply')">
|
|
400
|
+
<el-icon>
|
|
401
|
+
<ChatLineRound />
|
|
402
|
+
</el-icon> 回复
|
|
403
|
+
</div>
|
|
404
|
+
<div v-if="menu.hasMedia"
|
|
405
|
+
class="px-4 py-2.5 cursor-pointer text-sm hover:bg-[var(--k-button-hover-bg)] transition-colors flex items-center gap-2"
|
|
406
|
+
@click="handleMessageAction('download')">
|
|
407
|
+
<el-icon>
|
|
408
|
+
<Download />
|
|
409
|
+
</el-icon> 下载媒体
|
|
410
|
+
</div>
|
|
411
|
+
</template>
|
|
240
412
|
</div>
|
|
241
413
|
</div>
|
|
242
414
|
</template>
|
|
243
415
|
|
|
244
416
|
<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'
|
|
417
|
+
import { ref, h, defineComponent, onMounted, reactive, watch, computed, nextTick } from 'vue'
|
|
418
|
+
import { StarFilled, Star, Picture, CircleCloseFilled, ArrowLeft, Delete, Collection, Download, Loading, DocumentCopy, CirclePlus, ChatLineRound, Close, Top } from '@element-plus/icons-vue'
|
|
247
419
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
|
248
420
|
import { send } from '@koishijs/client'
|
|
249
421
|
import { useChatLogic } from './chat-logic'
|
|
@@ -251,26 +423,36 @@ import { useChatLogic } from './chat-logic'
|
|
|
251
423
|
const {
|
|
252
424
|
bots, selectedBot, selectedChannel, currentChannels, currentMessages, currentChannelName,
|
|
253
425
|
inputText, uploadedImages, isSending, pinnedBots, pinnedChannels, scrollRef,
|
|
254
|
-
isMobile, mobileView, goBack, forwardData, imageViewer, isLoadingHistory,
|
|
426
|
+
isMobile, mobileView, goBack, forwardData, imageViewer, imageZoom, rawMessage, isLoadingHistory,
|
|
427
|
+
forwardDialogVisible, imageViewerVisible, rawMessageVisible, replyingTo, userProfile, userProfileVisible,
|
|
428
|
+
selectedBotPlatform, keyboardHeight,
|
|
255
429
|
selectBot, selectChannel, handleSend, togglePinBot, togglePinChannel, deleteBotData, deleteChannelData,
|
|
256
|
-
getCachedImageUrl, cacheImage, showForward, openImageViewer, downloadImage, handleScroll
|
|
430
|
+
getCachedImageUrl, cacheImage, showForward, openImageViewer, handleImageWheel, downloadImage, handleScroll,
|
|
431
|
+
repeatMessage, handlePaste, copyToClipboard, onBotMenu, onChannelMenu, onMessageMenu, handleMenuAction, handleMessageAction, showUserProfile,
|
|
432
|
+
menu, inputRef, scrollToMessage
|
|
257
433
|
} = useChatLogic()
|
|
258
434
|
|
|
259
435
|
const formatTime = (ts: number) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
260
436
|
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
})
|
|
437
|
+
const onHandleMenuAction = async (action: string) => {
|
|
438
|
+
if (action === 'delete') {
|
|
439
|
+
try {
|
|
440
|
+
await ElMessageBox.confirm('确定删除所有数据吗?此操作不可恢复。', '警告', {
|
|
441
|
+
type: 'warning',
|
|
442
|
+
confirmButtonClass: 'el-button--danger'
|
|
443
|
+
})
|
|
444
|
+
await handleMenuAction(action)
|
|
445
|
+
} catch (e) {
|
|
446
|
+
// 取消删除
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
await handleMenuAction(action)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
270
452
|
|
|
271
453
|
// 消息渲染组件
|
|
272
454
|
const RenderElement = defineComponent({
|
|
273
|
-
props: ['element', 'botId', 'channelId'],
|
|
455
|
+
props: ['element', 'botId', 'channelId', 'inQuote'],
|
|
274
456
|
setup(props) {
|
|
275
457
|
const imgUrl = ref('')
|
|
276
458
|
const isMedia = ['img', 'image', 'mface', 'audio', 'video'].includes(props.element.type)
|
|
@@ -290,6 +472,20 @@ const RenderElement = defineComponent({
|
|
|
290
472
|
if (isMedia) loadMedia()
|
|
291
473
|
watch(() => props.element.attrs.src || props.element.attrs.url || props.element.attrs.file, loadMedia)
|
|
292
474
|
|
|
475
|
+
// 图片加载完成后尝试滚动到底部(如果当前就在底部附近)
|
|
476
|
+
const onImageLoad = () => {
|
|
477
|
+
if (props.inQuote) return
|
|
478
|
+
const wrap = document.querySelector('.el-main .el-scrollbar__wrap')
|
|
479
|
+
if (wrap) {
|
|
480
|
+
const isAtBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 150
|
|
481
|
+
if (isAtBottom) {
|
|
482
|
+
nextTick(() => {
|
|
483
|
+
wrap.scrollTop = wrap.scrollHeight
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
293
489
|
return () => {
|
|
294
490
|
const { element, botId, channelId } = props
|
|
295
491
|
const type = element.type
|
|
@@ -297,6 +493,7 @@ const RenderElement = defineComponent({
|
|
|
297
493
|
|
|
298
494
|
if (type === 'text') return h('span', attrs.content)
|
|
299
495
|
if (type === 'img' || type === 'image' || type === 'mface') {
|
|
496
|
+
if (props.inQuote) return h('span', { class: 'opacity-60 italic mx-1' }, '[图片]')
|
|
300
497
|
const isMface = type === 'mface'
|
|
301
498
|
return h('div', { class: 'block my-1.5' }, [
|
|
302
499
|
h('img', {
|
|
@@ -307,6 +504,7 @@ const RenderElement = defineComponent({
|
|
|
307
504
|
e.stopPropagation()
|
|
308
505
|
openImageViewer(imgUrl.value)
|
|
309
506
|
},
|
|
507
|
+
onLoad: onImageLoad,
|
|
310
508
|
onError: (e: any) => {
|
|
311
509
|
e.target.src = ''
|
|
312
510
|
e.target.alt = '图片加载失败'
|
|
@@ -380,33 +578,6 @@ const handleFileChange = async (file: any) => {
|
|
|
380
578
|
const removeImage = (id: string) => {
|
|
381
579
|
uploadedImages.value = uploadedImages.value.filter(i => i.tempId !== id)
|
|
382
580
|
}
|
|
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
581
|
</script>
|
|
411
582
|
|
|
412
583
|
<style>
|
|
@@ -420,6 +591,13 @@ onMounted(() => {
|
|
|
420
591
|
resize: none;
|
|
421
592
|
}
|
|
422
593
|
|
|
594
|
+
.chat-patch-wrapper .raw-content-area .el-textarea__inner {
|
|
595
|
+
font-family: monospace;
|
|
596
|
+
font-size: 14px;
|
|
597
|
+
line-height: 1.5;
|
|
598
|
+
background: var(--k-card-bg) !important;
|
|
599
|
+
}
|
|
600
|
+
|
|
423
601
|
.chat-patch-wrapper .el-button--primary {
|
|
424
602
|
border-radius: 10px;
|
|
425
603
|
}
|