diff --git a/electron/main.ts b/electron/main.ts index 6f35fb6..5362784 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -799,6 +799,14 @@ function registerIpcHandlers() { return chatService.getNewMessages(sessionId, minTime, limit) }) + ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, newContent: string) => { + return chatService.updateMessage(sessionId, localId, newContent) + }) + + ipcMain.handle('chat:deleteMessage', async (_, sessionId: string, localId: number, createTime: number, dbPathHint?: string) => { + return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint) + }) + ipcMain.handle('chat:getContact', async (_, username: string) => { return await chatService.getContact(username) }) diff --git a/electron/preload.ts b/electron/preload.ts index d44cf5b..d86821f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -131,6 +131,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), + updateMessage: (sessionId: string, localId: number, newContent: string) => + ipcRenderer.invoke('chat:updateMessage', sessionId, localId, newContent), + deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => + ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint), resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index afa4a4b..4dcf078 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -84,6 +84,7 @@ export interface Message { datadesc: string datatitle?: string }> + _db_path?: string // 内部字段:记录消息所属数据库路径 } export interface Contact { @@ -109,7 +110,7 @@ const emojiDownloading: Map> = new Map() class ChatService { private configService: ConfigService private connected = false - private messageCursors: Map = new Map() + private messageCursors: Map = new Map() private readonly messageBatchDefault = 50 private avatarCache: Map private readonly avatarCacheTtlMs = 10 * 60 * 1000 @@ -269,6 +270,32 @@ class ChatService { this.connected = false } + /** + * 修改消息内容 + */ + async updateMessage(sessionId: string, localId: number, newContent: string): Promise<{ success: boolean; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + return await wcdbService.updateMessage(sessionId, localId, newContent) + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 删除消息 + */ + async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + return await wcdbService.deleteMessage(sessionId, localId, createTime, dbPathHint) + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载) */ @@ -729,7 +756,7 @@ class ChatService { // 4. startTime/endTime 改变(视为全新查询) // 5. ascending 改变 const needNewCursor = !state || - offset === 0 || + offset !== state.fetched || // Offset mismatch -> must reset cursor state.batchSize !== batchSize || state.startTime !== startTime || state.endTime !== endTime || @@ -761,6 +788,7 @@ class ChatService { // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; // 当 startTime/endTime 变化导致重建时,offset 应由前端重置为 0 + state.bufferedMessages = [] if (offset > 0) { console.warn(`[ChatService] 新游标需跳过 ${offset} 条消息(startTime=${startTime}, endTime=${endTime})`) let skipped = 0 @@ -777,8 +805,22 @@ class ChatService { console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`) return { success: true, messages: [], hasMore: false } } - skipped += skipBatch.rows.length - state.fetched += skipBatch.rows.length + + const count = skipBatch.rows.length + // Check if we overshot the offset + if (skipped + count > offset) { + const keepIndex = offset - skipped + if (keepIndex < count) { + state.bufferedMessages = skipBatch.rows.slice(keepIndex) + } + } + + skipped += count + state.fetched += count + + // If satisfied offset, break + if (skipped >= offset) break; + if (!skipBatch.hasMore) { console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`) return { success: true, messages: [], hasMore: false } @@ -787,13 +829,8 @@ class ChatService { if (attempts >= maxSkipAttempts) { console.error(`[ChatService] 跳过消息超过最大尝试次数: attempts=${attempts}`) } - console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}`) + console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}, buffered=${state.bufferedMessages?.length || 0}`) } - } else if (state && offset !== state.fetched) { - // offset 与 fetched 不匹配,说明状态不一致 - console.warn(`[ChatService] 游标状态不一致: offset=${offset}, fetched=${state.fetched}, 继续使用现有游标`) - // 不重新创建游标,而是继续使用现有游标 - // 这样可以避免频繁重建导致的问题 } // 确保 state 已初始化 @@ -803,19 +840,35 @@ class ChatService { } // 获取当前批次的消息 - const batch = await wcdbService.fetchMessageBatch(state.cursor) - if (!batch.success) { - console.error('[ChatService] 获取消息批次失败:', batch.error) - return { success: false, error: batch.error || '获取消息失败' } + // Use buffered rows from skip logic if available + let rows: any[] = state.bufferedMessages || [] + state.bufferedMessages = undefined // Clear buffer after use + + // If buffer is not enough to fill a batch, try to fetch more + // Or if buffer is empty, fetch a batch + if (rows.length < batchSize) { + const nextBatch = await wcdbService.fetchMessageBatch(state.cursor) + if (nextBatch.success && nextBatch.rows) { + rows = rows.concat(nextBatch.rows) + state.fetched += nextBatch.rows.length + } else if (!nextBatch.success) { + console.error('[ChatService] 获取消息批次失败:', nextBatch.error) + // If we have some buffered rows, we can still return them? + // Or fail? Let's return what we have if any, otherwise fail. + if (rows.length === 0) { + return { success: false, error: nextBatch.error || '获取消息失败' } + } + } } - if (!batch.rows) { - console.error('[ChatService] 获取消息失败: 返回数据为空') - return { success: false, error: '获取消息失败: 返回数据为空' } + // If we have more than limit (due to buffer + full batch), slice it + if (rows.length > limit) { + rows = rows.slice(0, limit) + // Note: We don't adjust state.fetched here because it tracks cursor position. + // Next time offset will catch up or mismatch trigger reset. } - const rows = batch.rows as Record[] - const hasMore = batch.hasMore === true + const hasMore = rows.length > 0 // Simplified hasMore check for now, can be improved const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows)) diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index d6634d2..8f9e078 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -27,6 +27,8 @@ export class WcdbCore { private wcdbCloseAccount: any = null private wcdbSetMyWxid: any = null private wcdbFreeString: any = null + private wcdbUpdateMessage: any = null + private wcdbDeleteMessage: any = null private wcdbGetSessions: any = null private wcdbGetMessages: any = null private wcdbGetMessageCount: any = null @@ -385,6 +387,20 @@ export class WcdbCore { this.wcdbSetMyWxid = null } + // wcdb_status wcdb_update_message(wcdb_handle handle, const char* session_id, int64_t local_id, const char* new_content, char** out_error) + try { + this.wcdbUpdateMessage = this.lib.func('int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, const char* newContent, _Out_ void** outError)') + } catch { + this.wcdbUpdateMessage = null + } + + // wcdb_status wcdb_delete_message(wcdb_handle handle, const char* session_id, int64_t local_id, char** out_error) + try { + this.wcdbDeleteMessage = this.lib.func('int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)') + } catch { + this.wcdbDeleteMessage = null + } + // void wcdb_free_string(char* ptr) this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)') @@ -1774,4 +1790,62 @@ export class WcdbCore { return { success: false, error: String(e) } } } -} \ No newline at end of file + /** + * 修改消息内容 + */ + async updateMessage(sessionId: string, localId: number, newContent: string): Promise<{ success: boolean; error?: string }> { + if (!this.initialized || !this.wcdbUpdateMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' } + if (!this.handle) return { success: false, error: 'Not Connected' } + + return new Promise((resolve) => { + try { + const outError = [null as any] + const result = this.wcdbUpdateMessage(this.handle, sessionId, localId, newContent, outError) + + if (result !== 0) { + let errorMsg = 'Unknown Error' + if (outError[0]) { + errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)' + } + resolve({ success: false, error: errorMsg }) + return + } + + resolve({ success: true }) + } catch (e) { + resolve({ success: false, error: String(e) }) + } + }) + } + + /** + * 删除消息 + */ + async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> { + if (!this.initialized || !this.wcdbDeleteMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' } + if (!this.handle) return { success: false, error: 'Not Connected' } + + return new Promise((resolve) => { + try { + const outError = [null as any] + const result = this.wcdbDeleteMessage(this.handle, sessionId, localId, createTime || 0, dbPathHint || '', outError) + + if (result !== 0) { + let errorMsg = 'Unknown Error' + if (outError[0]) { + errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)' + } + console.error(`[WcdbCore] deleteMessage fail: code=${result}, error=${errorMsg}`) + resolve({ success: false, error: errorMsg }) + return + } + + resolve({ success: true }) + } catch (e) { + console.error(`[WcdbCore] deleteMessage exception:`, e) + resolve({ success: false, error: String(e) }) + } + }) + } +} + diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 516c11b..f9bcd7d 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -431,6 +431,20 @@ export class WcdbService { return this.callWorker('verifyUser', { message, hwnd }) } + /** + * 修改消息内容 + */ + async updateMessage(sessionId: string, localId: number, newContent: string): Promise<{ success: boolean; error?: string }> { + return this.callWorker('updateMessage', { sessionId, localId, newContent }) + } + + /** + * 删除消息 + */ + async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> { + return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint }) + } + } diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index a18acc9..ef60574 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -150,6 +150,12 @@ if (parentPort) { case 'verifyUser': result = await core.verifyUser(payload.message, payload.hwnd) break + case 'updateMessage': + result = await core.updateMessage(payload.sessionId, payload.localId, payload.newContent) + break + case 'deleteMessage': + result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint) + break default: result = { success: false, error: `Unknown method: ${type}` } diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 0ec075d..a5fad48 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 108352e..05fb05a 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1,8 +1,214 @@ .chat-page { + position: relative; display: flex; height: 100%; gap: 16px; + // 批量删除进度遮罩 + .delete-progress-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; + + .delete-progress-card { + width: 400px; + padding: 24px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + text-align: center; + + .progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + h3 { + margin: 0; + font-size: 18px; + color: var(--text-primary); + } + + .count { + font-variant-numeric: tabular-nums; + font-weight: 600; + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 4px; + color: var(--primary); + } + } + + .progress-bar-container { + height: 10px; + background: var(--bg-tertiary); + border-radius: 5px; + overflow: hidden; + margin-bottom: 20px; + + .progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary), var(--primary-light)); + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + } + + .progress-footer { + display: flex; + flex-direction: column; + gap: 16px; + + p { + font-size: 13px; + color: var(--text-tertiary); + margin: 0; + } + + .cancel-delete-btn { + padding: 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + + &:hover:not(:disabled) { + background: var(--danger-light); + color: var(--danger); + border-color: var(--danger); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + } + } + } + + // 自定义删除确认对话框 + .delete-confirm-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease; + + .delete-confirm-card { + width: 360px; + padding: 32px 24px 24px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + + .confirm-icon { + margin-bottom: 20px; + padding: 16px; + background: var(--danger-light); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + } + + .confirm-content { + margin-bottom: 32px; + + h3 { + margin: 0 0 12px; + font-size: 20px; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } + } + + .confirm-actions { + display: flex; + gap: 12px; + width: 100%; + + button { + flex: 1; + padding: 12px; + border-radius: 12px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + .btn-secondary { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .btn-danger-filled { + background: var(--danger); + border: none; + color: white; + + &:hover { + background: #e54d45; // Darker red + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(229, 77, 69, 0.3); + } + + &:active { + transform: translateY(0); + } + } + } + } + } + + @keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + // 独立窗口模式 - EchoTrace 特色风格(使用主题变量) &.standalone { height: 100vh; @@ -2253,24 +2459,24 @@ background: var(--bg-tertiary); border-radius: 8px; min-width: 200px; - + .card-icon { flex-shrink: 0; color: var(--primary); } - + .card-info { flex: 1; min-width: 0; } - + .card-name { font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 2px; } - + .card-label { font-size: 12px; color: var(--text-tertiary); @@ -2285,7 +2491,7 @@ padding: 8px 12px; color: var(--text-secondary); font-size: 13px; - + svg { flex-shrink: 0; } @@ -2303,21 +2509,21 @@ min-width: 220px; cursor: pointer; transition: background 0.2s; - + &:hover { background: var(--bg-hover); } - + .file-icon { flex-shrink: 0; color: var(--primary); } - + .file-info { flex: 1; min-width: 0; } - + .file-name { font-size: 14px; font-weight: 500; @@ -2327,7 +2533,7 @@ text-overflow: ellipsis; white-space: nowrap; } - + .file-meta { font-size: 12px; color: var(--text-tertiary); @@ -2351,7 +2557,7 @@ // 聊天记录消息 - 复用 link-message 基础样式 .chat-record-message { cursor: pointer; - + .link-header { padding-bottom: 4px; } @@ -2426,17 +2632,17 @@ background: var(--bg-tertiary); border-radius: 8px; min-width: 200px; - + .miniapp-icon { flex-shrink: 0; color: var(--primary); } - + .miniapp-info { flex: 1; min-width: 0; } - + .miniapp-title { font-size: 14px; font-weight: 500; @@ -2446,7 +2652,7 @@ text-overflow: ellipsis; white-space: nowrap; } - + .miniapp-label { font-size: 12px; color: var(--text-tertiary); @@ -2511,17 +2717,18 @@ // 发送消息中的特殊消息类型适配(除了文件和转账) .message-bubble.sent { + .card-message, .chat-record-message, .miniapp-message { background: rgba(255, 255, 255, 0.15); - + .card-name, .miniapp-title, .source-name { color: white; } - + .card-label, .miniapp-label, .chat-record-item, @@ -2529,21 +2736,21 @@ .chat-record-desc { color: rgba(255, 255, 255, 0.8); } - + .card-icon, .miniapp-icon, .chat-record-icon { color: white; } - + .chat-record-more { color: rgba(255, 255, 255, 0.9); } } - + .call-message { color: rgba(255, 255, 255, 0.9); - + svg { color: white; } @@ -2551,15 +2758,15 @@ .announcement-message { background: rgba(255, 255, 255, 0.15); - + .announcement-label { color: rgba(255, 255, 255, 0.8); } - + .announcement-text { color: white; } - + .announcement-icon { color: white; } @@ -2575,7 +2782,7 @@ background: var(--hover-color); border-radius: 12px; max-width: 320px; - + .announcement-icon { flex-shrink: 0; width: 28px; @@ -2584,23 +2791,23 @@ align-items: center; justify-content: center; color: #f59e42; - + svg { width: 20px; height: 20px; } } - + .announcement-content { flex: 1; min-width: 0; - + .announcement-label { font-size: 12px; color: var(--text-tertiary); margin-bottom: 4px; } - + .announcement-text { font-size: 14px; color: var(--text-primary); @@ -2616,6 +2823,7 @@ &:hover:not(:disabled) { color: var(--primary-color); } + &.transcribing { color: var(--primary-color); cursor: pointer; @@ -2637,7 +2845,9 @@ padding: 1.5rem; border-bottom: 1px solid var(--border-color); - svg { color: var(--primary-color); } + svg { + color: var(--primary-color); + } h3 { margin: 0; @@ -2697,7 +2907,10 @@ li { border-bottom: 1px solid var(--border-color); - &:last-child { border-bottom: none; } + + &:last-child { + border-bottom: none; + } } .batch-date-row { @@ -2708,7 +2921,9 @@ cursor: pointer; transition: background 0.15s; - &:hover { background: var(--bg-hover); } + &:hover { + background: var(--bg-hover); + } input[type="checkbox"] { accent-color: var(--primary-color); @@ -2806,14 +3021,186 @@ &.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); - &:hover { background: var(--border-color); } + + &:hover { + background: var(--border-color); + } } - &.btn-primary, &.batch-transcribe-start-btn { + &.btn-primary, + &.batch-transcribe-start-btn { background: var(--primary-color); color: white; - &:hover { opacity: 0.9; } + + &:hover { + opacity: 0.9; + } } } } } + +// Context Menu +.context-menu-overlay { + background: transparent; +} + +.context-menu { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 4px; + min-width: 140px; + backdrop-filter: blur(10px); + animation: fadeIn 0.1s ease-out; + + .menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + border-radius: 4px; + color: var(--text-primary); + font-size: 13px; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } + + &.delete { + color: var(--danger); + + &:hover { + background: rgba(220, 53, 69, 0.1); + } + } + + svg { + opacity: 0.7; + } + } +} + +// Modal Overlay +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + padding: 20px; +} + +// Edit Message Modal +.edit-message-modal { + width: 500px; + max-width: 90vw; + max-height: 85vh; + background: var(--card-bg); + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + + h3 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + .close-btn { + width: 28px; + height: 28px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .edit-message-textarea { + flex: 1; + border: none; + padding: 16px 20px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + resize: none; + outline: none; + min-height: 120px; + + &:focus { + background: var(--bg-primary); + } + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--border-color); + background: var(--card-bg); + + button { + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + .btn-secondary { + background: var(--bg-tertiary); + border: 1px solid transparent; + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .btn-primary { + background: var(--primary); + border: 1px solid transparent; + color: #fff; + box-shadow: 0 2px 8px var(--primary-light); + + &:hover { + background: var(--primary-hover); + box-shadow: 0 4px 12px var(--primary-light); + } + } + } +} \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index bc0dfa6..f5b8dfc 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, Download, BarChart3 } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2 } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -18,6 +18,102 @@ const SYSTEM_MESSAGE_TYPES = [ 266287972401, // 拍一拍 ] +interface XmlField { + key: string; + value: string; + type: 'attr' | 'node'; + tagName?: string; + path: string; +} + +// 尝试解析 XML 为可编辑字段 +function parseXmlToFields(xml: string): XmlField[] { + const fields: XmlField[] = [] + if (!xml || !xml.includes('<')) return [] + try { + const parser = new DOMParser() + // 包装一下确保是单一根节点 + const wrappedXml = xml.trim().startsWith('${xml}` + const doc = parser.parseFromString(wrappedXml, 'text/xml') + const errorNode = doc.querySelector('parsererror') + if (errorNode) return [] + + const walk = (node: Node, path: string = '') => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element + if (element.tagName === 'root') { + node.childNodes.forEach((child, index) => walk(child, path)) + return + } + + const currentPath = path ? `${path} > ${element.tagName}` : element.tagName + + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i] + fields.push({ + key: attr.name, + value: attr.value, + type: 'attr', + tagName: element.tagName, + path: `${currentPath}[@${attr.name}]` + }) + } + + if (element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) { + const text = element.textContent?.trim() || '' + if (text) { + fields.push({ + key: element.tagName, + value: text, + type: 'node', + path: currentPath + }) + } + } else { + node.childNodes.forEach((child, index) => walk(child, `${currentPath}[${index}]`)) + } + } + } + doc.childNodes.forEach((node, index) => walk(node, '')) + } catch (e) { + console.warn('[XML Parse] Failed:', e) + } + return fields +} + +// 将编辑后的字段同步回 XML +function updateXmlWithFields(xml: string, fields: XmlField[]): string { + try { + const parser = new DOMParser() + const wrappedXml = xml.trim().startsWith('${xml}` + const doc = parser.parseFromString(wrappedXml, 'text/xml') + const errorNode = doc.querySelector('parsererror') + if (errorNode) return xml + + fields.forEach(f => { + if (f.type === 'attr') { + const elements = doc.getElementsByTagName(f.tagName!) + if (elements.length > 0) { + elements[0].setAttribute(f.key, f.value) + } + } else { + const elements = doc.getElementsByTagName(f.key) + if (elements.length > 0 && (elements[0].childNodes.length <= 1)) { + elements[0].textContent = f.value + } + } + }) + + let result = new XMLSerializer().serializeToString(doc) + if (!xml.trim().startsWith('', '').replace('', '').replace('', '') + } + return result + } catch (e) { + return xml + } +} + // 判断是否为系统消息 function isSystemMessage(localType: number): boolean { return SYSTEM_MESSAGE_TYPES.includes(localType) @@ -32,6 +128,12 @@ function formatFileSize(bytes: number): string { return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] } +// 清理消息内容的辅助函数 +function cleanMessageContent(content: string): string { + if (!content) return '' + return content.trim() +} + interface ChatPageProps { // 保留接口以备将来扩展 } @@ -182,6 +284,18 @@ function ChatPage(_props: ChatPageProps) { const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) + // 消息右键菜单 + const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) + const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null) + + // 多选模式 + const [isSelectionMode, setIsSelectionMode] = useState(false) + const [selectedMessages, setSelectedMessages] = useState>(new Set()) + + // 编辑消息额外状态 + const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw') + const [tempFields, setTempFields] = useState([]) + // 批量语音转文字相关状态(进度/结果 由全局 store 管理) const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() const [showBatchConfirm, setShowBatchConfirm] = useState(false) @@ -190,6 +304,19 @@ function ChatPage(_props: ChatPageProps) { const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) + // 批量删除相关状态 + const [isDeleting, setIsDeleting] = useState(false) + const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 }) + const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false) + + // 自定义删除确认对话框 + const [deleteConfirm, setDeleteConfirm] = useState<{ + show: boolean; + mode: 'single' | 'batch'; + message?: Message; + count?: number; + }>({ show: false, mode: 'single' }) + // 联系人信息加载控制 const isEnrichingRef = useRef(false) const enrichCancelledRef = useRef(false) @@ -1394,13 +1521,302 @@ function ChatPage(_props: ChatPageProps) { const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates]) const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), []) + const lastSelectedIdRef = useRef(null) + + const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { + setSelectedMessages(prev => { + const next = new Set(prev) + + // Range selection with Shift key + if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { + const currentMsgs = useChatStore.getState().messages + const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current) + const idx2 = currentMsgs.findIndex(m => m.localId === localId) + + if (idx1 !== -1 && idx2 !== -1) { + const start = Math.min(idx1, idx2) + const end = Math.max(idx1, idx2) + for (let i = start; i <= end; i++) { + next.add(currentMsgs[i].localId) + } + } + } else { + // Normal toggle + if (next.has(localId)) { + next.delete(localId) + lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction. + } else { + next.add(localId) + lastSelectedIdRef.current = localId + } + } + return next + }) + }, []) + const formatBatchDateLabel = useCallback((dateStr: string) => { const [y, m, d] = dateStr.split('-').map(Number) return `${y}年${m}月${d}日` }, []) + // 消息右键菜单处理 + const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { + e.preventDefault() + setContextMenu({ + x: e.clientX, + y: e.clientY, + message + }) + }, []) + + // 关闭右键菜单 + useEffect(() => { + const handleClick = () => { + setContextMenu(null) + } + window.addEventListener('click', handleClick) + return () => { + window.removeEventListener('click', handleClick) + } + }, []) + + // 删除消息 - 触发确认弹窗 + const handleDelete = useCallback((target: { message: Message } | null = null) => { + const msg = target?.message || contextMenu?.message + if (!currentSessionId || !msg) return + + setDeleteConfirm({ + show: true, + mode: 'single', + message: msg + }) + setContextMenu(null) + }, [contextMenu, currentSessionId]) + + // 执行单条删除动作 + const performSingleDelete = async (msg: Message) => { + try { + const dbPathHint = (msg as any)._db_path + const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) + if (result.success) { + const currentMessages = useChatStore.getState().messages + const newMessages = currentMessages.filter(m => m.localId !== msg.localId) + useChatStore.getState().setMessages(newMessages) + } else { + alert('删除失败: ' + (result.error || '原因未知')) + } + } catch (e) { + console.error(e) + alert('删除异常: ' + String(e)) + } + } + + // 修改消息 + const handleEditMessage = useCallback(() => { + if (contextMenu) { + // 允许编辑所有类型的消息 + // 如果是文本消息(1),使用 parsedContent + // 如果是其他类型(如系统消息 10000),使用 rawContent 或 content 作为 XML 源码编辑 + const isText = contextMenu.message.localType === 1 + const rawXml = contextMenu.message.content || (contextMenu.message as any).rawContent || contextMenu.message.parsedContent || '' + + const contentToEdit = isText + ? cleanMessageContent(contextMenu.message.parsedContent) + : rawXml + + if (!isText) { + const fields = parseXmlToFields(rawXml) + setTempFields(fields) + setEditMode(fields.length > 0 ? 'fields' : 'raw') + } else { + setEditMode('raw') + setTempFields([]) + } + + setEditingMessage({ + message: contextMenu.message, + content: contentToEdit + }) + setContextMenu(null) + } + }, [contextMenu]) + + // 确认修改消息 + const handleSaveEdit = useCallback(async () => { + if (editingMessage && currentSessionId) { + let finalContent = editingMessage.content + + // 如果是字段编辑模式,先同步回 XML + if (editMode === 'fields' && tempFields.length > 0) { + finalContent = updateXmlWithFields(editingMessage.content, tempFields) + } + + if (!finalContent.trim()) { + handleDelete({ message: editingMessage.message }) + setEditingMessage(null) + return + } + + try { + const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, finalContent) + if (result.success) { + const currentMessages = useChatStore.getState().messages + const newMessages = currentMessages.map(m => { + if (m.localId === editingMessage.message.localId) { + return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } + } + return m + }) + useChatStore.getState().setMessages(newMessages) + setEditingMessage(null) + } else { + alert('修改失败: ' + result.error) + } + } catch (e) { + alert('修改异常: ' + String(e)) + } + } + }, [editingMessage, currentSessionId, editMode, tempFields, handleDelete]) + + // 用于在异步循环中获取最新的取消状态 + const cancelDeleteRef = useRef(false) + + const handleBatchDelete = () => { + if (selectedMessages.size === 0) { + alert('请先选择要删除的消息') + return + } + if (!currentSessionId) return + + setDeleteConfirm({ + show: true, + mode: 'batch', + count: selectedMessages.size + }) + } + + const performBatchDelete = async () => { + setIsDeleting(true) + setDeleteProgress({ current: 0, total: selectedMessages.size }) + setCancelDeleteRequested(false) + cancelDeleteRef.current = false + + try { + const currentMessages = useChatStore.getState().messages + const selectedIds = Array.from(selectedMessages) + const deletedIds = new Set() + + for (let i = 0; i < selectedIds.length; i++) { + if (cancelDeleteRef.current) break + + const id = selectedIds[i] + const msgObj = currentMessages.find(m => m.localId === id) + const dbPathHint = (msgObj as any)?._db_path + const createTime = msgObj?.createTime || 0 + + try { + const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint) + if (result.success) { + deletedIds.add(id) + } + } catch (err) { + console.error(`删除消息 ${id} 失败:`, err) + } + + setDeleteProgress({ current: i + 1, total: selectedIds.length }) + } + + const finalMessages = useChatStore.getState().messages.filter(m => !deletedIds.has(m.localId)) + useChatStore.getState().setMessages(finalMessages) + + setIsSelectionMode(false) + setSelectedMessages(new Set()) + + if (cancelDeleteRef.current) { + alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`) + } + } catch (e) { + alert('批量删除出现错误: ' + String(e)) + console.error(e) + } finally { + setIsDeleting(false) + setCancelDeleteRequested(false) + cancelDeleteRef.current = false + } + } + return (
+ {/* 自定义删除确认对话框 */} + {deleteConfirm.show && ( +
+
+
+ +
+
+

确认删除

+

+ {deleteConfirm.mode === 'single' + ? '确定要删除这条消息吗?此操作不可恢复。' + : `确定要删除选中的 ${deleteConfirm.count} 条消息吗?`} +

+
+
+ + +
+
+
+ )} + + {/* 批量删除进度遮罩 */} + {isDeleting && ( +
+
+
+

正在彻底删除消息...

+ {deleteProgress.current} / {deleteProgress.total} +
+
+
+
+
+

请勿关闭应用或切换会话,确保所有副本都被清理。

+ +
+
+
+ )} {/* 左侧会话列表 */}
) @@ -1897,6 +2317,187 @@ function ChatPage(_props: ChatPageProps) {
, document.body )} + {/* 消息右键菜单 */} + {contextMenu && createPortal( + <> +
setContextMenu(null)} + style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9998 }} /> +
e.stopPropagation()} + > +
+ + {contextMenu.message.localType === 1 ? '修改消息' : '编辑源码'} +
+
{ + setIsSelectionMode(true) + setSelectedMessages(new Set([contextMenu.message.localId])) + setContextMenu(null) + }}> + + 多选 +
+
{ e.stopPropagation(); handleDelete() }}> + + 删除消息 +
+
+ , + document.body + )} + + {/* 修改消息弹窗 */} + {editingMessage && createPortal( +
+
+
+

{editingMessage.message.localType === 1 ? '修改消息' : '编辑消息'}

+ +
+ +
+ {editMode === 'raw' ? ( +