diff --git a/electron/main.ts b/electron/main.ts index 2e635b6..8036e98 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -82,6 +82,8 @@ let configService: ConfigService | null = null // 协议窗口实例 let agreementWindow: BrowserWindow | null = null let onboardingWindow: BrowserWindow | null = null +// Splash 启动窗口 +let splashWindow: BrowserWindow | null = null const keyService = new KeyService() let mainWindowReady = false @@ -122,9 +124,10 @@ function createWindow(options: { autoShow?: boolean } = {}) { }) // 窗口准备好后显示 + // Splash 模式下不在这里 show,由启动流程统一控制 win.once('ready-to-show', () => { mainWindowReady = true - if (autoShow || shouldShowMain) { + if (autoShow && !splashWindow) { win.show() } }) @@ -250,6 +253,73 @@ function createAgreementWindow() { return agreementWindow } +/** + * 创建 Splash 启动窗口 + * 使用纯 HTML 页面,不依赖 React,确保极速显示 + */ +function createSplashWindow(): BrowserWindow { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + splashWindow = new BrowserWindow({ + width: 760, + height: 460, + resizable: false, + frame: false, + transparent: true, + backgroundColor: '#00000000', + hasShadow: false, + center: true, + skipTaskbar: false, + icon: iconPath, + webPreferences: { + contextIsolation: true, + nodeIntegration: false + // 不需要 preload —— 通过 executeJavaScript 单向推送进度 + }, + show: false + }) + + if (isDev) { + splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`) + } else { + splashWindow.loadFile(join(__dirname, '../dist/splash.html')) + } + + splashWindow.once('ready-to-show', () => { + splashWindow?.show() + }) + + splashWindow.on('closed', () => { + splashWindow = null + }) + + return splashWindow +} + +/** + * 向 Splash 窗口发送进度更新 + */ +function updateSplashProgress(percent: number, text: string, indeterminate = false) { + if (splashWindow && !splashWindow.isDestroyed()) { + splashWindow.webContents + .executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`) + .catch(() => {}) + } +} + +/** + * 关闭 Splash 窗口 + */ +function closeSplash() { + if (splashWindow && !splashWindow.isDestroyed()) { + splashWindow.close() + splashWindow = null + } +} + /** * 创建首次引导窗口 */ @@ -1012,6 +1082,26 @@ function registerIpcHandlers() { return { canceled: false, filePath: result.filePaths[0] } }) + ipcMain.handle('sns:installBlockDeleteTrigger', async () => { + return snsService.installSnsBlockDeleteTrigger() + }) + + ipcMain.handle('sns:uninstallBlockDeleteTrigger', async () => { + return snsService.uninstallSnsBlockDeleteTrigger() + }) + + ipcMain.handle('sns:checkBlockDeleteTrigger', async () => { + return snsService.checkSnsBlockDeleteTrigger() + }) + + ipcMain.handle('sns:deleteSnsPost', async (_, postId: string) => { + return snsService.deleteSnsPost(postId) + }) + + ipcMain.handle('sns:downloadEmoji', async (_, params: { url: string; encryptUrl?: string; aesKey?: string }) => { + return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey) + }) + // 私聊克隆 @@ -1508,26 +1598,70 @@ function checkForUpdatesOnStartup() { }, 3000) } -app.whenReady().then(() => { +app.whenReady().then(async () => { + // 立即创建 Splash 窗口,确保用户尽快看到反馈 + createSplashWindow() + + // 等待 Splash 页面加载完成后再推送进度 + if (splashWindow) { + await new Promise((resolve) => { + if (splashWindow!.webContents.isLoading()) { + splashWindow!.webContents.once('did-finish-load', () => resolve()) + } else { + resolve() + } + }) + splashWindow.webContents + .executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`) + .catch(() => {}) + } + + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + + // 初始化配置服务 + updateSplashProgress(5, '正在加载配置...') configService = new ConfigService() + + // 将用户主题配置推送给 Splash 窗口 + if (splashWindow && !splashWindow.isDestroyed()) { + const themeId = configService.get('themeId') || 'cloud-dancer' + const themeMode = configService.get('theme') || 'system' + splashWindow.webContents + .executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`) + .catch(() => {}) + } + await delay(200) + + // 设置资源路径 + updateSplashProgress(10, '正在初始化...') const candidateResources = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') const fallbackResources = join(process.cwd(), 'resources') const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources const userDataPath = app.getPath('userData') + await delay(200) + + // 初始化数据库服务 + updateSplashProgress(18, '正在初始化...') wcdbService.setPaths(resourcesPath, userDataPath) wcdbService.setLogEnabled(configService.get('logEnabled') === true) + await delay(200) + + // 注册 IPC 处理器 + updateSplashProgress(25, '正在初始化...') registerIpcHandlers() + await delay(200) + + // 检查配置状态 const onboardingDone = configService.get('onboardingDone') shouldShowMain = onboardingDone === true - mainWindow = createWindow({ autoShow: shouldShowMain }) - if (!onboardingDone) { - createOnboardingWindow() - } + // 创建主窗口(不显示,由启动流程统一控制) + updateSplashProgress(30, '正在加载界面...') + mainWindow = createWindow({ autoShow: false }) - // 解决朋友圈图片无法加载问题(添加 Referer) + // 配置网络服务 session.defaultSession.webRequest.onBeforeSendHeaders( { urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*'] @@ -1538,7 +1672,31 @@ app.whenReady().then(() => { } ) - // 启动时检测更新 + // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点) + updateSplashProgress(30, '正在加载界面...', true) + await new Promise((resolve) => { + if (mainWindowReady) { + resolve() + } else { + mainWindow!.once('ready-to-show', () => { + mainWindowReady = true + resolve() + }) + } + }) + + // 加载完成,收尾 + updateSplashProgress(100, '启动完成') + await new Promise((resolve) => setTimeout(resolve, 250)) + closeSplash() + + if (!onboardingDone) { + createOnboardingWindow() + } else { + mainWindow?.show() + } + + // 启动时检测更新(不阻塞启动) checkForUpdatesOnStartup() app.on('activate', () => { diff --git a/electron/preload.ts b/electron/preload.ts index 4cf585b..76ad1c7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -294,7 +294,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('sns:exportProgress') }, - selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir') + selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'), + installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'), + uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'), + checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'), + deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId), + downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) }, // HTTP API 服务 diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 001a855..875be7a 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -76,17 +76,13 @@ class AnalyticsService { const map: Record = {} if (usernames.length === 0) return map + // C++ 层不支持参数绑定,直接内联转义后的字符串值 const chunkSize = 200 for (let i = 0; i < usernames.length; i += chunkSize) { const chunk = usernames.slice(i, i + chunkSize) - // 使用参数化查询防止SQL注入 - const placeholders = chunk.map(() => '?').join(',') - const sql = ` - SELECT username, alias - FROM contact - WHERE username IN (${placeholders}) - ` - const result = await wcdbService.execQuery('contact', null, sql, chunk) + const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') + const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})` + const result = await wcdbService.execQuery('contact', null, sql) if (!result.success || !result.rows) continue for (const row of result.rows as Record[]) { const username = row.username || '' diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index eda6e7a..be86d54 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -991,12 +991,34 @@ class ChatService { } console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`) + // 数据库未命中时,尝试从本地 emoji 缓存目录查找(转发的表情包只有 md5,无 CDN URL) + this.findEmojiInLocalCache(msg) } catch (e) { console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e) } } + /** + * 从本地 WeFlow emoji 缓存目录按 md5 查找文件 + */ + private findEmojiInLocalCache(msg: Message): void { + if (!msg.emojiMd5) return + const cacheDir = this.getEmojiCacheDir() + if (!existsSync(cacheDir)) return + + const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg'] + for (const ext of extensions) { + const filePath = join(cacheDir, `${msg.emojiMd5}${ext}`) + if (existsSync(filePath)) { + msg.emojiLocalPath = filePath + // 同步写入内存缓存,避免重复查找 + emojiCache.set(msg.emojiMd5, filePath) + return + } + } + } + /** * 查找 emoticon.db 路径 */ @@ -1338,6 +1360,9 @@ class ChatService { chatRecordList = type49Info.chatRecordList transferPayerUsername = type49Info.transferPayerUsername transferReceiverUsername = type49Info.transferReceiverUsername + // 引用消息(appmsg type=57)的 quotedContent/quotedSender + if (type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent + if (type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender } else if (localType === 244813135921 || (content && content.includes('57'))) { const quoteInfo = this.parseQuoteMessage(content) quotedContent = quoteInfo.content @@ -1381,6 +1406,8 @@ class ChatService { chatRecordList = chatRecordList || type49Info.chatRecordList transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername + if (!quotedContent && type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent + if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender } messages.push({ @@ -1549,7 +1576,17 @@ class ChatService { private parseType49(content: string): string { const title = this.extractXmlValue(content, 'title') - const type = this.extractXmlValue(content, 'type') + // 从 appmsg 直接子节点提取 type,避免匹配到 refermsg 内部的 + let type = '' + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const inner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(inner) + if (typeMatch) type = typeMatch[1].trim() + } + if (!type) type = this.extractXmlValue(content, 'type') const normalized = content.toLowerCase() const locationLabel = this.extractXmlAttribute(content, 'location', 'label') || @@ -1964,6 +2001,8 @@ class ChatService { */ private parseType49Message(content: string): { xmlType?: string + quotedContent?: string + quotedSender?: string linkTitle?: string linkUrl?: string linkThumb?: string @@ -2008,8 +2047,20 @@ class ChatService { try { if (!content) return {} - // 提取 appmsg 中的 type - const xmlType = this.extractXmlValue(content, 'type') + // 提取 appmsg 直接子节点的 type,避免匹配到 refermsg 内部的 + // 先尝试从 ... 块内提取,再用正则跳过嵌套标签 + let xmlType = '' + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + // 在 appmsg 内容中,找第一个 但跳过在子元素内部的(如 refermsg > type) + // 策略:去掉所有嵌套块(refermsg、patMsg 等),再提取 type + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) xmlType = typeMatch[1].trim() + } + if (!xmlType) xmlType = this.extractXmlValue(content, 'type') if (!xmlType) return {} const result: any = { xmlType } @@ -2126,6 +2177,12 @@ class ChatService { result.appMsgKind = 'transfer' } else if (xmlType === '87') { result.appMsgKind = 'announcement' + } else if (xmlType === '57') { + // 引用回复消息,解析 refermsg + result.appMsgKind = 'quote' + const quoteInfo = this.parseQuoteMessage(content) + result.quotedContent = quoteInfo.content + result.quotedSender = quoteInfo.sender } else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) { result.appMsgKind = 'official-link' } else if (url) { diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index b6168b6..86ea6c9 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -10,6 +10,7 @@ import { chatService, Message } from './chatService' import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { videoService } from './videoService' +import { imageDecryptService } from './imageDecryptService' // ChatLab 格式定义 interface ChatLabHeader { @@ -69,6 +70,7 @@ interface ApiExportedMedia { kind: MediaKind fileName: string fullPath: string + relativePath: string } // ChatLab 消息类型映射 @@ -236,6 +238,8 @@ class HttpService { await this.handleSessions(url, res) } else if (pathname === '/api/v1/contacts') { await this.handleContacts(url, res) + } else if (pathname.startsWith('/api/v1/media/')) { + this.handleMediaRequest(pathname, res) } else { this.sendError(res, 404, 'Not Found') } @@ -245,6 +249,40 @@ class HttpService { } } + private handleMediaRequest(pathname: string, res: http.ServerResponse): void { + const mediaBasePath = this.getApiMediaExportPath() + const relativePath = pathname.replace('/api/v1/media/', '') + const fullPath = path.join(mediaBasePath, relativePath) + + if (!fs.existsSync(fullPath)) { + this.sendError(res, 404, 'Media not found') + return + } + + const ext = path.extname(fullPath).toLowerCase() + const mimeTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.wav': 'audio/wav', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4' + } + const contentType = mimeTypes[ext] || 'application/octet-stream' + + try { + const fileBuffer = fs.readFileSync(fullPath) + res.setHeader('Content-Type', contentType) + res.setHeader('Content-Length', fileBuffer.length) + res.writeHead(200) + res.end(fileBuffer) + } catch (e) { + this.sendError(res, 500, 'Failed to read media file') + } + } + /** * 批量获取消息(循环游标直到满足 limit) * 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标 @@ -380,7 +418,7 @@ class HttpService { const queryOffset = keyword ? 0 : offset const queryLimit = keyword ? 10000 : limit - const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true) + const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false) if (!result.success || !result.messages) { this.sendError(res, 500, result.error || 'Failed to get messages') return @@ -576,19 +614,44 @@ class HttpService { ): Promise { try { if (msg.localType === 3 && options.exportImages) { - const result = await chatService.getImageData(talker, String(msg.localId)) - if (result.success && result.data) { - const imageBuffer = Buffer.from(result.data, 'base64') - const ext = this.detectImageExt(imageBuffer) - const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) - const fileName = `${fileBase}${ext}` - const targetDir = path.join(sessionDir, 'images') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.writeFileSync(fullPath, imageBuffer) + const result = await imageDecryptService.decryptImage({ + sessionId: talker, + imageMd5: msg.imageMd5, + imageDatName: msg.imageDatName, + force: true + }) + if (result.success && result.localPath) { + let imagePath = result.localPath + if (imagePath.startsWith('data:')) { + const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/) + if (base64Match) { + const imageBuffer = Buffer.from(base64Match[1], 'base64') + const ext = this.detectImageExt(imageBuffer) + const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) + const fileName = `${fileBase}${ext}` + const targetDir = path.join(sessionDir, 'images') + const fullPath = path.join(targetDir, fileName) + this.ensureDir(targetDir) + if (!fs.existsSync(fullPath)) { + fs.writeFileSync(fullPath, imageBuffer) + } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` + return { kind: 'image', fileName, fullPath, relativePath } + } + } else if (fs.existsSync(imagePath)) { + const imageBuffer = fs.readFileSync(imagePath) + const ext = this.detectImageExt(imageBuffer) + const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) + const fileName = `${fileBase}${ext}` + const targetDir = path.join(sessionDir, 'images') + const fullPath = path.join(targetDir, fileName) + this.ensureDir(targetDir) + if (!fs.existsSync(fullPath)) { + fs.copyFileSync(imagePath, fullPath) + } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` + return { kind: 'image', fileName, fullPath, relativePath } } - return { kind: 'image', fileName, fullPath } } } @@ -607,7 +670,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64')) } - return { kind: 'voice', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}` + return { kind: 'voice', fileName, fullPath, relativePath } } } @@ -622,7 +686,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.copyFileSync(info.videoUrl, fullPath) } - return { kind: 'video', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}` + return { kind: 'video', fileName, fullPath, relativePath } } } @@ -637,7 +702,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.copyFileSync(result.localPath, fullPath) } - return { kind: 'emoji', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}` + return { kind: 'emoji', fileName, fullPath, relativePath } } } } catch (e) { @@ -661,7 +727,8 @@ class HttpService { parsedContent: msg.parsedContent, mediaType: media?.kind, mediaFileName: media?.fileName, - mediaPath: media?.fullPath + mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined, + mediaLocalPath: media?.fullPath } } @@ -784,7 +851,7 @@ class HttpService { type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), platformMessageId: msg.serverId ? String(msg.serverId) : undefined, - mediaPath: mediaMap.get(msg.localId)?.fullPath + mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined } }) diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index e12a22d..34e7bd0 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -843,7 +843,7 @@ export class KeyService { private findTemplateDatFiles(rootDir: string): string[] { const files: string[] = [] const stack = [rootDir] - const maxFiles = 32 + const maxFiles = 256 while (stack.length && files.length < maxFiles) { const dir = stack.pop() as string let entries: string[] @@ -877,7 +877,7 @@ export class KeyService { if (ma && mb) return mb.localeCompare(ma) return 0 }) - return files.slice(0, 16) + return files.slice(0, 128) } private getXorKey(templateFiles: string[]): number | null { diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index ffc1c23..835850f 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -6,6 +6,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { basename, join } from 'path' import crypto from 'crypto' import { WasmService } from './wasmService' +import zlib from 'zlib' export interface SnsLivePhoto { url: string @@ -28,6 +29,7 @@ export interface SnsMedia { export interface SnsPost { id: string + tid?: string // 数据库主键(雪花 ID),用于精确删除 username: string nickname: string avatarUrl?: string @@ -36,7 +38,7 @@ export interface SnsPost { type?: number media: SnsMedia[] likes: string[] - comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] + comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] rawXml?: string linkTitle?: string linkUrl?: string @@ -122,6 +124,107 @@ const extractVideoKey = (xml: string): string | undefined => { return match ? match[1] : undefined } +/** + * 从 XML 中解析评论信息(含表情包、回复关系) + */ +function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] { + if (!xml) return [] + + type CommentItem = { + id: string; nickname: string; username?: string; content: string + refCommentId: string; refUsername?: string; refNickname?: string + emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] + } + const comments: CommentItem[] = [] + + try { + // 支持多种标签格式 + let listMatch = xml.match(/([\s\S]*?)<\/CommentUserList>/i) + if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentUserList>/i) + if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentList>/i) + if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/comment_user_list>/i) + if (!listMatch) return comments + + const listXml = listMatch[1] + const itemRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi + let m: RegExpExecArray | null + + while ((m = itemRegex.exec(listXml)) !== null) { + const c = m[1] + + const idMatch = c.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i) + const usernameMatch = c.match(/([^<]*)<\/username>/i) + let nicknameMatch = c.match(/([^<]*)<\/nickname>/i) + if (!nicknameMatch) nicknameMatch = c.match(/([^<]*)<\/nickName>/i) + const contentMatch = c.match(/([^<]*)<\/content>/i) + const refIdMatch = c.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i) + const refNickMatch = c.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i) + const refUserMatch = c.match(/([^<]*)<\/ref_username>/i) + + // 解析表情包 + const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = [] + const emojiRegex = /([\s\S]*?)<\/emojiinfo>/gi + let em: RegExpExecArray | null + while ((em = emojiRegex.exec(c)) !== null) { + const ex = em[1] + const externUrl = ex.match(/([^<]*)<\/extern_url>/i) + const cdnUrl = ex.match(/([^<]*)<\/cdn_url>/i) + const plainUrl = ex.match(/([^<]*)<\/url>/i) + const urlMatch = externUrl || cdnUrl || plainUrl + const md5Match = ex.match(/([^<]*)<\/md5>/i) + const wMatch = ex.match(/([^<]*)<\/width>/i) + const hMatch = ex.match(/([^<]*)<\/height>/i) + const encMatch = ex.match(/([^<]*)<\/encrypt_url>/i) + const aesMatch = ex.match(/([^<]*)<\/aes_key>/i) + + const url = urlMatch ? urlMatch[1].trim().replace(/&/g, '&') : '' + const encryptUrl = encMatch ? encMatch[1].trim().replace(/&/g, '&') : undefined + const aesKey = aesMatch ? aesMatch[1].trim() : undefined + + if (url || encryptUrl) { + emojis.push({ + url, + md5: md5Match ? md5Match[1].trim() : '', + width: wMatch ? parseInt(wMatch[1]) : 0, + height: hMatch ? parseInt(hMatch[1]) : 0, + encryptUrl, + aesKey + }) + } + } + + if (nicknameMatch && (contentMatch || emojis.length > 0)) { + const refId = refIdMatch ? refIdMatch[1].trim() : '' + comments.push({ + id: idMatch ? idMatch[1].trim() : `cmt_${Date.now()}_${Math.random()}`, + nickname: nicknameMatch[1].trim(), + username: usernameMatch ? usernameMatch[1].trim() : undefined, + content: contentMatch ? contentMatch[1].trim() : '', + refCommentId: refId === '0' ? '' : refId, + refUsername: refUserMatch ? refUserMatch[1].trim() : undefined, + refNickname: refNickMatch ? refNickMatch[1].trim() : undefined, + emojis: emojis.length > 0 ? emojis : undefined + }) + } + } + + // 二次解析:通过 refUsername 补全 refNickname + const userMap = new Map() + for (const c of comments) { + if (c.username && c.nickname) userMap.set(c.username, c.nickname) + } + for (const c of comments) { + if (!c.refNickname && c.refUsername && c.refCommentId) { + c.refNickname = userMap.get(c.refUsername) + } + } + } catch (e) { + console.error('[SnsService] parseCommentsFromXml 失败:', e) + } + + return comments +} + class SnsService { private configService: ConfigService private contactCache: ContactCacheService @@ -132,6 +235,104 @@ class SnsService { this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + private parseLikesFromXml(xml: string): string[] { + if (!xml) return [] + const likes: string[] = [] + try { + let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) + if (!likeListMatch) return likes + + const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi + let m: RegExpExecArray | null + while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { + let nick = m[1].match(/([^<]*)<\/nickname>/i) + if (!nick) nick = m[1].match(/([^<]*)<\/nickName>/i) + if (nick) likes.push(nick[1].trim()) + } + } catch (e) { + console.error('[SnsService] 解析点赞失败:', e) + } + return likes + } + + private parseMediaFromXml(xml: string): { media: SnsMedia[]; videoKey?: string } { + if (!xml) return { media: [] } + const media: SnsMedia[] = [] + let videoKey: string | undefined + try { + const encMatch = xml.match(/([\s\S]*?)<\/media>/gi + let mediaMatch: RegExpExecArray | null + while ((mediaMatch = mediaRegex.exec(xml)) !== null) { + const mx = mediaMatch[1] + const urlMatch = mx.match(/]*>([^<]+)<\/url>/i) + const urlTagMatch = mx.match(/]*)>/i) + const thumbMatch = mx.match(/]*>([^<]+)<\/thumb>/i) + const thumbTagMatch = mx.match(/]*)>/i) + + let urlToken: string | undefined, urlKey: string | undefined + let urlMd5: string | undefined, urlEncIdx: string | undefined + if (urlTagMatch?.[1]) { + const a = urlTagMatch[1] + urlToken = a.match(/token="([^"]+)"/i)?.[1] + urlKey = a.match(/key="([^"]+)"/i)?.[1] + urlMd5 = a.match(/md5="([^"]+)"/i)?.[1] + urlEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] + } + let thumbToken: string | undefined, thumbKey: string | undefined, thumbEncIdx: string | undefined + if (thumbTagMatch?.[1]) { + const a = thumbTagMatch[1] + thumbToken = a.match(/token="([^"]+)"/i)?.[1] + thumbKey = a.match(/key="([^"]+)"/i)?.[1] + thumbEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] + } + + const item: SnsMedia = { + url: urlMatch ? urlMatch[1].trim() : '', + thumb: thumbMatch ? thumbMatch[1].trim() : '', + token: urlToken || thumbToken, + key: urlKey || thumbKey, + md5: urlMd5, + encIdx: urlEncIdx || thumbEncIdx + } + + const livePhotoMatch = mx.match(/([\s\S]*?)<\/livePhoto>/i) + if (livePhotoMatch) { + const lx = livePhotoMatch[1] + const lpUrl = lx.match(/]*>([^<]+)<\/url>/i) + const lpUrlTag = lx.match(/]*)>/i) + const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) + const lpThumbTag = lx.match(/]*)>/i) + let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined + if (lpUrlTag?.[1]) { + const a = lpUrlTag[1] + lpToken = a.match(/token="([^"]+)"/i)?.[1] + lpKey = a.match(/key="([^"]+)"/i)?.[1] + lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] + } + if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1] + if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1] + item.livePhoto = { + url: lpUrl ? lpUrl[1].trim() : '', + thumb: lpThumb ? lpThumb[1].trim() : '', + token: lpToken, + key: lpKey, + encIdx: lpEncIdx + } + } + media.push(item) + } + } catch (e) { + console.error('[SnsService] 解析媒体 XML 失败:', e) + } + return { media, videoKey } + } + private getSnsCacheDir(): string { const cachePath = this.configService.getCacheBasePath() const snsCacheDir = join(cachePath, 'sns_cache') @@ -147,7 +348,6 @@ class SnsService { return join(this.getSnsCacheDir(), `${hash}${ext}`) } - // 获取所有发过朋友圈的用户名列表 async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine') if (!result.success || !result.rows) { @@ -159,51 +359,142 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } - async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { - const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + // 安装朋友圈删除拦截 + async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + return wcdbService.installSnsBlockDeleteTrigger() + } - if (result.success && result.timeline) { - const enrichedTimeline = result.timeline.map((post: any) => { - const contact = this.contactCache.get(post.username) - const isVideoPost = post.type === 15 + // 卸载朋友圈删除拦截 + async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { + return wcdbService.uninstallSnsBlockDeleteTrigger() + } - // 尝试从 rawXml 中提取视频解密密钥 (针对视频号视频) - const videoKey = extractVideoKey(post.rawXml || '') + // 查询朋友圈删除拦截是否已安装 + async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { + return wcdbService.checkSnsBlockDeleteTrigger() + } - const fixedMedia = (post.media || []).map((m: any) => ({ - // 如果是视频动态,url 是视频,thumb 是缩略图 - url: fixSnsUrl(m.url, m.token, isVideoPost), - thumb: fixSnsUrl(m.thumb, m.token, false), - md5: m.md5, - token: m.token, - // 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体 - // 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏 - key: isVideoPost ? (videoKey || m.key) : m.key, - encIdx: m.encIdx || m.enc_idx, - livePhoto: m.livePhoto - ? { - ...m.livePhoto, - url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), - thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), - token: m.livePhoto.token, - // 实况照片的视频部分优先使用从 XML 提取的 Key - key: videoKey || m.livePhoto.key || m.key, - encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx - } - : undefined + // 从数据库直接删除朋友圈记录 + async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { + return wcdbService.deleteSnsPost(postId) + } + + /** + * 补全 DLL 返回的评论中缺失的 refNickname + * DLL 返回的 refCommentId 是被回复评论的 cmtid + * 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增 + */ + private fixCommentRefs(comments: any[]): any[] { + if (!comments || comments.length === 0) return [] + + // DLL 现在返回完整的评论数据(含 emojis、refNickname) + // 此处做最终的格式化和兜底补全 + const idToNickname = new Map() + comments.forEach((c, idx) => { + if (c.id) idToNickname.set(c.id, c.nickname || '') + // 兜底:按索引映射(部分旧数据 id 可能为空) + idToNickname.set(String(idx + 1), c.nickname || '') + }) + + return comments.map((c) => { + const refId = c.refCommentId + let refNickname = c.refNickname || '' + + if (refId && refId !== '0' && refId !== '' && !refNickname) { + refNickname = idToNickname.get(refId) || '' + } + + // 处理 emojis:过滤掉空的 url 和 encryptUrl + const emojis = (c.emojis || []) + .filter((e: any) => e.url || e.encryptUrl) + .map((e: any) => ({ + url: (e.url || '').replace(/&/g, '&'), + md5: e.md5 || '', + width: e.width || 0, + height: e.height || 0, + encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&/g, '&') : undefined, + aesKey: e.aesKey || undefined })) - return { - ...post, - avatarUrl: contact?.avatarUrl, - nickname: post.nickname || contact?.displayName || post.username, - media: fixedMedia - } - }) - return { ...result, timeline: enrichedTimeline } + return { + id: c.id || '', + nickname: c.nickname || '', + content: c.content || '', + refCommentId: (refId === '0') ? '' : (refId || ''), + refNickname, + emojis: emojis.length > 0 ? emojis : undefined + } + }) + } + + async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { + const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + if (!result.success || !result.timeline || result.timeline.length === 0) return result + + // 诊断:测试 execQuery 查 content 字段 + try { + const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1') + if (testResult.success && testResult.rows?.[0]) { + const r = testResult.rows[0] + console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200)) + console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList')) + } else { + console.log('[SnsService] execQuery 诊断失败:', testResult.error) + } + } catch (e) { + console.log('[SnsService] execQuery 诊断异常:', e) } - return result + const enrichedTimeline = result.timeline.map((post: any) => { + const contact = this.contactCache.get(post.username) + const isVideoPost = post.type === 15 + const videoKey = extractVideoKey(post.rawXml || '') + + const fixedMedia = (post.media || []).map((m: any) => ({ + url: fixSnsUrl(m.url, m.token, isVideoPost), + thumb: fixSnsUrl(m.thumb, m.token, false), + md5: m.md5, + token: m.token, + key: isVideoPost ? (videoKey || m.key) : m.key, + encIdx: m.encIdx || m.enc_idx, + livePhoto: m.livePhoto ? { + ...m.livePhoto, + url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), + token: m.livePhoto.token, + key: videoKey || m.livePhoto.key || m.key, + encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx + } : undefined + })) + + // DLL 已返回完整评论数据(含 emojis、refNickname) + // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 + const dllComments: any[] = post.comments || [] + const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) + const rawXml = post.rawXml || '' + + let finalComments: any[] + if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { + // DLL 数据完整,直接使用 + finalComments = this.fixCommentRefs(dllComments) + } else if (rawXml) { + // 回退:从 rawXml 重新解析(兼容旧版 DLL) + const xmlComments = parseCommentsFromXml(rawXml) + finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments) + } else { + finalComments = this.fixCommentRefs(dllComments) + } + + return { + ...post, + avatarUrl: contact?.avatarUrl, + nickname: post.nickname || contact?.displayName || post.username, + media: fixedMedia, + comments: finalComments + } + }) + + return { ...result, timeline: enrichedTimeline } } async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { @@ -857,6 +1148,316 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } }) } + + /** 判断 buffer 是否为有效图片头 */ + private isValidImageBuffer(buf: Buffer): boolean { + if (!buf || buf.length < 12) return false + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true + if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true + if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 + && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true + return false + } + + /** 根据图片头返回扩展名 */ + private getImageExtFromBuffer(buf: Buffer): string { + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif' + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png' + if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return '.jpg' + if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 + && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return '.webp' + return '.gif' + } + + /** 构建多种密钥派生方式 */ + private buildKeyTries(aesKey: string): { name: string; key: Buffer }[] { + const keyTries: { name: string; key: Buffer }[] = [] + const hexStr = aesKey.replace(/\s/g, '') + if (hexStr.length >= 32 && /^[0-9a-fA-F]+$/.test(hexStr)) { + try { + const keyBuf = Buffer.from(hexStr.slice(0, 32), 'hex') + if (keyBuf.length === 16) keyTries.push({ name: 'hex-decode', key: keyBuf }) + } catch { } + const rawKey = Buffer.from(hexStr.slice(0, 32), 'utf8') + if (rawKey.length === 32) keyTries.push({ name: 'raw-hex-str-32', key: rawKey }) + } + if (aesKey.length >= 16) { + keyTries.push({ name: 'utf8-16', key: Buffer.from(aesKey, 'utf8').subarray(0, 16) }) + } + keyTries.push({ name: 'md5', key: crypto.createHash('md5').update(aesKey).digest() }) + try { + const b64Buf = Buffer.from(aesKey, 'base64') + if (b64Buf.length >= 16) keyTries.push({ name: 'base64', key: b64Buf.subarray(0, 16) }) + } catch { } + return keyTries + } + + /** 构建多种 GCM 数据布局 */ + private buildGcmLayouts(encData: Buffer): { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] { + const layouts: { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = [] + // 格式 A:GcmData 块格式 + if (encData.length > 63 && encData[0] === 0xAB && encData[8] === 0xAB && encData[9] === 0x00) { + const payloadSize = encData.readUInt32LE(10) + if (payloadSize > 16 && 63 + payloadSize <= encData.length) { + const nonce = encData.subarray(19, 31) + const payload = encData.subarray(63, 63 + payloadSize) + layouts.push({ nonce, ciphertext: payload.subarray(0, payload.length - 16), tag: payload.subarray(payload.length - 16) }) + } + } + // 格式 B:尾部 [ciphertext][nonce 12B][tag 16B] + if (encData.length > 28) { + layouts.push({ + ciphertext: encData.subarray(0, encData.length - 28), + nonce: encData.subarray(encData.length - 28, encData.length - 16), + tag: encData.subarray(encData.length - 16) + }) + } + // 格式 C:前置 [nonce 12B][ciphertext][tag 16B] + if (encData.length > 28) { + layouts.push({ + nonce: encData.subarray(0, 12), + ciphertext: encData.subarray(12, encData.length - 16), + tag: encData.subarray(encData.length - 16) + }) + } + // 格式 D:零 nonce + if (encData.length > 16) { + layouts.push({ + nonce: Buffer.alloc(12, 0), + ciphertext: encData.subarray(0, encData.length - 16), + tag: encData.subarray(encData.length - 16) + }) + } + // 格式 E:[nonce 12B][tag 16B][ciphertext] + if (encData.length > 28) { + layouts.push({ + nonce: encData.subarray(0, 12), + tag: encData.subarray(12, 28), + ciphertext: encData.subarray(28) + }) + } + return layouts + } + + /** 尝试 AES-GCM 解密 */ + private tryGcmDecrypt(key: Buffer, nonce: Buffer, ciphertext: Buffer, tag: Buffer): Buffer | null { + try { + const algo = key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm' + const decipher = crypto.createDecipheriv(algo, key, nonce) + decipher.setAuthTag(tag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (this.isValidImageBuffer(decrypted)) return decrypted + for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) { + try { + const d = fn(decrypted) + if (this.isValidImageBuffer(d)) return d + } catch { } + } + return decrypted + } catch { + return null + } + } + + /** + * 解密表情数据(多种算法 + 多种密钥派生) + * 移植自 ciphertalk 的逆向实现 + */ + private decryptEmojiAes(encData: Buffer, aesKey: string): Buffer | null { + if (encData.length <= 16) return null + + const keyTries = this.buildKeyTries(aesKey) + const tag = encData.subarray(encData.length - 16) + const ciphertext = encData.subarray(0, encData.length - 16) + + // 最高优先级:nonce-tail 格式 [ciphertext][nonce 12B][tag 16B] + if (encData.length > 28) { + const nonceTail = encData.subarray(encData.length - 28, encData.length - 16) + const tagTail = encData.subarray(encData.length - 16) + const cipherTail = encData.subarray(0, encData.length - 28) + for (const { key } of keyTries) { + if (key.length !== 16 && key.length !== 32) continue + const result = this.tryGcmDecrypt(key, nonceTail, cipherTail, tagTail) + if (result) return result + } + } + + // 次优先级:nonce = key 前 12 字节 + for (const { key } of keyTries) { + if (key.length !== 16 && key.length !== 32) continue + const nonce = key.subarray(0, 12) + const result = this.tryGcmDecrypt(key, nonce, ciphertext, tag) + if (result) return result + } + + // 其他 GCM 布局 + const layouts = this.buildGcmLayouts(encData) + for (const layout of layouts) { + for (const { key } of keyTries) { + if (key.length !== 16 && key.length !== 32) continue + const result = this.tryGcmDecrypt(key, layout.nonce, layout.ciphertext, layout.tag) + if (result) return result + } + } + + // 回退:AES-128-CBC / AES-128-ECB + for (const { key } of keyTries) { + if (key.length !== 16) continue + // CBC:IV = key + if (encData.length >= 16 && encData.length % 16 === 0) { + try { + const dec = crypto.createDecipheriv('aes-128-cbc', key, key) + dec.setAutoPadding(true) + const result = Buffer.concat([dec.update(encData), dec.final()]) + if (this.isValidImageBuffer(result)) return result + for (const fn of [zlib.inflateSync, zlib.gunzipSync]) { + try { const d = fn(result); if (this.isValidImageBuffer(d)) return d } catch { } + } + } catch { } + } + // CBC:前 16 字节作为 IV + if (encData.length > 32) { + try { + const iv = encData.subarray(0, 16) + const dec = crypto.createDecipheriv('aes-128-cbc', key, iv) + dec.setAutoPadding(true) + const result = Buffer.concat([dec.update(encData.subarray(16)), dec.final()]) + if (this.isValidImageBuffer(result)) return result + } catch { } + } + // ECB + try { + const dec = crypto.createDecipheriv('aes-128-ecb', key, null) + dec.setAutoPadding(true) + const result = Buffer.concat([dec.update(encData), dec.final()]) + if (this.isValidImageBuffer(result)) return result + } catch { } + } + + return null + } + + /** 下载原始数据到本地临时文件,支持重定向 */ + private doDownloadRaw(targetUrl: string, cacheKey: string, cacheDir: string): Promise { + return new Promise((resolve) => { + try { + const fs = require('fs') + const https = require('https') + const http = require('http') + let fixedUrl = targetUrl.replace(/&/g, '&') + const urlObj = new URL(fixedUrl) + const protocol = fixedUrl.startsWith('https') ? https : http + + const options = { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/7.0.20.1781(0x67001431)', + 'Accept': '*/*', + 'Connection': 'keep-alive' + }, + rejectUnauthorized: false, + timeout: 15000 + } + + const request = protocol.get(fixedUrl, options, (response: any) => { + // 处理重定向 + if ([301, 302, 303, 307].includes(response.statusCode)) { + const redirectUrl = response.headers.location + if (redirectUrl) { + const full = redirectUrl.startsWith('http') ? redirectUrl : `${urlObj.protocol}//${urlObj.host}${redirectUrl}` + this.doDownloadRaw(full, cacheKey, cacheDir).then(resolve) + return + } + } + if (response.statusCode !== 200) { resolve(null); return } + + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.on('end', () => { + const buffer = Buffer.concat(chunks) + if (buffer.length === 0) { resolve(null); return } + const ext = this.isValidImageBuffer(buffer) ? this.getImageExtFromBuffer(buffer) : '.bin' + const filePath = join(cacheDir, `${cacheKey}${ext}`) + try { + fs.writeFileSync(filePath, buffer) + resolve(filePath) + } catch { resolve(null) } + }) + response.on('error', () => resolve(null)) + }) + request.on('error', () => resolve(null)) + request.setTimeout(15000, () => { request.destroy(); resolve(null) }) + } catch { resolve(null) } + }) + } + + /** + * 下载朋友圈评论中的表情包(多种解密算法,移植自 ciphertalk) + */ + async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> { + if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' } + + const fs = require('fs') + const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') + const cachePath = this.configService.getCacheBasePath() + const emojiDir = join(cachePath, 'sns_emoji_cache') + if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true }) + + // 检查本地缓存 + for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) { + const filePath = join(emojiDir, `${cacheKey}${ext}`) + if (existsSync(filePath)) return { success: true, localPath: filePath } + } + + // 保存解密后的图片 + const saveDecrypted = (buf: Buffer): { success: boolean; localPath?: string } => { + const ext = this.isValidImageBuffer(buf) ? this.getImageExtFromBuffer(buf) : '.gif' + const filePath = join(emojiDir, `${cacheKey}${ext}`) + try { fs.writeFileSync(filePath, buf); return { success: true, localPath: filePath } } + catch { return { success: false } } + } + + // 1. 优先:encryptUrl + aesKey + if (encryptUrl && aesKey) { + const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', emojiDir) + if (encResult) { + const encData = fs.readFileSync(encResult) + if (this.isValidImageBuffer(encData)) { + const ext = this.getImageExtFromBuffer(encData) + const filePath = join(emojiDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, encData) + try { fs.unlinkSync(encResult) } catch { } + return { success: true, localPath: filePath } + } + const decrypted = this.decryptEmojiAes(encData, aesKey) + if (decrypted) { + try { fs.unlinkSync(encResult) } catch { } + return saveDecrypted(decrypted) + } + try { fs.unlinkSync(encResult) } catch { } + } + } + + // 2. 直接下载 url + if (url) { + const result = await this.doDownloadRaw(url, cacheKey, emojiDir) + if (result) { + const buf = fs.readFileSync(result) + if (this.isValidImageBuffer(buf)) return { success: true, localPath: result } + // 用 aesKey 解密 + if (aesKey) { + const decrypted = this.decryptEmojiAes(buf, aesKey) + if (decrypted) { + try { fs.unlinkSync(result) } catch { } + return saveDecrypted(decrypted) + } + } + try { fs.unlinkSync(result) } catch { } + } + } + + return { success: false, error: '下载表情包失败' } + } } export const snsService = new SnsService() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 89b5039..90b7fa7 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -63,6 +63,10 @@ export class WcdbCore { private wcdbGetVoiceData: any = null private wcdbGetSnsTimeline: any = null private wcdbGetSnsAnnualStats: any = null + private wcdbInstallSnsBlockDeleteTrigger: any = null + private wcdbUninstallSnsBlockDeleteTrigger: any = null + private wcdbCheckSnsBlockDeleteTrigger: any = null + private wcdbDeleteSnsPost: any = null private wcdbVerifyUser: any = null private wcdbStartMonitorPipe: any = null private wcdbStopMonitorPipe: any = null @@ -600,6 +604,34 @@ export class WcdbCore { this.wcdbGetSnsAnnualStats = null } + // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) + try { + this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') + } catch { + this.wcdbInstallSnsBlockDeleteTrigger = null + } + + // wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error) + try { + this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') + } catch { + this.wcdbUninstallSnsBlockDeleteTrigger = null + } + + // wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed) + try { + this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)') + } catch { + this.wcdbCheckSnsBlockDeleteTrigger = null + } + + // wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error) + try { + this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)') + } catch { + this.wcdbDeleteSnsPost = null + } + // Named pipe IPC for monitoring (replaces callback) try { this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()') @@ -1813,6 +1845,94 @@ export class WcdbCore { return { success: false, error: String(e) } } } + /** + * 为朋友圈安装删除 + */ + async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outPtr = [null] + const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status === 1) { + // DLL 返回 1 表示已安装 + return { success: true, alreadyInstalled: true } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true, alreadyInstalled: false } + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 关闭朋友圈删除拦截 + */ + async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outPtr = [null] + const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 查询朋友圈删除拦截是否已安装 + */ + async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outInstalled = [0] + const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled) + if (status !== 0) { + return { success: false, error: `DLL error ${status}` } + } + return { success: true, installed: outInstalled[0] === 1 } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outPtr = [null] + const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index da1037d..50cd354 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -416,6 +416,34 @@ export class WcdbService { return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) } + /** + * 安装朋友圈删除拦截 + */ + async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + return this.callWorker('installSnsBlockDeleteTrigger') + } + + /** + * 卸载朋友圈删除拦截 + */ + async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { + return this.callWorker('uninstallSnsBlockDeleteTrigger') + } + + /** + * 查询朋友圈删除拦截是否已安装 + */ + async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { + return this.callWorker('checkSnsBlockDeleteTrigger') + } + + /** + * 从数据库直接删除朋友圈记录 + */ + async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { + return this.callWorker('deleteSnsPost', { postId }) + } + /** * 获取 DLL 内部日志 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 31461a7..436b3da 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -144,6 +144,18 @@ if (parentPort) { case 'getSnsAnnualStats': result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) break + case 'installSnsBlockDeleteTrigger': + result = await core.installSnsBlockDeleteTrigger() + break + case 'uninstallSnsBlockDeleteTrigger': + result = await core.uninstallSnsBlockDeleteTrigger() + break + case 'checkSnsBlockDeleteTrigger': + result = await core.checkSnsBlockDeleteTrigger() + break + case 'deleteSnsPost': + result = await core.deleteSnsPost(payload.postId) + break case 'getLogs': result = await core.getLogs() break diff --git a/public/splash.html b/public/splash.html new file mode 100644 index 0000000..d71c241 --- /dev/null +++ b/public/splash.html @@ -0,0 +1,249 @@ + + + + + + WeFlow + + + +
+
+ +
+
WeFlow
+
微信聊天记录管理工具
+
+
+ +
+ +
+
+
+
+
+
正在启动...
+
+
+
+
+ + + + diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 8035ca0..ed735a0 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.scss b/src/App.scss index 2613fa3..3c137bd 100644 --- a/src/App.scss +++ b/src/App.scss @@ -4,6 +4,48 @@ flex-direction: column; background: var(--bg-primary); animation: appFadeIn 0.35s ease-out; + position: relative; + overflow: hidden; +} + +// 繁花如梦:底色层(::before)+ 光晕层(::after)分离,避免 blur 吃掉边缘 +[data-theme="blossom-dream"] .app-container { + background: transparent; +} + +// ::before 纯底色,不模糊 +[data-theme="blossom-dream"] .app-container::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: -2; + background: var(--bg-primary); +} + +// ::after 光晕层,模糊叠加在底色上 +[data-theme="blossom-dream"] .app-container::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: -1; + background: + radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%), + radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%), + radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%); + filter: blur(80px); + opacity: 0.75; +} + +// 深色模式光晕更克制 +[data-theme="blossom-dream"][data-mode="dark"] .app-container::after { + background: + radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%), + radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%), + radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%); + filter: blur(100px); + opacity: 0.2; } .window-drag-region { diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss index b48013c..d442af7 100644 --- a/src/components/NotificationToast.scss +++ b/src/components/NotificationToast.scss @@ -7,10 +7,12 @@ -webkit-backdrop-filter: blur(20px); border: 1px solid var(--border-light); - // 浅色模式下使用不透明背景,避免透明窗口中通知过于透明 + // 浅色模式下使用完全不透明背景,并禁用毛玻璃效果 [data-mode="light"] &, :not([data-mode]) & { background: rgba(255, 255, 255, 1); + backdrop-filter: none; + -webkit-backdrop-filter: none; } border-radius: 12px; @@ -46,10 +48,16 @@ backdrop-filter: none !important; -webkit-backdrop-filter: none !important; - // 确保背景不透明 - background: var(--bg-secondary, #2c2c2c); + // 确保背景完全不透明(通知是独立窗口,透明背景会穿透) + background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c)); color: var(--text-primary, #ffffff); + // 浅色模式强制完全不透明白色背景 + [data-mode="light"] &, + :not([data-mode]) & { + background: #ffffff !important; + } + box-shadow: none !important; // NO SHADOW border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1)); diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index 6899c93..d2a1b7f 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -103,4 +103,31 @@ background: var(--bg-tertiary); color: var(--text-primary); } +} + +// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色 +[data-theme="blossom-dream"] .sidebar { + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-right: 1px solid rgba(255, 255, 255, 0.4); +} + +[data-theme="blossom-dream"][data-mode="dark"] .sidebar { + background: rgba(34, 30, 36, 0.75); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-right: 1px solid rgba(255, 255, 255, 0.06); +} + +// 激活项:主品牌色纵向微渐变 +[data-theme="blossom-dream"] .nav-item.active { + background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%); +} + +// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法) +[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active { + background: rgba(209, 158, 187, 0.15); + color: #D19EBB; + border: 1px solid rgba(209, 158, 187, 0.2); } \ No newline at end of file diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index bf65dca..76972fb 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -1,5 +1,6 @@ -import React, { useState, useMemo } from 'react' -import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react' +import React, { useState, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react' import { SnsPost, SnsLinkCardData } from '../../types/sns' import { Avatar } from '../Avatar' import { SnsMediaGrid } from './SnsMediaGrid' @@ -178,14 +179,78 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { ) } +// 表情包内存缓存 +const emojiLocalCache = new Map() + +// 评论表情包组件 +const CommentEmoji: React.FC<{ + emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string } + onPreview?: (src: string) => void +}> = ({ emoji, onPreview }) => { + const cacheKey = emoji.encryptUrl || emoji.url + const [localSrc, setLocalSrc] = useState(() => emojiLocalCache.get(cacheKey) || '') + + useEffect(() => { + if (!cacheKey) return + if (emojiLocalCache.has(cacheKey)) { + setLocalSrc(emojiLocalCache.get(cacheKey)!) + return + } + let cancelled = false + const load = async () => { + try { + const res = await window.electronAPI.sns.downloadEmoji({ + url: emoji.url, + encryptUrl: emoji.encryptUrl, + aesKey: emoji.aesKey + }) + if (cancelled) return + if (res.success && res.localPath) { + const fileUrl = res.localPath.startsWith('file:') + ? res.localPath + : `file://${res.localPath.replace(/\\/g, '/')}` + emojiLocalCache.set(cacheKey, fileUrl) + setLocalSrc(fileUrl) + } + } catch { /* 静默失败 */ } + } + load() + return () => { cancelled = true } + }, [cacheKey]) + + if (!localSrc) return null + + return ( + emoji { e.stopPropagation(); onPreview?.(localSrc) }} + style={{ + width: Math.min(emoji.width || 24, 30), + height: Math.min(emoji.height || 24, 30), + verticalAlign: 'middle', + marginLeft: 2, + borderRadius: 4, + cursor: onPreview ? 'pointer' : 'default' + }} + /> + ) +} + interface SnsPostItemProps { post: SnsPost onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void + onDelete?: (postId: string) => void } -export const SnsPostItem: React.FC = ({ post, onPreview, onDebug }) => { +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete }) => { const [mediaDeleted, setMediaDeleted] = useState(false) + const [dbDeleted, setDbDeleted] = useState(false) + const [deleting, setDeleting] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const linkCard = buildLinkCardData(post) const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia @@ -221,8 +286,29 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb }) } + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (deleting || dbDeleted) return + setShowDeleteConfirm(true) + } + + const handleDeleteConfirm = async () => { + setShowDeleteConfirm(false) + setDeleting(true) + try { + const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) + if (r.success) { + setDbDeleted(true) + onDelete?.(post.id) + } + } finally { + setDeleting(false) + } + } + return ( -
+ <> +
= ({ post, onPreview, onDeb {formatTime(post.createTime)}
- {mediaDeleted && ( + {(mediaDeleted || dbDeleted) && ( 已删除 )} +
))}
@@ -298,5 +401,24 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb )}
+ + {/* 删除确认弹窗 - 用 Portal 挂到 body,避免父级 transform 影响 fixed 定位 */} + {showDeleteConfirm && createPortal( +
setShowDeleteConfirm(false)}> +
e.stopPropagation()}> +
+ +
+
删除这条记录?
+
将从本地数据库中永久删除,无法恢复。
+
+ + +
+
+
, + document.body + )} + ) } diff --git a/src/components/TitleBar.scss b/src/components/TitleBar.scss index f17c998..9c18972 100644 --- a/src/components/TitleBar.scss +++ b/src/components/TitleBar.scss @@ -10,6 +10,12 @@ gap: 8px; } +// 繁花如梦:标题栏毛玻璃 +[data-theme="blossom-dream"] .title-bar { + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + .title-logo { width: 20px; height: 20px; diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8a6a9c5..d8c81b9 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2243,6 +2243,18 @@ .quoted-text { color: var(--text-secondary); white-space: pre-wrap; + + .quoted-type-label { + font-style: italic; + opacity: 0.8; + } + + .quoted-emoji-image { + width: 40px; + height: 40px; + vertical-align: middle; + object-fit: contain; + } } } @@ -2897,7 +2909,6 @@ display: flex; align-items: center; gap: 6px; - padding: 8px 12px; color: var(--text-secondary); font-size: 13px; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index e5389eb..b5ec3df 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -2780,6 +2780,31 @@ const voiceTranscriptCache = new Map() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() +// 引用消息中的动画表情组件 +function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { + const cacheKey = md5 || cdnUrl + const [localPath, setLocalPath] = useState(() => emojiDataUrlCache.get(cacheKey)) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + + useEffect(() => { + if (localPath || loading || error) return + setLoading(true) + window.electronAPI.chat.downloadEmoji(cdnUrl, md5).then((result: { success: boolean; localPath?: string }) => { + if (result.success && result.localPath) { + emojiDataUrlCache.set(cacheKey, result.localPath) + setLocalPath(result.localPath) + } else { + setError(true) + } + }).catch(() => setError(true)).finally(() => setLoading(false)) + }, [cdnUrl, md5, cacheKey, localPath, loading, error]) + + if (error || (!loading && !localPath)) return [动画表情] + if (loading) return [动画表情] + return 动画表情 +} + // 消息气泡组件 function MessageBubble({ message, @@ -2901,7 +2926,7 @@ function MessageBubble({ // 从缓存获取表情包 data URL const cacheKey = message.emojiMd5 || message.emojiCdnUrl || '' const [emojiLocalPath, setEmojiLocalPath] = useState( - () => emojiDataUrlCache.get(cacheKey) + () => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath ) const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` const [imageLocalPath, setImageLocalPath] = useState( @@ -3036,10 +3061,15 @@ function MessageBubble({ // 自动下载表情包 useEffect(() => { if (emojiLocalPath) return + // 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况) + if (isEmoji && message.emojiLocalPath && !emojiLocalPath) { + setEmojiLocalPath(message.emojiLocalPath) + return + } if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) { downloadEmoji() } - }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) + }, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError]) const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => { if (!isImage) return @@ -3971,11 +4001,13 @@ function MessageBubble({ // 通话消息 if (isCall) { return ( -
- - - - {message.parsedContent || '[通话]'} +
+
+ + + + {message.parsedContent || '[通话]'} +
) } @@ -4043,11 +4075,39 @@ function MessageBubble({ const replyText = q('title') || cleanMessageContent(message.parsedContent) || '' const referContent = q('refermsg > content') || '' const referSender = q('refermsg > displayname') || '' + const referType = q('refermsg > type') || '' + + // 根据被引用消息类型渲染对应内容 + const renderReferContent = () => { + // 动画表情:解析嵌套 XML 提取 cdnurl 渲染 + if (referType === '47') { + try { + const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml') + const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || '' + const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || '' + if (cdnUrl) return + } catch { /* 解析失败降级 */ } + return [动画表情] + } + + // 各类型名称映射 + const typeLabels: Record = { + '3': '图片', '34': '语音', '43': '视频', + '49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息', + } + if (referType && typeLabels[referType]) { + return [{typeLabels[referType]}] + } + + // 普通文本或未知类型 + return <>{renderTextWithEmoji(cleanMessageContent(referContent))} + } + return (
{referSender && {referSender}} - {renderTextWithEmoji(cleanMessageContent(referContent))} + {renderReferContent()}
{renderTextWithEmoji(cleanMessageContent(replyText))}
@@ -4143,6 +4203,22 @@ function MessageBubble({
) + if (kind === 'quote') { + // 引用回复消息(appMsgKind='quote',xmlType=57) + const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || '' + const referContent = message.quotedContent || q('refermsg > content') || '' + const referSender = message.quotedSender || q('refermsg > displayname') || '' + return ( +
+
+ {referSender && {referSender}} + {renderTextWithEmoji(cleanMessageContent(referContent))} +
+
{renderTextWithEmoji(cleanMessageContent(replyText))}
+
+ ) + } + if (kind === 'red-packet') { // 专属红包卡片 const greeting = (() => { @@ -4347,6 +4423,44 @@ function MessageBubble({ console.error('解析 AppMsg 失败:', e) } + // 引用回复消息 (type=57),防止被误判为链接 + if (appMsgType === '57') { + const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || '' + const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' + const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || '' + const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' + + const renderReferContent2 = () => { + if (referType === '47') { + try { + const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml') + const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || '' + const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || '' + if (cdnUrl) return + } catch { /* 解析失败降级 */ } + return [动画表情] + } + const typeLabels: Record = { + '3': '图片', '34': '语音', '43': '视频', + '49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息', + } + if (referType && typeLabels[referType]) { + return [{typeLabels[referType]}] + } + return <>{renderTextWithEmoji(cleanMessageContent(referContent))} + } + + return ( +
+
+ {referSender && {referSender}} + {renderReferContent2()} +
+
{renderTextWithEmoji(cleanMessageContent(replyText))}
+
+ ) + } + // 群公告消息 (type=87) if (appMsgType === '87') { const announcementText = textAnnouncement || desc || '群公告' @@ -4579,7 +4693,7 @@ function MessageBubble({ if (isEmoji) { // ... (keep existing emoji logic) // 没有 cdnUrl 或加载失败,显示占位符 - if (!message.emojiCdnUrl || emojiError) { + if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) { return (
diff --git a/src/pages/HomePage.scss b/src/pages/HomePage.scss index 6b12cb1..cd4cb78 100644 --- a/src/pages/HomePage.scss +++ b/src/pages/HomePage.scss @@ -29,7 +29,7 @@ .blob-1 { width: 400px; height: 400px; - background: rgba(139, 115, 85, 0.25); + background: rgba(var(--primary-rgb), 0.25); top: -100px; left: -50px; animation-duration: 25s; @@ -38,7 +38,7 @@ .blob-2 { width: 350px; height: 350px; - background: rgba(139, 115, 85, 0.15); + background: rgba(var(--primary-rgb), 0.15); bottom: -50px; right: -50px; animation-duration: 30s; @@ -74,7 +74,7 @@ margin: 0 0 16px; color: var(--text-primary); letter-spacing: -2px; - background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%); + background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 48b2f2e..4361e33 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -939,8 +939,16 @@ function SettingsPage() {
{themes.map((theme) => (
setTheme(theme.id)}> -
-
+
+
{theme.name} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index a88f842..e9620ae 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -190,6 +190,32 @@ background: var(--bg-tertiary); border-color: var(--text-secondary); } + + &.delete-btn:hover { + color: #ff4d4f; + border-color: rgba(255, 77, 79, 0.4); + background: rgba(255, 77, 79, 0.08); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + .post-protected-badge { + display: flex; + align-items: center; + gap: 3px; + opacity: 0; + transition: opacity 0.2s; + color: var(--color-success, #4caf50); + font-size: 11px; + font-weight: 500; + padding: 3px 7px; + border-radius: 5px; + background: rgba(76, 175, 80, 0.08); + border: 1px solid rgba(76, 175, 80, 0.2); } } @@ -197,6 +223,258 @@ opacity: 1; } +.sns-post-item:hover .post-protected-badge { + opacity: 1; +} + +// 删除确认弹窗 +.sns-confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.sns-confirm-dialog { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 28px 28px 22px; + width: 300px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + + .sns-confirm-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: rgba(255, 77, 79, 0.1); + color: #ff4d4f; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; + } + + .sns-confirm-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .sns-confirm-desc { + font-size: 13px; + color: var(--text-secondary); + text-align: center; + line-height: 1.5; + margin-bottom: 8px; + } + + .sns-confirm-actions { + display: flex; + gap: 10px; + width: 100%; + margin-top: 4px; + + button { + flex: 1; + height: 36px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border-color); + transition: all 0.15s; + } + + .sns-confirm-cancel { + background: var(--bg-tertiary); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .sns-confirm-ok { + background: #ff4d4f; + color: #fff; + border-color: #ff4d4f; + + &:hover { + background: #ff7875; + border-color: #ff7875; + } + } + } +} + +// 朋友圈防删除插件对话框 +.sns-protect-dialog { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 340px; + padding: 32px 28px 24px; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + + .sns-protect-close { + position: absolute; + top: 14px; + right: 14px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + } + + .sns-protect-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-bottom: 20px; + } + + .sns-protect-icon-wrap { + width: 64px; + height: 64px; + border-radius: 18px; + background: var(--bg-tertiary); + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + + &.active { + background: rgba(76, 175, 80, 0.12); + color: var(--color-success, #4caf50); + } + } + + .sns-protect-title { + font-size: 17px; + font-weight: 600; + color: var(--text-primary); + } + + .sns-protect-status-badge { + font-size: 12px; + font-weight: 500; + padding: 3px 10px; + border-radius: 20px; + + &.on { + background: rgba(76, 175, 80, 0.12); + color: var(--color-success, #4caf50); + } + + &.off { + background: var(--bg-tertiary); + color: var(--text-tertiary); + } + } + + .sns-protect-desc { + font-size: 13px; + color: var(--text-secondary); + text-align: center; + line-height: 1.6; + margin-bottom: 16px; + } + + .sns-protect-feedback { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + padding: 8px 12px; + border-radius: 8px; + width: 100%; + margin-bottom: 14px; + box-sizing: border-box; + + &.success { + background: rgba(76, 175, 80, 0.1); + color: var(--color-success, #4caf50); + } + + &.error { + background: rgba(244, 67, 54, 0.1); + color: var(--color-error, #f44336); + } + } + + .sns-protect-actions { + width: 100%; + } + + .sns-protect-btn { + width: 100%; + height: 40px; + border-radius: 10px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + transition: all 0.15s; + + &.primary { + background: var(--color-primary, #1677ff); + color: #fff; + + &:hover:not(:disabled) { + filter: brightness(1.1); + } + } + + &.danger { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + + &:hover:not(:disabled) { + background: rgba(255, 77, 79, 0.08); + color: #ff4d4f; + border-color: rgba(255, 77, 79, 0.3); + } + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + .post-text { font-size: 15px; line-height: 1.6; @@ -322,6 +600,13 @@ .comment-colon { margin-right: 4px; } + + .comment-custom-emoji { + display: inline-block; + vertical-align: middle; + border-radius: 4px; + margin-left: 2px; + } } } } @@ -950,7 +1235,7 @@ display: flex; &:hover { - background: rgba(0, 0, 0, 0.05); + background: var(--bg-primary); color: var(--text-primary); } } @@ -992,7 +1277,7 @@ Export Dialog ========================================= */ .export-dialog { - background: rgba(255, 255, 255, 0.88); + background: var(--bg-secondary); border-radius: var(--sns-border-radius-lg); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); width: 480px; @@ -1028,7 +1313,7 @@ display: flex; &:hover { - background: rgba(0, 0, 0, 0.05); + background: var(--bg-primary); color: var(--text-primary); } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 56f9e28..452931c 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react' -import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react' +import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react' import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' import { SnsPost } from '../types/sns' @@ -46,6 +46,12 @@ export default function SnsPage() { const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) + // 触发器相关状态 + const [showTriggerDialog, setShowTriggerDialog] = useState(false) + const [triggerInstalled, setTriggerInstalled] = useState(null) + const [triggerLoading, setTriggerLoading] = useState(false) + const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + const postsContainerRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) @@ -56,7 +62,6 @@ export default function SnsPage() { useEffect(() => { postsRef.current = posts }, [posts]) - // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; @@ -285,6 +290,25 @@ export default function SnsPage() {

朋友圈

+
@@ -426,6 +451,101 @@ export default function SnsPage() {
)} + {/* 朋友圈防删除插件对话框 */} + {showTriggerDialog && ( +
{ setShowTriggerDialog(false); setTriggerMessage(null) }}> +
e.stopPropagation()}> + + + {/* 顶部图标区 */} +
+
+ {triggerLoading + ? + : triggerInstalled + ? + : + } +
+
朋友圈防删除
+
+ {triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'} +
+
+ + {/* 说明 */} +
+ 启用后,WeFlow将拦截朋友圈删除操作
已同步的动态不会从本地数据库中消失
新的动态仍可正常同步。 +
+ + {/* 操作反馈 */} + {triggerMessage && ( +
+ {triggerMessage.type === 'success' ? : } + {triggerMessage.text} +
+ )} + + {/* 操作按钮 */} +
+ {!triggerInstalled ? ( + + ) : ( + + )} +
+
+
+ )} + {/* 导出对话框 */} {showExportDialog && (
!isExporting && setShowExportDialog(false)}> diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts index df2bd9d..523d3e4 100644 --- a/src/stores/themeStore.ts +++ b/src/stores/themeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' +export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream' export type ThemeMode = 'light' | 'dark' | 'system' export interface ThemeInfo { @@ -10,6 +10,8 @@ export interface ThemeInfo { description: string primaryColor: string bgColor: string + // 可选副色,用于多彩主题的渐变预览 + accentColor?: string } export const themes: ThemeInfo[] = [ @@ -20,6 +22,14 @@ export const themes: ThemeInfo[] = [ primaryColor: '#8B7355', bgColor: '#F0EEE9' }, + { + id: 'blossom-dream', + name: '繁花如梦', + description: '晨曦花境 · 夜阑幽梦', + primaryColor: '#D4849A', + bgColor: '#FCF9FB', + accentColor: '#FFBE98' + }, { id: 'corundum-blue', name: '刚玉蓝', diff --git a/src/styles/main.scss b/src/styles/main.scss index 9f81ffd..1939130 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -153,6 +153,43 @@ --sent-card-bg: var(--primary); } +// 繁花如梦 - 浅色(晨曦花境) +[data-theme="blossom-dream"][data-mode="light"], +[data-theme="blossom-dream"]:not([data-mode]) { + // 三色定义(供伪元素光晕使用,饱和度提高以便在底色上可见) + --blossom-pink: #F0A0B8; + --blossom-peach: #FFB07A; + --blossom-blue: #90B8E0; + + // 主品牌色:Pantone 粉晶 Rose Quartz + --primary: #D4849A; + --primary-rgb: 212, 132, 154; + --primary-hover: #C4748A; + --primary-light: rgba(212, 132, 154, 0.12); + + // 背景三层:主背景最深(相对),面板次之,卡片最白 + --bg-primary: #F5EDF2; + --bg-secondary: rgba(255, 255, 255, 0.82); + --bg-tertiary: rgba(212, 132, 154, 0.06); + --bg-hover: rgba(212, 132, 154, 0.09); + + // 文字:提高对比度,主色接近纯黑只带微弱紫调 + --text-primary: #1E1A22; + --text-secondary: #6B5F70; + --text-tertiary: #9A8A9E; + // 边框:粉色半透明,有存在感但不强硬 + --border-color: rgba(212, 132, 154, 0.18); + + --bg-gradient: linear-gradient(150deg, #F5EDF2 0%, #F0EAF6 50%, #EAF0F8 100%); + --primary-gradient: linear-gradient(135deg, #D4849A 0%, #E8A8B8 100%); + + // 卡片:高不透明度白,与背景形成明显层次 + --card-bg: rgba(255, 255, 255, 0.88); + --card-inner-bg: rgba(255, 255, 255, 0.95); + + --sent-card-bg: var(--primary); +} + // ==================== 深色主题 ==================== // 云上舞白 - 深色 @@ -163,6 +200,7 @@ --primary-light: rgba(201, 168, 108, 0.15); --bg-primary: #1a1816; --bg-secondary: rgba(40, 36, 32, 0.9); + --bg-secondary-solid: #282420; --bg-tertiary: rgba(255, 255, 255, 0.05); --bg-hover: rgba(255, 255, 255, 0.08); --text-primary: #F0EEE9; @@ -184,6 +222,7 @@ --primary-light: rgba(106, 154, 170, 0.15); --bg-primary: #141a1c; --bg-secondary: rgba(30, 40, 44, 0.9); + --bg-secondary-solid: #1e282c; --bg-tertiary: rgba(255, 255, 255, 0.05); --bg-hover: rgba(255, 255, 255, 0.08); --text-primary: #E8EEF0; @@ -205,6 +244,7 @@ --primary-light: rgba(154, 186, 124, 0.15); --bg-primary: #161a14; --bg-secondary: rgba(34, 42, 30, 0.9); + --bg-secondary-solid: #222a1e; --bg-tertiary: rgba(255, 255, 255, 0.05); --bg-hover: rgba(255, 255, 255, 0.08); --text-primary: #E8F0E4; @@ -226,6 +266,7 @@ --primary-light: rgba(192, 96, 104, 0.15); --bg-primary: #1a1416; --bg-secondary: rgba(42, 32, 34, 0.9); + --bg-secondary-solid: #2a2022; --bg-tertiary: rgba(255, 255, 255, 0.05); --bg-hover: rgba(255, 255, 255, 0.08); --text-primary: #F0E8E8; @@ -247,6 +288,7 @@ --primary-light: rgba(122, 186, 170, 0.15); --bg-primary: #121a1a; --bg-secondary: rgba(28, 42, 42, 0.9); + --bg-secondary-solid: #1c2a2a; --bg-tertiary: rgba(255, 255, 255, 0.05); --bg-hover: rgba(255, 255, 255, 0.08); --text-primary: #E4F0F0; @@ -260,6 +302,43 @@ --sent-card-bg: var(--primary); } +// 繁花如梦 - 深色(夜阑幽梦) +[data-theme="blossom-dream"][data-mode="dark"] { + // 光晕色(供伪元素使用,降低饱和度避免刺眼) + --blossom-pink: #C670C3; + --blossom-purple: #5F4B8B; + --blossom-blue: #3A2A50; + + // 主品牌色:藕粉/烟紫粉,降饱和度不刺眼 + --primary: #D19EBB; + --primary-rgb: 209, 158, 187; + --primary-hover: #DDB0C8; + --primary-light: rgba(209, 158, 187, 0.15); + + // 背景三层:极深黑灰底(去掉紫薯色),面板略浅,卡片再浅一级 + --bg-primary: #151316; + --bg-secondary: rgba(34, 30, 36, 0.92); + --bg-secondary-solid: #221E24; + --bg-tertiary: rgba(255, 255, 255, 0.04); + --bg-hover: rgba(209, 158, 187, 0.1); + + // 文字 + --text-primary: #F0EAF4; + --text-secondary: #A898AE; + --text-tertiary: #6A5870; + // 边框:极细白色内发光,剥离层级 + --border-color: rgba(255, 255, 255, 0.07); + + --bg-gradient: linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%); + --primary-gradient: linear-gradient(135deg, #D19EBB 0%, #A878A8 100%); + + // 卡片:比面板更亮一档,用深灰而非紫色 + --card-bg: rgba(42, 38, 46, 0.92); + --card-inner-bg: rgba(52, 48, 56, 0.96); + + --sent-card-bg: var(--primary); +} + // 重置样式 * { margin: 0; diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b00f3c0..ee0c0f1 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -500,7 +500,7 @@ export interface ElectronAPI { } }> likes: Array - comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }> + comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }> rawXml?: string }> error?: string @@ -520,6 +520,11 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> + uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> + checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> + deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }> + downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }> } http: { start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }> diff --git a/src/types/sns.ts b/src/types/sns.ts index b909433..9193385 100644 --- a/src/types/sns.ts +++ b/src/types/sns.ts @@ -16,16 +16,27 @@ export interface SnsMedia { livePhoto?: SnsLivePhoto } +export interface SnsCommentEmoji { + url: string + md5: string + width: number + height: number + encryptUrl?: string + aesKey?: string +} + export interface SnsComment { id: string nickname: string content: string refCommentId: string refNickname?: string + emojis?: SnsCommentEmoji[] } export interface SnsPost { id: string + tid?: string // 数据库主键(雪花 ID),用于精确删除 username: string nickname: string avatarUrl?: string @@ -38,6 +49,7 @@ export interface SnsPost { rawXml?: string linkTitle?: string linkUrl?: string + isProtected?: boolean // 是否受保护(已安装时标记) } export interface SnsLinkCardData {