This commit is contained in:
xuncha
2026-02-27 14:15:57 +08:00
27 changed files with 2254 additions and 112 deletions

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(/&amp;/g, '&') : ''
const encryptUrl = encMatch ? encMatch[1].trim().replace(/&amp;/g, '&') : undefined
const aesKey = aesMatch ? aesMatch[1].trim() : undefined
if (url || encryptUrl) {
emojis.push({
url,
md5: md5Match ? md5Match[1].trim() : '',
width: wMatch ? parseInt(wMatch[1]) : 0,
height: hMatch ? parseInt(hMatch[1]) : 0,
encryptUrl,
aesKey
})
}
}
if (nicknameMatch && (contentMatch || emojis.length > 0)) {
const refId = refIdMatch ? refIdMatch[1].trim() : ''
comments.push({
id: idMatch ? idMatch[1].trim() : `cmt_${Date.now()}_${Math.random()}`,
nickname: nicknameMatch[1].trim(),
username: usernameMatch ? usernameMatch[1].trim() : undefined,
content: contentMatch ? contentMatch[1].trim() : '',
refCommentId: refId === '0' ? '' : refId,
refUsername: refUserMatch ? refUserMatch[1].trim() : undefined,
refNickname: refNickMatch ? refNickMatch[1].trim() : undefined,
emojis: emojis.length > 0 ? emojis : undefined
})
}
}
// 二次解析:通过 refUsername 补全 refNickname
const userMap = new Map<string, string>()
for (const c of comments) {
if (c.username && c.nickname) userMap.set(c.username, c.nickname)
}
for (const c of comments) {
if (!c.refNickname && c.refUsername && c.refCommentId) {
c.refNickname = userMap.get(c.refUsername)
}
}
} catch (e) {
console.error('[SnsService] parseCommentsFromXml 失败:', e)
}
return comments
}
class SnsService { 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(/&amp;/g, '&'),
md5: e.md5 || '',
width: e.width || 0,
height: e.height || 0,
encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&amp;/g, '&') : undefined,
aesKey: e.aesKey || undefined
})) }))
return { 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 }[] = []
// 格式 AGcmData 块格式
if (encData.length > 63 && encData[0] === 0xAB && encData[8] === 0xAB && encData[9] === 0x00) {
const payloadSize = encData.readUInt32LE(10)
if (payloadSize > 16 && 63 + payloadSize <= encData.length) {
const nonce = encData.subarray(19, 31)
const payload = encData.subarray(63, 63 + payloadSize)
layouts.push({ nonce, ciphertext: payload.subarray(0, payload.length - 16), tag: payload.subarray(payload.length - 16) })
}
}
// 格式 B尾部 [ciphertext][nonce 12B][tag 16B]
if (encData.length > 28) {
layouts.push({
ciphertext: encData.subarray(0, encData.length - 28),
nonce: encData.subarray(encData.length - 28, encData.length - 16),
tag: encData.subarray(encData.length - 16)
})
}
// 格式 C前置 [nonce 12B][ciphertext][tag 16B]
if (encData.length > 28) {
layouts.push({
nonce: encData.subarray(0, 12),
ciphertext: encData.subarray(12, encData.length - 16),
tag: encData.subarray(encData.length - 16)
})
}
// 格式 D零 nonce
if (encData.length > 16) {
layouts.push({
nonce: Buffer.alloc(12, 0),
ciphertext: encData.subarray(0, encData.length - 16),
tag: encData.subarray(encData.length - 16)
})
}
// 格式 E[nonce 12B][tag 16B][ciphertext]
if (encData.length > 28) {
layouts.push({
nonce: encData.subarray(0, 12),
tag: encData.subarray(12, 28),
ciphertext: encData.subarray(28)
})
}
return layouts
}
/** 尝试 AES-GCM 解密 */
private tryGcmDecrypt(key: Buffer, nonce: Buffer, ciphertext: Buffer, tag: Buffer): Buffer | null {
try {
const algo = key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm'
const decipher = crypto.createDecipheriv(algo, key, nonce)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (this.isValidImageBuffer(decrypted)) return decrypted
for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) {
try {
const d = fn(decrypted)
if (this.isValidImageBuffer(d)) return d
} catch { }
}
return decrypted
} catch {
return null
}
}
/**
* 解密表情数据(多种算法 + 多种密钥派生)
* 移植自 ciphertalk 的逆向实现
*/
private decryptEmojiAes(encData: Buffer, aesKey: string): Buffer | null {
if (encData.length <= 16) return null
const keyTries = this.buildKeyTries(aesKey)
const tag = encData.subarray(encData.length - 16)
const ciphertext = encData.subarray(0, encData.length - 16)
// 最高优先级nonce-tail 格式 [ciphertext][nonce 12B][tag 16B]
if (encData.length > 28) {
const nonceTail = encData.subarray(encData.length - 28, encData.length - 16)
const tagTail = encData.subarray(encData.length - 16)
const cipherTail = encData.subarray(0, encData.length - 28)
for (const { key } of keyTries) {
if (key.length !== 16 && key.length !== 32) continue
const result = this.tryGcmDecrypt(key, nonceTail, cipherTail, tagTail)
if (result) return result
}
}
// 次优先级nonce = key 前 12 字节
for (const { key } of keyTries) {
if (key.length !== 16 && key.length !== 32) continue
const nonce = key.subarray(0, 12)
const result = this.tryGcmDecrypt(key, nonce, ciphertext, tag)
if (result) return result
}
// 其他 GCM 布局
const layouts = this.buildGcmLayouts(encData)
for (const layout of layouts) {
for (const { key } of keyTries) {
if (key.length !== 16 && key.length !== 32) continue
const result = this.tryGcmDecrypt(key, layout.nonce, layout.ciphertext, layout.tag)
if (result) return result
}
}
// 回退AES-128-CBC / AES-128-ECB
for (const { key } of keyTries) {
if (key.length !== 16) continue
// CBCIV = key
if (encData.length >= 16 && encData.length % 16 === 0) {
try {
const dec = crypto.createDecipheriv('aes-128-cbc', key, key)
dec.setAutoPadding(true)
const result = Buffer.concat([dec.update(encData), dec.final()])
if (this.isValidImageBuffer(result)) return result
for (const fn of [zlib.inflateSync, zlib.gunzipSync]) {
try { const d = fn(result); if (this.isValidImageBuffer(d)) return d } catch { }
}
} catch { }
}
// CBC前 16 字节作为 IV
if (encData.length > 32) {
try {
const iv = encData.subarray(0, 16)
const dec = crypto.createDecipheriv('aes-128-cbc', key, iv)
dec.setAutoPadding(true)
const result = Buffer.concat([dec.update(encData.subarray(16)), dec.final()])
if (this.isValidImageBuffer(result)) return result
} catch { }
}
// ECB
try {
const dec = crypto.createDecipheriv('aes-128-ecb', key, null)
dec.setAutoPadding(true)
const result = Buffer.concat([dec.update(encData), dec.final()])
if (this.isValidImageBuffer(result)) return result
} catch { }
}
return null
}
/** 下载原始数据到本地临时文件,支持重定向 */
private doDownloadRaw(targetUrl: string, cacheKey: string, cacheDir: string): Promise<string | null> {
return new Promise((resolve) => {
try {
const fs = require('fs')
const https = require('https')
const http = require('http')
let fixedUrl = targetUrl.replace(/&amp;/g, '&')
const urlObj = new URL(fixedUrl)
const protocol = fixedUrl.startsWith('https') ? https : http
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/7.0.20.1781(0x67001431)',
'Accept': '*/*',
'Connection': 'keep-alive'
},
rejectUnauthorized: false,
timeout: 15000
}
const request = protocol.get(fixedUrl, options, (response: any) => {
// 处理重定向
if ([301, 302, 303, 307].includes(response.statusCode)) {
const redirectUrl = response.headers.location
if (redirectUrl) {
const full = redirectUrl.startsWith('http') ? redirectUrl : `${urlObj.protocol}//${urlObj.host}${redirectUrl}`
this.doDownloadRaw(full, cacheKey, cacheDir).then(resolve)
return
}
}
if (response.statusCode !== 200) { resolve(null); return }
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', () => {
const buffer = Buffer.concat(chunks)
if (buffer.length === 0) { resolve(null); return }
const ext = this.isValidImageBuffer(buffer) ? this.getImageExtFromBuffer(buffer) : '.bin'
const filePath = join(cacheDir, `${cacheKey}${ext}`)
try {
fs.writeFileSync(filePath, buffer)
resolve(filePath)
} catch { resolve(null) }
})
response.on('error', () => resolve(null))
})
request.on('error', () => resolve(null))
request.setTimeout(15000, () => { request.destroy(); resolve(null) })
} catch { resolve(null) }
})
}
/**
* 下载朋友圈评论中的表情包(多种解密算法,移植自 ciphertalk
*/
async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> {
if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' }
const fs = require('fs')
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
const cachePath = this.configService.getCacheBasePath()
const emojiDir = join(cachePath, 'sns_emoji_cache')
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
// 检查本地缓存
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
const filePath = join(emojiDir, `${cacheKey}${ext}`)
if (existsSync(filePath)) return { success: true, localPath: filePath }
}
// 保存解密后的图片
const saveDecrypted = (buf: Buffer): { success: boolean; localPath?: string } => {
const ext = this.isValidImageBuffer(buf) ? this.getImageExtFromBuffer(buf) : '.gif'
const filePath = join(emojiDir, `${cacheKey}${ext}`)
try { fs.writeFileSync(filePath, buf); return { success: true, localPath: filePath } }
catch { return { success: false } }
}
// 1. 优先encryptUrl + aesKey
if (encryptUrl && aesKey) {
const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', emojiDir)
if (encResult) {
const encData = fs.readFileSync(encResult)
if (this.isValidImageBuffer(encData)) {
const ext = this.getImageExtFromBuffer(encData)
const filePath = join(emojiDir, `${cacheKey}${ext}`)
fs.writeFileSync(filePath, encData)
try { fs.unlinkSync(encResult) } catch { }
return { success: true, localPath: filePath }
}
const decrypted = this.decryptEmojiAes(encData, aesKey)
if (decrypted) {
try { fs.unlinkSync(encResult) } catch { }
return saveDecrypted(decrypted)
}
try { fs.unlinkSync(encResult) } catch { }
}
}
// 2. 直接下载 url
if (url) {
const result = await this.doDownloadRaw(url, cacheKey, emojiDir)
if (result) {
const buf = fs.readFileSync(result)
if (this.isValidImageBuffer(buf)) return { success: true, localPath: result }
// 用 aesKey 解密
if (aesKey) {
const decrypted = this.decryptEmojiAes(buf, aesKey)
if (decrypted) {
try { fs.unlinkSync(result) } catch { }
return saveDecrypted(decrypted)
}
}
try { fs.unlinkSync(result) } catch { }
}
}
return { success: false, error: '下载表情包失败' }
}
} }
export const snsService = new SnsService() export const snsService = new SnsService()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '刚玉蓝',

View File

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

View File

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

View File

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