koishi-plugin-chat-patch 1.3.0 → 2.0.1

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.
@@ -1,362 +1,434 @@
1
- <style scoped src="./style.css"></style>
2
-
3
1
  <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>
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>
44
28
  </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) }}
29
+ </el-scrollbar>
30
+ </el-aside>
52
31
 
53
- </div>
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
+ 频道
54
40
  </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
- <!-- 加载更多指示器 -->
70
- <div v-if="isLoadingMore" class="loading-more-indicator">
71
- <div class="loading-spinner"></div>
72
- <span>加载更多消息中...</span>
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>
73
53
  </div>
54
+ </el-scrollbar>
55
+ </el-aside>
74
56
 
75
- <div v-for="message in currentMessages" :key="message.id"
76
- :class="['message-item', { 'bot-message': message.isBot }]"
77
- @contextmenu="message.isBot ? null : handleMessageRightClick($event, message)">
78
- <div class="message-avatar">
79
- <AvatarComponent v-if="message.avatar" :src="message.avatar" :alt="message.username"
80
- :channel-key="currentChannelKey" />
81
- <div v-else class="avatar-placeholder">{{ message.username.charAt(0).toUpperCase() }}</div>
82
- </div>
83
- <div class="message-content-wrapper">
84
- <div class="message-header">
85
- <span class="message-username">{{ message.username }}</span>
86
- <span class="message-time">{{ formatTime(message.timestamp) }}</span>
87
- </div>
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>
88
66
 
89
- <!-- 引用消息显示 -->
90
- <div v-if="message.quote || getInlineQuoteMessage(message, currentMessages)" class="message-quote">
91
- <div class="quote-header">
92
- <div class="quote-avatar">
93
- <AvatarComponent v-if="getQuoteUser(message, currentMessages).avatar"
94
- :src="getQuoteUser(message, currentMessages).avatar"
95
- :alt="getQuoteUser(message, currentMessages).username" :channel-key="currentChannelKey" />
96
- <div v-else class="avatar-placeholder">{{
97
- getQuoteUser(message, currentMessages).username.charAt(0).toUpperCase() }}
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>
98
108
  </div>
99
109
  </div>
100
- <span class="quote-username">{{ getQuoteUser(message, currentMessages).username }}</span>
101
- <span class="quote-time">{{ formatTime(getQuoteTimestamp(message, currentMessages)) }}</span>
102
- </div>
103
- <div class="quote-content">
104
- <template
105
- v-if="getQuoteElements(message, currentMessages) && getQuoteElements(message, currentMessages).length > 0">
106
- <MessageElement v-for="(element, index) in getQuoteElements(message, currentMessages)"
107
- :key="`quote-${index}`" :element="element" :channel-key="currentChannelKey" />
108
- </template>
109
- <template v-else>
110
- {{ getQuoteContent(message, currentMessages) }}
111
- </template>
112
110
  </div>
113
111
  </div>
112
+ </el-scrollbar>
113
+ </div>
114
114
 
115
- <div class="message-text">
116
- <template v-if="message.elements && message.elements.length > 0">
117
- <MessageElement v-for="(element, index) in message.elements" :key="index" :element="element"
118
- :channel-key="currentChannelKey" />
119
- </template>
120
- <template v-else>
121
- {{ getMessageContentWithoutQuote(message, currentMessages) }}
122
- </template>
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>
123
128
  </div>
124
129
  </div>
125
- </div>
126
- </div>
127
-
128
- <!-- 悬浮的滚动到底部按钮 -->
129
- <div class="floating-scroll-button" v-show="showScrollButton" @click="scrollToBottom">
130
- <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
131
- <path d="M7 10l5 5 5-5z" />
132
- </svg>
133
- </div>
134
-
135
- <!-- 输入框 -->
136
- <div class="message-input">
137
- <!-- 图片预览区域 -->
138
- <div v-if="uploadedImages.length > 0" class="image-preview-container">
139
- <div v-for="image in uploadedImages" :key="image.tempId" class="image-preview-item">
140
- <img :src="image.preview" :alt="image.filename" class="preview-image" />
141
- <button class="remove-image-btn" @click="removeImage(image.tempId)" title="删除图片">
142
- <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
143
- <path
144
- d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
145
- </svg>
146
- </button>
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>
147
145
  </div>
