mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
支持朋友圈防撤回;修复朋友圈回复嵌套关系错误;支持朋友圈评论表情解析;支持删除本地朋友圈记录
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
|
||||
@@ -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 服务
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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(/&/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<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(/&/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<string | null> {
|
||||
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()
|
||||
|
||||
@@ -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 未连接' }
|
||||
|
||||
@@ -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 内部日志
|
||||
*/
|
||||
|
||||
@@ -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.
@@ -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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
7
src/types/electron.d.ts
vendored
7
src/types/electron.d.ts
vendored
@@ -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 }>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user