支持朋友圈防撤回;修复朋友圈回复嵌套关系错误;支持朋友圈评论表情解析;支持删除本地朋友圈记录

This commit is contained in:
cc
2026-02-27 13:40:13 +08:00
parent 4a09b682b2
commit 9ae1b455f4
13 changed files with 1388 additions and 62 deletions

View File

@@ -1082,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)
})
// 私聊克隆

View File

@@ -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 服务

View File

@@ -76,17 +76,13 @@ class AnalyticsService {
const map: Record<string, string> = {}
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<string, any>[]) {
const username = row.username || ''

View File

@@ -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(/<CommentUserList>([\s\S]*?)<\/CommentUserList>/i)
if (!listMatch) listMatch = xml.match(/<commentUserList>([\s\S]*?)<\/commentUserList>/i)
if (!listMatch) listMatch = xml.match(/<commentList>([\s\S]*?)<\/commentList>/i)
if (!listMatch) listMatch = xml.match(/<comment_user_list>([\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>([^<]*)<\/username>/i)
let nicknameMatch = c.match(/<nickname>([^<]*)<\/nickname>/i)
if (!nicknameMatch) nicknameMatch = c.match(/<nickName>([^<]*)<\/nickName>/i)
const contentMatch = c.match(/<content>([^<]*)<\/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>([^<]*)<\/ref_username>/i)
// 解析表情包
const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = []
const emojiRegex = /<emojiinfo>([\s\S]*?)<\/emojiinfo>/gi
let em: RegExpExecArray | null
while ((em = emojiRegex.exec(c)) !== null) {
const ex = em[1]
const externUrl = ex.match(/<extern_url>([^<]*)<\/extern_url>/i)
const cdnUrl = ex.match(/<cdn_url>([^<]*)<\/cdn_url>/i)
const plainUrl = ex.match(/<url>([^<]*)<\/url>/i)
const urlMatch = externUrl || cdnUrl || plainUrl
const md5Match = ex.match(/<md5>([^<]*)<\/md5>/i)
const wMatch = ex.match(/<width>([^<]*)<\/width>/i)
const hMatch = ex.match(/<height>([^<]*)<\/height>/i)
const encMatch = ex.match(/<encrypt_url>([^<]*)<\/encrypt_url>/i)
const aesMatch = ex.match(/<aes_key>([^<]*)<\/aes_key>/i)
const url = urlMatch ? urlMatch[1].trim().replace(/&amp;/g, '&') : ''
const encryptUrl = encMatch ? encMatch[1].trim().replace(/&amp;/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<string, string>()
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(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\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>([^<]*)<\/nickname>/i)
if (!nick) nick = m[1].match(/<nickName>([^<]*)<\/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(/<enc\s+key="(\d+)"/i)
if (encMatch) videoKey = encMatch[1]
const mediaRegex = /<media>([\s\S]*?)<\/media>/gi
let mediaMatch: RegExpExecArray | null
while ((mediaMatch = mediaRegex.exec(xml)) !== null) {
const mx = mediaMatch[1]
const urlMatch = mx.match(/<url[^>]*>([^<]+)<\/url>/i)
const urlTagMatch = mx.match(/<url([^>]*)>/i)
const thumbMatch = mx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
const thumbTagMatch = mx.match(/<thumb([^>]*)>/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(/<livePhoto>([\s\S]*?)<\/livePhoto>/i)
if (livePhotoMatch) {
const lx = livePhotoMatch[1]
const lpUrl = lx.match(/<url[^>]*>([^<]+)<\/url>/i)
const lpUrlTag = lx.match(/<url([^>]*)>/i)
const lpThumb = lx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
const lpThumbTag = lx.match(/<thumb([^>]*)>/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<string, string>()
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(/&amp;/g, '&'),
md5: e.md5 || '',
width: e.width || 0,
height: e.height || 0,
encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&amp;/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 }[] = []
// 格式 AGcmData 块格式
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
// CBCIV = 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<string | null> {
return new Promise((resolve) => {
try {
const fs = require('fs')
const https = require('https')
const http = require('http')
let fixedUrl = targetUrl.replace(/&amp;/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()

View File

@@ -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 未连接' }

View File

@@ -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 内部日志
*/

View File

@@ -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

Binary file not shown.

View File

@@ -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<string, string>()
// 评论表情包组件
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<string>(() => 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 (
<img
src={localSrc}
alt="emoji"
className="comment-custom-emoji"
draggable={false}
onClick={(e) => { 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<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ 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<SnsPostItemProps> = ({ 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 (
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
<>
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
<div className="post-avatar-col">
<Avatar
src={post.avatarUrl}
@@ -239,12 +325,20 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
<div className="post-header-actions">
{mediaDeleted && (
{(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge">
<Trash2 size={12} />
<span></span>
</span>
)}
<button
className="icon-btn-ghost debug-btn delete-btn"
onClick={handleDeleteClick}
disabled={deleting || dbDeleted}
title="从数据库删除此条记录"
>
<Trash2 size={14} />
</button>
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
@@ -289,7 +383,16 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
</>
)}
<span className="comment-colon"></span>
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
{c.content && (
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
)}
{c.emojis && c.emojis.map((emoji, ei) => (
<CommentEmoji
key={ei}
emoji={emoji}
onPreview={(src) => onPreview(src)}
/>
))}
</div>
))}
</div>
@@ -298,5 +401,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
)}
</div>
</div>
{/* 删除确认弹窗 - 用 Portal 挂到 body避免父级 transform 影响 fixed 定位 */}
{showDeleteConfirm && createPortal(
<div className="sns-confirm-overlay" onClick={() => setShowDeleteConfirm(false)}>
<div className="sns-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className="sns-confirm-icon">
<Trash2 size={22} />
</div>
<div className="sns-confirm-title"></div>
<div className="sns-confirm-desc"></div>
<div className="sns-confirm-actions">
<button className="sns-confirm-cancel" onClick={() => setShowDeleteConfirm(false)}></button>
<button className="sns-confirm-ok" onClick={handleDeleteConfirm}></button>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -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);
}

View File

@@ -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<boolean | null>(null)
const [triggerLoading, setTriggerLoading] = useState(false)
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(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() {
<div className="feed-header">
<h2></h2>
<div className="header-actions">
<button
onClick={async () => {
setTriggerMessage(null)
setShowTriggerDialog(true)
setTriggerLoading(true)
try {
const r = await window.electronAPI.sns.checkBlockDeleteTrigger()
setTriggerInstalled(r.success ? (r.installed ?? false) : false)
} catch {
setTriggerInstalled(false)
} finally {
setTriggerLoading(false)
}
}}
className="icon-btn"
title="朋友圈保护插件"
>
<Shield size={20} />
</button>
<button
onClick={() => {
setExportResult(null)
@@ -329,7 +353,7 @@ export default function SnsPage() {
{posts.map(post => (
<SnsPostItem
key={post.id}
post={post}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
@@ -338,6 +362,7 @@ export default function SnsPage() {
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
/>
))}
</div>
@@ -426,6 +451,101 @@ export default function SnsPage() {
</div>
)}
{/* 朋友圈防删除插件对话框 */}
{showTriggerDialog && (
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
<div className="sns-protect-dialog" onClick={(e) => e.stopPropagation()}>
<button className="close-btn sns-protect-close" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
<X size={18} />
</button>
{/* 顶部图标区 */}
<div className="sns-protect-hero">
<div className={`sns-protect-icon-wrap ${triggerInstalled ? 'active' : ''}`}>
{triggerLoading
? <RefreshCw size={28} className="spinning" />
: triggerInstalled
? <Shield size={28} />
: <ShieldOff size={28} />
}
</div>
<div className="sns-protect-title"></div>
<div className={`sns-protect-status-badge ${triggerInstalled ? 'on' : 'off'}`}>
{triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'}
</div>
</div>
{/* 说明 */}
<div className="sns-protect-desc">
WeFlow将拦截朋友圈删除操作<br/><br/>
</div>
{/* 操作反馈 */}
{triggerMessage && (
<div className={`sns-protect-feedback ${triggerMessage.type}`}>
{triggerMessage.type === 'success' ? <CheckCircle size={14} /> : <AlertCircle size={14} />}
<span>{triggerMessage.text}</span>
</div>
)}
{/* 操作按钮 */}
<div className="sns-protect-actions">
{!triggerInstalled ? (
<button
className="sns-protect-btn primary"
disabled={triggerLoading}
onClick={async () => {
setTriggerLoading(true)
setTriggerMessage(null)
try {
const r = await window.electronAPI.sns.installBlockDeleteTrigger()
if (r.success) {
setTriggerInstalled(true)
setTriggerMessage({ type: 'success', text: r.alreadyInstalled ? '插件已存在,无需重复安装' : '已启用朋友圈防删除保护' })
} else {
setTriggerMessage({ type: 'error', text: r.error || '安装失败' })
}
} catch (e: any) {
setTriggerMessage({ type: 'error', text: e.message || String(e) })
} finally {
setTriggerLoading(false)
}
}}
>
<Shield size={15} />
</button>
) : (
<button
className="sns-protect-btn danger"
disabled={triggerLoading}
onClick={async () => {
setTriggerLoading(true)
setTriggerMessage(null)
try {
const r = await window.electronAPI.sns.uninstallBlockDeleteTrigger()
if (r.success) {
setTriggerInstalled(false)
setTriggerMessage({ type: 'success', text: '已关闭朋友圈防删除保护' })
} else {
setTriggerMessage({ type: 'error', text: r.error || '卸载失败' })
}
} catch (e: any) {
setTriggerMessage({ type: 'error', text: e.message || String(e) })
} finally {
setTriggerLoading(false)
}
}}
>
<ShieldOff size={15} />
</button>
)}
</div>
</div>
</div>
)}
{/* 导出对话框 */}
{showExportDialog && (
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>

View File

@@ -500,7 +500,7 @@ export interface ElectronAPI {
}
}>
likes: Array<string>
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 }>

View File

@@ -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 {