148
146
  </div>
147
+ </template>
148
+ <el-empty v-else description="请选择频道开始对话" class="h-full flex items-center justify-center opacity-60" />
149
+ </el-main>
149
150
 
150
- <div class="input-row">
151
- <!-- 加号按钮 -->
152
- <div class="input-actions">
153
- <button class="add-button" @click="toggleActionMenu" :class="{ active: showActionMenu }" title="更多操作">
154
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
155
- stroke-linecap="round" stroke-linejoin="round">
156
- <line x1="12" y1="5" x2="12" y2="19"></line>
157
- <line x1="5" y1="12" x2="19" y2="12"></line>
158
- </svg>
159
- </button>
160
-
161
- <!-- 操作菜单 -->
162
- <div v-if="showActionMenu" class="action-menu" @click.stop>
163
- <button class="action-menu-item" @click="triggerImageUpload">
164
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
165
- stroke-linecap="round" stroke-linejoin="round">
166
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
167
- <circle cx="8.5" cy="8.5" r="1.5"></circle>
168
- <polyline points="21,15 16,10 5,21"></polyline>
169
- </svg>
170
- 上传图片
171
- </button>
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>
172
171
  </div>
173
172
  </div>
174
-
175
- <input v-model="inputMessage" type="text" :placeholder="inputPlaceholder" @keyup.enter="sendMessage"
176
- :disabled="!canInputMessage" ref="messageInput" @paste="handlePaste" @focus="handleInputFocus" />
177
- <button @click="sendMessage" :disabled="!canSendMessage" :class="{ 'is-sending': isSending }">
178
- {{ isSending ? '发送中...' : '发送' }}
179
- </button>
180
173
  </div>
174
+ </el-scrollbar>
175
+ </el-main>
181
176
 
182
- <!-- 隐藏的文件输入 -->
183
- <input type="file" ref="fileInput" @change="handleFileSelect" accept="image/*" multiple
184
- style="display: none;" />
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
185
  </div>
186
- </div>
187
- </div>
188
-
189
- <!-- 右键菜单 -->
190
- <div v-if="contextMenu.show" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
191
- @click.stop>
192
- <!-- 机器人右键菜单 -->
193
- <template v-if="contextMenu.type === 'bot'">
194
- <div class="context-menu-item" @click="toggleBotPin(contextMenu.targetId)">
195
- {{ pinnedBots.has(contextMenu.targetId) ? '取消置顶' : '置顶' }}
196
- </div>
197
- <div class="context-menu-item danger" @click="deleteBotMessages(contextMenu.targetId)">
198
- 彻底删除此机器人所有数据
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" />
199
188
  </div>
200
- </template>
189
+ </el-main>
190
+ </el-container>
201
191
 
202
- <!-- 频道右键菜单 -->
203
- <template v-if="contextMenu.type === 'channel'">
204
- <div class="context-menu-item" @click="toggleChannelPin(contextMenu.targetId)">
205
- {{ pinnedChannels.has(`${selectedBot}:${contextMenu.targetId}`) ? '取消置顶' : '置顶' }}
206
- </div>
207
- <div class="context-menu-item danger" @click="deleteChannelMessages(contextMenu.targetId)">
208
- 彻底删除此频道所有数据
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
209
  </div>
210
- </template>
210
+ </el-scrollbar>
211
+ </el-dialog>
211
212
 
212
- <!-- 消息右键菜单 -->
213
- <template v-if="contextMenu.type === 'message' && contextMenu.message">
214
- <div class="context-menu-item" @click="handlePlusOne(contextMenu.message)">
215
- +1
216
- </div>
217
- <div class="context-menu-item" @click="handleCopyMessage(contextMenu.message)">
218
- 复制
219
- </div>
220
- <div class="context-menu-item" @click="handleReplyMessage(contextMenu.message)">
221
- 回复
222
- </div>
223
- </template>
224
- </div>
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>
225
221
 
