mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev
This commit is contained in:
174
electron/main.ts
174
electron/main.ts
@@ -82,6 +82,8 @@ let configService: ConfigService | null = null
|
|||||||
// 协议窗口实例
|
// 协议窗口实例
|
||||||
let agreementWindow: BrowserWindow | null = null
|
let agreementWindow: BrowserWindow | null = null
|
||||||
let onboardingWindow: BrowserWindow | null = null
|
let onboardingWindow: BrowserWindow | null = null
|
||||||
|
// Splash 启动窗口
|
||||||
|
let splashWindow: BrowserWindow | null = null
|
||||||
const keyService = new KeyService()
|
const keyService = new KeyService()
|
||||||
|
|
||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
@@ -122,9 +124,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 窗口准备好后显示
|
// 窗口准备好后显示
|
||||||
|
// Splash 模式下不在这里 show,由启动流程统一控制
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
mainWindowReady = true
|
mainWindowReady = true
|
||||||
if (autoShow || shouldShowMain) {
|
if (autoShow && !splashWindow) {
|
||||||
win.show()
|
win.show()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -250,6 +253,73 @@ function createAgreementWindow() {
|
|||||||
return agreementWindow
|
return agreementWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Splash 启动窗口
|
||||||
|
* 使用纯 HTML 页面,不依赖 React,确保极速显示
|
||||||
|
*/
|
||||||
|
function createSplashWindow(): BrowserWindow {
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
const iconPath = isDev
|
||||||
|
? join(__dirname, '../public/icon.ico')
|
||||||
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
|
splashWindow = new BrowserWindow({
|
||||||
|
width: 760,
|
||||||
|
height: 460,
|
||||||
|
resizable: false,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
hasShadow: false,
|
||||||
|
center: true,
|
||||||
|
skipTaskbar: false,
|
||||||
|
icon: iconPath,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
// 不需要 preload —— 通过 executeJavaScript 单向推送进度
|
||||||
|
},
|
||||||
|
show: false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`)
|
||||||
|
} else {
|
||||||
|
splashWindow.loadFile(join(__dirname, '../dist/splash.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
splashWindow.once('ready-to-show', () => {
|
||||||
|
splashWindow?.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
splashWindow.on('closed', () => {
|
||||||
|
splashWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return splashWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 Splash 窗口发送进度更新
|
||||||
|
*/
|
||||||
|
function updateSplashProgress(percent: number, text: string, indeterminate = false) {
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 Splash 窗口
|
||||||
|
*/
|
||||||
|
function closeSplash() {
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
splashWindow.close()
|
||||||
|
splashWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建首次引导窗口
|
* 创建首次引导窗口
|
||||||
*/
|
*/
|
||||||
@@ -1012,6 +1082,26 @@ function registerIpcHandlers() {
|
|||||||
return { canceled: false, filePath: result.filePaths[0] }
|
return { canceled: false, filePath: result.filePaths[0] }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:installBlockDeleteTrigger', async () => {
|
||||||
|
return snsService.installSnsBlockDeleteTrigger()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:uninstallBlockDeleteTrigger', async () => {
|
||||||
|
return snsService.uninstallSnsBlockDeleteTrigger()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:checkBlockDeleteTrigger', async () => {
|
||||||
|
return snsService.checkSnsBlockDeleteTrigger()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:deleteSnsPost', async (_, postId: string) => {
|
||||||
|
return snsService.deleteSnsPost(postId)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:downloadEmoji', async (_, params: { url: string; encryptUrl?: string; aesKey?: string }) => {
|
||||||
|
return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey)
|
||||||
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
@@ -1508,26 +1598,70 @@ function checkForUpdatesOnStartup() {
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
|
// 立即创建 Splash 窗口,确保用户尽快看到反馈
|
||||||
|
createSplashWindow()
|
||||||
|
|
||||||
|
// 等待 Splash 页面加载完成后再推送进度
|
||||||
|
if (splashWindow) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (splashWindow!.webContents.isLoading()) {
|
||||||
|
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
// 初始化配置服务
|
||||||
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
|
|
||||||
|
// 将用户主题配置推送给 Splash 窗口
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
const themeId = configService.get('themeId') || 'cloud-dancer'
|
||||||
|
const themeMode = configService.get('theme') || 'system'
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 设置资源路径
|
||||||
|
updateSplashProgress(10, '正在初始化...')
|
||||||
const candidateResources = app.isPackaged
|
const candidateResources = app.isPackaged
|
||||||
? join(process.resourcesPath, 'resources')
|
? join(process.resourcesPath, 'resources')
|
||||||
: join(app.getAppPath(), 'resources')
|
: join(app.getAppPath(), 'resources')
|
||||||
const fallbackResources = join(process.cwd(), 'resources')
|
const fallbackResources = join(process.cwd(), 'resources')
|
||||||
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||||
const userDataPath = app.getPath('userData')
|
const userDataPath = app.getPath('userData')
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 初始化数据库服务
|
||||||
|
updateSplashProgress(18, '正在初始化...')
|
||||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 注册 IPC 处理器
|
||||||
|
updateSplashProgress(25, '正在初始化...')
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 检查配置状态
|
||||||
const onboardingDone = configService.get('onboardingDone')
|
const onboardingDone = configService.get('onboardingDone')
|
||||||
shouldShowMain = onboardingDone === true
|
shouldShowMain = onboardingDone === true
|
||||||
mainWindow = createWindow({ autoShow: shouldShowMain })
|
|
||||||
|
|
||||||
if (!onboardingDone) {
|
// 创建主窗口(不显示,由启动流程统一控制)
|
||||||
createOnboardingWindow()
|
updateSplashProgress(30, '正在加载界面...')
|
||||||
}
|
mainWindow = createWindow({ autoShow: false })
|
||||||
|
|
||||||
// 解决朋友圈图片无法加载问题(添加 Referer)
|
// 配置网络服务
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
{
|
{
|
||||||
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||||
@@ -1538,7 +1672,31 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 启动时检测更新
|
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||||
|
updateSplashProgress(30, '正在加载界面...', true)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (mainWindowReady) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
mainWindow!.once('ready-to-show', () => {
|
||||||
|
mainWindowReady = true
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载完成,收尾
|
||||||
|
updateSplashProgress(100, '启动完成')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
closeSplash()
|
||||||
|
|
||||||
|
if (!onboardingDone) {
|
||||||
|
createOnboardingWindow()
|
||||||
|
} else {
|
||||||
|
mainWindow?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时检测更新(不阻塞启动)
|
||||||
checkForUpdatesOnStartup()
|
checkForUpdatesOnStartup()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
|
|||||||
@@ -991,12 +991,34 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`)
|
console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`)
|
||||||
|
// 数据库未命中时,尝试从本地 emoji 缓存目录查找(转发的表情包只有 md5,无 CDN URL)
|
||||||
|
this.findEmojiInLocalCache(msg)
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e)
|
console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地 WeFlow emoji 缓存目录按 md5 查找文件
|
||||||
|
*/
|
||||||
|
private findEmojiInLocalCache(msg: Message): void {
|
||||||
|
if (!msg.emojiMd5) return
|
||||||
|
const cacheDir = this.getEmojiCacheDir()
|
||||||
|
if (!existsSync(cacheDir)) return
|
||||||
|
|
||||||
|
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const filePath = join(cacheDir, `${msg.emojiMd5}${ext}`)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
msg.emojiLocalPath = filePath
|
||||||
|
// 同步写入内存缓存,避免重复查找
|
||||||
|
emojiCache.set(msg.emojiMd5, filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查找 emoticon.db 路径
|
* 查找 emoticon.db 路径
|
||||||
*/
|
*/
|
||||||
@@ -1338,6 +1360,9 @@ class ChatService {
|
|||||||
chatRecordList = type49Info.chatRecordList
|
chatRecordList = type49Info.chatRecordList
|
||||||
transferPayerUsername = type49Info.transferPayerUsername
|
transferPayerUsername = type49Info.transferPayerUsername
|
||||||
transferReceiverUsername = type49Info.transferReceiverUsername
|
transferReceiverUsername = type49Info.transferReceiverUsername
|
||||||
|
// 引用消息(appmsg type=57)的 quotedContent/quotedSender
|
||||||
|
if (type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
|
||||||
|
if (type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
||||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||||
const quoteInfo = this.parseQuoteMessage(content)
|
const quoteInfo = this.parseQuoteMessage(content)
|
||||||
quotedContent = quoteInfo.content
|
quotedContent = quoteInfo.content
|
||||||
@@ -1381,6 +1406,8 @@ class ChatService {
|
|||||||
chatRecordList = chatRecordList || type49Info.chatRecordList
|
chatRecordList = chatRecordList || type49Info.chatRecordList
|
||||||
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
|
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
|
||||||
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
|
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
|
||||||
|
if (!quotedContent && type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
|
||||||
|
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -1549,7 +1576,17 @@ class ChatService {
|
|||||||
|
|
||||||
private parseType49(content: string): string {
|
private parseType49(content: string): string {
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
const type = this.extractXmlValue(content, 'type')
|
// 从 appmsg 直接子节点提取 type,避免匹配到 refermsg 内部的 <type>
|
||||||
|
let type = ''
|
||||||
|
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||||
|
if (appmsgMatch) {
|
||||||
|
const inner = appmsgMatch[1]
|
||||||
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||||
|
if (typeMatch) type = typeMatch[1].trim()
|
||||||
|
}
|
||||||
|
if (!type) type = this.extractXmlValue(content, 'type')
|
||||||
const normalized = content.toLowerCase()
|
const normalized = content.toLowerCase()
|
||||||
const locationLabel =
|
const locationLabel =
|
||||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||||
@@ -1964,6 +2001,8 @@ class ChatService {
|
|||||||
*/
|
*/
|
||||||
private parseType49Message(content: string): {
|
private parseType49Message(content: string): {
|
||||||
xmlType?: string
|
xmlType?: string
|
||||||
|
quotedContent?: string
|
||||||
|
quotedSender?: string
|
||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
linkThumb?: string
|
linkThumb?: string
|
||||||
@@ -2008,8 +2047,20 @@ class ChatService {
|
|||||||
try {
|
try {
|
||||||
if (!content) return {}
|
if (!content) return {}
|
||||||
|
|
||||||
// 提取 appmsg 中的 type
|
// 提取 appmsg 直接子节点的 type,避免匹配到 refermsg 内部的 <type>
|
||||||
const xmlType = this.extractXmlValue(content, 'type')
|
// 先尝试从 <appmsg>...</appmsg> 块内提取,再用正则跳过嵌套标签
|
||||||
|
let xmlType = ''
|
||||||
|
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||||
|
if (appmsgMatch) {
|
||||||
|
// 在 appmsg 内容中,找第一个 <type> 但跳过在子元素内部的(如 refermsg > type)
|
||||||
|
// 策略:去掉所有嵌套块(refermsg、patMsg 等),再提取 type
|
||||||
|
const appmsgInner = appmsgMatch[1]
|
||||||
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
|
||||||
|
if (typeMatch) xmlType = typeMatch[1].trim()
|
||||||
|
}
|
||||||
|
if (!xmlType) xmlType = this.extractXmlValue(content, 'type')
|
||||||
if (!xmlType) return {}
|
if (!xmlType) return {}
|
||||||
|
|
||||||
const result: any = { xmlType }
|
const result: any = { xmlType }
|
||||||
@@ -2126,6 +2177,12 @@ class ChatService {
|
|||||||
result.appMsgKind = 'transfer'
|
result.appMsgKind = 'transfer'
|
||||||
} else if (xmlType === '87') {
|
} else if (xmlType === '87') {
|
||||||
result.appMsgKind = 'announcement'
|
result.appMsgKind = 'announcement'
|
||||||
|
} else if (xmlType === '57') {
|
||||||
|
// 引用回复消息,解析 refermsg
|
||||||
|
result.appMsgKind = 'quote'
|
||||||
|
const quoteInfo = this.parseQuoteMessage(content)
|
||||||
|
result.quotedContent = quoteInfo.content
|
||||||
|
result.quotedSender = quoteInfo.sender
|
||||||
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
||||||
result.appMsgKind = 'official-link'
|
result.appMsgKind = 'official-link'
|
||||||
} else if (url) {
|
} else if (url) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { chatService, Message } from './chatService'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -69,6 +70,7 @@ interface ApiExportedMedia {
|
|||||||
kind: MediaKind
|
kind: MediaKind
|
||||||
fileName: string
|
fileName: string
|
||||||
fullPath: string
|
fullPath: string
|
||||||
|
relativePath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatLab 消息类型映射
|
// ChatLab 消息类型映射
|
||||||
@@ -236,6 +238,8 @@ class HttpService {
|
|||||||
await this.handleSessions(url, res)
|
await this.handleSessions(url, res)
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} else {
|
||||||
this.sendError(res, 404, 'Not Found')
|
this.sendError(res, 404, 'Not Found')
|
||||||
}
|
}
|
||||||
@@ -245,6 +249,40 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||||
|
const mediaBasePath = this.getApiMediaExportPath()
|
||||||
|
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||||
|
const fullPath = path.join(mediaBasePath, relativePath)
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
this.sendError(res, 404, 'Media not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(fullPath).toLowerCase()
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.mp4': 'video/mp4'
|
||||||
|
}
|
||||||
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = fs.readFileSync(fullPath)
|
||||||
|
res.setHeader('Content-Type', contentType)
|
||||||
|
res.setHeader('Content-Length', fileBuffer.length)
|
||||||
|
res.writeHead(200)
|
||||||
|
res.end(fileBuffer)
|
||||||
|
} catch (e) {
|
||||||
|
this.sendError(res, 500, 'Failed to read media file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量获取消息(循环游标直到满足 limit)
|
* 批量获取消息(循环游标直到满足 limit)
|
||||||
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||||
@@ -380,7 +418,7 @@ class HttpService {
|
|||||||
const queryOffset = keyword ? 0 : offset
|
const queryOffset = keyword ? 0 : offset
|
||||||
const queryLimit = keyword ? 10000 : limit
|
const queryLimit = keyword ? 10000 : limit
|
||||||
|
|
||||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
@@ -576,19 +614,44 @@ class HttpService {
|
|||||||
): Promise<ApiExportedMedia | null> {
|
): Promise<ApiExportedMedia | null> {
|
||||||
try {
|
try {
|
||||||
if (msg.localType === 3 && options.exportImages) {
|
if (msg.localType === 3 && options.exportImages) {
|
||||||
const result = await chatService.getImageData(talker, String(msg.localId))
|
const result = await imageDecryptService.decryptImage({
|
||||||
if (result.success && result.data) {
|
sessionId: talker,
|
||||||
const imageBuffer = Buffer.from(result.data, 'base64')
|
imageMd5: msg.imageMd5,
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
imageDatName: msg.imageDatName,
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
force: true
|
||||||
const fileName = `${fileBase}${ext}`
|
})
|
||||||
const targetDir = path.join(sessionDir, 'images')
|
if (result.success && result.localPath) {
|
||||||
const fullPath = path.join(targetDir, fileName)
|
let imagePath = result.localPath
|
||||||
this.ensureDir(targetDir)
|
if (imagePath.startsWith('data:')) {
|
||||||
if (!fs.existsSync(fullPath)) {
|
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||||
fs.writeFileSync(fullPath, imageBuffer)
|
if (base64Match) {
|
||||||
|
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||||
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
|
const fileName = `${fileBase}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'images')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.writeFileSync(fullPath, imageBuffer)
|
||||||
|
}
|
||||||
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
|
}
|
||||||
|
} else if (fs.existsSync(imagePath)) {
|
||||||
|
const imageBuffer = fs.readFileSync(imagePath)
|
||||||
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
|
const fileName = `${fileBase}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'images')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.copyFileSync(imagePath, fullPath)
|
||||||
|
}
|
||||||
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
return { kind: 'image', fileName, fullPath }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +670,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||||
}
|
}
|
||||||
return { kind: 'voice', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}`
|
||||||
|
return { kind: 'voice', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +686,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(info.videoUrl, fullPath)
|
fs.copyFileSync(info.videoUrl, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'video', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}`
|
||||||
|
return { kind: 'video', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,7 +702,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(result.localPath, fullPath)
|
fs.copyFileSync(result.localPath, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'emoji', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}`
|
||||||
|
return { kind: 'emoji', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -661,7 +727,8 @@ class HttpService {
|
|||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
mediaFileName: media?.fileName,
|
mediaFileName: media?.fileName,
|
||||||
mediaPath: media?.fullPath
|
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,7 +851,7 @@ class HttpService {
|
|||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||||
mediaPath: mediaMap.get(msg.localId)?.fullPath
|
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -843,7 +843,7 @@ export class KeyService {
|
|||||||
private findTemplateDatFiles(rootDir: string): string[] {
|
private findTemplateDatFiles(rootDir: string): string[] {
|
||||||
const files: string[] = []
|
const files: string[] = []
|
||||||
const stack = [rootDir]
|
const stack = [rootDir]
|
||||||
const maxFiles = 32
|
const maxFiles = 256
|
||||||
while (stack.length && files.length < maxFiles) {
|
while (stack.length && files.length < maxFiles) {
|
||||||
const dir = stack.pop() as string
|
const dir = stack.pop() as string
|
||||||
let entries: string[]
|
let entries: string[]
|
||||||
@@ -877,7 +877,7 @@ export class KeyService {
|
|||||||
if (ma && mb) return mb.localeCompare(ma)
|
if (ma && mb) return mb.localeCompare(ma)
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
return files.slice(0, 16)
|
return files.slice(0, 128)
|
||||||
}
|
}
|
||||||
|
|
||||||
private getXorKey(templateFiles: string[]): number | null {
|
private getXorKey(templateFiles: string[]): number | null {
|
||||||
|
|||||||
@@ -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 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) => {
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
const contact = this.contactCache.get(post.username)
|
return wcdbService.uninstallSnsBlockDeleteTrigger()
|
||||||
const isVideoPost = post.type === 15
|
}
|
||||||
|
|
||||||
// 尝试从 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 是缩略图
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
return wcdbService.deleteSnsPost(postId)
|
||||||
thumb: fixSnsUrl(m.thumb, m.token, false),
|
}
|
||||||
md5: m.md5,
|
|
||||||
token: m.token,
|
/**
|
||||||
// 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体
|
* 补全 DLL 返回的评论中缺失的 refNickname
|
||||||
// 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏
|
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
||||||
key: isVideoPost ? (videoKey || m.key) : m.key,
|
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||||||
encIdx: m.encIdx || m.enc_idx,
|
*/
|
||||||
livePhoto: m.livePhoto
|
private fixCommentRefs(comments: any[]): any[] {
|
||||||
? {
|
if (!comments || comments.length === 0) return []
|
||||||
...m.livePhoto,
|
|
||||||
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
||||||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
// 此处做最终的格式化和兜底补全
|
||||||
token: m.livePhoto.token,
|
const idToNickname = new Map<string, string>()
|
||||||
// 实况照片的视频部分优先使用从 XML 提取的 Key
|
comments.forEach((c, idx) => {
|
||||||
key: videoKey || m.livePhoto.key || m.key,
|
if (c.id) idToNickname.set(c.id, c.nickname || '')
|
||||||
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
// 兜底:按索引映射(部分旧数据 id 可能为空)
|
||||||
}
|
idToNickname.set(String(idx + 1), c.nickname || '')
|
||||||
: undefined
|
})
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
...post,
|
id: c.id || '',
|
||||||
avatarUrl: contact?.avatarUrl,
|
nickname: c.nickname || '',
|
||||||
nickname: post.nickname || contact?.displayName || post.username,
|
content: c.content || '',
|
||||||
media: fixedMedia
|
refCommentId: (refId === '0') ? '' : (refId || ''),
|
||||||
}
|
refNickname,
|
||||||
})
|
emojis: emojis.length > 0 ? emojis : undefined
|
||||||
return { ...result, timeline: enrichedTimeline }
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }> {
|
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
|
||||||
|
|||||||
249
public/splash.html
Normal file
249
public/splash.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WeFlow</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌区 */
|
||||||
|
.brand {
|
||||||
|
padding: 48px 52px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.app-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* 底部进度区 */
|
||||||
|
.bottom {
|
||||||
|
padding: 0 48px 40px;
|
||||||
|
animation: fadeIn 0.4s ease 0.1s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条轨道 */
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条填充 */
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扫光:只在有进度时显示,不循环 */
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
|
||||||
|
animation: sweep 1.2s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等待阶段:进度条末端呼吸光点 */
|
||||||
|
.progress-fill.waiting::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px; right: -2px;
|
||||||
|
width: 6px; height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
filter: blur(2px);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes sweep {
|
||||||
|
0% { opacity: 0; transform: translateX(-100%); }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
80% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scaleX(1); }
|
||||||
|
50% { opacity: 1; transform: scaleX(1.8); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="splash" id="splash">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="logo" src="./logo.png" alt="WeFlow" />
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="app-name" id="appName">WeFlow</div>
|
||||||
|
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<div class="bottom">
|
||||||
|
<div class="progress-track" id="progressTrack">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-row">
|
||||||
|
<div class="progress-text" id="progressText">正在启动...</div>
|
||||||
|
<div class="version" id="versionText"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var themes = {
|
||||||
|
'cloud-dancer': {
|
||||||
|
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
|
||||||
|
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
|
||||||
|
},
|
||||||
|
'corundum-blue': {
|
||||||
|
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
|
||||||
|
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
|
||||||
|
},
|
||||||
|
'kiwi-green': {
|
||||||
|
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
|
||||||
|
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
|
||||||
|
},
|
||||||
|
'spicy-red': {
|
||||||
|
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
|
||||||
|
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
|
||||||
|
},
|
||||||
|
'teal-water': {
|
||||||
|
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
|
||||||
|
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
|
||||||
|
},
|
||||||
|
'blossom-dream': {
|
||||||
|
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
|
||||||
|
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyTheme(themeId, mode) {
|
||||||
|
var t = themes[themeId] || themes['cloud-dancer'];
|
||||||
|
var isDark = mode === 'dark';
|
||||||
|
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var c = isDark ? t.dark : t.light;
|
||||||
|
|
||||||
|
var el = document.getElementById('splash');
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
|
||||||
|
if (themeId === 'blossom-dream') {
|
||||||
|
if (isDark) {
|
||||||
|
// 深色
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
// 浅色
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
// 进度条
|
||||||
|
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
if (isDark) {
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
fill.style.background = c.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('appName').style.color = c.text;
|
||||||
|
document.getElementById('appDesc').style.color = c.desc;
|
||||||
|
document.getElementById('progressText').style.color = c.text;
|
||||||
|
document.getElementById('versionText').style.color = c.text;
|
||||||
|
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
|
||||||
|
}
|
||||||
|
|
||||||
|
// percent: 实际进度值;waiting: 是否处于等待阶段
|
||||||
|
function updateProgress(percent, text, waiting) {
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
var label = document.getElementById('progressText');
|
||||||
|
|
||||||
|
if (fill) {
|
||||||
|
fill.style.width = percent + '%';
|
||||||
|
if (waiting) {
|
||||||
|
fill.classList.add('waiting');
|
||||||
|
} else {
|
||||||
|
fill.classList.remove('waiting');
|
||||||
|
// 触发扫光:重置动画
|
||||||
|
fill.style.animation = 'none';
|
||||||
|
fill.offsetHeight;
|
||||||
|
fill.style.animation = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label && text) label.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVersion(ver) {
|
||||||
|
var el = document.getElementById('versionText');
|
||||||
|
if (el) el.textContent = 'v' + ver;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme('cloud-dancer', 'light');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
42
src/App.scss
42
src/App.scss
@@ -4,6 +4,48 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
animation: appFadeIn 0.35s ease-out;
|
animation: appFadeIn 0.35s ease-out;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦:底色层(::before)+ 光晕层(::after)分离,避免 blur 吃掉边缘
|
||||||
|
[data-theme="blossom-dream"] .app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::before 纯底色,不模糊
|
||||||
|
[data-theme="blossom-dream"] .app-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -2;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::after 光晕层,模糊叠加在底色上
|
||||||
|
[data-theme="blossom-dream"] .app-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
|
||||||
|
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色模式光晕更克制
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
|
||||||
|
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-drag-region {
|
.window-drag-region {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
|
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||||
[data-mode="light"] &,
|
[data-mode="light"] &,
|
||||||
:not([data-mode]) & {
|
:not([data-mode]) & {
|
||||||
background: rgba(255, 255, 255, 1);
|
background: rgba(255, 255, 255, 1);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -46,10 +48,16 @@
|
|||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
// 确保背景不透明
|
// 确保背景完全不透明(通知是独立窗口,透明背景会穿透)
|
||||||
background: var(--bg-secondary, #2c2c2c);
|
background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c));
|
||||||
color: var(--text-primary, #ffffff);
|
color: var(--text-primary, #ffffff);
|
||||||
|
|
||||||
|
// 浅色模式强制完全不透明白色背景
|
||||||
|
[data-mode="light"] &,
|
||||||
|
:not([data-mode]) & {
|
||||||
|
background: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
box-shadow: none !important; // NO SHADOW
|
box-shadow: none !important; // NO SHADOW
|
||||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||||
|
|
||||||
|
|||||||
@@ -104,3 +104,30 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||||
|
[data-theme="blossom-dream"] .sidebar {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
|
||||||
|
background: rgba(34, 30, 36, 0.75);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活项:主品牌色纵向微渐变
|
||||||
|
[data-theme="blossom-dream"] .nav-item.active {
|
||||||
|
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
|
||||||
|
background: rgba(209, 158, 187, 0.15);
|
||||||
|
color: #D19EBB;
|
||||||
|
border: 1px solid rgba(209, 158, 187, 0.2);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦:标题栏毛玻璃
|
||||||
|
[data-theme="blossom-dream"] .title-bar {
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.title-logo {
|
.title-logo {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -2243,6 +2243,18 @@
|
|||||||
.quoted-text {
|
.quoted-text {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
.quoted-type-label {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-emoji-image {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
vertical-align: middle;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2897,7 +2909,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
|
|||||||
@@ -2780,6 +2780,31 @@ const voiceTranscriptCache = new Map<string, string>()
|
|||||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||||
|
|
||||||
|
// 引用消息中的动画表情组件
|
||||||
|
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
||||||
|
const cacheKey = md5 || cdnUrl
|
||||||
|
const [localPath, setLocalPath] = useState<string | undefined>(() => emojiDataUrlCache.get(cacheKey))
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localPath || loading || error) return
|
||||||
|
setLoading(true)
|
||||||
|
window.electronAPI.chat.downloadEmoji(cdnUrl, md5).then((result: { success: boolean; localPath?: string }) => {
|
||||||
|
if (result.success && result.localPath) {
|
||||||
|
emojiDataUrlCache.set(cacheKey, result.localPath)
|
||||||
|
setLocalPath(result.localPath)
|
||||||
|
} else {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}).catch(() => setError(true)).finally(() => setLoading(false))
|
||||||
|
}, [cdnUrl, md5, cacheKey, localPath, loading, error])
|
||||||
|
|
||||||
|
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
if (loading) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
|
||||||
|
}
|
||||||
|
|
||||||
// 消息气泡组件
|
// 消息气泡组件
|
||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
@@ -2901,7 +2926,7 @@ function MessageBubble({
|
|||||||
// 从缓存获取表情包 data URL
|
// 从缓存获取表情包 data URL
|
||||||
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
||||||
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
|
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
|
||||||
() => emojiDataUrlCache.get(cacheKey)
|
() => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath
|
||||||
)
|
)
|
||||||
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
||||||
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
||||||
@@ -3036,10 +3061,15 @@ function MessageBubble({
|
|||||||
// 自动下载表情包
|
// 自动下载表情包
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emojiLocalPath) return
|
if (emojiLocalPath) return
|
||||||
|
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
|
||||||
|
if (isEmoji && message.emojiLocalPath && !emojiLocalPath) {
|
||||||
|
setEmojiLocalPath(message.emojiLocalPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
|
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
|
||||||
downloadEmoji()
|
downloadEmoji()
|
||||||
}
|
}
|
||||||
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
|
}, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError])
|
||||||
|
|
||||||
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
@@ -3971,11 +4001,13 @@ function MessageBubble({
|
|||||||
// 通话消息
|
// 通话消息
|
||||||
if (isCall) {
|
if (isCall) {
|
||||||
return (
|
return (
|
||||||
<div className="call-message">
|
<div className="bubble-content">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<div className="call-message">
|
||||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
</svg>
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||||
<span>{message.parsedContent || '[通话]'}</span>
|
</svg>
|
||||||
|
<span>{message.parsedContent || '[通话]'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4043,11 +4075,39 @@ function MessageBubble({
|
|||||||
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
|
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
|
||||||
const referContent = q('refermsg > content') || ''
|
const referContent = q('refermsg > content') || ''
|
||||||
const referSender = q('refermsg > displayname') || ''
|
const referSender = q('refermsg > displayname') || ''
|
||||||
|
const referType = q('refermsg > type') || ''
|
||||||
|
|
||||||
|
// 根据被引用消息类型渲染对应内容
|
||||||
|
const renderReferContent = () => {
|
||||||
|
// 动画表情:解析嵌套 XML 提取 cdnurl 渲染
|
||||||
|
if (referType === '47') {
|
||||||
|
try {
|
||||||
|
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
|
||||||
|
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
|
||||||
|
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
|
||||||
|
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
|
||||||
|
} catch { /* 解析失败降级 */ }
|
||||||
|
return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各类型名称映射
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
'3': '图片', '34': '语音', '43': '视频',
|
||||||
|
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
||||||
|
}
|
||||||
|
if (referType && typeLabels[referType]) {
|
||||||
|
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通文本或未知类型
|
||||||
|
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bubble-content">
|
<div className="bubble-content">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||||
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
|
<span className="quoted-text">{renderReferContent()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4143,6 +4203,22 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (kind === 'quote') {
|
||||||
|
// 引用回复消息(appMsgKind='quote',xmlType=57)
|
||||||
|
const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || ''
|
||||||
|
const referContent = message.quotedContent || q('refermsg > content') || ''
|
||||||
|
const referSender = message.quotedSender || q('refermsg > displayname') || ''
|
||||||
|
return (
|
||||||
|
<div className="bubble-content">
|
||||||
|
<div className="quoted-message">
|
||||||
|
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||||
|
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (kind === 'red-packet') {
|
if (kind === 'red-packet') {
|
||||||
// 专属红包卡片
|
// 专属红包卡片
|
||||||
const greeting = (() => {
|
const greeting = (() => {
|
||||||
@@ -4347,6 +4423,44 @@ function MessageBubble({
|
|||||||
console.error('解析 AppMsg 失败:', e)
|
console.error('解析 AppMsg 失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 引用回复消息 (type=57),防止被误判为链接
|
||||||
|
if (appMsgType === '57') {
|
||||||
|
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || ''
|
||||||
|
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
|
||||||
|
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
|
||||||
|
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
|
||||||
|
|
||||||
|
const renderReferContent2 = () => {
|
||||||
|
if (referType === '47') {
|
||||||
|
try {
|
||||||
|
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
|
||||||
|
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
|
||||||
|
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
|
||||||
|
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
|
||||||
|
} catch { /* 解析失败降级 */ }
|
||||||
|
return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
}
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
'3': '图片', '34': '语音', '43': '视频',
|
||||||
|
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
||||||
|
}
|
||||||
|
if (referType && typeLabels[referType]) {
|
||||||
|
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
||||||
|
}
|
||||||
|
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bubble-content">
|
||||||
|
<div className="quoted-message">
|
||||||
|
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||||
|
<span className="quoted-text">{renderReferContent2()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 群公告消息 (type=87)
|
// 群公告消息 (type=87)
|
||||||
if (appMsgType === '87') {
|
if (appMsgType === '87') {
|
||||||
const announcementText = textAnnouncement || desc || '群公告'
|
const announcementText = textAnnouncement || desc || '群公告'
|
||||||
@@ -4579,7 +4693,7 @@ function MessageBubble({
|
|||||||
if (isEmoji) {
|
if (isEmoji) {
|
||||||
// ... (keep existing emoji logic)
|
// ... (keep existing emoji logic)
|
||||||
// 没有 cdnUrl 或加载失败,显示占位符
|
// 没有 cdnUrl 或加载失败,显示占位符
|
||||||
if (!message.emojiCdnUrl || emojiError) {
|
if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) {
|
||||||
return (
|
return (
|
||||||
<div className="emoji-unavailable">
|
<div className="emoji-unavailable">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
.blob-1 {
|
.blob-1 {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: rgba(139, 115, 85, 0.25);
|
background: rgba(var(--primary-rgb), 0.25);
|
||||||
top: -100px;
|
top: -100px;
|
||||||
left: -50px;
|
left: -50px;
|
||||||
animation-duration: 25s;
|
animation-duration: 25s;
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
.blob-2 {
|
.blob-2 {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
height: 350px;
|
height: 350px;
|
||||||
background: rgba(139, 115, 85, 0.15);
|
background: rgba(var(--primary-rgb), 0.15);
|
||||||
bottom: -50px;
|
bottom: -50px;
|
||||||
right: -50px;
|
right: -50px;
|
||||||
animation-duration: 30s;
|
animation-duration: 30s;
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: -2px;
|
letter-spacing: -2px;
|
||||||
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
|
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
|
|||||||
@@ -939,8 +939,16 @@ function SettingsPage() {
|
|||||||
<div className="theme-grid">
|
<div className="theme-grid">
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => (
|
||||||
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
||||||
<div className="theme-preview" style={{ background: effectiveMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
|
<div className="theme-preview" style={{
|
||||||
<div className="theme-accent" style={{ background: theme.primaryColor }} />
|
background: effectiveMode === 'dark'
|
||||||
|
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
|
||||||
|
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
|
||||||
|
}}>
|
||||||
|
<div className="theme-accent" style={{
|
||||||
|
background: theme.accentColor
|
||||||
|
? `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.accentColor} 100%)`
|
||||||
|
: theme.primaryColor
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className="theme-info">
|
<div className="theme-info">
|
||||||
<span className="theme-name">{theme.name}</span>
|
<span className="theme-name">{theme.name}</span>
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
|
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream'
|
||||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
export interface ThemeInfo {
|
export interface ThemeInfo {
|
||||||
@@ -10,6 +10,8 @@ export interface ThemeInfo {
|
|||||||
description: string
|
description: string
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
bgColor: string
|
bgColor: string
|
||||||
|
// 可选副色,用于多彩主题的渐变预览
|
||||||
|
accentColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themes: ThemeInfo[] = [
|
export const themes: ThemeInfo[] = [
|
||||||
@@ -20,6 +22,14 @@ export const themes: ThemeInfo[] = [
|
|||||||
primaryColor: '#8B7355',
|
primaryColor: '#8B7355',
|
||||||
bgColor: '#F0EEE9'
|
bgColor: '#F0EEE9'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'blossom-dream',
|
||||||
|
name: '繁花如梦',
|
||||||
|
description: '晨曦花境 · 夜阑幽梦',
|
||||||
|
primaryColor: '#D4849A',
|
||||||
|
bgColor: '#FCF9FB',
|
||||||
|
accentColor: '#FFBE98'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'corundum-blue',
|
id: 'corundum-blue',
|
||||||
name: '刚玉蓝',
|
name: '刚玉蓝',
|
||||||
|
|||||||
@@ -153,6 +153,43 @@
|
|||||||
--sent-card-bg: var(--primary);
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦 - 浅色(晨曦花境)
|
||||||
|
[data-theme="blossom-dream"][data-mode="light"],
|
||||||
|
[data-theme="blossom-dream"]:not([data-mode]) {
|
||||||
|
// 三色定义(供伪元素光晕使用,饱和度提高以便在底色上可见)
|
||||||
|
--blossom-pink: #F0A0B8;
|
||||||
|
--blossom-peach: #FFB07A;
|
||||||
|
--blossom-blue: #90B8E0;
|
||||||
|
|
||||||
|
// 主品牌色:Pantone 粉晶 Rose Quartz
|
||||||
|
--primary: #D4849A;
|
||||||
|
--primary-rgb: 212, 132, 154;
|
||||||
|
--primary-hover: #C4748A;
|
||||||
|
--primary-light: rgba(212, 132, 154, 0.12);
|
||||||
|
|
||||||
|
// 背景三层:主背景最深(相对),面板次之,卡片最白
|
||||||
|
--bg-primary: #F5EDF2;
|
||||||
|
--bg-secondary: rgba(255, 255, 255, 0.82);
|
||||||
|
--bg-tertiary: rgba(212, 132, 154, 0.06);
|
||||||
|
--bg-hover: rgba(212, 132, 154, 0.09);
|
||||||
|
|
||||||
|
// 文字:提高对比度,主色接近纯黑只带微弱紫调
|
||||||
|
--text-primary: #1E1A22;
|
||||||
|
--text-secondary: #6B5F70;
|
||||||
|
--text-tertiary: #9A8A9E;
|
||||||
|
// 边框:粉色半透明,有存在感但不强硬
|
||||||
|
--border-color: rgba(212, 132, 154, 0.18);
|
||||||
|
|
||||||
|
--bg-gradient: linear-gradient(150deg, #F5EDF2 0%, #F0EAF6 50%, #EAF0F8 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #D4849A 0%, #E8A8B8 100%);
|
||||||
|
|
||||||
|
// 卡片:高不透明度白,与背景形成明显层次
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.88);
|
||||||
|
--card-inner-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 深色主题 ====================
|
// ==================== 深色主题 ====================
|
||||||
|
|
||||||
// 云上舞白 - 深色
|
// 云上舞白 - 深色
|
||||||
@@ -163,6 +200,7 @@
|
|||||||
--primary-light: rgba(201, 168, 108, 0.15);
|
--primary-light: rgba(201, 168, 108, 0.15);
|
||||||
--bg-primary: #1a1816;
|
--bg-primary: #1a1816;
|
||||||
--bg-secondary: rgba(40, 36, 32, 0.9);
|
--bg-secondary: rgba(40, 36, 32, 0.9);
|
||||||
|
--bg-secondary-solid: #282420;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #F0EEE9;
|
--text-primary: #F0EEE9;
|
||||||
@@ -184,6 +222,7 @@
|
|||||||
--primary-light: rgba(106, 154, 170, 0.15);
|
--primary-light: rgba(106, 154, 170, 0.15);
|
||||||
--bg-primary: #141a1c;
|
--bg-primary: #141a1c;
|
||||||
--bg-secondary: rgba(30, 40, 44, 0.9);
|
--bg-secondary: rgba(30, 40, 44, 0.9);
|
||||||
|
--bg-secondary-solid: #1e282c;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E8EEF0;
|
--text-primary: #E8EEF0;
|
||||||
@@ -205,6 +244,7 @@
|
|||||||
--primary-light: rgba(154, 186, 124, 0.15);
|
--primary-light: rgba(154, 186, 124, 0.15);
|
||||||
--bg-primary: #161a14;
|
--bg-primary: #161a14;
|
||||||
--bg-secondary: rgba(34, 42, 30, 0.9);
|
--bg-secondary: rgba(34, 42, 30, 0.9);
|
||||||
|
--bg-secondary-solid: #222a1e;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E8F0E4;
|
--text-primary: #E8F0E4;
|
||||||
@@ -226,6 +266,7 @@
|
|||||||
--primary-light: rgba(192, 96, 104, 0.15);
|
--primary-light: rgba(192, 96, 104, 0.15);
|
||||||
--bg-primary: #1a1416;
|
--bg-primary: #1a1416;
|
||||||
--bg-secondary: rgba(42, 32, 34, 0.9);
|
--bg-secondary: rgba(42, 32, 34, 0.9);
|
||||||
|
--bg-secondary-solid: #2a2022;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #F0E8E8;
|
--text-primary: #F0E8E8;
|
||||||
@@ -247,6 +288,7 @@
|
|||||||
--primary-light: rgba(122, 186, 170, 0.15);
|
--primary-light: rgba(122, 186, 170, 0.15);
|
||||||
--bg-primary: #121a1a;
|
--bg-primary: #121a1a;
|
||||||
--bg-secondary: rgba(28, 42, 42, 0.9);
|
--bg-secondary: rgba(28, 42, 42, 0.9);
|
||||||
|
--bg-secondary-solid: #1c2a2a;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E4F0F0;
|
--text-primary: #E4F0F0;
|
||||||
@@ -260,6 +302,43 @@
|
|||||||
--sent-card-bg: var(--primary);
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦 - 深色(夜阑幽梦)
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] {
|
||||||
|
// 光晕色(供伪元素使用,降低饱和度避免刺眼)
|
||||||
|
--blossom-pink: #C670C3;
|
||||||
|
--blossom-purple: #5F4B8B;
|
||||||
|
--blossom-blue: #3A2A50;
|
||||||
|
|
||||||
|
// 主品牌色:藕粉/烟紫粉,降饱和度不刺眼
|
||||||
|
--primary: #D19EBB;
|
||||||
|
--primary-rgb: 209, 158, 187;
|
||||||
|
--primary-hover: #DDB0C8;
|
||||||
|
--primary-light: rgba(209, 158, 187, 0.15);
|
||||||
|
|
||||||
|
// 背景三层:极深黑灰底(去掉紫薯色),面板略浅,卡片再浅一级
|
||||||
|
--bg-primary: #151316;
|
||||||
|
--bg-secondary: rgba(34, 30, 36, 0.92);
|
||||||
|
--bg-secondary-solid: #221E24;
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-hover: rgba(209, 158, 187, 0.1);
|
||||||
|
|
||||||
|
// 文字
|
||||||
|
--text-primary: #F0EAF4;
|
||||||
|
--text-secondary: #A898AE;
|
||||||
|
--text-tertiary: #6A5870;
|
||||||
|
// 边框:极细白色内发光,剥离层级
|
||||||
|
--border-color: rgba(255, 255, 255, 0.07);
|
||||||
|
|
||||||
|
--bg-gradient: linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #D19EBB 0%, #A878A8 100%);
|
||||||
|
|
||||||
|
// 卡片:比面板更亮一档,用深灰而非紫色
|
||||||
|
--card-bg: rgba(42, 38, 46, 0.92);
|
||||||
|
--card-inner-bg: rgba(52, 48, 56, 0.96);
|
||||||
|
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
// 重置样式
|
// 重置样式
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
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