mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
支持朋友圈防撤回;修复朋友圈回复嵌套关系错误;支持朋友圈评论表情解析;支持删除本地朋友圈记录
This commit is contained in:
@@ -1082,6 +1082,26 @@ function registerIpcHandlers() {
|
|||||||
return { canceled: false, filePath: result.filePaths[0] }
|
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))
|
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
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 服务
|
// HTTP API 服务
|
||||||
|
|||||||
@@ -76,17 +76,13 @@ class AnalyticsService {
|
|||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (usernames.length === 0) return map
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
|
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
||||||
const chunkSize = 200
|
const chunkSize = 200
|
||||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||||
const chunk = usernames.slice(i, i + chunkSize)
|
const chunk = usernames.slice(i, i + chunkSize)
|
||||||
// 使用参数化查询防止SQL注入
|
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||||
const placeholders = chunk.map(() => '?').join(',')
|
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
|
||||||
const sql = `
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
SELECT username, alias
|
|
||||||
FROM contact
|
|
||||||
WHERE username IN (${placeholders})
|
|
||||||
`
|
|
||||||
const result = await wcdbService.execQuery('contact', null, sql, chunk)
|
|
||||||
if (!result.success || !result.rows) continue
|
if (!result.success || !result.rows) continue
|
||||||
for (const row of result.rows as Record<string, any>[]) {
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
const username = row.username || ''
|
const username = row.username || ''
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises'
|
|||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { WasmService } from './wasmService'
|
import { WasmService } from './wasmService'
|
||||||
|
import zlib from 'zlib'
|
||||||
|
|
||||||
export interface SnsLivePhoto {
|
export interface SnsLivePhoto {
|
||||||
url: string
|
url: string
|
||||||
@@ -28,6 +29,7 @@ export interface SnsMedia {
|
|||||||
|
|
||||||
export interface SnsPost {
|
export interface SnsPost {
|
||||||
id: string
|
id: string
|
||||||
|
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
@@ -36,7 +38,7 @@ export interface SnsPost {
|
|||||||
type?: number
|
type?: number
|
||||||
media: SnsMedia[]
|
media: SnsMedia[]
|
||||||
likes: string[]
|
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
|
rawXml?: string
|
||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
@@ -122,6 +124,107 @@ const extractVideoKey = (xml: string): string | undefined => {
|
|||||||
return match ? match[1] : 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 {
|
class SnsService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
@@ -132,6 +235,104 @@ class SnsService {
|
|||||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
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 {
|
private getSnsCacheDir(): string {
|
||||||
const cachePath = this.configService.getCacheBasePath()
|
const cachePath = this.configService.getCacheBasePath()
|
||||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||||
@@ -147,7 +348,6 @@ class SnsService {
|
|||||||
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有发过朋友圈的用户名列表
|
|
||||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||||
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
||||||
if (!result.success || !result.rows) {
|
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) }
|
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 安装朋友圈删除拦截
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
return wcdbService.installSnsBlockDeleteTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卸载朋友圈删除拦截
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return wcdbService.uninstallSnsBlockDeleteTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询朋友圈删除拦截是否已安装
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
return wcdbService.checkSnsBlockDeleteTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库直接删除朋友圈记录
|
||||||
|
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 {
|
||||||
|
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 }> {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success && result.timeline) {
|
|
||||||
const enrichedTimeline = result.timeline.map((post: any) => {
|
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||||
const contact = this.contactCache.get(post.username)
|
const contact = this.contactCache.get(post.username)
|
||||||
const isVideoPost = post.type === 15
|
const isVideoPost = post.type === 15
|
||||||
|
|
||||||
// 尝试从 rawXml 中提取视频解密密钥 (针对视频号视频)
|
|
||||||
const videoKey = extractVideoKey(post.rawXml || '')
|
const videoKey = extractVideoKey(post.rawXml || '')
|
||||||
|
|
||||||
const fixedMedia = (post.media || []).map((m: any) => ({
|
const fixedMedia = (post.media || []).map((m: any) => ({
|
||||||
// 如果是视频动态,url 是视频,thumb 是缩略图
|
|
||||||
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
||||||
thumb: fixSnsUrl(m.thumb, m.token, false),
|
thumb: fixSnsUrl(m.thumb, m.token, false),
|
||||||
md5: m.md5,
|
md5: m.md5,
|
||||||
token: m.token,
|
token: m.token,
|
||||||
// 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体
|
|
||||||
// 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏
|
|
||||||
key: isVideoPost ? (videoKey || m.key) : m.key,
|
key: isVideoPost ? (videoKey || m.key) : m.key,
|
||||||
encIdx: m.encIdx || m.enc_idx,
|
encIdx: m.encIdx || m.enc_idx,
|
||||||
livePhoto: m.livePhoto
|
livePhoto: m.livePhoto ? {
|
||||||
? {
|
|
||||||
...m.livePhoto,
|
...m.livePhoto,
|
||||||
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
||||||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
||||||
token: m.livePhoto.token,
|
token: m.livePhoto.token,
|
||||||
// 实况照片的视频部分优先使用从 XML 提取的 Key
|
|
||||||
key: videoKey || m.livePhoto.key || m.key,
|
key: videoKey || m.livePhoto.key || m.key,
|
||||||
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
||||||
}
|
} : undefined
|
||||||
: 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 {
|
return {
|
||||||
...post,
|
...post,
|
||||||
avatarUrl: contact?.avatarUrl,
|
avatarUrl: contact?.avatarUrl,
|
||||||
nickname: post.nickname || contact?.displayName || post.username,
|
nickname: post.nickname || contact?.displayName || post.username,
|
||||||
media: fixedMedia
|
media: fixedMedia,
|
||||||
|
comments: finalComments
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return { ...result, timeline: enrichedTimeline }
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return { ...result, timeline: enrichedTimeline }
|
||||||
}
|
}
|
||||||
|
|
||||||
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
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()
|
export const snsService = new SnsService()
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export class WcdbCore {
|
|||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
private wcdbGetSnsTimeline: any = null
|
private wcdbGetSnsTimeline: any = null
|
||||||
private wcdbGetSnsAnnualStats: 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 wcdbVerifyUser: any = null
|
||||||
private wcdbStartMonitorPipe: any = null
|
private wcdbStartMonitorPipe: any = null
|
||||||
private wcdbStopMonitorPipe: any = null
|
private wcdbStopMonitorPipe: any = null
|
||||||
@@ -600,6 +604,34 @@ export class WcdbCore {
|
|||||||
this.wcdbGetSnsAnnualStats = null
|
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)
|
// Named pipe IPC for monitoring (replaces callback)
|
||||||
try {
|
try {
|
||||||
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||||
@@ -1813,6 +1845,94 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
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 }> {
|
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
|||||||
@@ -416,6 +416,34 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
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 内部日志
|
* 获取 DLL 内部日志
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ if (parentPort) {
|
|||||||
case 'getSnsAnnualStats':
|
case 'getSnsAnnualStats':
|
||||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
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':
|
case 'getLogs':
|
||||||
result = await core.getLogs()
|
result = await core.getLogs()
|
||||||
break
|
break
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo, useEffect } from 'react'
|
||||||
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react'
|
||||||
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||||
import { Avatar } from '../Avatar'
|
import { Avatar } from '../Avatar'
|
||||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
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 {
|
interface SnsPostItemProps {
|
||||||
post: SnsPost
|
post: SnsPost
|
||||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
onDebug: (post: SnsPost) => 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 [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
|
const [dbDeleted, setDbDeleted] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const linkCard = buildLinkCardData(post)
|
const linkCard = buildLinkCardData(post)
|
||||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
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 (
|
return (
|
||||||
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
|
<>
|
||||||
|
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
||||||
<div className="post-avatar-col">
|
<div className="post-avatar-col">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={post.avatarUrl}
|
src={post.avatarUrl}
|
||||||
@@ -239,12 +325,20 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="post-header-actions">
|
<div className="post-header-actions">
|
||||||
{mediaDeleted && (
|
{(mediaDeleted || dbDeleted) && (
|
||||||
<span className="post-deleted-badge">
|
<span className="post-deleted-badge">
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
<span>已删除</span>
|
<span>已删除</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) => {
|
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDebug(post);
|
onDebug(post);
|
||||||
@@ -289,7 +383,16 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="comment-colon">:</span>
|
<span className="comment-colon">:</span>
|
||||||
|
{c.content && (
|
||||||
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -298,5 +401,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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);
|
background: var(--bg-tertiary);
|
||||||
border-color: var(--text-secondary);
|
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;
|
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 {
|
.post-text {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -322,6 +600,13 @@
|
|||||||
.comment-colon {
|
.comment-colon {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-custom-emoji {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -950,7 +1235,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,7 +1277,7 @@
|
|||||||
Export Dialog
|
Export Dialog
|
||||||
========================================= */
|
========================================= */
|
||||||
.export-dialog {
|
.export-dialog {
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: var(--bg-secondary);
|
||||||
border-radius: var(--sns-border-radius-lg);
|
border-radius: var(--sns-border-radius-lg);
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||||
width: 480px;
|
width: 480px;
|
||||||
@@ -1028,7 +1313,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
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 JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
import './SnsPage.scss'
|
import './SnsPage.scss'
|
||||||
import { SnsPost } from '../types/sns'
|
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 [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null)
|
||||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
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 postsContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const [hasNewer, setHasNewer] = useState(false)
|
const [hasNewer, setHasNewer] = useState(false)
|
||||||
const [loadingNewer, setLoadingNewer] = useState(false)
|
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||||
@@ -56,7 +62,6 @@ export default function SnsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
postsRef.current = posts
|
postsRef.current = posts
|
||||||
}, [posts])
|
}, [posts])
|
||||||
|
|
||||||
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const snapshot = scrollAdjustmentRef.current;
|
const snapshot = scrollAdjustmentRef.current;
|
||||||
@@ -285,6 +290,25 @@ export default function SnsPage() {
|
|||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<h2>朋友圈</h2>
|
<h2>朋友圈</h2>
|
||||||
<div className="header-actions">
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExportResult(null)
|
setExportResult(null)
|
||||||
@@ -329,7 +353,7 @@ export default function SnsPage() {
|
|||||||
{posts.map(post => (
|
{posts.map(post => (
|
||||||
<SnsPostItem
|
<SnsPostItem
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={{ ...post, isProtected: triggerInstalled === true }}
|
||||||
onPreview={(src, isVideo, liveVideoPath) => {
|
onPreview={(src, isVideo, liveVideoPath) => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
void window.electronAPI.window.openVideoPlayerWindow(src)
|
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||||
@@ -338,6 +362,7 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDebug={(p) => setDebugPost(p)}
|
onDebug={(p) => setDebugPost(p)}
|
||||||
|
onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -426,6 +451,101 @@ export default function SnsPage() {
|
|||||||
</div>
|
</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 && (
|
{showExportDialog && (
|
||||||
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
|
<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>
|
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
|
rawXml?: string
|
||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
@@ -520,6 +520,11 @@ export interface ElectronAPI {
|
|||||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: 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: {
|
http: {
|
||||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
|
|||||||
@@ -16,16 +16,27 @@ export interface SnsMedia {
|
|||||||
livePhoto?: SnsLivePhoto
|
livePhoto?: SnsLivePhoto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SnsCommentEmoji {
|
||||||
|
url: string
|
||||||
|
md5: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
encryptUrl?: string
|
||||||
|
aesKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnsComment {
|
export interface SnsComment {
|
||||||
id: string
|
id: string
|
||||||
nickname: string
|
nickname: string
|
||||||
content: string
|
content: string
|
||||||
refCommentId: string
|
refCommentId: string
|
||||||
refNickname?: string
|
refNickname?: string
|
||||||
|
emojis?: SnsCommentEmoji[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnsPost {
|
export interface SnsPost {
|
||||||
id: string
|
id: string
|
||||||
|
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
@@ -38,6 +49,7 @@ export interface SnsPost {
|
|||||||
rawXml?: string
|
rawXml?: string
|
||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
|
isProtected?: boolean // 是否受保护(已安装时标记)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnsLinkCardData {
|
export interface SnsLinkCardData {
|
||||||
|
|||||||
Reference in New Issue
Block a user