226
- <!-- 滑动指示器 -->
227
- <div class="swipe-indicator" :class="{ show: swipeIndicator.show }">
228
- {{ swipeIndicator.text }}
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>
229
240
  </div>
230
241
  </div>
231
242
  </template>
232
243
 
233
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'
234
249
  import { useChatLogic } from './chat-logic'
235
250
 
236
- const chatLogic = useChatLogic()
237
-
238
- // 解构
239
251
  const {
240
- // 组件
241
- AvatarComponent,
242
- ImageComponent,
243
- JsonCardComponent,
244
- ForwardMessageComponent,
245
- MessageElement,
246
-
247
- // 响应式数据
248
- chatData,
249
- channelMessageCounts,
250
- pluginConfig,
251
- selectedBot,
252
- selectedChannel,
253
- inputMessage,
254
- imageBlobUrls,
255
- pinnedBots,
256
- pinnedChannels,
257
- uploadedImages,
258
- showActionMenu,
259
- isMobile,
260
- mobileView,
261
- touchStart,
262
- touchCurrent,
263
- isSwipeActive,
264
- swipeIndicator,
265
- messageHistory,
266
- messageInput,
267
- showScrollButton,
268
- isUserScrolling,
269
- isSending,
270
- isLoadingMore,
271
- draggingChannel,
272
- dragStartPos,
273
- dragCurrentPos,
274
- dragElementInitialPos,
275
- dragOffset,
276
- dragThreshold,
277
- isDragReady,
278
- draggedBubbleElement,
279
- contextMenu,
280
- fileInput,
281
-
282
- // 计算属性
283
- bots,
284
- currentChannels,
285
- currentMessages,
286
- currentChannelName,
287
- currentChannelKey,
288
- canSendMessage,
289
- canInputMessage,
290
- mobileViewClass,
291
- inputPlaceholder,
292
- chatContainerStyle,
293
-
294
- // 方法
295
- selectBot,
296
- selectChannel,
297
- handleBotRightClick,
298
- handleChannelRightClick,
299
- showContextMenu,
300
- hideContextMenu,
301
- handleKeyDown,
302
- toggleBotPin,
303
- toggleChannelPin,
304
- deleteBotMessages,
305
- deleteChannelMessages,
306
- sendMessage,
307
- toggleActionMenu,
308
- triggerImageUpload,
309
- handleFileSelect,
310
- handlePaste,
311
- uploadImage,
312
- removeImage,
313
- fileToBase64,
314
- handleClickOutside,
315
- formatTime,
316
- getChannelTypeText,
317
- scrollToBottom,
318
- checkScrollPosition,
319
- isNearBottom,
320
- getChannelMessageCount,
321
- startDrag,
322
- handleDragMove,
323
- handleDragEnd,
324
- resetDragState,
325
- getDragStyle,
326
- getDragDistance,
327
- clearChannelHistory,
328
- showNotification,
329
- createThresholdCircle,
330
- removeThresholdCircle,
331
- handleTouchStart,
332
- handleTouchMove,
333
- handleTouchEnd,
334
- handleInputFocus,
335
-
336
- // 消息右键菜单处理函数
337
- handleMessageRightClick,
338
- handlePlusOne,
339
- handleCopyMessage,
340
- handleReplyMessage,
341
-
342
- // 图片缓存相关
343
- getCachedImageUrl,
344
- cacheImage,
345
- clearChannelImageCache,
346
- getMemoryStats,
347
- getCacheStats,
348
-
349
- // 其他工具函数
350
- isFileUrl,
351
- loadHistoryMessages,
352
- handleMessageEvent,
353
- handleBotMessageSentEvent,
354
- parseInlineQuote,
355
- getInlineQuoteMessage,
356
- getQuoteUser,
357
- getQuoteTimestamp,
358
- getQuoteContent,
359
- getQuoteElements,
360
- getMessageContentWithoutQuote
361
- } = chatLogic
362
- </script>
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>